diff --git a/README.md b/README.md index 74872fd..1b13f0c 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,278 @@ -# React + TypeScript + Vite +# 🛍️ Soporte para variantes de productos (Nueva funcionalidad) -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +## 📌 Contexto general -Currently, two official plugins are available: +- Nuestro objetivo es consolidar productos con variantes (en vez de crear productos separados por cada opción). +- Polos → Variantes por "talla": Small, Medium, Large (el precio NO cambia según la talla). +- Stickers → Variantes por "tamaño": 3x3cm, 5x5cm, 10x10cm (el precio SÍ cambia según el tamaño). +- Tazas → Sin variantes (se mantiene tal como esta). -- [@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 +--- -## Expanding the ESLint configuration +## 📖 Justificación del diseño UI (PLP vs PDP) -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: +### ¿Por qué mostrar variantes en la PDP y no en la PLP? -- Configure the top-level `parserOptions` property like this: +1. **Escaneabilidad y usabilidad en la grilla (PLP)** -```js -export default tseslint.config({ - languageOptions: { - // other options... - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - }, -}) + - La Product Listing Page (PLP) debe ser ligera y fácil de escanear. Incluir selectores de talla en cada tarjeta sobrecarga visualmente, rompe el ritmo de navegación y empeora el rendimiento [1]. + - Mostrar tallas en la PLP también aumenta la carga cognitiva y dificulta comparar rápidamente múltiples productos [1]. + +2. **Complejidad de inventario y performance** + + - Las tallas multiplican combinatorias (ej. precio × talla). Gestionar esta disponibilidad en la PLP incrementa costos de datos, ralentiza el rendimiento (LCP/INP) y genera riesgos de inconsistencias [2]. + - Los grandes e-commerce (Amazon, Mercado Libre, SHEIN) centralizan esta lógica en la PDP para asegurar precisión en stock y reducir errores de compra [3][4]. + +3. **Decisión de compra responsable** + + - La PDP ofrece guías de tallas, reseñas y recomendaciones específicas, ayudando a los usuarios a elegir con menor riesgo de devolución [5]. + - En cambio, seleccionar talla directamente desde la PLP puede inducir a errores de talla y aumentar devoluciones [5]. + +4. **Consistencia en mobile y desktop** + - En dispositivos móviles, los selectores de talla en la PLP ocupan demasiado espacio y reducen la accesibilidad de los tap targets [6]. + - Mantener la lógica en la PDP garantiza una experiencia consistente y más usable en todos los dispositivos [6]. + +--- + +### Beneficios de este enfoque + +✅ **Experiencia de usuario mejorada**: la PLP se mantiene rápida, clara y visualmente ligera. +✅ **Menos errores y devoluciones**: la elección de talla se respalda con guías y reseñas en la PDP. +✅ **Mejor rendimiento**: menos datos cargados en la PLP → scroll y carga más veloces. +✅ **Gestión de catálogo más simple**: evita duplicados y asegura consistencia de inventario. +✅ **Práctica alineada con líderes del mercado**: Amazon, Mercado Libre, SHEIN, Falabella y Ripley siguen este patrón. + +--- + +### Ventajas y Desventajas de centralizar la selección de variantes en la PDP + +- **Ventajas** + + - Favorece la escaneabilidad y usabilidad en la PLP. + - Reduce costos de rendimiento y evita errores de inventario. + - Permite decisiones de compra informadas (guías de talla, reseñas). + - Consistencia cross-platform (desktop y mobile). + +- **Desventajas** + - Un clic adicional para el usuario (pasar de PLP → PDP). + - Puede percibirse como más lento para usuarios avanzados que ya conocen su talla. + - Requiere un buen diseño de PDP (clara, ágil y optimizada). + +--- + +## 📚 Referencias + +[1] Baymard Institute, _Ecommerce UX: Product Lists & Filtering Guidelines_, 2024. [Online]. Available: https://baymard.com +[2] Baymard Institute, _Performance and Loading UX Research_, 2024. [Online]. Available: https://baymard.com +[3] Amazon Seller Central Ireland, _Managing Product Variations_, 2023. [Online]. Available: https://sellercentral.amazon.ie +[4] Mercado Libre, _Guía de publicación con variantes_, 2023. [Online]. Available: https://www.mercadolibre.com.pe/ayuda +[5] SHEIN, _Seller Guidelines – Size Guide and Variations_, 2023. [Online]. Available: https://seller-us.shein.com +[6] Baymard Institute, _Mobile UX Research_, 2024. [Online]. Available: https://baymard.com + +--- + +--- + +## ✨ Cambios principales en el proyecto + +### 🔧 Base de datos (Prisma + Migraciones) + +- **Nuevo modelo `ProductVariant`** con: + - `id`, `productId`, `type` (ej: `"talla"`, `"tamaño"`), `value` (ej: `"Small"`, `"3x3cm"`), `price (Decimal)`, `timestamps`. + - Relación `Product.variants`. +- Ajustes en `CartItem` y `OrderItem` para referenciar `productVariantId` opcional. +- Migraciones para crear tabla `product_variants` + índices/foreign keys. + 📂 Archivos clave: +- [`prisma/schema.prisma`](prisma/schema.prisma) +- [`prisma/migrations`](prisma/migrations) + +--- + +### 🌱 Seed y Datos Iniciales + +- Generación automática de variantes: + - **Polos**: Tallas `S`, `M`, `L` (precio base). + - **Stickers**: Tamaños `3x3`, `5x5`, `10x10` (precio multiplicador). + - **Tazas**: sin variantes. +- Uso de `prisma.productVariant.createMany` para poblar datos. + 📂 Archivos clave: +- [`prisma/initial_data.ts`](prisma/initial_data.ts) +- [`prisma/seed.ts`](prisma/seed.ts) + +--- + +### 🧩 Modelos y Servicios + +- `Product` ahora expone `variants: { id, type, value, price }[]`. +- Servicios (`product.service`, `cart.service`, `order.service`) cargan variantes y mantienen firmas originales. +- Lógica de carrito/orden ahora distingue ítems por `(productId, productVariantId)`. + 📂 Archivos clave: +- [`src/models/product.model.ts`](src/models/product.model.ts) +- [`src/services/product.service.ts`](src/services/product.service.ts) +- [`src/services/cart.service.ts`](src/services/cart.service.ts) +- [`src/services/order.service.ts`](src/services/order.service.ts) + +--- + +### 🖥️ UI + +- **Categorías (PLP)**: + - Precio mostrado usa `displayedPrice` (mínimo o rango si hay variantes). +- **Producto (PDP)**: + - Selección de variantes con `selectedVariant` / `hoveredVariant`. + - Botón "Agregar al Carrito" envía `productVariantId`. +- **Carrito**: + - Renderiza `Producto (Variante)` cuando aplica. + - Controles `+/-` separados por variante. +- **Checkout**: + - Resumen y total con precios de variantes. + - Orden guarda `productVariantId` y precios correctos. +- **Órdenes**: + - Órdenes guardan `productVariantId`. + - Render en UI muestra **Producto (Variante)**. + +📂 Archivos clave: + +- [`src/routes/category`](src/routes/category) +- [`src/routes/product/index.tsx`](src/routes/product/index.tsx) +- [`src/routes/cart/index.tsx`](src/routes/cart/index.tsx) +- [`src/routes/checkout/index.tsx`](src/routes/checkout/index.tsx) +- [`src/services/order.service.ts`](src/services/order.service.ts) +- [`src/routes/account/orders/index.tsx`](src/routes/account/orders/index.tsx) + +--- + +### 🤖 Chatbot + +- `sendMessage` y `generateSystemPrompt` listan variantes con precios y links (`?variant=`). +- Instrucciones para el bot: + - Preguntar por talla/tamaño cuando sea necesario. + - Respetar selección previa. +- Carrito mostrado al usuario incluye variantes y subtotales. + 📂 Archivos clave: +- [`src/services/chat.service.ts`](src/services/chat.service.ts) +- [`src/services/chat-system-prompt.ts`](src/services/chat-system-prompt.ts) + +--- + +### ✅ Testing + +- **Unitarios e integración**: + - Cobertura de servicios y UI con variantes (`product.service.test`, `product.test.tsx`, `cart.ui.variants.test.tsx`). +- **E2E (Playwright)**: + - Flujo completo: selección de variantes → carrito → checkout → Culqi sandbox. + 📂 Archivos clave: +- [`src/routes/product/product.test.tsx`](src/routes/product/product.test.tsx) +- [`src/routes/cart/cart.ui.variants.test.tsx`](src/routes/cart/cart.ui.variants.test.tsx) +- [`src/e2e/cart-variants.spec.ts`](src/e2e/cart-variants.spec.ts) + +--- + +## Visión general (diagramas) + +### Diagrama de datos (ER) + +```mermaid +erDiagram + CATEGORY ||--o{ PRODUCT : contains + PRODUCT ||--o{ PRODUCT_VARIANT : has + CART ||--o{ CART_ITEM : includes + "ORDER" ||--o{ ORDER_ITEM : contains + + PRODUCT_VARIANT ||--o{ CART_ITEM : optional + PRODUCT_VARIANT ||--o{ ORDER_ITEM : optional + + CATEGORY { + int id PK + string title + string slug + } + PRODUCT { + int id PK + int categoryId FK + string title + string imgSrc + string alt + decimal price + boolean isOnSale + } + PRODUCT_VARIANT { + int id PK + int productId FK + string type // talla | tamaño + string value // S | M | L | 3x3cm | 10x10cm + decimal price + } + CART { + int id PK + string sessionCartId + int userId + } + CART_ITEM { + int id PK + int cartId FK + int productId FK + int productVariantId FK "nullable" + int quantity + } + ORDER { + int id PK + int userId + string paymentId + timestamp createdAt + } + ORDER_ITEM { + int id PK + int orderId FK + int productId FK + int productVariantId FK "nullable" + string title + decimal price + int quantity + } +``` + +## Cómo ejecutar + +### Base de datos y seeds + +```bash +# Prisma +npx run prisma:generate +npx run prisma:migrate +npx run prisma:seed +``` + +### Desarrollo + +```bash +npm install +npm run dev ``` -- 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: - -```js -// eslint.config.js -import react from 'eslint-plugin-react' - -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, - }, -}) +### Pruebas unitarias/integración + +```bash +npm run test ``` + +### Pruebas E2E (Playwright) + +```bash +# Instalar navegadores y dependencias del sistema (Linux) +npx playwright install chromium +sudo npx playwright install-deps + +# Ejecutar E2E +npm run test:e2e +``` + +> En local, el servidor de pruebas se levanta con `.env.test` para apuntar a la base de datos de testing. + +--- + +## Notas finales + +- Todos los cambios mantuvieron compatibilidad hacia atrás cuando fue posible. +- El soporte de variantes está integrado de extremo a extremo: lectura, UI, carrito, checkout, órdenes, chatbot y pruebas. +- Ante cualquier divergencia de UI en E2E, ajustar selectores manteniendo la intención de validación. diff --git a/prisma/initial_data.ts b/prisma/initial_data.ts index 0520e9e..2bcb59c 100644 --- a/prisma/initial_data.ts +++ b/prisma/initial_data.ts @@ -1,4 +1,4 @@ -import type { CategorySlug } from "../generated/prisma/client"; +import type { CategorySlug, Product } from "../generated/prisma/client"; const imagesBaseUrl = "https://fullstock-images.s3.us-east-2.amazonaws.com"; @@ -29,6 +29,34 @@ export const categories = [ }, ]; +export const productVariants = (product: Product) => { + // Polos + if (product.categoryId === 1) { + return ["Small", "Medium", "Large"].map((size) => ({ + productId: product.id, + type: "talla", + value: size, + price: product.price, + })); + } + + // Stickers + if (product.categoryId === 3) { + return [ + { value: "3x3cm", multiplier: 1 }, + { value: "5x5cm", multiplier: 1.67 }, + { value: "10x10cm", multiplier: 3.33 }, + ].map(({ value, multiplier }) => ({ + productId: product.id, + type: "tamaño", + value, + price: Number(product.price) * multiplier, + })); + } + + return []; +}; + export const products = [ { title: "Polo React", diff --git a/prisma/migrations/20250830101142_add_product_variants/migration.sql b/prisma/migrations/20250830101142_add_product_variants/migration.sql new file mode 100644 index 0000000..608d75d --- /dev/null +++ b/prisma/migrations/20250830101142_add_product_variants/migration.sql @@ -0,0 +1,42 @@ +/* + Warnings: + + - A unique constraint covering the columns `[cart_id,product_id,productVariantId]` 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 "productVariantId" INTEGER; + +-- AlterTable +ALTER TABLE "order_items" ADD COLUMN "productVariantId" INTEGER; + +-- CreateTable +CREATE TABLE "ProductVariant" ( + "id" SERIAL NOT NULL, + "productId" INTEGER NOT NULL, + "type" TEXT NOT NULL, + "value" TEXT NOT NULL, + "price" DECIMAL(10,2) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProductVariant_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "ProductVariant_productId_idx" ON "ProductVariant"("productId"); + +-- CreateIndex +CREATE UNIQUE INDEX "cart_items_cart_id_product_id_productVariantId_key" ON "cart_items"("cart_id", "product_id", "productVariantId"); + +-- AddForeignKey +ALTER TABLE "ProductVariant" ADD CONSTRAINT "ProductVariant_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "cart_items" ADD CONSTRAINT "cart_items_productVariantId_fkey" FOREIGN KEY ("productVariantId") REFERENCES "ProductVariant"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "order_items" ADD CONSTRAINT "order_items_productVariantId_fkey" FOREIGN KEY ("productVariantId") REFERENCES "ProductVariant"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e0f992b..69bc599 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -63,13 +63,29 @@ model Product { 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: SetNull) + category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) cartItems CartItem[] orderItems OrderItem[] + variants ProductVariant[] @@map("products") } +model ProductVariant { + id Int @id @default(autoincrement()) + productId Int + type String // "talla" o "tamaño" + value String // "Small", "Medium", "Large" o "3x3cm", "5x5cm", "10x10cm" + price Decimal @db.Decimal(10, 2) // Para polos (usa precio base), valor específico para stickers + product Product @relation(fields: [productId], references: [id]) + cartItems CartItem[] + orderItems OrderItem[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([productId]) +} + model Cart { id Int @id @default(autoincrement()) sessionCartId String @unique @default(dbgenerated("gen_random_uuid()")) @map("session_cart_id") @db.Uuid @@ -91,10 +107,13 @@ model CartItem { 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) + cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + productVariant ProductVariant? @relation(fields: [productVariantId], references: [id]) + productVariantId Int? - @@unique([cartId, productId], name: "unique_cart_item") + // Allow same product multiple times when variant differs + @@unique([cartId, productId, productVariantId], name: "unique_cart_item") @@map("cart_items") } @@ -133,8 +152,10 @@ model OrderItem { 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) + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + product Product? @relation(fields: [productId], references: [id], onDelete: SetNull) + productVariant ProductVariant? @relation(fields: [productVariantId], references: [id]) + productVariantId Int? @@map("order_items") } diff --git a/prisma/seed.ts b/prisma/seed.ts index 106da46..ccdbf0a 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,4 +1,4 @@ -import { categories, products } from "./initial_data"; +import { categories, products, productVariants } from "./initial_data"; import { PrismaClient } from "../generated/prisma/client"; const prisma = new PrismaClient(); @@ -9,9 +9,18 @@ async function seedDb() { }); console.log("1. Categories successfully inserted"); - await prisma.product.createMany({ - data: products, - }); + for ( const product of products) { + const createdProduct = await prisma.product.create({ + data: product, + }) + + //Tazas no tienen variantes + if(createdProduct.categoryId !== 2) { + await prisma.productVariant.createMany({ + data: productVariants(createdProduct), + }); + } + } console.log("2. Products successfully inserted"); } diff --git a/src/e2e/cart-variants.spec.ts b/src/e2e/cart-variants.spec.ts new file mode 100644 index 0000000..d89be77 --- /dev/null +++ b/src/e2e/cart-variants.spec.ts @@ -0,0 +1,134 @@ +import { test, expect } from "@playwright/test"; + +import { + baseUrl, + cleanDatabase, + createOrderFormData, + creditCards, +} from "./utils-tests-e2e"; + +test.beforeEach(async () => { + await cleanDatabase(); +}); + +test.describe("Cart with product variants", () => { + test("adds same product with different variants as separate items and completes checkout", async ({ + page, + }) => { + await page.goto(baseUrl); + + // Ir a Stickers y abrir un producto + await page.getByRole("menuitem", { name: "Stickers", exact: true }).click(); + await page.getByTestId("product-item").first().click(); + + // Seleccionar 3x3cm y agregar al carrito (permanece en la página del producto) + await page.getByRole("button", { name: "3x3cm" }).click(); + await page.getByRole("button", { name: "Agregar al Carrito" }).click(); + + // Seleccionar 10x10cm y agregar al carrito + await page.getByRole("button", { name: "10x10cm" }).click(); + await page.getByRole("button", { name: "Agregar al Carrito" }).click(); + + // Ir al carrito + await page.getByRole("link", { name: "Carrito de compras" }).click(); + + // Verificar que ambas variantes están separadas + const itemRows = page + .locator("div.border-b") + .filter({ hasText: "Sticker" }); + await expect(itemRows).toHaveCount(2); + + // Verificar nombres con variante + const firstRowText = await itemRows.nth(0).innerText(); + const secondRowText = await itemRows.nth(1).innerText(); + expect(firstRowText).toMatch(/\(\s*3x3cm\s*\)/); + expect(secondRowText).toMatch(/\(\s*10x10cm\s*\)/); + + // Capturar precios unitarios de cada fila + const getUnitPrice = async (rowIdx: number) => { + const priceText = await itemRows + .nth(rowIdx) + .locator("p.text-sm.font-medium") + .innerText(); + // "S/9.95" -> 9.95 + return parseFloat(priceText.replace(/[^\d.]/g, "")); + }; + const price1 = await getUnitPrice(0); + const price2 = await getUnitPrice(1); + + // Verificar total = suma de ambos (cada uno con cantidad 1) + const totalTextBefore = await page + .locator("div:has-text('Total') >> nth=1") + .locator("p") + .last() + .innerText(); + const currentTotal = parseFloat(totalTextBefore.replace(/[^\d.]/g, "")); + expect(currentTotal).toBeCloseTo(price1 + price2, 2); + + // Incrementar cantidad SOLO del primer ítem (usar el segundo form de la primera fila) + const addItemForms = page.locator('form[action="/cart/add-item"]'); + await addItemForms.nth(1).locator("button").click(); + + // Verificar que el total aumentó en +price1 + const totalTextAfterInc = await page + .locator("div:has-text('Total') >> nth=1") + .locator("p") + .last() + .innerText(); + const newTotalAfterInc = parseFloat( + totalTextAfterInc.replace(/[^\d.]/g, "") + ); + expect(newTotalAfterInc).toBeCloseTo(currentTotal + price1, 2); + + // Eliminar el segundo ítem (botón con name=itemId, segundo de la lista) + const removeButtons = page.locator('button[name="itemId"]'); + await removeButtons.nth(1).click(); + + // Confirmar que queda 1 fila + await expect(itemRows).toHaveCount(1); + + // Continuar compra + await page.getByRole("link", { name: "Continuar Compra" }).click(); + + // Completar checkout (flujo similar a tests existentes) + const orderForm = createOrderFormData(); + for (const [label, value] of Object.entries(orderForm)) { + await page.getByRole("textbox", { name: label }).fill(value); + } + await page.getByRole("combobox", { name: "País" }).selectOption("PE"); + await page.getByRole("button", { name: "Confirmar Orden" }).click(); + + const checkoutFrame = page.locator('iframe[name="checkout_frame"]'); + await expect(checkoutFrame).toBeVisible({ timeout: 10000 }); + + const validCard = creditCards.valid; + await checkoutFrame + .contentFrame() + .getByRole("textbox", { name: "#### #### #### ####" }) + .fill(validCard.number); + await expect( + checkoutFrame.contentFrame().getByRole("img", { name: "Culqi icon" }) + ).toBeVisible(); + await checkoutFrame + .contentFrame() + .getByRole("textbox", { name: "MM/AA" }) + .fill(validCard.exp); + await checkoutFrame + .contentFrame() + .getByRole("textbox", { name: "CVV" }) + .fill(validCard.cvv); + await checkoutFrame + .contentFrame() + .getByRole("textbox", { name: "correo@electronico.com" }) + .fill(orderForm["Correo electrónico"]); + await checkoutFrame + .contentFrame() + .getByRole("button", { name: "Pagar S/" }) + .click(); + + await expect(page.getByText("¡Muchas gracias por tu compra!")).toBeVisible({ + timeout: 10000, + }); + await expect(page.getByTestId("orderId")).toBeVisible(); + }); +}); diff --git a/src/lib/cart.ts b/src/lib/cart.ts index e0308df..01468ed 100644 --- a/src/lib/cart.ts +++ b/src/lib/cart.ts @@ -19,14 +19,16 @@ export async function addToCart( userId: number | undefined, sessionCartId: string | undefined, productId: Product["id"], - quantity: number = 1 + quantity: number = 1, + productVariantId?: number ) { try { const updatedCart = await alterQuantityCartItem( userId, sessionCartId, productId, - quantity + quantity, + productVariantId ); return updatedCart; } catch (error) { @@ -62,10 +64,11 @@ export function calculateTotal(items: CartItem[] | CartItemInput[]): number { // Type guard to determine which type we're working with if ("product" in item) { // CartItem - has a product property - return total + item.product.price * item.quantity; + const unitPrice = item.productVariant?.price ?? item.product.price; + return total + unitPrice * item.quantity; } else { // CartItemInput - has price directly return total + item.price * item.quantity; } }, 0); -} +} \ No newline at end of file diff --git a/src/lib/utils.tests.ts b/src/lib/utils.tests.ts index 1526f23..6928420 100644 --- a/src/lib/utils.tests.ts +++ b/src/lib/utils.tests.ts @@ -126,6 +126,7 @@ export const createTestOrderItem = ( title: "Test Product", price: 100, imgSrc: "test-image.jpg", + productVariantId: null, createdAt: new Date(), updatedAt: new Date(), ...overrides, @@ -142,6 +143,7 @@ export const createTestDBOrderItem = ( title: "Test Product", price: new Decimal(100), imgSrc: "test-image.jpg", + productVariantId: null, createdAt: new Date(), updatedAt: new Date(), ...overrides, diff --git a/src/models/cart.model.ts b/src/models/cart.model.ts index ad4206a..1fc73aa 100644 --- a/src/models/cart.model.ts +++ b/src/models/cart.model.ts @@ -1,4 +1,4 @@ -import { type Product } from "./product.model"; +import { type Product, type ProductVariant } from "./product.model"; import type { Cart as PrismaCart, @@ -10,6 +10,10 @@ export type CartItem = PrismaCartItem & { Product, "id" | "title" | "imgSrc" | "alt" | "price" | "isOnSale" >; + productVariant?: Pick< + ProductVariant, + "id" | "type" | "value" | "price" + > | null; }; export type Cart = PrismaCart; @@ -20,10 +24,10 @@ export interface CartItemInput { title: Product["title"]; price: Product["price"]; imgSrc: Product["imgSrc"]; + productVariantId?: number; } // Tipo para representar un producto simplificado en el carrito - export type CartProductInfo = Pick< Product, "id" | "title" | "imgSrc" | "alt" | "price" | "isOnSale" @@ -33,9 +37,13 @@ export type CartProductInfo = Pick< export type CartItemWithProduct = { product: CartProductInfo; quantity: number; + productVariant?: Pick< + ProductVariant, + "id" | "type" | "value" | "price" + > | null; }; // Tipo para el carrito con items y productos incluidos export type CartWithItems = Cart & { items: CartItem[]; -}; +}; \ No newline at end of file diff --git a/src/models/product.model.ts b/src/models/product.model.ts index 96ba043..35472ea 100644 --- a/src/models/product.model.ts +++ b/src/models/product.model.ts @@ -1,5 +1,13 @@ -import type { Product as PrismaProduct } from "@/../generated/prisma/client"; +import type { + Product as PrismaProduct, + ProductVariant as PrismaProductVariant, +} from "@/../generated/prisma/client"; + +export type ProductVariant = Omit & { + price: number; +}; export type Product = Omit & { price: number; + variants?: ProductVariant[]; }; diff --git a/src/routes/account/orders/index.tsx b/src/routes/account/orders/index.tsx index dccd7c0..80e9638 100644 --- a/src/routes/account/orders/index.tsx +++ b/src/routes/account/orders/index.tsx @@ -88,7 +88,13 @@ export default function Orders({ loaderData }: Route.ComponentProps) { {order.items.map((item) => ( - +
diff --git a/src/routes/cart/add-item/index.tsx b/src/routes/cart/add-item/index.tsx index ac49758..1533c8d 100644 --- a/src/routes/cart/add-item/index.tsx +++ b/src/routes/cart/add-item/index.tsx @@ -9,12 +9,15 @@ export async function action({ request }: Route.ActionArgs) { const formData = await request.formData(); const productId = Number(formData.get("productId")); const quantity = Number(formData.get("quantity")) || 1; + const productVariantId = formData.get("productVariantId") + ? Number(formData.get("productVariantId")) + : undefined; const redirectTo = formData.get("redirectTo") as string | null; const session = await getSession(request.headers.get("Cookie")); const sessionCartId = session.get("sessionCartId"); const userId = session.get("userId"); - await addToCart(userId, sessionCartId, productId, quantity); + await addToCart(userId, sessionCartId, productId, quantity, productVariantId); return redirect(redirectTo || "/cart"); } diff --git a/src/routes/cart/cart.ui.variants.test.tsx b/src/routes/cart/cart.ui.variants.test.tsx new file mode 100644 index 0000000..038d4fa --- /dev/null +++ b/src/routes/cart/cart.ui.variants.test.tsx @@ -0,0 +1,132 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; + +import { calculateTotal } from "@/lib/cart"; +import type { CartWithItems } from "@/models/cart.model"; + +import Cart from "."; + +import type { Route } from "./+types"; + +// Mock react-router Form/Link to render as plain elements preserving props +vi.mock("react-router", () => ({ + Form: vi.fn(({ children, ...props }) =>
{children}
), + Link: vi.fn(({ children, ...props }) => {children}), + // minimal mock for session.server usage + createCookieSessionStorage: vi.fn(() => ({ + getSession: vi.fn(async () => ({ + get: vi.fn(), + set: vi.fn(), + })), + commitSession: vi.fn(), + destroySession: vi.fn(), + })), +})); + +function createLoaderData(): Route.ComponentProps["loaderData"] { + const product = { + id: 1, + title: "Polo React", + imgSrc: "/polos/polo-react.png", + alt: "Polo React", + price: 20, + isOnSale: false, + }; + + const items: CartWithItems["items"] = [ + { + id: 1, + cartId: 1, + productId: product.id, + quantity: 1, + createdAt: new Date(), + updatedAt: new Date(), + productVariantId: 10, + product, + productVariant: { + id: 10, + type: "talla", + value: "Large", + price: 20, + }, + }, + { + id: 2, + cartId: 1, + productId: product.id, + quantity: 1, + createdAt: new Date(), + updatedAt: new Date(), + productVariantId: 11, + product, + productVariant: { + id: 11, + type: "talla", + value: "Medium", + price: 20, + }, + }, + ]; + + const cart = { + id: 1, + items, + sessionCartId: "test-session", + userId: null, + createdAt: new Date(), + updatedAt: new Date(), + } as unknown as CartWithItems; + + return { + cart, + total: calculateTotal(items), + }; +} + +const createProps = (): Route.ComponentProps => ({ + loaderData: createLoaderData(), + params: {}, + matches: [] as unknown as Route.ComponentProps["matches"], +}); + +describe("Cart Route - variants rendering", () => { + it("renders same product with different variants as separate items and shows correct total", () => { + const props = createProps(); + render(); + + expect(screen.getByText("Polo React (Large)")).toBeInTheDocument(); + expect(screen.getByText("Polo React (Medium)")).toBeInTheDocument(); + + // Each item shows price and image + const priceLabels = screen.getAllByText("S/20.00"); + expect(priceLabels).toHaveLength(2); + + const images = screen.getAllByRole("img", { name: /polo react/i }); + expect(images.length).toBeGreaterThanOrEqual(2); + + // Total equals sum of both variants + expect(screen.getByText("S/40.00")).toBeInTheDocument(); + + // Each row has independent +/- forms carrying its productVariantId + const container = screen.getByText("Carrito de compras").closest("div")!; + const variantForms = Array.from( + container.querySelectorAll('form[action="/cart/add-item"]') + ); + + // There are two forms per row (minus, plus) + expect(variantForms.length).toBeGreaterThanOrEqual(4); + + // Check hidden inputs exist for productId and productVariantId for first row forms + const firstRowForms = variantForms.slice(0, 2); + firstRowForms.forEach((form) => { + const productIdInput = (form as HTMLElement).querySelector( + 'input[name="productId"][value="1"]' + ) as HTMLInputElement | null; + expect(productIdInput).not.toBeNull(); + const variantInput = (form as HTMLElement).querySelector( + 'input[name="productVariantId"][value="10"]' + ) as HTMLInputElement | null; + expect(variantInput).not.toBeNull(); + }); + }); +}); diff --git a/src/routes/cart/cart.variants.test.ts b/src/routes/cart/cart.variants.test.ts new file mode 100644 index 0000000..4bd7e37 --- /dev/null +++ b/src/routes/cart/cart.variants.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from "vitest"; + +import { calculateTotal } from "@/lib/cart"; +import type { CartItem, CartItemInput } from "@/models/cart.model"; + +describe("calculateTotal with product variants", () => { + it("sums using variant prices when present (CartItem)", () => { + const items = [ + { + id: 1, + cartId: 1, + productId: 101, + quantity: 1, + createdAt: new Date(), + updatedAt: new Date(), + product: { + id: 101, + title: "Sticker Docker", + imgSrc: "/stickers/docker.png", + alt: "Sticker Docker", + price: 2.99, + isOnSale: false, + }, + productVariant: { + id: 1001, + type: "tamaño", + value: "3x3cm", + price: 2.99, + }, + }, + { + id: 2, + cartId: 1, + productId: 101, + quantity: 1, + createdAt: new Date(), + updatedAt: new Date(), + product: { + id: 101, + title: "Sticker Docker", + imgSrc: "/stickers/docker.png", + alt: "Sticker Docker", + price: 2.99, + isOnSale: false, + }, + productVariant: { + id: 1002, + type: "tamaño", + value: "10x10cm", + price: 9.95, + }, + }, + ] as unknown as CartItem[]; + + const total = calculateTotal(items); + expect(total).toBeCloseTo(12.94, 2); + }); + + it("falls back to product base price when no variant (CartItem)", () => { + const items = [ + { + id: 1, + cartId: 1, + productId: 201, + quantity: 2, + createdAt: new Date(), + updatedAt: new Date(), + product: { + id: 201, + title: "Taza React", + imgSrc: "/tazas/react.png", + alt: "Taza React", + price: 13.99, + isOnSale: false, + }, + productVariant: null, + }, + ] as unknown as CartItem[]; + + const total = calculateTotal(items); + expect(total).toBeCloseTo(27.98, 2); + }); + + it("sums CartItemInput (flattened) correctly for variants", () => { + const items: CartItemInput[] = [ + { + productId: 301, + title: "Sticker JS (3x3cm)", + price: 2.99, + imgSrc: "/stickers/js.png", + quantity: 1, + }, + { + productId: 301, + title: "Sticker JS (10x10cm)", + price: 9.95, + imgSrc: "/stickers/js.png", + quantity: 2, + }, + ]; + + const total = calculateTotal(items); + expect(total).toBeCloseTo(22.89, 2); + }); +}); diff --git a/src/routes/cart/index.tsx b/src/routes/cart/index.tsx index d330cef..8b62ad4 100644 --- a/src/routes/cart/index.tsx +++ b/src/routes/cart/index.tsx @@ -3,7 +3,6 @@ import { Form, Link } from "react-router"; import { Button, Container, Section } from "@/components/ui"; import { calculateTotal, getCart } from "@/lib/cart"; -import { type Cart } from "@/models/cart.model"; import { getSession } from "@/session.server"; import type { Route } from "./+types"; @@ -30,64 +29,87 @@ export default function Cart({ loaderData }: Route.ComponentProps) { Carrito de compras
- {cart?.items?.map(({ product, quantity, id }) => ( -
-
- {product.alt -
-
-
-

{product.title}

-
- -
+ {cart?.items?.map(({ id, product, quantity, productVariant }) => { + const price = productVariant ? productVariant.price : product.price; + + return ( +
+
+ {product.alt
-
-

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

-
-
- +
+
+

+ {product.title} + {productVariant ? ` (${productVariant.value})` : ""} +

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

S/{price.toFixed(2)}

+
+
+ + + {productVariant && ( + + )} + +
+ + {quantity} + +
+ + + {productVariant && ( + + )} + +
+
+
-
- ))} + ); + })}

Total

S/{total.toFixed(2)}

diff --git a/src/routes/category/components/product-card/index.tsx b/src/routes/category/components/product-card/index.tsx index a6abe33..6ddd1a9 100644 --- a/src/routes/category/components/product-card/index.tsx +++ b/src/routes/category/components/product-card/index.tsx @@ -1,15 +1,24 @@ import { Link } from "react-router"; -import type { Product } from "@/models/product.model"; +import type { ProductWithDisplayPrice } from "../../index"; interface ProductCardProps { - product: Product; + product: ProductWithDisplayPrice; } export function ProductCard({ product }: ProductCardProps) { + // Obtener variante con el precio mostrado + const matchedVariant = product.variants?.find( + (v) => v.price === product.displayedPrice + ); + return ( @@ -25,7 +34,7 @@ export function ProductCard({ product }: ProductCardProps) {

{product.title}

{product.description}

-

S/{product.price}

+

S/{product.displayedPrice}

{product.isOnSale && ( diff --git a/src/routes/category/index.tsx b/src/routes/category/index.tsx index 7c0aef5..246b45b 100644 --- a/src/routes/category/index.tsx +++ b/src/routes/category/index.tsx @@ -1,7 +1,7 @@ import { redirect } from "react-router"; import { Container } from "@/components/ui"; -import { isValidCategorySlug, type Category } from "@/models/category.model"; +import { isValidCategorySlug} from "@/models/category.model"; import type { Product } from "@/models/product.model"; import { getCategoryBySlug } from "@/services/category.service"; import { getProductsByCategorySlug } from "@/services/product.service"; @@ -11,6 +11,8 @@ import { ProductCard } from "./components/product-card"; import type { Route } from "./+types"; +export type ProductWithDisplayPrice = Product & { displayedPrice: number }; + export async function loader({ params, request }: Route.LoaderArgs) { const { category: categorySlug } = params; @@ -32,14 +34,52 @@ export async function loader({ params, request }: Route.LoaderArgs) { products: Product[], minPrice: string, maxPrice: string - ) => { + ): ProductWithDisplayPrice[] => { const min = minPrice ? parseFloat(minPrice) : 0; const max = maxPrice ? parseFloat(maxPrice) : Infinity; - return products.filter( - (product) => product.price >= min && product.price <= max - ); + + return products + .map((product) => { + if (product.variants && product.variants.length > 0) { + const sortedVariants = [...product.variants].sort( + (a, b) => a.price - b.price + ); + + const lowest = sortedVariants[0].price; + const highest = sortedVariants[sortedVariants.length - 1].price; + + let selectedVariant = sortedVariants[0]; + + if (min <= lowest && max >= highest) { + // caso 1 -> rango incluye todos los precios + selectedVariant = sortedVariants[0]; + } else { + // caso 2 -> primer variant en rango + const variantInRange = sortedVariants.find( + (v) => v.price >= min && v.price <= max + ); + if (variantInRange) { + selectedVariant = variantInRange; + } + } + + return { + ...product, + displayedPrice: selectedVariant.price, + }; + } + + // Si no tiene variants, usar price del producto + return { + ...product, + displayedPrice: product.price, + }; + }) + .filter((p) => p.displayedPrice >= min && p.displayedPrice <= max); }; + + const filteredProducts = filterProductsByPrice( products, minPrice, diff --git a/src/routes/checkout/index.tsx b/src/routes/checkout/index.tsx index 1ceb7ae..b872bb9 100644 --- a/src/routes/checkout/index.tsx +++ b/src/routes/checkout/index.tsx @@ -106,9 +106,12 @@ export async function action({ request }: Route.ActionArgs) { const items = cartItems.map((item) => ({ productId: item.product.id, quantity: item.quantity, - title: item.product.title, - price: item.product.price, + title: `${item.product.title}${ + item.productVariant ? ` (${item.productVariant.value})` : "" + }`, + price: item.productVariant?.price ?? item.product.price, imgSrc: item.product.imgSrc, + productVariantId: item.productVariant?.id, })); const { id: orderId } = await createOrder( @@ -249,11 +252,8 @@ export default function Checkout({

Resumen de la orden

- {cart?.items?.map(({ product, quantity }) => ( -
+ {cart?.items?.map(({ id, product, quantity, productVariant }) => ( +
-

{product.title}

+

+ {product.title} + {productVariant ? ` (${productVariant.value})` : ""} +

{quantity}

-

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

+

+ S/{(productVariant?.price ?? product.price).toFixed(2)} +

@@ -374,4 +379,4 @@ export default function Checkout({ ); -} +} \ No newline at end of file diff --git a/src/routes/product/index.tsx b/src/routes/product/index.tsx index f444f0b..c75307d 100644 --- a/src/routes/product/index.tsx +++ b/src/routes/product/index.tsx @@ -1,81 +1,142 @@ +import { useState } from "react"; import { Form, useNavigation } from "react-router"; import { Button, Container, Separator } from "@/components/ui"; -import { type Product } from "@/models/product.model"; +import { type ProductVariant } from "@/models/product.model"; import { getProductById } from "@/services/product.service"; import NotFound from "../not-found"; import type { Route } from "./+types"; -export async function loader({ params }: Route.LoaderArgs) { +export async function loader({ params, request }: Route.LoaderArgs) { try { const product = await getProductById(parseInt(params.id)); - return { product }; + + const url = new URL(request.url); + const variantParam = url.searchParams.get("variant"); + + return { product, variantParam }; } catch { return {}; } } export default function Product({ loaderData }: Route.ComponentProps) { - const { product } = loaderData; + const { product, variantParam } = loaderData; const navigation = useNavigation(); const cartLoading = navigation.state === "submitting"; - if (!product) { - return ; - } + const initialVariant = + product?.variants?.find((v) => variantParam && v.value === variantParam) ?? + product?.variants?.[0] ?? + null; + + const [selectedVariant, setSelectedVariant] = useState( + initialVariant + ); + + const [hoveredVariant, setHoveredVariant] = useState( + null + ); + + if (!product) return ; + + const variantLabel = + product.categoryId === 1 + ? "Talla" + : product.categoryId === 3 + ? "Tamaño" + : ""; + + const displayedPrice = + hoveredVariant?.price ?? selectedVariant?.price ?? product.price; return ( - <> -
- -
- {product.title} -
-
-

- {product.title} -

-

S/{product.price}

-

- {product.description} -

-
- - -
- -
-

- Características -

-
    - {product.features.map((feature, index) => ( -
  • {feature}
  • - ))} -
+
+ +
+ {product.title} +
+
+

{product.title}

+

S/{displayedPrice}

+

+ {product.description} +

+ + {product.variants && product.variants.length > 0 && ( +
+ + {variantLabel}: + +
+ {product.variants.map((variant) => { + const isSelected = selectedVariant?.id === variant.id; + return ( + + ); + })} +
+ )} + +
+ + + +
+ + + +
+

+ Características +

+
    + {product.features.map((feature, index) => ( +
  • {feature}
  • + ))} +
- -
- +
+ +
); } diff --git a/src/routes/product/product.test.tsx b/src/routes/product/product.test.tsx index f70059d..6450354 100644 --- a/src/routes/product/product.test.tsx +++ b/src/routes/product/product.test.tsx @@ -32,7 +32,7 @@ vi.mock("react-router", () => ({ const createTestProps = ( productData: Partial = {} ): Route.ComponentProps => ({ - loaderData: { product: createTestProduct(productData) }, + loaderData: { product: createTestProduct(productData), variantParam: null }, params: { id: "123" }, // Hack to satisfy type requirements matches: [] as unknown as Route.ComponentProps["matches"], @@ -123,8 +123,9 @@ describe("Product Component", () => { // Step 3: Call render(); // Step 4: Verify - const redirectInput = screen.queryByDisplayValue( - `/products/${productId}` + // The redirectTo includes a variant param when variants exist; assert it starts with the product path + const redirectInput = screen.getByDisplayValue( + new RegExp(`^/products/${productId}`) ); expect(redirectInput).toBeInTheDocument(); }); @@ -160,4 +161,4 @@ describe("Product Component", () => { expect(screen.getByTestId("not-found")).toBeInTheDocument(); }); }); -}); +}); \ No newline at end of file diff --git a/src/services/cart.service.ts b/src/services/cart.service.ts index f742706..b72bad8 100644 --- a/src/services/cart.service.ts +++ b/src/services/cart.service.ts @@ -34,6 +34,7 @@ async function getCart( isOnSale: true, }, }, + productVariant: true, }, orderBy: { id: "asc", @@ -52,6 +53,12 @@ async function getCart( ...item.product, price: item.product.price.toNumber(), }, + productVariant: item.productVariant + ? { + ...item.productVariant, + price: item.productVariant.price.toNumber(), + } + : null, })), }; } @@ -73,11 +80,10 @@ export async function getOrCreateCart( } // 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, + sessionCartId: sessionCartId || undefined, }, include: { items: { @@ -92,6 +98,7 @@ export async function getOrCreateCart( isOnSale: true, }, }, + productVariant: true, }, }, }, @@ -107,6 +114,12 @@ export async function getOrCreateCart( ...item.product, price: item.product.price.toNumber(), }, + productVariant: item.productVariant + ? { + ...item.productVariant, + price: item.productVariant.price.toNumber(), + } + : null, })), }; } @@ -132,6 +145,7 @@ export async function createRemoteItems( cartId: cart.id, productId: item.product.id, quantity: item.quantity, + productVariantId: item.productVariant?.id ?? null, })), }); } @@ -147,11 +161,16 @@ export async function alterQuantityCartItem( userId: User["id"] | undefined, sessionCartId: string | undefined, productId: number, - quantity: number = 1 + quantity: number = 1, + productVariantId?: number ): Promise { const cart = await getOrCreateCart(userId, sessionCartId); - const existingItem = cart.items.find((item) => item.product.id === productId); + const existingItem = cart.items.find( + (item) => + item.product.id === productId && + (item.productVariant?.id ?? null) === (productVariantId ?? null) + ); if (existingItem) { const newQuantity = existingItem.quantity + quantity; @@ -172,6 +191,7 @@ export async function alterQuantityCartItem( cartId: cart.id, productId, quantity, + productVariantId: productVariantId ?? null, }, }); } @@ -246,6 +266,7 @@ export async function linkCartToUser( isOnSale: true, }, }, + productVariant: true, }, }, }, @@ -261,6 +282,12 @@ export async function linkCartToUser( ...item.product, price: item.product.price.toNumber(), }, + productVariant: item.productVariant + ? { + ...item.productVariant, + price: item.productVariant.price.toNumber(), + } + : null, })), }; } @@ -295,6 +322,7 @@ export async function mergeGuestCartWithUserCart( isOnSale: true, }, }, + productVariant: true, }, }, }, @@ -307,6 +335,12 @@ export async function mergeGuestCartWithUserCart( ...item.product, price: item.product.price.toNumber(), }, + productVariant: item.productVariant + ? { + ...item.productVariant, + price: item.productVariant.price.toNumber(), + } + : null, })), }; } @@ -330,6 +364,7 @@ export async function mergeGuestCartWithUserCart( cartId: userCart.id, productId: item.productId, quantity: item.quantity, + productVariantId: item.productVariantId ?? null, })), }); @@ -340,4 +375,4 @@ export async function mergeGuestCartWithUserCart( // Devolver el carrito actualizado del usuario return await getCart(userId); -} +} \ No newline at end of file diff --git a/src/services/chat-system-prompt.ts b/src/services/chat-system-prompt.ts index 30401b0..c8e3c2a 100644 --- a/src/services/chat-system-prompt.ts +++ b/src/services/chat-system-prompt.ts @@ -8,6 +8,55 @@ interface SystemPromptConfig { userCart?: CartWithItems | null; } +const PEN = (num: number) => `S/${Number(num).toFixed(2)}`; + +function formatProductVariants(product: Product): string { + const variants = product.variants as + | { id: number; type: string; value: string; price: number }[] + | undefined; + + if (!variants || variants.length === 0) return ""; + + const typeLabel = variants[0].type; + const prices = variants.map((v) => Number(v.price)); + const min = Math.min(...prices); + const max = Math.max(...prices); + + const list = variants + .map( + (v) => + `- [${v.value}](/products/${product.id}?variant=${encodeURIComponent( + v.value + )}): ${PEN(Number(v.price))}` + ) + .join("\n"); + + return ` +- Variantes (${typeLabel}): +${list} +- Rango de precios por variante: ${PEN(min)} - ${PEN(max)} +`; +} + +function formatCartItemLine(item: CartWithItems["items"][number]): string { + const unit = Number(item.productVariant?.price ?? item.product.price); + const subtotal = unit * item.quantity; + const variantSuffix = item.productVariant + ? ` (${item.productVariant.value})` + : ""; + const productLink = item.productVariant + ? `/products/${item.product.id}?variant=${encodeURIComponent( + item.productVariant.value + )}` + : `/products/${item.product.id}`; + return ` +- **${item.product.title}${variantSuffix}** x${item.quantity} — ${PEN( + unit + )} c/u (Subtotal: ${PEN(subtotal)}) + Link: [Ver producto](${productLink}) +`; +} + export function generateSystemPrompt({ categories, products, @@ -32,29 +81,20 @@ ${onSaleProducts ? ` ## 🛒 CARRITO ACTUAL DEL USUARIO: El usuario tiene actualmente ${userCart.items.length} producto(s) en su carrito: -${userCart.items - .map( - (item) => ` -- **${item.product.title}** (Cantidad: ${item.quantity}) - S/${item.product.price} - Link: [Ver producto](/products/${item.product.id}) -` - ) - .join("")} - +${userCart.items.map(formatCartItemLine).join("")} **IMPORTANTE**: Usa esta información para hacer recomendaciones inteligentes: - **PRIORIDAD**: Si piden recomendaciones, sugiere PRIMERO productos de la misma categoría o tema que los productos en su carrito - Si tienen un producto de React, recomienda otros productos relacionados con React o frontend - Si tienen productos backend, prioriza otros productos backend o de tecnologías relacionadas - 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 -` +- Menciona que puedes ver lo que ya tienen seleccionado y personalizar las sugerencias` : ""; return ` # Asistente Virtual de Full Stock -Eres un asistente virtual especializado en **Full Stock**, una tienda de productos para desarrolladores web. +Eres un asistente virtual especializado en **Full Stock**, una tienda de productos para desarrolladores web. ## PERSONALIDAD Y COMPORTAMIENTO: - Sé educado, amable, alegre y entusiasta como un vendedor experto @@ -74,7 +114,7 @@ ${categories (cat) => ` **${cat.title}** (${cat.slug}) - Descripción: ${cat.description} -- Link: [Ver categoría](/category/${cat.slug}) +- Link: [Ver categoría](/${cat.slug}) ` ) .join("\n")} @@ -85,11 +125,12 @@ ${products const category = categories.find((c) => c.id === product.categoryId); return ` **${product.title}** -- 💰 Precio: S/${product.price}${product.isOnSale ? " ⚡ ¡EN OFERTA!" : ""} +- 💰 Precio base: S/${product.price}${product.isOnSale ? " ⚡ ¡EN OFERTA!" : ""} - 📝 Descripción: ${product.description} - 🏷️ Categoría: ${category?.title || "Sin categoría"} - ✨ Características: ${product.features.join(", ")} - 🔗 Link: [Ver producto](/products/${product.id}) +${formatProductVariants(product)} `; }) .join("\n")} @@ -102,7 +143,8 @@ ${cartSection} - **MANTÉN LAS RESPUESTAS BREVES Y DIRECTAS** (máximo 2-3 oraciones) - Ve directo al punto, sin explicaciones largas - Cuando recomiendes productos, SIEMPRE incluye el link en formato: [Nombre del Producto](/products/ID) -- Para categorías, usa links como: [Categoría](/category/slug) +- Para categorías, usa links como: [Categoría](/slug) +- Si mencionas una variante específica (talla/tamaño), enlaza al producto con el query param ?variant=VALOR. Ejemplo: /products/123?variant=10x10cm - Responde en **Markdown** para dar formato atractivo - Sé específico sobre precios, características y beneficios - Si hay productos en oferta, destácalos con emojis y texto llamativo @@ -136,6 +178,11 @@ ${cartSection} - Backend → Node.js, Python, Docker - Frontend → React, JavaScript, CSS +## MANEJO DE VARIANTES (talla/tamaño): +- Si un producto tiene variantes, pregunta la preferencia de **talla** o **tamaño** antes de cerrar la compra. +- Si el usuario ya tiene una variante en el carrito, sugiérela por defecto y ofrece cambiarla si desea. +- Indica precio correcto por variante cuando lo menciones (usa el rango si aplica). + ## 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 @@ -143,8 +190,7 @@ Cuando te pregunten sobre tecnologías que tenemos en productos (React, Docker, 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?" +## RESPUESTAS A PREGUNTAS COMUNES - **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" @@ -155,7 +201,7 @@ Cuando te pregunten sobre tecnologías que tenemos en productos (React, Docker, - **Ejemplo de pregunta técnica relacionada**: "¡La ballena de Docker representa la facilidad de transportar aplicaciones! 🐳 Nuestro [Sticker Docker](/products/X) captura perfectamente esa filosofía. ¿Te gusta coleccionar stickers de tecnología?" - **Ejemplo con carrito (React)**: "Veo que tienes el Polo React en tu carrito! Para completar tu look frontend, te recomiendo la [Taza React](/products/Y). ¿Te interesa?" - **Ejemplo con carrito (Backend)**: "Perfecto, tienes productos backend en tu carrito. El [Sticker Node.js](/products/Z) combinaría genial. ¿Lo agregamos?" - +- **Ejemplo cuando el usuario dice "añade X"**: "¡Listo! Aquí está el enlace con la variante: [Sticker React 10x10cm](/products/12?variant=10x10cm). Abre el enlace y presiona ‘Agregar al Carrito’. ¿Quieres otra talla o tamaño?" ¿En qué puedo ayudarte hoy a encontrar el producto perfecto para ti? 🛒✨ `; -} +} \ No newline at end of file diff --git a/src/services/chat.service.ts b/src/services/chat.service.ts index b44fc5c..8e79cee 100644 --- a/src/services/chat.service.ts +++ b/src/services/chat.service.ts @@ -1,6 +1,8 @@ import { type Chat, GoogleGenAI } from "@google/genai"; import dotenv from "dotenv"; +import type { Product, ProductVariant } from "@/models/product.model"; + import { getOrCreateCart } from "./cart.service"; import { getAllCategories } from "./category.service"; import { generateSystemPrompt } from "./chat-system-prompt"; @@ -22,11 +24,26 @@ export async function sendMessage( ) { if (!chats[sessionId]) { // Obtener datos de la base de datos - const [categories, products] = await Promise.all([ + const [categories, rawProducts] = await Promise.all([ getAllCategories(), getAllProducts(), ]); + // Normalizar posibles precios de variantes (Decimal -> number) para el prompt + const products: Product[] = rawProducts.map( + (p): Product => ({ + ...p, + variants: Array.isArray(p?.variants) + ? p.variants.map( + (v): ProductVariant => ({ + ...v, + price: Number(v.price), + }) + ) + : undefined, + }) + ); + // Obtener carrito del usuario si está disponible let userCart = null; if (userId || sessionCartId) { @@ -60,4 +77,4 @@ export async function sendMessage( message, }); return response.text; -} +} \ No newline at end of file diff --git a/src/services/order.service.ts b/src/services/order.service.ts index 6f5948a..1df955f 100644 --- a/src/services/order.service.ts +++ b/src/services/order.service.ts @@ -30,6 +30,9 @@ export async function createOrder( title: item.title, price: item.price, imgSrc: item.imgSrc, + ...(item.productVariantId + ? { productVariantId: item.productVariantId } + : {}), })), }, paymentId: paymentId, diff --git a/src/services/product.service.test.ts b/src/services/product.service.test.ts index 7bac27a..9a72f88 100644 --- a/src/services/product.service.test.ts +++ b/src/services/product.service.test.ts @@ -7,7 +7,12 @@ import type { Category } from "@/models/category.model"; import { getCategoryBySlug } from "./category.service"; import { getProductById, getProductsByCategorySlug } from "./product.service"; -import type { Product as PrismaProduct } from "@/../generated/prisma/client"; +import type { + Product as PrismaProduct, + ProductVariant as PrismaProductVariant, +} from "@/../generated/prisma/client"; + +import { Decimal } from "@/../generated/prisma/internal/prismaNamespace"; vi.mock("@/db/prisma", () => ({ prisma: { @@ -30,13 +35,32 @@ describe("Product Service", () => { it("should return products for a valid category slug", async () => { // Step 1: Setup - Create test data with valid category and products const testCategory = createTestCategory(); - const mockedProducts: PrismaProduct[] = [ - createTestDBProduct({ id: 1, categoryId: testCategory.id }), - createTestDBProduct({ - id: 2, - title: "Test Product 2", - categoryId: testCategory.id, - }), + type PrismaProductWithVariants = PrismaProduct & { + variants: PrismaProductVariant[]; + }; + const mockedProducts: PrismaProductWithVariants[] = [ + { + ...createTestDBProduct({ id: 1, categoryId: testCategory.id }), + variants: [ + { + id: 10, + productId: 1, + type: "talla", + value: "Medium", + price: new Decimal(20), + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }, + { + ...createTestDBProduct({ + id: 2, + title: "Test Product 2", + categoryId: testCategory.id, + }), + variants: [], + }, ]; // Step 2: Mock - Configure responses @@ -51,11 +75,16 @@ describe("Product Service", () => { expect(getCategoryBySlug).toHaveBeenCalledWith(testCategory.slug); expect(mockPrisma.product.findMany).toHaveBeenCalledWith({ where: { categoryId: testCategory.id }, + include: { variants: true }, }); expect(products).toEqual( mockedProducts.map((product) => ({ ...product, price: product.price.toNumber(), + variants: product.variants.map((v) => ({ + ...v, + price: (v.price as Decimal).toNumber(), + })), })) ); }); @@ -83,9 +112,27 @@ describe("Product Service", () => { it("should return product for valid ID", async () => { // Step 1: Setup - Create test data for existing product const testProduct = createTestDBProduct(); + const productWithVariants = { + ...testProduct, + variants: [ + { + id: 21, + productId: testProduct.id, + type: "talla", + value: "Large", + price: new Decimal(25), + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + } as unknown as PrismaProduct & { variants: PrismaProductVariant[] }; // Step 2: Mock - Configure Prisma response - vi.mocked(mockPrisma.product.findUnique).mockResolvedValue(testProduct); + vi.mocked(mockPrisma.product.findUnique).mockResolvedValue( + productWithVariants as PrismaProduct & { + variants: PrismaProductVariant[]; + } + ); // Step 3: Call service function const result = await getProductById(testProduct.id); @@ -93,10 +140,15 @@ describe("Product Service", () => { // Step 4: Verify expected behavior expect(mockPrisma.product.findUnique).toHaveBeenCalledWith({ where: { id: testProduct.id }, + include: { variants: true }, }); expect(result).toEqual({ - ...testProduct, + ...productWithVariants, price: testProduct.price.toNumber(), + variants: productWithVariants.variants.map((v) => ({ + ...v, + price: (v.price as Decimal).toNumber(), + })), }); }); @@ -114,7 +166,8 @@ describe("Product Service", () => { await expect(productPromise).rejects.toThrow("Product not found"); expect(mockPrisma.product.findUnique).toHaveBeenCalledWith({ where: { id: nonExistentId }, + include: { variants: true }, }); }); }); -}); +}); \ No newline at end of file diff --git a/src/services/product.service.ts b/src/services/product.service.ts index 3406570..6d890aa 100644 --- a/src/services/product.service.ts +++ b/src/services/product.service.ts @@ -10,29 +10,50 @@ export async function getProductsByCategorySlug( const category = await getCategoryBySlug(categorySlug); const products = await prisma.product.findMany({ where: { categoryId: category.id }, + include: { variants: true }, }); - return products.map((product) => ({ - ...product, - price: product.price.toNumber(), + return products.map((p) => ({ + ...p, + price: p.price.toNumber(), + variants: p.variants.map((v) => ({ + ...v, + price: v.price.toNumber(), + })), })); } export async function getProductById(id: number): Promise { const product = await prisma.product.findUnique({ where: { id }, + include: { variants: true }, }); if (!product) { throw new Error("Product not found"); } - return { ...product, price: product.price.toNumber() }; + return { + ...product, + price: product.price.toNumber(), + variants: product.variants.map((v) => ({ + ...v, + price: v.price.toNumber(), + })), + }; } export async function getAllProducts(): Promise { - return (await prisma.product.findMany()).map((p) => ({ + const products = await prisma.product.findMany({ + include: { variants: true }, + }); + + return products.map((p) => ({ ...p, price: p.price.toNumber(), + variants: p.variants.map((v) => ({ + ...v, + price: v.price.toNumber(), + })), })); }