From cba1cee320a697f7a1064c3bd15581abff99b10d Mon Sep 17 00:00:00 2001 From: Jota Date: Thu, 21 Aug 2025 11:11:19 -0500 Subject: [PATCH 1/9] se agregan modificaciones a la base de datos para soportar el nuevo requerimiento --- prisma/initial_data.ts | 40 ++++++++++ .../migration.sql | 47 +++++++++++ prisma/schema.prisma | 78 +++++++++++++------ prisma/seed.ts | 27 ++++++- 4 files changed, 165 insertions(+), 27 deletions(-) create mode 100644 prisma/migrations/20250821150826_add_category_variants/migration.sql diff --git a/prisma/initial_data.ts b/prisma/initial_data.ts index 0520e9e..88a35f1 100644 --- a/prisma/initial_data.ts +++ b/prisma/initial_data.ts @@ -10,6 +10,7 @@ export const categories = [ alt: "Hombre luciendo polo azul", description: "Polos exclusivos con diseños que todo desarrollador querrá lucir. Ideales para llevar el código a donde vayas.", + hasVariants: true, // Los polos tienen variantes de talla }, { title: "Tazas", @@ -18,6 +19,7 @@ export const categories = [ alt: "Tazas con diseño de código", description: "Tazas que combinan perfectamente con tu café matutino y tu pasión por la programación. ¡Empieza el día con estilo!", + hasVariants: false, // Las tazas NO tienen variantes }, { title: "Stickers", @@ -26,6 +28,44 @@ export const categories = [ alt: "Stickers de desarrollo web", description: "Personaliza tu espacio de trabajo con nuestros stickers únicos y muestra tu amor por el desarrollo web.", + hasVariants: true, // Los stickers tienen variantes de tamaño + }, +]; + +// Variantes por categoría +export const categoryVariants = [ + // Variantes para Polos (categoryId: 1) - sin modificador de precio + { categoryId: 1, value: "small", label: "S", priceModifier: 0, sortOrder: 1 }, + { + categoryId: 1, + value: "medium", + label: "M", + priceModifier: 0, + sortOrder: 2, + }, + { categoryId: 1, value: "large", label: "L", priceModifier: 0, sortOrder: 3 }, + + // Variantes para Stickers (categoryId: 3) - con modificador de precio + { + categoryId: 3, + value: "3x3", + label: "3×3 cm", + priceModifier: 0, + sortOrder: 1, + }, + { + categoryId: 3, + value: "5x5", + label: "5×5 cm", + priceModifier: 1.0, + sortOrder: 2, + }, + { + categoryId: 3, + value: "10x10", + label: "10×10 cm", + priceModifier: 3.0, + sortOrder: 3, }, ]; diff --git a/prisma/migrations/20250821150826_add_category_variants/migration.sql b/prisma/migrations/20250821150826_add_category_variants/migration.sql new file mode 100644 index 0000000..52b106e --- /dev/null +++ b/prisma/migrations/20250821150826_add_category_variants/migration.sql @@ -0,0 +1,47 @@ +/* + Warnings: + + - A unique constraint covering the columns `[cart_id,product_id,category_variant_id]` on the table `cart_items` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropIndex +DROP INDEX "cart_items_cart_id_product_id_key"; + +-- AlterTable +ALTER TABLE "cart_items" ADD COLUMN "category_variant_id" INTEGER; + +-- AlterTable +ALTER TABLE "categories" ADD COLUMN "has_variants" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "order_items" ADD COLUMN "category_variant_id" INTEGER, +ADD COLUMN "variant_info" TEXT; + +-- CreateTable +CREATE TABLE "category_variants" ( + "id" SERIAL NOT NULL, + "category_id" INTEGER NOT NULL, + "value" TEXT NOT NULL, + "label" TEXT NOT NULL, + "price_modifier" DECIMAL(10,2) NOT NULL DEFAULT 0, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "category_variants_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "category_variants_category_id_value_key" ON "category_variants"("category_id", "value"); + +-- CreateIndex +CREATE UNIQUE INDEX "cart_items_cart_id_product_id_category_variant_id_key" ON "cart_items"("cart_id", "product_id", "category_variant_id"); + +-- AddForeignKey +ALTER TABLE "category_variants" ADD CONSTRAINT "category_variants_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "cart_items" ADD CONSTRAINT "cart_items_category_variant_id_fkey" FOREIGN KEY ("category_variant_id") REFERENCES "category_variants"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "order_items" ADD CONSTRAINT "order_items_category_variant_id_fkey" FOREIGN KEY ("category_variant_id") REFERENCES "category_variants"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e0f992b..d6bf3a1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -42,14 +42,35 @@ model Category { imgSrc String? @map("img_src") alt String? description String? + hasVariants Boolean @default(false) @map("has_variants") // Indica si esta categoría maneja variantes createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) - products Product[] + products Product[] + categoryVariants CategoryVariant[] @@map("categories") } +// Variantes disponibles para cada categoría +model CategoryVariant { + id Int @id @default(autoincrement()) + categoryId Int @map("category_id") + value String // "small", "medium", "large", "3x3", "5x5", "10x10" + label String // "S", "M", "L", "3×3 cm", "5×5 cm", "10×10 cm" + priceModifier Decimal @default(0) @map("price_modifier") @db.Decimal(10, 2) + sortOrder Int @default(0) @map("sort_order") // Para ordenar las opciones + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) + + category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade) + cartItems CartItem[] + orderItems OrderItem[] + + @@unique([categoryId, value]) + @@map("category_variants") +} + model Product { id Int @id @default(autoincrement()) title String @@ -83,18 +104,21 @@ model Cart { @@map("carts") } +// Actualizar CartItem para variantes de categoría model CartItem { - id Int @id @default(autoincrement()) - cartId Int @map("cart_id") - productId Int @map("product_id") - quantity Int - createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) - updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) - - cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade) - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) - - @@unique([cartId, productId], name: "unique_cart_item") + id Int @id @default(autoincrement()) + cartId Int @map("cart_id") + productId Int @map("product_id") + categoryVariantId Int? @map("category_variant_id") // Variante seleccionada + quantity Int + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) + + cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + categoryVariant CategoryVariant? @relation(fields: [categoryVariantId], references: [id], onDelete: SetNull) + + @@unique([cartId, productId, categoryVariantId], name: "unique_cart_item_variant") @@map("cart_items") } @@ -122,19 +146,23 @@ model Order { @@map("orders") } +// Actualizar OrderItem model OrderItem { - id Int @id @default(autoincrement()) - orderId Int @map("order_id") - productId Int? @map("product_id") - quantity Int - title String - price Decimal @db.Decimal(10, 2) - imgSrc String? @map("img_src") - createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) - updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) - - order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) - product Product? @relation(fields: [productId], references: [id], onDelete: SetNull) + id Int @id @default(autoincrement()) + orderId Int @map("order_id") + productId Int? @map("product_id") + categoryVariantId Int? @map("category_variant_id") + quantity Int + title String + variantInfo String? @map("variant_info") // "Talla: M" o "Tamaño: 5×5 cm" + price Decimal @db.Decimal(10, 2) // Precio final (base + modificador) + imgSrc String? @map("img_src") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) + + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + product Product? @relation(fields: [productId], references: [id], onDelete: SetNull) + categoryVariant CategoryVariant? @relation(fields: [categoryVariantId], references: [id], onDelete: SetNull) @@map("order_items") -} +} \ No newline at end of file diff --git a/prisma/seed.ts b/prisma/seed.ts index 106da46..827a800 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,18 +1,41 @@ -import { categories, products } from "./initial_data"; +import { categories, categoryVariants, products } from "./initial_data"; import { PrismaClient } from "../generated/prisma/client"; const prisma = new PrismaClient(); async function seedDb() { + await prisma.orderItem.deleteMany(); + console.log(" ✅ OrderItems eliminados"); + + await prisma.cart.deleteMany(); + console.log(" ✅ Carts eliminados"); + + await prisma.cartItem.deleteMany(); + console.log(" ✅ CartItems eliminados"); + + await prisma.categoryVariant.deleteMany(); + console.log(" ✅ CategoryVariants eliminados"); + + await prisma.product.deleteMany(); + console.log(" ✅ Products eliminados"); + + await prisma.category.deleteMany(); + console.log(" ✅ Categories eliminadas"); + await prisma.category.createMany({ data: categories, }); console.log("1. Categories successfully inserted"); + await prisma.categoryVariant.createMany({ + data: categoryVariants, + }); + console.log("2. Category variants successfully inserted"); + await prisma.product.createMany({ data: products, }); - console.log("2. Products successfully inserted"); + console.log("3. Products successfully inserted"); } seedDb() From 640cd78beefad21449a936c45176640896cf9926 Mon Sep 17 00:00:00 2001 From: Jota Date: Thu, 21 Aug 2025 18:43:19 -0500 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20agregamos=20la=20logica=20de=20mane?= =?UTF-8?q?jo=20para=20la=20interface=20de=20producto=20y=20poder=20mostra?= =?UTF-8?q?r=20las=20variantes=20asi=20como=20su=20comportamiento=20UI=20y?= =?UTF-8?q?=20dise=C3=B1o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prisma/initial_data.ts | 12 ++-- src/routes/product/index.tsx | 95 +++++++++++++++++++++++++++++++- src/services/category.service.ts | 25 ++++++++- 3 files changed, 123 insertions(+), 9 deletions(-) diff --git a/prisma/initial_data.ts b/prisma/initial_data.ts index 88a35f1..0633483 100644 --- a/prisma/initial_data.ts +++ b/prisma/initial_data.ts @@ -48,22 +48,22 @@ export const categoryVariants = [ // Variantes para Stickers (categoryId: 3) - con modificador de precio { categoryId: 3, - value: "3x3", - label: "3×3 cm", + value: "3x3 cm", + label: "3×3", priceModifier: 0, sortOrder: 1, }, { categoryId: 3, - value: "5x5", - label: "5×5 cm", + value: "5x5 cm", + label: "5×5", priceModifier: 1.0, sortOrder: 2, }, { categoryId: 3, - value: "10x10", - label: "10×10 cm", + value: "10x10 cm", + label: "10×10", priceModifier: 3.0, sortOrder: 3, }, diff --git a/src/routes/product/index.tsx b/src/routes/product/index.tsx index f444f0b..d7b8674 100644 --- a/src/routes/product/index.tsx +++ b/src/routes/product/index.tsx @@ -1,31 +1,79 @@ import { Form, useNavigation } from "react-router"; import { Button, Container, Separator } from "@/components/ui"; +import { cn } from "@/lib/utils"; import { type Product } from "@/models/product.model"; import { getProductById } from "@/services/product.service"; import NotFound from "../not-found"; import type { Route } from "./+types"; +import { useEffect, useState } from "react"; +import { getCategoryWithVariants } from "@/services/category.service"; + +interface CategoryVariant { + id: number; + value: string; + label: string; +} export async function loader({ params }: Route.LoaderArgs) { try { const product = await getProductById(parseInt(params.id)); - return { product }; + const categoryWithVariants = product.categoryId + ? await getCategoryWithVariants(product.categoryId) + : null; + return { product, categoryWithVariants }; } catch { return {}; } } export default function Product({ loaderData }: Route.ComponentProps) { - const { product } = loaderData; + const { product, categoryWithVariants } = loaderData; const navigation = useNavigation(); const cartLoading = navigation.state === "submitting"; + // Estado simple para variantes + const [variants, setVariants] = useState([]); + const [selectedVariant, setSelectedVariant] = + useState(null); + + // Cargar variantes si la categoría las tiene + useEffect(() => { + if ( + !categoryWithVariants?.hasVariants || + !categoryWithVariants.categoryVariants.length + ) { + setVariants([]); + setSelectedVariant(null); + return; + } + + const mappedVariants: CategoryVariant[] = + categoryWithVariants.categoryVariants.map((variant) => ({ + id: variant.id, + value: variant.value, + label: variant.label, + })); + + setVariants(mappedVariants); + setSelectedVariant(mappedVariants[0] || null); + }, [categoryWithVariants?.id, categoryWithVariants?.hasVariants]); + if (!product) { return ; } + const hasVariants = categoryWithVariants?.hasVariants && variants.length > 0; + + // Helper para obtener el label de la variante + const getVariantLabel = () => { + if (product.categoryId === 1) return "Talla"; + if (product.categoryId === 3) return "Tamaño"; + return "Opciones"; + }; + return ( <>
@@ -45,12 +93,55 @@ export default function Product({ loaderData }: Route.ComponentProps) {

{product.description}

+ + {/* Toggle Button Group para Variantes - Implementación directa */} + {hasVariants && ( +
+
+ +
+ {variants.map((variant) => ( + + ))} +
+
+
+ )} +
+ + {selectedVariant && ( + + )} + + +
+ ); + } + return (
@@ -30,75 +49,101 @@ export default function Cart({ loaderData }: Route.ComponentProps) { Carrito de compras
- {cart?.items?.map(({ product, quantity, id }) => ( -
-
- {product.alt -
-
-
-

{product.title}

- - - + {cart.items.map( + ({ product, quantity, id, finalPrice, categoryVariant }) => ( +
+
+ {product.alt
-
-

- ${product.price.toFixed(2)} -

-
-
- +
+
+
+

{product.title}

+ {/* ✅ CORREGIDO: Mejor layout para la variante */} + {categoryVariant && ( +

({categoryVariant.label})

+ )} +
+ - - - {quantity} - -
-
+
+

+ S/{finalPrice.toFixed(2)} +

+
+
+ + + {categoryVariant && ( + + )} + +
+ + {quantity} + +
+ + {categoryVariant && ( + + )} + +
+
+
-
- ))} + ) + )}

Total

S/{total.toFixed(2)}

diff --git a/src/routes/product/index.tsx b/src/routes/product/index.tsx index d7b8674..d1d5090 100644 --- a/src/routes/product/index.tsx +++ b/src/routes/product/index.tsx @@ -134,7 +134,6 @@ export default function Product({ loaderData }: Route.ComponentProps) { name="redirectTo" value={`/products/${product.id}`} /> - {selectedVariant && ( ({ - ...item, - product: { - ...item.product, - price: item.product.price.toNumber(), - }, - })), - }; + return { + ...data, + items: data.items.map((item) => ({ + id: item.id, + cartId: item.cartId, + productId: item.productId, + categoryVariantId: item.categoryVariantId, + quantity: item.quantity, + finalPrice: item.finalPrice + ? Number(item.finalPrice) + : Number(item.product.price), + createdAt: item.createdAt, + updatedAt: item.updatedAt, + product: { + ...item.product, + price: Number(item.product.price), + }, + categoryVariant: item.categoryVariant + ? { + id: item.categoryVariant.id, + label: item.categoryVariant.label, + value: item.categoryVariant.value, + priceModifier: Number(item.categoryVariant.priceModifier), + } + : null, + })), + }; + } catch (error) { + console.error("Error in getCart:", error); + return null; + } } export async function getRemoteCart( @@ -66,55 +99,45 @@ export async function getOrCreateCart( userId: User["id"] | undefined, sessionCartId: string | undefined ): Promise { - const cart = await getCart(userId, sessionCartId); + try { + const cart = await getCart(userId, sessionCartId); - if (cart) { - return cart; - } - - // Si no se encontró un carrito creamos uno nuevo - - // Creamos un carrito con userId si se proporciona - const newCart = await prisma.cart.create({ - data: { - userId: userId || null, - }, - include: { - items: { - include: { - product: { - select: { - id: true, - title: true, - imgSrc: true, - alt: true, - price: true, - isOnSale: true, - }, - }, - }, + if (cart) { + return cart; + } + const newCart = await prisma.cart.create({ + data: { + userId: userId || null, + sessionCartId: sessionCartId, }, - }, - }); - - if (!newCart) throw new Error("Failed to create cart"); + }); - return { - ...newCart, - items: newCart.items.map((item) => ({ - ...item, - product: { - ...item.product, - price: item.product.price.toNumber(), - }, - })), - }; + if (!newCart) { + throw new Error("Failed to create new cart"); + } + + // ✅ IMPORTANTE: Usar getCart para obtener el carrito con todas las relaciones + const cartWithItems = await getCart( + newCart.userId || undefined, + newCart.sessionCartId || undefined, + newCart.id + ); + + if (!cartWithItems) { + throw new Error("Failed to fetch cart after creation"); + } + + return cartWithItems; + } catch (error) { + console.error("Error in getOrCreateCart:", error); + throw new Error(`Failed to get or create cart: ${error}`); + } } export async function createRemoteItems( userId: User["id"] | undefined, sessionCartId: string | undefined, - items: CartItemWithProduct[] = [] + items: CartItem[] = [] ): Promise { const cart = await getOrCreateCart(userId, sessionCartId); @@ -132,11 +155,17 @@ export async function createRemoteItems( cartId: cart.id, productId: item.product.id, quantity: item.quantity, + categoryVariantId: item.categoryVariantId, + finalPrice: item.finalPrice, })), }); } - const updatedCart = await getCart(userId, sessionCartId, cart.id); + const updatedCart = await getCart( + cart.userId || undefined, + cart.sessionCartId || undefined, + cart.id + ); if (!updatedCart) throw new Error("Cart not found after creation"); @@ -147,11 +176,18 @@ export async function alterQuantityCartItem( userId: User["id"] | undefined, sessionCartId: string | undefined, productId: number, - quantity: number = 1 + quantity: number = 1, + categoryVariantId: number | null = null ): Promise { const cart = await getOrCreateCart(userId, sessionCartId); - const existingItem = cart.items.find((item) => item.product.id === productId); + const existingItem = cart.items.find( + (item) => + item.productId === productId && + item.categoryVariantId === categoryVariantId + ); + + const finalPrice = await calculateFinalPrice(productId, categoryVariantId); if (existingItem) { const newQuantity = existingItem.quantity + quantity; @@ -164,6 +200,7 @@ export async function alterQuantityCartItem( }, data: { quantity: newQuantity, + finalPrice, }, }); } else { @@ -171,7 +208,9 @@ export async function alterQuantityCartItem( data: { cartId: cart.id, productId, + categoryVariantId, quantity, + finalPrice, }, }); } @@ -183,6 +222,35 @@ export async function alterQuantityCartItem( return updatedCart; } +async function calculateFinalPrice( + productId: number, + categoryVariantId: number | null +): Promise { + // Obtener producto + const product = await prisma.product.findUnique({ + where: { id: productId }, + select: { price: true }, + }); + + if (!product) throw new Error("Product not found"); + + let finalPrice = Number(product.price); + + // Si hay variante, sumar el modificador + if (categoryVariantId) { + const variant = await prisma.categoryVariant.findUnique({ + where: { id: categoryVariantId }, + select: { priceModifier: true }, + }); + + if (variant) { + finalPrice += Number(variant.priceModifier); + } + } + + return finalPrice; +} + export async function deleteRemoteCartItem( userId: User["id"] | undefined, sessionCartId: string | undefined, @@ -261,6 +329,7 @@ export async function linkCartToUser( ...item.product, price: item.product.price.toNumber(), }, + finalPrice: Number(item.finalPrice), })), }; } @@ -307,6 +376,7 @@ export async function mergeGuestCartWithUserCart( ...item.product, price: item.product.price.toNumber(), }, + finalPrice: Number(item.finalPrice), })), }; } @@ -326,11 +396,18 @@ export async function mergeGuestCartWithUserCart( // Mover los items del carrito de invitado al carrito de usuario await prisma.cartItem.createMany({ - data: guestCart.items.map((item) => ({ - cartId: userCart.id, - productId: item.productId, - quantity: item.quantity, - })), + data: await Promise.all( + guestCart.items.map(async (item) => ({ + cartId: userCart.id, + productId: item.productId, + quantity: item.quantity, + categoryVariantId: item.categoryVariantId ?? null, + finalPrice: await calculateFinalPrice( + item.productId, + item.categoryVariantId ?? null + ), + })) + ), }); // Eliminar el carrito de invitado From c4c9ed649fa8e65aa09dbf1592eaf1e569f84be9 Mon Sep 17 00:00:00 2001 From: Jota Date: Mon, 25 Aug 2025 13:16:54 -0500 Subject: [PATCH 4/9] feat(order item): agregamos la logica para mostrar la variante a nivel de la grilla de detalle de ordenes, asi como el precio con la variante, el total, se logra crear la orden con los order items, asociados para usuarios visitantes y existentes. --- prisma/initial_data.ts | 12 +++---- src/models/cart.model.ts | 2 ++ src/models/order.model.ts | 2 ++ src/routes/checkout/index.tsx | 64 +++++++++++++++++++++++------------ src/services/order.service.ts | 2 ++ 5 files changed, 55 insertions(+), 27 deletions(-) diff --git a/prisma/initial_data.ts b/prisma/initial_data.ts index 0633483..88a35f1 100644 --- a/prisma/initial_data.ts +++ b/prisma/initial_data.ts @@ -48,22 +48,22 @@ export const categoryVariants = [ // Variantes para Stickers (categoryId: 3) - con modificador de precio { categoryId: 3, - value: "3x3 cm", - label: "3×3", + value: "3x3", + label: "3×3 cm", priceModifier: 0, sortOrder: 1, }, { categoryId: 3, - value: "5x5 cm", - label: "5×5", + value: "5x5", + label: "5×5 cm", priceModifier: 1.0, sortOrder: 2, }, { categoryId: 3, - value: "10x10 cm", - label: "10×10", + value: "10x10", + label: "10×10 cm", priceModifier: 3.0, sortOrder: 3, }, diff --git a/src/models/cart.model.ts b/src/models/cart.model.ts index e2d9f96..5425555 100644 --- a/src/models/cart.model.ts +++ b/src/models/cart.model.ts @@ -32,6 +32,8 @@ export type CartItem = { export interface CartItemInput { productId: Product["id"]; quantity: number; + categoryVariantId: number | null; + variantInfo: string | null; title: Product["title"]; price: Product["price"]; imgSrc: Product["imgSrc"]; diff --git a/src/models/order.model.ts b/src/models/order.model.ts index 3ac10a4..d0fc837 100644 --- a/src/models/order.model.ts +++ b/src/models/order.model.ts @@ -29,8 +29,10 @@ export type Order = Omit & { export interface OrderItemInput { productId: number; + categoryVariantId?: number | null; quantity: number; title: string; + variantInfo?: string | null; // ← NUEVO price: number; imgSrc: string; } diff --git a/src/routes/checkout/index.tsx b/src/routes/checkout/index.tsx index 1ceb7ae..e17dc33 100644 --- a/src/routes/checkout/index.tsx +++ b/src/routes/checkout/index.tsx @@ -105,9 +105,13 @@ export async function action({ request }: Route.ActionArgs) { const items = cartItems.map((item) => ({ productId: item.product.id, + categoryVariantId: item.categoryVariantId, // ← NUEVO quantity: item.quantity, title: item.product.title, - price: item.product.price, + variantInfo: item.categoryVariant + ? getVariantInfoText(item.categoryVariantId, item.categoryVariant) + : null, + price: item.finalPrice, imgSrc: item.product.imgSrc, })); @@ -130,6 +134,15 @@ export async function action({ request }: Route.ActionArgs) { }); } +function getVariantInfoText( + categoryId: number | null, + categoryVariant: any +): string { + if (categoryId === 1) return `Talla: ${categoryVariant.label}`; + if (categoryId === 3) return `Tamaño: ${categoryVariant.label}`; + return `Opción: ${categoryVariant.label}`; +} + export async function loader({ request }: Route.LoaderArgs) { const session = await getSession(request.headers.get("Cookie")); const sessionCartId = session.get("sessionCartId"); @@ -249,28 +262,37 @@ export default function Checkout({

Resumen de la orden

- {cart?.items?.map(({ product, quantity }) => ( -
-
- {product.title} -
-
-

{product.title}

-
-

{quantity}

- -

S/{product.price.toFixed(2)}

+ {cart?.items?.map( + ({ product, quantity, finalPrice, categoryVariant }) => ( +
+
+ {product.title} +
+
+
+

{product.title}

+ {categoryVariant && ( +

+ ({categoryVariant.label}) +

+ )} +
+
+

{quantity}

+ +

S/{finalPrice.toFixed(2)}

+
-
- ))} + ) + )}

Total

S/{total.toFixed(2)}

diff --git a/src/services/order.service.ts b/src/services/order.service.ts index 6f5948a..244075f 100644 --- a/src/services/order.service.ts +++ b/src/services/order.service.ts @@ -26,8 +26,10 @@ export async function createOrder( items: { create: items.map((item) => ({ productId: item.productId, + categoryVariantId: item.categoryVariantId, quantity: item.quantity, title: item.title, + variantInfo: item.variantInfo, price: item.price, imgSrc: item.imgSrc, })), From 0ee8125d741b74493fdba2f563e683048ca0cb80 Mon Sep 17 00:00:00 2001 From: Jota Date: Tue, 26 Aug 2025 13:41:55 -0500 Subject: [PATCH 5/9] fix(test): se agregaron ajustes para formato de codigo, y de tipos generados, asi como se actualizo algunos test unitarios. --- src/lib/utils.tests.ts | 5 +++ src/models/cart.model.ts | 15 ++----- src/models/category.model.ts | 7 +++ src/routes/checkout/index.tsx | 12 +++--- src/routes/product/index.tsx | 6 +-- src/routes/product/product.loader.test.ts | 52 ++++++++++++++++++++++- src/routes/product/product.test.tsx | 5 ++- src/services/cart.service.ts | 6 +-- src/services/order.service.test.ts | 6 +++ 9 files changed, 86 insertions(+), 28 deletions(-) diff --git a/src/lib/utils.tests.ts b/src/lib/utils.tests.ts index 1526f23..c7eb3cf 100644 --- a/src/lib/utils.tests.ts +++ b/src/lib/utils.tests.ts @@ -94,6 +94,7 @@ export const createTestCategory = ( imgSrc: "/images/polos.jpg", alt: "Colección de polos para programadores", description: "Explora nuestra colección de polos para programadores", + hasVariants: true, createdAt: new Date(), updatedAt: new Date(), ...overrides, @@ -126,6 +127,8 @@ export const createTestOrderItem = ( title: "Test Product", price: 100, imgSrc: "test-image.jpg", + variantInfo: "Tamaño: L", + categoryVariantId: 1, createdAt: new Date(), updatedAt: new Date(), ...overrides, @@ -142,6 +145,8 @@ export const createTestDBOrderItem = ( title: "Test Product", price: new Decimal(100), imgSrc: "test-image.jpg", + variantInfo: "Tamaño: L", + categoryVariantId: 1, createdAt: new Date(), updatedAt: new Date(), ...overrides, diff --git a/src/models/cart.model.ts b/src/models/cart.model.ts index 5425555..cf1d207 100644 --- a/src/models/cart.model.ts +++ b/src/models/cart.model.ts @@ -1,9 +1,7 @@ -import { type Product } from "./product.model"; +import { type Product } from "./product.model"; -import type { - Cart as PrismaCart, - CartItem as PrismaCartItem, -} from "@/../generated/prisma/client"; +import type { CategoryVariant } from "./category.model"; +import type { Cart as PrismaCart } from "@/../generated/prisma/client"; export type Cart = PrismaCart; @@ -21,12 +19,7 @@ export type CartItem = { Product, "id" | "title" | "imgSrc" | "alt" | "price" | "isOnSale" >; - categoryVariant?: { - id: number; - label: string; - value: string; - priceModifier: number; - } | null; + categoryVariant?: CategoryVariant | null; }; export interface CartItemInput { diff --git a/src/models/category.model.ts b/src/models/category.model.ts index 9c05ae1..e228152 100644 --- a/src/models/category.model.ts +++ b/src/models/category.model.ts @@ -4,6 +4,13 @@ export const VALID_SLUGS = ["polos", "stickers", "tazas"] as const; export type Category = PrismaCategory; +export type CategoryVariant = { + id: number; + label: string; + value: string; + priceModifier: number; +}; + export function isValidCategorySlug( categorySlug: unknown ): categorySlug is Category["slug"] { diff --git a/src/routes/checkout/index.tsx b/src/routes/checkout/index.tsx index e17dc33..c13df7c 100644 --- a/src/routes/checkout/index.tsx +++ b/src/routes/checkout/index.tsx @@ -20,6 +20,7 @@ import { } from "@/hooks/use-culqui"; import { calculateTotal, getCart } from "@/lib/cart"; import { type CartItem } from "@/models/cart.model"; +import type { CategoryVariant } from "@/models/category.model"; import { getCurrentUser } from "@/services/auth.service"; import { deleteRemoteCart } from "@/services/cart.service"; import { createOrder } from "@/services/order.service"; @@ -109,7 +110,7 @@ export async function action({ request }: Route.ActionArgs) { quantity: item.quantity, title: item.product.title, variantInfo: item.categoryVariant - ? getVariantInfoText(item.categoryVariantId, item.categoryVariant) + ? getVariantInfoText(item.categoryVariant) : null, price: item.finalPrice, imgSrc: item.product.imgSrc, @@ -134,12 +135,9 @@ export async function action({ request }: Route.ActionArgs) { }); } -function getVariantInfoText( - categoryId: number | null, - categoryVariant: any -): string { - if (categoryId === 1) return `Talla: ${categoryVariant.label}`; - if (categoryId === 3) return `Tamaño: ${categoryVariant.label}`; +function getVariantInfoText(categoryVariant: CategoryVariant): string { + if (categoryVariant.id === 1) return `Talla: ${categoryVariant.label}`; + if (categoryVariant.id === 3) return `Tamaño: ${categoryVariant.label}`; return `Opción: ${categoryVariant.label}`; } diff --git a/src/routes/product/index.tsx b/src/routes/product/index.tsx index d1d5090..2a4695b 100644 --- a/src/routes/product/index.tsx +++ b/src/routes/product/index.tsx @@ -1,15 +1,15 @@ +import { useEffect, useState } from "react"; import { Form, useNavigation } from "react-router"; import { Button, Container, Separator } from "@/components/ui"; import { cn } from "@/lib/utils"; import { type Product } from "@/models/product.model"; +import { getCategoryWithVariants } from "@/services/category.service"; import { getProductById } from "@/services/product.service"; import NotFound from "../not-found"; import type { Route } from "./+types"; -import { useEffect, useState } from "react"; -import { getCategoryWithVariants } from "@/services/category.service"; interface CategoryVariant { id: number; @@ -59,7 +59,7 @@ export default function Product({ loaderData }: Route.ComponentProps) { setVariants(mappedVariants); setSelectedVariant(mappedVariants[0] || null); - }, [categoryWithVariants?.id, categoryWithVariants?.hasVariants]); + }, [categoryWithVariants]); if (!product) { return ; diff --git a/src/routes/product/product.loader.test.ts b/src/routes/product/product.loader.test.ts index 794b99b..7c4e611 100644 --- a/src/routes/product/product.loader.test.ts +++ b/src/routes/product/product.loader.test.ts @@ -1,16 +1,27 @@ +import { Decimal } from "decimal.js"; import { describe, expect, it, vi } from "vitest"; import { createTestProduct } from "@/lib/utils.tests"; +import * as categoryService from "@/services/category.service"; import * as productService from "@/services/product.service"; import { loader } from "."; +import type { CategorySlug } from "generated/prisma/client"; + // Mock the product service vi.mock("@/services/product.service", () => ({ getProductById: vi.fn(), // mock function })); +vi.mock("@/services/category.service", () => ({ + getCategoryWithVariants: vi.fn(), +})); + const mockGetProductById = vi.mocked(productService.getProductById); +const mockGetCategoryWithVariants = vi.mocked( + categoryService.getCategoryWithVariants +); describe("Product loader", () => { const createLoaderArgs = (id: string) => ({ @@ -20,15 +31,54 @@ describe("Product loader", () => { }); it("returns a product when it exists", async () => { - const mockProduct = createTestProduct(); + const mockProduct = createTestProduct({ categoryId: 1 }); + + const mockCategoryWithVariants = { + id: 1, + title: "Polos", + slug: "POLOS" as CategorySlug, + hasVariants: true, + imgSrc: "POLOS", + alt: "POLOS", + description: "POLOS", + createdAt: new Date(), + updatedAt: new Date(), + categoryVariants: [ + { + id: 1, + value: "small", + label: "S", + priceModifier: new Decimal(0.0), + categoryId: 1, + createdAt: new Date(), + updatedAt: new Date(), + sortOrder: 1, + }, + { + id: 2, + value: "medium", + label: "M", + priceModifier: new Decimal(0.0), + categoryId: 1, + createdAt: new Date(), + updatedAt: new Date(), + sortOrder: 2, + }, + ], + }; mockGetProductById.mockResolvedValue(mockProduct); + mockGetCategoryWithVariants.mockResolvedValue(mockCategoryWithVariants); const result = await loader(createLoaderArgs("1")); expect(result.product).toBeDefined(); + expect(result.categoryWithVariants).toBeDefined(); expect(result.product).toEqual(mockProduct); + expect(result.categoryWithVariants).toEqual(mockCategoryWithVariants); + expect(mockGetProductById).toHaveBeenCalledWith(1); + expect(mockGetCategoryWithVariants).toHaveBeenCalledWith(1); }); it("returns empty object when product does not exist", async () => { diff --git a/src/routes/product/product.test.tsx b/src/routes/product/product.test.tsx index f70059d..734c141 100644 --- a/src/routes/product/product.test.tsx +++ b/src/routes/product/product.test.tsx @@ -32,7 +32,10 @@ vi.mock("react-router", () => ({ const createTestProps = ( productData: Partial = {} ): Route.ComponentProps => ({ - loaderData: { product: createTestProduct(productData) }, + loaderData: { + product: createTestProduct(productData), + categoryWithVariants: null, + }, params: { id: "123" }, // Hack to satisfy type requirements matches: [] as unknown as Route.ComponentProps["matches"], diff --git a/src/services/cart.service.ts b/src/services/cart.service.ts index a82890c..41ca079 100644 --- a/src/services/cart.service.ts +++ b/src/services/cart.service.ts @@ -1,9 +1,5 @@ import { prisma } from "@/db/prisma"; -import type { - CartItem, - CartItemWithProduct, - CartWithItems, -} from "@/models/cart.model"; +import type { CartItem, CartWithItems } from "@/models/cart.model"; import type { User } from "@/models/user.model"; import { getSession } from "@/session.server"; diff --git a/src/services/order.service.test.ts b/src/services/order.service.test.ts index f5ffa51..29f7fe7 100644 --- a/src/services/order.service.test.ts +++ b/src/services/order.service.test.ts @@ -38,6 +38,8 @@ describe("Order Service", () => { title: "Test Product", price: 19.99, imgSrc: "test-product.jpg", + categoryVariantId: 1, + variantInfo: "Talla: S", }, { productId: 2, @@ -45,6 +47,8 @@ describe("Order Service", () => { title: "Another Product", price: 29.99, imgSrc: "another-product.jpg", + categoryVariantId: 2, + variantInfo: "Talla: M", }, ]; @@ -86,6 +90,8 @@ describe("Order Service", () => { title: item.title, price: item.price, imgSrc: item.imgSrc, + categoryVariantId: item.categoryVariantId, + variantInfo: item.variantInfo, })), }, paymentId: "payment-id", From a659dad603bdd2d9a05c8b154e1e9a17dbe955aa Mon Sep 17 00:00:00 2001 From: Jota Date: Thu, 28 Aug 2025 20:09:15 -0500 Subject: [PATCH 6/9] =?UTF-8?q?feat(chatbot,=20category,=20product):=20se?= =?UTF-8?q?=20agregan=20mejoras=20al=20dise=C3=B1o=20UI=20de=20pagina=20de?= =?UTF-8?q?=20categoria=20asi=20como=20el=20manejo=20de=20precios=20y=20va?= =?UTF-8?q?riantes,=20tambien=20se=20agrega=20la=20actualizacion=20del=20p?= =?UTF-8?q?recio=20cuando=20se=20selecciona=20una=20variante,=20y=20finalm?= =?UTF-8?q?ente=20modificamos=20el=20chatbot=20para=20incluir=20variantes?= =?UTF-8?q?=20de=20categoria=20y=20pueda=20responder=20abiertamente=20sobr?= =?UTF-8?q?e=20precios,=20variantes=20y=20productos.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/price-filter/index.tsx | 148 ++++++++++++++--- .../components/product-card/index.tsx | 57 ++++++- src/routes/category/index.tsx | 152 +++++++++++++++--- src/routes/product/index.tsx | 30 +++- src/routes/product/product.loader.test.ts | 78 +++++---- src/services/cart.service.ts | 2 +- src/services/category.service.ts | 81 ++++++++-- src/services/chat-system-prompt.ts | 91 +++++++---- src/services/chat.service.ts | 4 +- 9 files changed, 517 insertions(+), 126 deletions(-) diff --git a/src/routes/category/components/price-filter/index.tsx b/src/routes/category/components/price-filter/index.tsx index 5337413..4418d2f 100644 --- a/src/routes/category/components/price-filter/index.tsx +++ b/src/routes/category/components/price-filter/index.tsx @@ -1,47 +1,145 @@ -import { Form } from "react-router"; +import { useState } from "react"; +import { Form, useSearchParams } from "react-router"; -import { Button, Input } from "@/components/ui"; -import { cn } from "@/lib/utils"; +import { Button, InputField, Label } from "@/components/ui"; + +interface CategoryVariant { + id: number; + label: string; + value: string; +} interface PriceFilterProps { minPrice: string; maxPrice: string; + categoryVariants: CategoryVariant[]; + selectedVariants: string[]; className?: string; } export function PriceFilter({ minPrice, maxPrice, + categoryVariants, + selectedVariants, className, }: PriceFilterProps) { + const [searchParams] = useSearchParams(); + const [localMinPrice, setLocalMinPrice] = useState(minPrice); + const [localMaxPrice, setLocalMaxPrice] = useState(maxPrice); + const [localSelectedVariants, setLocalSelectedVariants] = + useState(selectedVariants); + + const handleVariantChange = (variantId: string, checked: boolean) => { + if (checked) { + setLocalSelectedVariants((prev) => [...prev, variantId]); + } else { + setLocalSelectedVariants((prev) => prev.filter((id) => id !== variantId)); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const form = e.target as HTMLFormElement; + const formData = new FormData(form); + + // Agregar variantes seleccionadas al FormData + localSelectedVariants.forEach((variantId) => { + formData.append("variants", variantId); + }); + + // Crear nueva URL con parámetros + const newSearchParams = new URLSearchParams(); + for (const [key, value] of formData.entries()) { + newSearchParams.append(key, value.toString()); + } + + // Mantener otros parámetros existentes + for (const [key, value] of searchParams.entries()) { + if (!["minPrice", "maxPrice", "variants"].includes(key)) { + newSearchParams.append(key, value); + } + } + + window.location.search = newSearchParams.toString(); + }; + return ( -
-
- Precio -
-
- - + + {/* ✅ EXISTENTE: Filtro por precio */} +
+ +
+ -
-
- - setLocalMinPrice(e.target.value)} + /> + setLocalMaxPrice(e.target.value)} />
-
- -
+ + {/* ✅ NUEVO: Filtro por variantes */} + {categoryVariants.length > 0 && ( +
+ +
+ {categoryVariants.map((variant) => ( + + ))} +
+
+ )} + +
+ + +
+ +
); } diff --git a/src/routes/category/components/product-card/index.tsx b/src/routes/category/components/product-card/index.tsx index a6abe33..be163f7 100644 --- a/src/routes/category/components/product-card/index.tsx +++ b/src/routes/category/components/product-card/index.tsx @@ -2,11 +2,58 @@ import { Link } from "react-router"; import type { Product } from "@/models/product.model"; +interface CategoryVariant { + id: number; + label: string; + value: string; +} + interface ProductCardProps { - product: Product; + product: Product & { + minPricexProduct?: number; + maxPricexProduct?: number; + hasVariants?: boolean; + categoryVariants?: CategoryVariant[]; + }; } export function ProductCard({ product }: ProductCardProps) { + // ✅ NUEVO: Calcular display de precio + const renderPrice = () => { + if ( + product.hasVariants && + product.minPricexProduct && + product.maxPricexProduct + ) { + if (product.minPricexProduct === product.maxPricexProduct) { + return `S/${product.minPricexProduct.toFixed(2)}`; + } + return `S/${product.minPricexProduct.toFixed( + 2 + )} - S/${product.maxPricexProduct.toFixed(2)}`; + } + return `S/${product.price.toFixed(2)}`; + }; + + const renderVariants = () => { + if (!product.hasVariants || !product.categoryVariants?.length) { + return null; + } + + return ( +
+ {product.categoryVariants?.slice(0, 3).map((variant) => ( + + {variant.label} + + ))} +
+ ); + }; + return (

{product.title}

{product.description}

-

S/{product.price}

+ +
+
+

{renderPrice()}

+ {renderVariants()} +
+
{product.isOnSale && ( diff --git a/src/routes/category/index.tsx b/src/routes/category/index.tsx index 7c0aef5..beca7d9 100644 --- a/src/routes/category/index.tsx +++ b/src/routes/category/index.tsx @@ -3,7 +3,11 @@ import { redirect } from "react-router"; import { Container } from "@/components/ui"; import { isValidCategorySlug, type Category } from "@/models/category.model"; import type { Product } from "@/models/product.model"; -import { getCategoryBySlug } from "@/services/category.service"; +import { calculateFinalPrice } from "@/services/cart.service"; +import { + getCategoryBySlug, + getCategoryWithVariants, +} from "@/services/category.service"; import { getProductsByCategorySlug } from "@/services/product.service"; import { PriceFilter } from "./components/price-filter"; @@ -21,6 +25,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { const url = new URL(request.url); const minPrice = url.searchParams.get("minPrice") || ""; const maxPrice = url.searchParams.get("maxPrice") || ""; + const selectedVariants = url.searchParams.getAll("variants") || []; try { const [category, products] = await Promise.all([ @@ -28,37 +33,140 @@ export async function loader({ params, request }: Route.LoaderArgs) { getProductsByCategorySlug(categorySlug), ]); - const filterProductsByPrice = ( - products: Product[], - minPrice: string, - maxPrice: string - ) => { - const min = minPrice ? parseFloat(minPrice) : 0; - const max = maxPrice ? parseFloat(maxPrice) : Infinity; - return products.filter( - (product) => product.price >= min && product.price <= max - ); - }; + const categoryWithVariants = await getCategoryWithVariants(category.id); + const categoryVariants = categoryWithVariants?.categoryVariants || []; + + const productxVariants = await Promise.all( + products.flatMap(async (product) => { + if (categoryVariants.length === 0) { + // Si no hay variantes, solo precio base + return [ + { + ...product, + categoryVariant: null, + finalPrice: product.price, + minPricexProduct: product.price, + maxPricexProduct: product.price, + }, + ]; + } + + // Crear un producto por cada variante + const productVariants = await Promise.all( + categoryVariants.map(async (variant) => { + const finalPrice = await calculateFinalPrice( + product.id, + variant.id + ); + return { + ...product, + categoryVariant: variant, + finalPrice, + minPricexProduct: finalPrice, + maxPricexProduct: finalPrice, + }; + }) + ); + + // Calcular rango de precios por producto + const prices = productVariants.map((pv) => pv.finalPrice); + const minPricexProduct = Math.min(...prices); + const maxPricexProduct = Math.max(...prices); - const filteredProducts = filterProductsByPrice( - products, + return productVariants.map((pv) => ({ + ...pv, + minPricexProduct, + maxPricexProduct, + })); + }) + ); + + // ✅ ACTUALIZADO: Filtrar por precio y variantes + const filteredProducts = filterProductsByPriceAndVariants( + productxVariants.flat(), minPrice, - maxPrice + maxPrice, + selectedVariants ); + // ✅ NUEVO: Agrupar productos únicos con sus rangos de precio + const uniqueProducts = products + .map((product) => { + const productVariants = filteredProducts.filter( + (pv) => pv.id === product.id + ); + if (productVariants.length === 0) return null; + + const prices = productVariants.map((pv) => pv.finalPrice); + const minPricexProduct = Math.min(...prices); + const maxPricexProduct = Math.max(...prices); + + return { + ...product, + minPricexProduct, + maxPricexProduct, + hasVariants: categoryVariants.length > 0, + categoryVariants: productVariants + .map((pv) => pv.categoryVariant) + .filter( + (v): v is { id: number; label: string; value: string } => + v !== null + ), + }; + }) + .filter(Boolean); + return { category, - products: filteredProducts, + products: uniqueProducts, + categoryVariants, minPrice, maxPrice, + selectedVariants, }; } catch (e) { throw new Response("Error loading category: " + e, { status: 500 }); } } +function filterProductsByPriceAndVariants( + productxVariants: (Product & { + categoryVariant: { id: number; label: string; value: string } | null; + finalPrice: number; + minPricexProduct: number; + maxPricexProduct: number; + })[], + minPrice: string, + maxPrice: string, + selectedVariants: string[] +) { + const min = minPrice ? parseFloat(minPrice) : 0; + const max = maxPrice ? parseFloat(maxPrice) : Infinity; + + return productxVariants.filter((productVariant) => { + // Filtro por precio + const priceInRange = + productVariant.finalPrice >= min && productVariant.finalPrice <= max; + + // Filtro por variantes (si hay variantes seleccionadas) + const variantMatch = + selectedVariants.length === 0 || + !productVariant.categoryVariant || + selectedVariants.includes(productVariant.categoryVariant.id.toString()); + + return priceInRange && variantMatch; + }); +} + export default function Category({ loaderData }: Route.ComponentProps) { - const { category, products, minPrice, maxPrice } = loaderData; + const { + category, + products, + categoryVariants, + minPrice, + maxPrice, + selectedVariants, + } = loaderData; return ( <> @@ -78,12 +186,16 @@ export default function Category({ loaderData }: Route.ComponentProps) {
- {products.map((product) => ( - - ))} + {products + .filter((product) => product !== null) + .map((product) => ( + + ))}
diff --git a/src/routes/product/index.tsx b/src/routes/product/index.tsx index 2a4695b..394c3f0 100644 --- a/src/routes/product/index.tsx +++ b/src/routes/product/index.tsx @@ -15,6 +15,7 @@ interface CategoryVariant { id: number; value: string; label: string; + priceModifier: number; } export async function loader({ params }: Route.LoaderArgs) { @@ -38,9 +39,21 @@ export default function Product({ loaderData }: Route.ComponentProps) { const [variants, setVariants] = useState([]); const [selectedVariant, setSelectedVariant] = useState(null); + const [finalPrice, setFinalPrice] = useState(0); + + const calculateDisplayPrice = (variant: CategoryVariant | null): number => { + const basePrice = product?.price || 0; + const modifier = variant?.priceModifier || 0; + return basePrice + modifier; + }; // Cargar variantes si la categoría las tiene useEffect(() => { + if (!product) return; + + // Establecer precio inicial + setFinalPrice(product.price); + if ( !categoryWithVariants?.hasVariants || !categoryWithVariants.categoryVariants.length @@ -55,11 +68,20 @@ export default function Product({ loaderData }: Route.ComponentProps) { id: variant.id, value: variant.value, label: variant.label, + priceModifier: variant.priceModifier, })); setVariants(mappedVariants); - setSelectedVariant(mappedVariants[0] || null); - }, [categoryWithVariants]); + + const firstVariant = mappedVariants[0] || null; + setSelectedVariant(firstVariant); + setFinalPrice(calculateDisplayPrice(firstVariant)); + }, [categoryWithVariants, product]); + + const handleVariantChange = (variant: CategoryVariant) => { + setSelectedVariant(variant); + setFinalPrice(calculateDisplayPrice(variant)); + }; if (!product) { return ; @@ -89,7 +111,7 @@ export default function Product({ loaderData }: Route.ComponentProps) {

{product.title}

-

S/{product.price}

+

S/{finalPrice.toFixed(2)}

{product.description}

@@ -112,7 +134,7 @@ export default function Product({ loaderData }: Route.ComponentProps) { : "outline" } size="default" - onClick={() => setSelectedVariant(variant)} + onClick={() => handleVariantChange(variant)} className={cn( "h-10 px-4 transition-all duration-200", selectedVariant?.id === variant.id diff --git a/src/routes/product/product.loader.test.ts b/src/routes/product/product.loader.test.ts index 7c4e611..6926d50 100644 --- a/src/routes/product/product.loader.test.ts +++ b/src/routes/product/product.loader.test.ts @@ -1,4 +1,3 @@ -import { Decimal } from "decimal.js"; import { describe, expect, it, vi } from "vitest"; import { createTestProduct } from "@/lib/utils.tests"; @@ -7,7 +6,7 @@ import * as productService from "@/services/product.service"; import { loader } from "."; -import type { CategorySlug } from "generated/prisma/client"; +import type { CategorySlug } from "generated/prisma/enums"; // Mock the product service vi.mock("@/services/product.service", () => ({ @@ -33,39 +32,48 @@ describe("Product loader", () => { it("returns a product when it exists", async () => { const mockProduct = createTestProduct({ categoryId: 1 }); - const mockCategoryWithVariants = { - id: 1, - title: "Polos", - slug: "POLOS" as CategorySlug, - hasVariants: true, - imgSrc: "POLOS", - alt: "POLOS", - description: "POLOS", - createdAt: new Date(), - updatedAt: new Date(), - categoryVariants: [ - { - id: 1, - value: "small", - label: "S", - priceModifier: new Decimal(0.0), - categoryId: 1, - createdAt: new Date(), - updatedAt: new Date(), - sortOrder: 1, - }, - { - id: 2, - value: "medium", - label: "M", - priceModifier: new Decimal(0.0), - categoryId: 1, - createdAt: new Date(), - updatedAt: new Date(), - sortOrder: 2, - }, - ], - }; + const mockCategoryWithVariants: categoryService.CategoryWithVariantsTransformed = + { + id: 1, + title: "Test Category", + slug: "polos" as CategorySlug, + hasVariants: true, + description: "Test category description", + createdAt: new Date(), + updatedAt: new Date(), + categoryVariants: [ + { + id: 1, + value: "small", + label: "S", + priceModifier: 0, // ← CORREGIDO: number en lugar de Decimal + categoryId: 1, + createdAt: new Date(), + updatedAt: new Date(), + sortOrder: 1, + }, + { + id: 2, + value: "medium", + label: "M", + priceModifier: 2, // ← CORREGIDO: number en lugar de Decimal + categoryId: 1, + createdAt: new Date(), + updatedAt: new Date(), + sortOrder: 2, + }, + { + id: 3, + value: "large", + label: "L", + priceModifier: 3, // ← CORREGIDO: number en lugar de Decimal + categoryId: 1, + createdAt: new Date(), + updatedAt: new Date(), + sortOrder: 3, + }, + ], + }; mockGetProductById.mockResolvedValue(mockProduct); mockGetCategoryWithVariants.mockResolvedValue(mockCategoryWithVariants); diff --git a/src/services/cart.service.ts b/src/services/cart.service.ts index 41ca079..18cd2b5 100644 --- a/src/services/cart.service.ts +++ b/src/services/cart.service.ts @@ -218,7 +218,7 @@ export async function alterQuantityCartItem( return updatedCart; } -async function calculateFinalPrice( +export async function calculateFinalPrice( productId: number, categoryVariantId: number | null ): Promise { diff --git a/src/services/category.service.ts b/src/services/category.service.ts index 678bd8e..4faba1c 100644 --- a/src/services/category.service.ts +++ b/src/services/category.service.ts @@ -1,20 +1,72 @@ import { prisma } from "@/db/prisma"; -import { - type Category, - type CategorySlug, - type Prisma, -} from "@/../generated/prisma/client"; +import { type Category, type CategorySlug } from "@/../generated/prisma/client"; -type CategoryWithVariants = Prisma.CategoryGetPayload<{ - include: { categoryVariants: true }; -}>; +export type CategoryWithVariantsInfo = { + id: number; + title: string; + slug: CategorySlug; + hasVariants: boolean; + description: string | null; + createdAt: Date; + updatedAt: Date; + categoryVariants: { + id: number; + value: string; + label: string; + priceModifier: number; + sortOrder: number; + }[]; +}; + +export type CategoryWithVariantsTransformed = { + id: number; + title: string; + slug: CategorySlug; + hasVariants: boolean; + description: string | null; + createdAt: Date; + updatedAt: Date; + categoryVariants: { + id: number; + value: string; + label: string; + priceModifier: number; // ← Convertido a number + categoryId: number; + sortOrder: number; + createdAt: Date; + updatedAt: Date; + }[]; +}; export async function getAllCategories(): Promise { const categories = await prisma.category.findMany(); return categories; } +export async function getAllCategoriesWithVariants(): Promise< + CategoryWithVariantsInfo[] +> { + const categories = await prisma.category.findMany({ + include: { + categoryVariants: { + orderBy: { sortOrder: "asc" }, + }, + }, + }); + + return categories.map((category) => ({ + ...category, + categoryVariants: category.categoryVariants.map((variant) => ({ + id: variant.id, + value: variant.value, + label: variant.label, + priceModifier: Number(variant.priceModifier), + sortOrder: variant.sortOrder, + })), + })); +} + export async function getCategoryBySlug(slug: CategorySlug): Promise { const category = await prisma.category.findUnique({ where: { slug }, @@ -29,7 +81,7 @@ export async function getCategoryBySlug(slug: CategorySlug): Promise { export async function getCategoryWithVariants( categoryId: number -): Promise { +): Promise { const category = await prisma.category.findUnique({ where: { id: categoryId }, include: { @@ -39,5 +91,14 @@ export async function getCategoryWithVariants( }, }); - return category; + if (!category) { + return null; + } + return { + ...category, + categoryVariants: category.categoryVariants.map((variant) => ({ + ...variant, + priceModifier: Number(variant.priceModifier), + })), + }; } diff --git a/src/services/chat-system-prompt.ts b/src/services/chat-system-prompt.ts index 30401b0..9f3d572 100644 --- a/src/services/chat-system-prompt.ts +++ b/src/services/chat-system-prompt.ts @@ -1,9 +1,9 @@ import type { CartWithItems } from "@/models/cart.model"; -import type { Category } from "@/models/category.model"; import type { Product } from "@/models/product.model"; +import type { CategoryWithVariantsInfo } from "@/services/category.service"; interface SystemPromptConfig { - categories: Category[]; + categories: CategoryWithVariantsInfo[]; products: Product[]; userCart?: CartWithItems | null; } @@ -48,6 +48,50 @@ ${userCart.items - Evita recomendar productos que ya están en el carrito - Ofrece bundles o combos cuando sea apropiado - Menciona que puedes ver lo que ya tienen seleccionado y personalizar las sugerencias +` + : ""; + + const variantsSection = categories + .filter((cat) => cat.hasVariants && cat.categoryVariants.length > 0) + .map((category) => { + const variantsByCategory = category.categoryVariants + .sort((a, b) => a.sortOrder - b.sortOrder) + .map((variant) => { + const priceInfo = + variant.priceModifier !== 0 + ? ` (${variant.priceModifier > 0 ? "+" : ""}S/${ + variant.priceModifier + })` + : ""; + return ` - **${variant.label}**${priceInfo}`; + }) + .join("\n"); + + return ` +### ${category.title}: +${variantsByCategory}`; + }) + .join("\n"); + + const variantsKnowledge = variantsSection + ? ` +## 📏 OPCIONES DISPONIBLES POR CATEGORÍA: + +**IMPORTANTE**: Conoces EXACTAMENTE qué variantes están disponibles para cada categoría: + +${variantsSection} + +### Cómo responder preguntas sobre variantes: +- **"¿Tienen stickers de 10×10 cm?"** → Consulta la lista de variantes de Stickers y confirma si está disponible +- **"¿Qué tallas tienen en polos?"** → Lista las opciones disponibles en la categoría Polos +- **"¿El precio cambia por tamaño?"** → Explica los modificadores de precio si existen +- **"¿Cuánto cuesta la talla L?"** → Precio base + modificador de la variante L + +### Reglas para variantes: +- **SÉ ESPECÍFICO**: Si preguntan por una variante, confirma si está disponible o sugiere alternativas +- **MENCIONA PRECIOS**: Si hay modificadores, explica el precio final +- **SUGIERE OPCIONES**: Si no tienen lo que buscan, ofrece lo más cercano +- **ENLAZA PRODUCTOS**: Siempre incluye el link al producto específico ` : ""; @@ -74,6 +118,7 @@ ${categories (cat) => ` **${cat.title}** (${cat.slug}) - Descripción: ${cat.description} +- Tiene variantes: ${cat.hasVariants ? "Sí" : "No"} - Link: [Ver categoría](/category/${cat.slug}) ` ) @@ -83,9 +128,18 @@ ${categories ${products .map((product) => { const category = categories.find((c) => c.id === product.categoryId); + const hasVariants = category?.hasVariants; + const variantInfo = hasVariants + ? ` | Opciones: ${category?.categoryVariants + .map((v) => v.label) + .join(", ")}` + : ""; + return ` **${product.title}** -- 💰 Precio: S/${product.price}${product.isOnSale ? " ⚡ ¡EN OFERTA!" : ""} +- 💰 Precio: S/${product.price}${ + product.isOnSale ? " ⚡ ¡EN OFERTA!" : "" + }${variantInfo} - 📝 Descripción: ${product.description} - 🏷️ Categoría: ${category?.title || "Sin categoría"} - ✨ Características: ${product.features.join(", ")} @@ -98,6 +152,8 @@ ${salesSection} ${cartSection} +${variantsKnowledge} + ## INSTRUCCIONES PARA RESPUESTAS: - **MANTÉN LAS RESPUESTAS BREVES Y DIRECTAS** (máximo 2-3 oraciones) - Ve directo al punto, sin explicaciones largas @@ -111,18 +167,12 @@ ${cartSection} - Siempre incluye al menos un enlace de producto en tu respuesta - Personaliza según el contexto (principiante, experto, stack específico) - Termina con una pregunta directa o llamada a la acción +- **PARA VARIANTES**: Confirma disponibilidad exacta y menciona precios modificados si aplica -## ESTRATEGIAS DE VENTA: -- **Cross-selling temático**: Si tienen React en el carrito, sugiere PRIMERO otros productos React/frontend -- **Cross-selling por categoría**: Prioriza productos de la misma categoría que los del carrito -- **Cross-selling tecnológico**: Si tienen backend, sugiere otras tecnologías backend relacionadas -- **Upselling**: Recomienda versiones premium cuando sea apropiado -- **Urgencia**: Menciona ofertas limitadas o productos populares -- **Beneficios**: Enfócate en cómo el producto ayuda al desarrollador -- **Social proof**: "Este es uno de nuestros productos más populares entre developers" -- **Personalización**: Adapta según el nivel o tecnología mencionada -- **Storytelling**: Usa curiosidades técnicas o historias para conectar emocionalmente con productos -- **Oportunidades educativas**: Si preguntan sobre tecnologías que tienes en productos, educa brevemente y conecta con la venta +## EJEMPLOS DE RESPUESTAS SOBRE VARIANTES: +- **"¿Tienen stickers de 10×10 cm?"** → "¡Sí! Tenemos [Stickers JavaScript](/products/X) en tamaño 10×10 cm por S/8.00 (precio base S/5.00 + S/3.00). ¿Te interesa alguna tecnología específica?" +- **"¿Qué tallas tienen?"** → "Nuestros polos vienen en S, M y L. La talla M y L tienen un costo adicional de S/2.00. ¿Cuál prefieres?" +- **"¿Cuánto cuesta talla L?"** → "El [Polo React](/products/1) en talla L cuesta S/23.00 (precio base S/20.00 + S/3.00 por talla L). ¡Es nuestro más popular! ¿Lo agregamos?" ## LÓGICA DE RECOMENDACIONES BASADAS EN CARRITO: **Si el usuario tiene productos en su carrito y pide recomendaciones:** @@ -136,19 +186,6 @@ ${cartSection} - Backend → Node.js, Python, Docker - Frontend → React, JavaScript, CSS -## MANEJO DE PREGUNTAS TÉCNICAS RELACIONADAS: -Cuando te pregunten sobre tecnologías que tenemos en productos (React, Docker, JavaScript, etc.): -1. **Responde brevemente** la pregunta técnica/histórica -2. **Conecta inmediatamente** con el producto relacionado -3. **Genera interés** usando esa información como gancho de venta -4. **Ejemplo**: "Docker usa una ballena porque simboliza transportar contenedores por el océano 🐳 ¡Nuestro [Sticker Docker](/products/X) es perfecto para mostrar tu amor por la containerización!" - -## RESPUESTAS A PREGUNTAS COMUNES: -- **Tallas**: "Nuestros polos vienen en tallas S, M, L, XL. ¿Cuál prefieres?" -- **Envío**: "Manejamos envío a todo el país. ¿A qué ciudad lo necesitas?" -- **Materiales**: "Usamos algodón 100% de alta calidad para máxima comodidad" -- **Cuidado**: "Para que dure más, lava en agua fría y evita la secadora" - ## EJEMPLOS DE RESPUESTAS CORTAS: - "¡Te recomiendo el [Polo React](/products/1) por S/20.00! 🚀 ¿Qué talla necesitas?" - "Perfecto para backend: [Polo Backend Developer](/products/3) ⚡ **EN OFERTA** por S/25.00. ¿Te animas?" diff --git a/src/services/chat.service.ts b/src/services/chat.service.ts index b44fc5c..af711c1 100644 --- a/src/services/chat.service.ts +++ b/src/services/chat.service.ts @@ -2,7 +2,7 @@ import { type Chat, GoogleGenAI } from "@google/genai"; import dotenv from "dotenv"; import { getOrCreateCart } from "./cart.service"; -import { getAllCategories } from "./category.service"; +import { getAllCategoriesWithVariants } from "./category.service"; import { generateSystemPrompt } from "./chat-system-prompt"; import { getAllProducts } from "./product.service"; @@ -23,7 +23,7 @@ export async function sendMessage( if (!chats[sessionId]) { // Obtener datos de la base de datos const [categories, products] = await Promise.all([ - getAllCategories(), + getAllCategoriesWithVariants(), getAllProducts(), ]); From 927b66623edfa447490f27c8ce27196106672ef2 Mon Sep 17 00:00:00 2001 From: Jota Date: Fri, 29 Aug 2025 12:23:24 -0500 Subject: [PATCH 7/9] docs(readme): se documenta el proyecto en el archivo README.md. --- README.md | 290 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 252 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 74872fd..fb7c649 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,264 @@ -# React + TypeScript + Vite +# 🛒 Full Stock Frontend -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +> Tienda online especilizada en productos como polos, tazas y stickers con tematica para desarrolladores, incluye chatbot AI integrado. -Currently, two official plugins are available: +### ✨ Características principales -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh +- 🛍️ **Catálogo de productos** con variantes (tallas, tamaños) y precios dinámicos +- 🤖 **Chatbot AI** que conoce inventario, precios y puede recomendar productos +- 🛒 **Carrito de compras** persistente con gestión de sesiones +- 📱 **Diseño responsive** optimizado para móviles y desktop +- ⚡ **Server-Side Rendering** con React Router v7 +- 🔍 **Filtros avanzados** por categoría, precio y variantes +- 💳 **Gestión de precios** con modificadores por variante -## Expanding the ESLint configuration +## 🛠️ Stack Tecnológico -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: +### **Frontend** -- Configure the top-level `parserOptions` property like this: +- **Framework**: React 19.1 con TypeScript +- **Routing**: React Router v7 (con SSR) +- **Styling**: Tailwind CSS + shadcn/ui +- **Build Tool**: Vite 6.0.1 +- **State**: React useState/useEffect + URL state + +### **Backend** + +- **Runtime**: Node.js (integrado con React Router) +- **Database**: PostgreSQL con Prisma ORM +- **AI**: Google Gemini AI para chatbot +- **Session**: Cookie-based sessions + +### **Dev Tools** + +- **Testing**: Vitest + React Testing Library +- **Linting**: ESLint + TypeScript ESLint +- **Formatting**: Prettier +- **Git Hooks**: Husky + lint-staged + +## 🚀 Instalación + +### **Prerrequisitos** + +- Node.js 18+ +- npm/yarn/pnpm +- PostgreSQL database +- Google AI API Key + +### **1. Clonar repositorio** + +```bash +git clone git@github.com:codeableorg/fullstock-frontend.git +cd fullstock-frontend +``` + +### **2. Instalar dependencias** + +```bash +npm install +# o +yarn install +# o +pnpm install +``` + +### **3. Configurar variables de entorno** + +```bash +# Crear archivo .env +cp .env.example .env +``` + +Completar con tus valores: + +```env +# Database +DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?schema=public" + +# Google AI +GOOGLE_API_KEY="tu_google_ai_api_key" +``` + +### **4. Configurar base de datos** + +```bash +# Generar cliente Prisma +npx prisma generate + +# Ejecutar migraciones +npx prisma migrate dev + +# (Opcional) Seed con datos de ejemplo +npx prisma db seed +``` + +### **5. Ejecutar en desarrollo** + +```bash +npm run dev +# o +yarn dev +# o +pnpm dev +``` + +🎉 **¡Listo!** Abre [http://localhost:5173](http://localhost:5173) + +## 📁 Estructura del Proyecto + +``` +fullstock-frontend/ +├── 📁 prisma/ # Esquemas y migraciones de DB +│ ├── schema.prisma # Modelo de datos +│ └── migrations/ # Historial de migraciones +├── 📁 src/ +│ ├── 📁 components/ # Componentes reutilizables +│ │ ├── ui/ # Componentes base (shadcn/ui) +│ │ └── layout/ # Layouts y navegación +│ ├── 📁 routes/ # Páginas y rutas (React Router v7) +│ │ ├── _index.tsx # Homepage +│ │ ├── products/ # Páginas de productos +│ │ ├── category/ # Páginas de categorías +│ │ └── cart/ # Carrito de compras +│ ├── 📁 services/ # Lógica de negocio +│ │ ├── product.service.ts # Gestión de productos +│ │ ├── cart.service.ts # Gestión de carrito +│ │ └── chat.service.ts # Chatbot AI +│ ├── 📁 models/ # Tipos TypeScript +│ ├── 📁 lib/ # Utilidades +│ └── 📁 db/ # Configuración de Prisma +├── 📁 public/ # Assets estáticos +├── 📄 package.json # Dependencias y scripts +├── 📄 tailwind.config.js # Configuración de Tailwind +├── 📄 vite.config.ts # Configuración de Vite +└── 📄 README.md # Este archivo +``` + +## 🎮 Scripts Disponibles + +```bash +# Desarrollo +npm run dev # Servidor de desarrollo +npm run build # Build para producción +npm run start # Servidor de producción +npm run preview # Preview del build + +# Base de datos +npm run db:generate # Generar cliente Prisma +npm run db:migrate # Ejecutar migraciones +npm run db:seed # Llenar con datos de ejemplo +npm run db:studio # Abrir Prisma Studio + +# Testing +npm run test # Ejecutar tests +npm run test:watch # Tests en modo watch +npm run test:coverage # Coverage report + +# Code Quality +npm run lint # Ejecutar ESLint +npm run lint:fix # Arreglar errores de lint +npm run type-check # Verificar tipos TypeScript +``` + +## 🔧 Configuración Adicional + +### **Google AI Setup** + +1. Ve a [Google AI Studio](https://aistudio.google.com/) +2. Crea un nuevo proyecto +3. Genera una API Key +4. Agrégala a tu `.env` como `GOOGLE_API_KEY` + +## 🤖 Chatbot Features + +El chatbot AI tiene conocimiento completo de: + +- **Inventario**: Productos disponibles, precios, variantes +- **Categorías**: Polos, Stickers, Tazas, etc. +- **Variantes**: Tallas (S, M, L), Tamaños (3×3, 5×5, 10×10 cm) +- **Precios**: Precios base + modificadores por variante +- **Carrito**: Productos que ya tiene el usuario + +### Ejemplos de interacción: -```js -export default tseslint.config({ - languageOptions: { - // other options... - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - }, -}) ``` +Usuario: "¿Tienen stickers de 10×10 cm?" +Bot: "¡Sí! Tenemos Stickers JavaScript en 10×10 cm por S/8.00. ¿Te interesa?" + +Usuario: "¿Qué tallas tienen en polos?" +Bot: "Nuestros polos vienen en S (S/20.00), M (S/22.00) y L (S/23.00)." +``` + +## 🧪 Testing -- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` -- Optionally add `...tseslint.configs.stylisticTypeChecked` -- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: +```bash +# Ejecutar todos los tests +npm run test -```js -// eslint.config.js -import react from 'eslint-plugin-react' +# Tests específicos +npm run test src/services/chat.service.test.ts +npm run test src/routes/product/ -export default tseslint.config({ - // Set the react version - settings: { react: { version: '18.3' } }, - plugins: { - // Add the react plugin - react, - }, - rules: { - // other rules... - // Enable its recommended rules - ...react.configs.recommended.rules, - ...react.configs['jsx-runtime'].rules, - }, -}) +# Coverage +npm run test:coverage ``` + +### **Estructura de tests** + +- Unit tests para services +- Integration tests para loaders +- Component tests para UI +- E2E tests con Playwright + +## 🚀 Deployment + +### **Docker** + +```dockerfile +# Dockerfile incluido en el proyecto +docker build -t fullstock . +docker run -p 3000:3000 fullstock +``` + +## 🤝 Contribuir + +1. **Fork** el repositorio +2. **Crea** una branch: `git checkout -b feature/nueva-funcionalidad` +3. **Commit** tus cambios: `git commit -m 'feat: add nueva funcionalidad'` +4. **Push** a la branch: `git push origin feature/nueva-funcionalidad` +5. **Abre** un Pull Request + +### **Convenciones de commits** + +Usamos [Conventional Commits](https://www.conventionalcommits.org/): + +```bash +feat(chat): add variant knowledge to system prompt +fix(product): resolve price calculation for variants +docs(readme): update installation instructions +``` + +## 📝 Roadmap + +- [ ] 🔐 Autenticación de usuarios +- [ ] 💳 Integración con pasarelas de pago Culqi +- [ ] 🌙 Modo oscuro + +## 📄 Licencia + +Este proyecto está bajo la licencia [MIT](./LICENSE). + +## 👨‍💻 Autor + +**Jota(Jhonattan Saldaña Camacho)** + +- GitHub: [@tuusuario](https://github.com/jhonattan) +- LinkedIn: [Tu LinkedIn](https://www.linkedin.com/in/jhonattansaldana/) +- Email: jsaldana999@gmail.com + +## 🙏 Agradecimientos + +- [shadcn/ui](https://ui.shadcn.com/) por los componentes base +- [React Router](https://reactrouter.com/) por el framework fullstack +- [Google AI](https://ai.google.com/) por la API de Gemini +- [Vercel](https://vercel.com/) por el hosting gratuito From 0c385ee4dd6570440d377a25dfdcdca164f3738d Mon Sep 17 00:00:00 2001 From: Jota Date: Fri, 29 Aug 2025 12:40:36 -0500 Subject: [PATCH 8/9] =?UTF-8?q?chore:=20limpieza=20y=20actualizaci=C3=B3n?= =?UTF-8?q?=20documentaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fb7c649..5992c20 100644 --- a/README.md +++ b/README.md @@ -252,8 +252,8 @@ Este proyecto está bajo la licencia [MIT](./LICENSE). **Jota(Jhonattan Saldaña Camacho)** -- GitHub: [@tuusuario](https://github.com/jhonattan) -- LinkedIn: [Tu LinkedIn](https://www.linkedin.com/in/jhonattansaldana/) +- GitHub: [@jhonattan](https://github.com/jhonattan) +- LinkedIn: [jhonattansaldana](https://www.linkedin.com/in/jhonattansaldana/) - Email: jsaldana999@gmail.com ## 🙏 Agradecimientos From 6de7ffd35c2d3fb7c8973494b07e188335f3e806 Mon Sep 17 00:00:00 2001 From: Jota Date: Sat, 6 Sep 2025 14:56:54 -0500 Subject: [PATCH 9/9] =?UTF-8?q?refactor:=20mejoramos=20los=20type=20para?= =?UTF-8?q?=20la=20carpeta=20modelo=20usando=20buenas=20practicas=20para?= =?UTF-8?q?=20tener=20una=20aplicaci=C3=B3n=20mas=20mantenible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/models/cart.model.ts | 35 +++++++++++++++-------------------- src/models/category.model.ts | 6 ++---- src/models/order.model.ts | 5 +++-- src/models/utils.model.ts | 3 +++ 4 files changed, 23 insertions(+), 26 deletions(-) create mode 100644 src/models/utils.model.ts diff --git a/src/models/cart.model.ts b/src/models/cart.model.ts index cf1d207..3e497de 100644 --- a/src/models/cart.model.ts +++ b/src/models/cart.model.ts @@ -1,49 +1,44 @@ -import { type Product } from "./product.model"; +import { type Product } from "./product.model"; import type { CategoryVariant } from "./category.model"; import type { Cart as PrismaCart } from "@/../generated/prisma/client"; +import type { Nullable } from "./utils.model"; export type Cart = PrismaCart; +type productInfo = Pick< + Product, + "id" | "title" | "imgSrc" | "alt" | "price" | "isOnSale" +>; + export type CartItem = { id: number; cartId: number; productId: number; - categoryVariantId: number | null; + categoryVariantId: Nullable; quantity: number; - finalPrice: number; // ← number, no Decimal + finalPrice: number; createdAt: Date; updatedAt: Date; - // Campos adicionales transformados - product: Pick< - Product, - "id" | "title" | "imgSrc" | "alt" | "price" | "isOnSale" - >; - categoryVariant?: CategoryVariant | null; + product: productInfo; + categoryVariant?: Nullable; }; export interface CartItemInput { productId: Product["id"]; quantity: number; - categoryVariantId: number | null; - variantInfo: string | null; + categoryVariantId: Nullable; + variantInfo: Nullable; title: Product["title"]; price: Product["price"]; imgSrc: Product["imgSrc"]; } -// Tipo para representar un producto simplificado en el carrito - -export type CartProductInfo = Pick< - Product, - "id" | "title" | "imgSrc" | "alt" | "price" | "isOnSale" ->; - // Tipo para representar un item de carrito con su producto export type CartItemWithProduct = { - product: CartProductInfo; + product: productInfo; quantity: number; - categoryVariantId: number | null; + categoryVariantId: Nullable; finalPrice: number; }; diff --git a/src/models/category.model.ts b/src/models/category.model.ts index e228152..598a234 100644 --- a/src/models/category.model.ts +++ b/src/models/category.model.ts @@ -1,13 +1,11 @@ import type { Category as PrismaCategory } from "@/../generated/prisma/client"; +import type { CategoryVariant as PrismaCategoryVariant } from "@/../generated/prisma/client"; export const VALID_SLUGS = ["polos", "stickers", "tazas"] as const; export type Category = PrismaCategory; -export type CategoryVariant = { - id: number; - label: string; - value: string; +export type CategoryVariant = Omit & { priceModifier: number; }; diff --git a/src/models/order.model.ts b/src/models/order.model.ts index d0fc837..5b8942f 100644 --- a/src/models/order.model.ts +++ b/src/models/order.model.ts @@ -2,6 +2,7 @@ import type { Order as PrismaOrder, OrderItem as PrismaOrderItem, } from "@/../generated/prisma/client"; +import type { Nullable } from "./utils.model"; export type OrderDetails = Pick< PrismaOrder, @@ -29,10 +30,10 @@ export type Order = Omit & { export interface OrderItemInput { productId: number; - categoryVariantId?: number | null; + categoryVariantId?: Nullable; quantity: number; title: string; - variantInfo?: string | null; // ← NUEVO + variantInfo?: Nullable; price: number; imgSrc: string; } diff --git a/src/models/utils.model.ts b/src/models/utils.model.ts new file mode 100644 index 0000000..26102a1 --- /dev/null +++ b/src/models/utils.model.ts @@ -0,0 +1,3 @@ +export type Nullable = T | null; +export type Optional = T | undefined; +export type Maybe = T | null | undefined;