diff --git a/.react-router/types/+register.ts b/.react-router/types/+register.ts index 8e951ae..5901e8b 100644 --- a/.react-router/types/+register.ts +++ b/.react-router/types/+register.ts @@ -29,4 +29,5 @@ type Params = { "/account/orders": {}; "/not-found": {}; "/verify-email": {}; + "/chat": {}; }; \ No newline at end of file diff --git a/README.md b/README.md index 74872fd..f593ea1 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,338 @@ -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@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 - -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: - -- Configure the top-level `parserOptions` property like this: - -```js -export default tseslint.config({ - languageOptions: { - // other options... - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - }, -}) -``` - -- 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, - }, -}) -``` +# FullStack E-commerce Frontend + +Una aplicación de e-commerce moderna construida con React Router, TypeScript, Prisma y diseñada específicamente para desarrolladores web. La aplicación incluye un sistema completo de variantes de producto que permite diferentes opciones como tallas y dimensiones. + +## 🚀 Características Principales + +- **Sistema de Variantes de Producto**: Soporte completo para productos con múltiples opciones (tallas para polos, dimensiones por stickers) +- **Carrito de Compras Inteligente**: Gestión de productos con sus variantes específicas +- **Checkout Seguro**: Integración con Culqi para pagos seguros +- **Chat Bot AI**: Asistente virtual powered by Gemini para recomendaciones de productos +- **Autenticación Completa**: Sistema de login/registro con gestión de sesiones +- **Responsive Design**: Interfaz adaptativa con Tailwind CSS + +## 📋 Implementación de Variantes de Producto + +### 🗄️ Modificaciones en la Base de Datos + +El sistema de variantes se implementó mediante las siguientes entidades en [prisma/schema.prisma](prisma/schema.prisma): + +```prisma +model VariantAttribute { + id Int @id @default(autoincrement()) + name String // "talla", "dimensiones", "color" + + variantAttributeValues VariantAttributeValue[] +} + +model VariantAttributeValue { + id Int @id @default(autoincrement()) + variantAttributeId Int @map("variant_attribute_id") + productId Int @map("product_id") + value String // "Small", "Medium", "Large", "3x3cm" + price Decimal @db.Decimal(10, 2) + + variantAttribute VariantAttribute @relation(fields: [variantAttributeId], references: [id]) + product Product @relation(fields: [productId], references: [id]) + cartItems CartItem[] +} +``` + +### 🎨 Interfaz de Usuario para Variantes + +#### Página de Producto ([src/routes/product/index.tsx](src/routes/product/index.tsx)) + +La página de producto fue actualizada para mostrar selectores de variantes dinámicos: + +- **Detección Automática**: Solo muestra selectores para productos con variantes (polos y stickers) +- **Agrupación por Atributo**: Las variantes se agrupan por tipo (talla, dimensión) +- **Actualización de Precio**: El precio se actualiza automáticamente al seleccionar una variante + +```tsx +// Ejemplo de selector de variantes +{shouldShowVariants && ( + <> + {Object.entries(variantGroups).map(([attributeName, variants]) => ( +
+

+ {attributeName.charAt(0).toUpperCase() + attributeName.slice(1)} +

+
+ {variants!.map((variant) => ( + + ))} +
+
+ ))} + +)} +``` + +#### Carrito de Compras ([src/routes/cart/index.tsx](src/routes/cart/index.tsx)) + +El carrito fue actualizado para mostrar la información completa de las variantes: + +- **Visualización de Variantes**: Muestra el valor de la variante seleccionada (ej: "Polo React (Medium)") +- **Gestión de Cantidades**: Cada variante se trata como un item único en el carrito +- **Precios Específicos**: Refleja el precio exacto de cada variante + +```tsx +// Ejemplo de visualización en el carrito +

+ {product.title} ({variantAttributeValue?.value}) +

+

+ S/{product.price!.toFixed(2)} c/u +

+``` + +#### Checkout ([src/routes/checkout/index.tsx](src/routes/checkout/index.tsx)) + +El proceso de checkout mantiene la información de variantes: + +- **Resumen Detallado**: Muestra cada producto con su variante específica +- **Cálculo Correcto**: Los totales reflejan los precios exactos de cada variante +- **Información Completa**: Se preserva toda la información para la orden final + +### 🛒 Lógica de Negocio + +#### Gestión de Carrito ([src/services/cart.service.ts](src/services/cart.service.ts)) + +```typescript +// Agregar producto con variante específica +export async function addToCart( + userId: number | undefined, + sessionCartId: string | undefined, + attributeValueId: number +): Promise +``` + +### 🔍 Lógica de Filtrado por Precios con Variantes + +El sistema de filtros implementa una lógica inteligente para productos con variantes: + +#### Comportamiento del Filtro +- **Evaluación por Variante**: El filtro analiza cada variante de precio individualmente +- **Inclusión Condicional**: Un producto se incluye si AL MENOS UNA de sus variantes está dentro del rango seleccionado +- **Visualización Resumida**: En las tarjetas se muestra el rango completo (precio mínimo - precio máximo) + +#### Ejemplo Práctico +``` +Producto: Sticker JavaScript +Variantes: S/2.99, S/3.99, S/4.99 +Rango mostrado: S/2.99 - S/4.99 + +Filtro S/1 - S/3: +✅ Incluido (porque S/2.99 está en el rango) + +Filtro S/5 - S/10: +❌ Excluido (ninguna variante está en el rango) +``` + + + +### 🤖 Integración con Chat Bot + +El chat bot fue actualizado para manejar preguntas sobre variantes ([src/services/chat-system-prompt.ts](src/services/chat-system-prompt.ts)): + +#### Manejo Inteligente de Variantes + +```typescript +// Formatear variantes según el tipo +switch (product.variantType) { + case 'talla': + const sizes = product.variants.map(v => v.value).join(", "); + variantDisplay = `\n- 👕 Tallas disponibles: ${sizes}`; + break; + case 'dimensión': + const dimensions = product.variants + .map(v => `${v.value} (S/${v.price})`) + .join(", "); + variantDisplay = `\n- 📐 Dimensiones: ${dimensions}`; + break; +} +``` + +#### Respuestas Contextuales + +- **Polos**: "¿Qué talla necesitas: Small, Medium o Large?" +- **Stickers**: "Tenemos 3 tamaños: 3x3cm (S/2.99), 5x5cm (S/3.99) o 10x10cm (S/4.99)" +- **Tazas**: Procede normal sin mencionar variantes + + + +## 📱 Consideraciones de UX/UI + +### Decisiones de Diseño para Variantes + +1. **Mostrar Solo Cuando Necesario**: Los selectores de variantes solo aparecen para productos que las tienen +2. **Agrupación Clara**: Las variantes se agrupan por tipo de atributo (talla, dimensión) +3. **Feedback Visual**: El botón seleccionado tiene un estilo diferente +4. **Precio Dinámico**: El precio se actualiza inmediatamente al cambiar variantes +5. **Validación**: No se puede agregar al carrito sin seleccionar una variante + +### Manejo de Precios + +- **Productos Sin Variantes**: Muestran precio fijo +- **Productos Con Variantes**: Muestran rango de precios, es decir el precio mínimo y máximo +- **Carrito y Checkout**: Siempre muestran precios específicos de la variante + + +## 🔧 Instalación y Configuración + +```bash +# Instalar dependencias +npm install + +# Configurar base de datos +npm run prisma:generate +npm run prisma:migrate:dev + +# Poblar con datos iniciales +npm run prisma:seed + +# Ejecutar en desarrollo +npm run dev + +# Ejecutar tests +npm run test +npm run test:e2e +``` + +### Productos con Variantes + +```typescript +// Polos - Variantes por talla +{ + title: "Polo React", + variantAttributeValues: [ + { value: "Small", price: 20.0, variantAttribute: { name: "talla" } }, + { value: "Medium", price: 20.0, variantAttribute: { name: "talla" } }, + { value: "Large", price: 20.0, variantAttribute: { name: "talla" } } + ] +} + +// Stickers - Variantes por dimensión +{ + title: "Sticker Docker", + variantAttributeValues: [ + { value: "3x3cm", price: 2.99, variantAttribute: { name: "dimensiones" } }, + { value: "5x5cm", price: 3.99, variantAttribute: { name: "dimensiones" } }, + { value: "10x10cm", price: 4.99, variantAttribute: { name: "dimensiones" } } + ] +} +``` + +## 🚀 Tecnologías Utilizadas + +- **Frontend**: React Router v7, TypeScript, Tailwind CSS +- **Backend**: Node.js con React Router Server Functions +- **Base de Datos**: PostgreSQL con Prisma ORM +- **Pagos**: Culqi Payment Gateway +- **AI**: Google Gemini para el chat bot +- **Testing**: Vitest, Playwright, React Testing Library +- **Deployment**: Docker, Railway + +## 📈 Tareas realizadas por cada integrante + +### 👨‍💻 Mike Vera +- **Sistema de Variantes de Producto**: Implementación completa del sistema de variantes con base de datos (Prisma schema) +- **Carrito con Variantes**: Actualización del carrito para mostrar información detallada de variantes seleccionadas + +### 👨‍💻 Sebastián +- **UI/UX Design & Responsive Design**: Implementación con Tailwind CSS y ajustes ligeros en 'initial_data.ts' y servicios para la correcta comunicación backend–frontend. +- **Actualización del ChatBot**: Actualización con nuevo contexto, prompts de comportamiento, estrategia de ventas, ejemplos de respuesta y lógica de recomendaciones. + + +### 👩‍💻 Janet +- **Modificacion del archivo de data inicial**: Modificación del archivo 'initial_data.ts' para las diferentes variables de productos +- **Actualización del servicio de productos**: Modificación de las funciones para integrar variantes de productos +- **Filtros de Precio Inteligentes**: Implementación de la lógica de filtrado que considera todas las variantes de precio +- **Test para Product**: Actualización de los test para product service y product route + + +### 🤝 Tareas Colaborativas +- **Arquitectura del Proyecto**: Definición conjunta de la estructura para contemplar variables de productos +- **Code Reviews**: Revisión cruzada de código y merge de pull requests entre los 3 integrantes + +- **Integración de Features**: Trabajo conjunto para integrar variantes, en los diferente modulos, cart, + +### 🔧 Metodología de Trabajo +- **Control de Versiones**: Git con GitHub Flow y branches por feature +- **Gestión de Proyecto**: Kanban board para tracking de tareas y sprints semanales +- **Comunicación**: Daily standups virtuales y sesiones de pair programming +- **Calidad de Código**: ESLint, Prettier y Husky configurados en conjunto + +--- + +*Este proyecto fue desarrollado como parte del bootcamp de Codeable, implementando un sistema completo de e-commerce con variantes de producto para una experiencia de usuario optimizada.* \ No newline at end of file diff --git a/prisma/initial_data.ts b/prisma/initial_data.ts index 0520e9e..a46b79b 100644 --- a/prisma/initial_data.ts +++ b/prisma/initial_data.ts @@ -29,11 +29,16 @@ export const categories = [ }, ]; +export const variantAttributes = [ + { name: "talla" }, + { name: "dimensiones" }, + { name: "no aplica" }, +] + export const products = [ { title: "Polo React", imgSrc: `${imagesBaseUrl}/polos/polo-react.png`, - price: 20.0, description: "Viste tu pasión por React con estilo y comodidad en cada línea de código.", categoryId: 1, @@ -48,7 +53,6 @@ export const products = [ { title: "Polo JavaScript", imgSrc: `${imagesBaseUrl}/polos/polo-js.png`, - price: 20.0, description: "Deja que tu amor por JavaScript hable a través de cada hilo de este polo.", categoryId: 1, @@ -63,7 +67,6 @@ export const products = [ { title: "Polo Node.js", imgSrc: `${imagesBaseUrl}/polos/polo-node.png`, - price: 20.0, description: "Conéctate al estilo con este polo de Node.js, tan robusto como tu código.", categoryId: 1, @@ -78,7 +81,6 @@ export const products = [ { title: "Polo TypeScript", imgSrc: `${imagesBaseUrl}/polos/polo-ts.png`, - price: 20.0, description: "Tipa tu estilo con precisión: lleva tu pasión por TypeScript en cada hilo.", categoryId: 1, @@ -93,7 +95,6 @@ export const products = [ { title: "Polo Backend Developer", imgSrc: `${imagesBaseUrl}/polos/polo-backend.png`, - price: 25.0, description: "Domina el servidor con estilo: viste con orgullo tu título de Backend Developer.", categoryId: 1, @@ -108,7 +109,6 @@ export const products = [ { title: "Polo Frontend Developer", imgSrc: `${imagesBaseUrl}/polos/polo-frontend.png`, - price: 25.0, description: "Construye experiencias con estilo: luce con orgullo tu polo de Frontend Developer.", categoryId: 1, @@ -123,7 +123,6 @@ export const products = [ { title: "Polo Full-Stack Developer", imgSrc: `${imagesBaseUrl}/polos/polo-fullstack.png`, - price: 25.0, description: "Domina ambos mundos con estilo: lleva tu título de FullStack Developer en cada línea de tu look.", categoryId: 1, @@ -138,7 +137,6 @@ export const products = [ { title: "Polo It's A Feature", imgSrc: `${imagesBaseUrl}/polos/polo-feature.png`, - price: 15.0, description: "Cuando el bug se convierte en arte: lleva con orgullo tu polo 'It's a feature'.", categoryId: 1, @@ -153,7 +151,6 @@ export const products = [ { title: "Polo It Works On My Machine", imgSrc: `${imagesBaseUrl}/polos/polo-works.png`, - price: 15.0, description: "El clásico del desarrollador: presume tu confianza con 'It works on my machine'.", categoryId: 1, @@ -168,7 +165,6 @@ export const products = [ { title: "Sticker JavaScript", imgSrc: `${imagesBaseUrl}/stickers/sticker-js.png`, - price: 2.99, description: "Muestra tu amor por JavaScript con este elegante sticker clásico.", categoryId: 3, @@ -183,7 +179,6 @@ export const products = [ { title: "Sticker React", imgSrc: `${imagesBaseUrl}/stickers/sticker-react.png`, - price: 2.49, description: "Decora tus dispositivos con el icónico átomo giratorio de React.", categoryId: 3, @@ -198,7 +193,6 @@ export const products = [ { title: "Sticker Git", imgSrc: `${imagesBaseUrl}/stickers/sticker-git.png`, - price: 3.99, description: "Visualiza el poder del control de versiones con este sticker de Git.", categoryId: 3, @@ -213,7 +207,6 @@ export const products = [ { title: "Sticker Docker", imgSrc: `${imagesBaseUrl}/stickers/sticker-docker.png`, - price: 2.99, description: "La adorable ballena de Docker llevando contenedores en un sticker único.", categoryId: 3, @@ -228,7 +221,6 @@ export const products = [ { title: "Sticker Linux", imgSrc: `${imagesBaseUrl}/stickers/sticker-linux.png`, - price: 2.49, description: "El querido pingüino Tux, mascota oficial de Linux, en formato sticker.", categoryId: 3, @@ -243,7 +235,6 @@ export const products = [ { title: "Sticker VS Code", imgSrc: `${imagesBaseUrl}/stickers/sticker-vscode.png`, - price: 2.49, description: "El elegante logo del editor favorito de los desarrolladores.", categoryId: 3, isOnSale: false, @@ -257,7 +248,6 @@ export const products = [ { title: "Sticker GitHub", imgSrc: `${imagesBaseUrl}/stickers/sticker-github.png`, - price: 2.99, description: "El alojamiento de repositorios más popular en un sticker de alta calidad.", categoryId: 3, @@ -272,7 +262,6 @@ export const products = [ { title: "Sticker HTML", imgSrc: `${imagesBaseUrl}/stickers/sticker-html.png`, - price: 2.99, description: "El escudo naranja de HTML5, el lenguaje que estructura la web.", categoryId: 3, @@ -287,7 +276,6 @@ export const products = [ { title: "Taza JavaScript", imgSrc: `${imagesBaseUrl}/tazas/taza-js.png`, - price: 14.99, description: "Disfruta tu café mientras programas con el logo de JavaScript.", categoryId: 2, @@ -302,7 +290,6 @@ export const products = [ { title: "Taza React", imgSrc: `${imagesBaseUrl}/tazas/taza-react.png`, - price: 13.99, description: "Una taza que hace render de tu bebida favorita con estilo React.", categoryId: 2, @@ -317,7 +304,6 @@ export const products = [ { title: "Taza Git", imgSrc: `${imagesBaseUrl}/tazas/taza-git.png`, - price: 12.99, description: "Commit a tu rutina diaria de café con esta taza de Git.", categoryId: 2, isOnSale: false, @@ -331,7 +317,6 @@ export const products = [ { title: "Taza SQL", imgSrc: `${imagesBaseUrl}/tazas/taza-sql.png`, - price: 15.99, description: "Tu amor por los lenguajes estructurados en una taza de SQL.", categoryId: 2, isOnSale: false, @@ -345,7 +330,6 @@ export const products = [ { title: "Taza Linux", imgSrc: `${imagesBaseUrl}/tazas/taza-linux.png`, - price: 13.99, description: "Toma tu café con la libertad que solo Linux puede ofrecer.", categoryId: 2, isOnSale: false, @@ -359,7 +343,6 @@ export const products = [ { title: "Taza GitHub", imgSrc: `${imagesBaseUrl}/tazas/taza-github.png`, - price: 14.99, description: "Colabora con tu café en esta taza con el logo de GitHub.", categoryId: 2, isOnSale: false, @@ -371,3 +354,83 @@ export const products = [ ], }, ]; + +export const variantAttributeValues = [ + // --- POLOS (talla: S, M, L) --- + { attributeId: 1, productId: 1, value: "Small", price: 20.0 }, + { attributeId: 1, productId: 1, value: "Medium", price: 20.0 }, + { attributeId: 1, productId: 1, value: "Large", price: 20.0 }, + + { attributeId: 1, productId: 2, value: "Small", price: 20.0 }, + { attributeId: 1, productId: 2, value: "Medium", price: 20.0 }, + { attributeId: 1, productId: 2, value: "Large", price: 20.0 }, + + { attributeId: 1, productId: 3, value: "Small", price: 20.0 }, + { attributeId: 1, productId: 3, value: "Medium", price: 20.0 }, + { attributeId: 1, productId: 3, value: "Large", price: 20.0 }, + + { attributeId: 1, productId: 4, value: "Small", price: 20.0 }, + { attributeId: 1, productId: 4, value: "Medium", price: 20.0 }, + { attributeId: 1, productId: 4, value: "Large", price: 20.0 }, + + { attributeId: 1, productId: 5, value: "Small", price: 25.0 }, + { attributeId: 1, productId: 5, value: "Medium", price: 25.0 }, + { attributeId: 1, productId: 5, value: "Large", price: 25.0 }, + + { attributeId: 1, productId: 6, value: "Small", price: 25.0 }, + { attributeId: 1, productId: 6, value: "Medium", price: 25.0 }, + { attributeId: 1, productId: 6, value: "Large", price: 25.0 }, + + { attributeId: 1, productId: 7, value: "Small", price: 25.0 }, + { attributeId: 1, productId: 7, value: "Medium", price: 25.0 }, + { attributeId: 1, productId: 7, value: "Large", price: 25.0 }, + + { attributeId: 1, productId: 8, value: "Small", price: 15.0 }, + { attributeId: 1, productId: 8, value: "Medium", price: 15.0 }, + { attributeId: 1, productId: 8, value: "Large", price: 15.0 }, + + { attributeId: 1, productId: 9, value: "Small", price: 15.0 }, + { attributeId: 1, productId: 9, value: "Medium", price: 15.0 }, + { attributeId: 1, productId: 9, value: "Large", price: 15.0 }, + + // --- STICKERS (dimensiones: 3x3, 6x6, 9x9) --- + { attributeId: 2, productId: 10, value: "3x3 cm", price: 2.99 }, + { attributeId: 2, productId: 10, value: "5x5 cm", price: 3.99 }, + { attributeId: 2, productId: 10, value: "10x10 cm", price: 4.99 }, + + { attributeId: 2, productId: 11, value: "3x3 cm", price: 2.49 }, + { attributeId: 2, productId: 11, value: "5x5 cm", price: 3.49 }, + { attributeId: 2, productId: 11, value: "10x10 cm", price: 4.49 }, + + { attributeId: 2, productId: 12, value: "3x3 cm", price: 3.99 }, + { attributeId: 2, productId: 12, value: "5x5 cm", price: 4.99 }, + { attributeId: 2, productId: 12, value: "10x10 cm", price: 5.99 }, + + { attributeId: 2, productId: 13, value: "3x3 cm", price: 2.99 }, + { attributeId: 2, productId: 13, value: "5x5 cm", price: 3.99 }, + { attributeId: 2, productId: 13, value: "10x10 cm", price: 4.99 }, + + { attributeId: 2, productId: 14, value: "3x3 cm", price: 2.49 }, + { attributeId: 2, productId: 14, value: "5x5 cm", price: 3.49 }, + { attributeId: 2, productId: 14, value: "10x10 cm", price: 4.49 }, + + { attributeId: 2, productId: 15, value: "3x3 cm", price: 2.49 }, + { attributeId: 2, productId: 15, value: "5x5 cm", price: 3.49 }, + { attributeId: 2, productId: 15, value: "10x10 cm", price: 4.49 }, + + { attributeId: 2, productId: 16, value: "3x3 cm", price: 2.99 }, + { attributeId: 2, productId: 16, value: "5x5 cm", price: 3.99 }, + { attributeId: 2, productId: 16, value: "10x10 cm", price: 4.99 }, + + { attributeId: 2, productId: 17, value: "3x3 cm", price: 2.99 }, + { attributeId: 2, productId: 17, value: "5x5 cm", price: 3.99 }, + { attributeId: 2, productId: 17, value: "10x10 cm", price: .99 }, + + // --- TAZAS (no aplica: Único) --- + { attributeId: 3, productId: 18, value: "Único", price: 14.99 }, + { attributeId: 3, productId: 19, value: "Único", price: 13.99 }, + { attributeId: 3, productId: 20, value: "Único", price: 12.99 }, + { attributeId: 3, productId: 21, value: "Único", price: 15.99 }, + { attributeId: 3, productId: 22, value: "Único", price: 13.99 }, + { attributeId: 3, productId: 23, value: "Único", price: 14.99 }, +]; \ No newline at end of file diff --git a/prisma/migrations/20250820183901_add_tables_variants_products/migration.sql b/prisma/migrations/20250820183901_add_tables_variants_products/migration.sql new file mode 100644 index 0000000..f64a02c --- /dev/null +++ b/prisma/migrations/20250820183901_add_tables_variants_products/migration.sql @@ -0,0 +1,43 @@ +/* + Warnings: + + - You are about to drop the column `price` on the `products` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "products" DROP COLUMN "price"; + +-- CreateTable +CREATE TABLE "variants_attributes" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "created_at" TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "variants_attributes_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "variants_attributes_values" ( + "id" SERIAL NOT NULL, + "attribute_id" INTEGER NOT NULL, + "product_id" INTEGER NOT NULL, + "value" TEXT NOT NULL, + "price" DECIMAL(10,2) NOT NULL, + "created_at" TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "variants_attributes_values_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "variants_attributes_name_key" ON "variants_attributes"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "variants_attributes_values_attribute_id_product_id_value_key" ON "variants_attributes_values"("attribute_id", "product_id", "value"); + +-- AddForeignKey +ALTER TABLE "variants_attributes_values" ADD CONSTRAINT "variants_attributes_values_attribute_id_fkey" FOREIGN KEY ("attribute_id") REFERENCES "variants_attributes"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "variants_attributes_values" ADD CONSTRAINT "variants_attributes_values_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20250822015032_update_table_cart_with_attribute_id/migration.sql b/prisma/migrations/20250822015032_update_table_cart_with_attribute_id/migration.sql new file mode 100644 index 0000000..aab9f1b --- /dev/null +++ b/prisma/migrations/20250822015032_update_table_cart_with_attribute_id/migration.sql @@ -0,0 +1,23 @@ +/* + Warnings: + + - You are about to drop the column `product_id` on the `cart_items` table. All the data in the column will be lost. + - A unique constraint covering the columns `[cart_id,attribute_value_id]` on the table `cart_items` will be added. If there are existing duplicate values, this will fail. + - Added the required column `attribute_value_id` to the `cart_items` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "cart_items" DROP CONSTRAINT "cart_items_product_id_fkey"; + +-- DropIndex +DROP INDEX "cart_items_cart_id_product_id_key"; + +-- AlterTable +ALTER TABLE "cart_items" DROP COLUMN "product_id", +ADD COLUMN "attribute_value_id" INTEGER NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "cart_items_cart_id_attribute_value_id_key" ON "cart_items"("cart_id", "attribute_value_id"); + +-- AddForeignKey +ALTER TABLE "cart_items" ADD CONSTRAINT "cart_items_attribute_value_id_fkey" FOREIGN KEY ("attribute_value_id") REFERENCES "variants_attributes_values"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20250901061035_update_table_order_item/migration.sql b/prisma/migrations/20250901061035_update_table_order_item/migration.sql new file mode 100644 index 0000000..02e9b86 --- /dev/null +++ b/prisma/migrations/20250901061035_update_table_order_item/migration.sql @@ -0,0 +1,16 @@ +/* + Warnings: + + - You are about to drop the column `product_id` on the `order_items` table. All the data in the column will be lost. + - Added the required column `attribute_value_id` to the `order_items` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "order_items" DROP CONSTRAINT "order_items_product_id_fkey"; + +-- AlterTable +ALTER TABLE "order_items" DROP COLUMN "product_id", +ADD COLUMN "attribute_value_id" INTEGER NOT NULL; + +-- AddForeignKey +ALTER TABLE "order_items" ADD CONSTRAINT "order_items_attribute_value_id_fkey" FOREIGN KEY ("attribute_value_id") REFERENCES "variants_attributes_values"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e0f992b..2a77ce9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -55,7 +55,6 @@ model Product { title String imgSrc String @map("img_src") alt String? - price Decimal @db.Decimal(10, 2) description String? categoryId Int? @map("category_id") isOnSale Boolean @default(false) @map("is_on_sale") @@ -63,13 +62,43 @@ 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) - cartItems CartItem[] - orderItems OrderItem[] + category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) + + variantAttributeValues VariantAttributeValue[] @@map("products") } +model VariantAttribute { + id Int @id @default(autoincrement()) + name String @unique + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) + + variantsAttributeValue VariantAttributeValue[] + + @@map("variants_attributes") +} + +model VariantAttributeValue { + id Int @id @default(autoincrement()) + attributeId Int @map("attribute_id") + productId Int @map("product_id") + value String + price Decimal @db.Decimal(10, 2) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) + + variantAttribute VariantAttribute @relation(fields: [attributeId], references: [id]) + product Product @relation(fields: [productId], references: [id]) + + CartItem CartItem[] + OrderItem OrderItem[] + + @@unique([attributeId, productId, value], name: "unique_attribute_product_value") + @@map("variants_attributes_values") +} + model Cart { id Int @id @default(autoincrement()) sessionCartId String @unique @default(dbgenerated("gen_random_uuid()")) @map("session_cart_id") @db.Uuid @@ -86,15 +115,15 @@ model Cart { model CartItem { id Int @id @default(autoincrement()) cartId Int @map("cart_id") - productId Int @map("product_id") + attributeValueId Int @map("attribute_value_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) + variantAttributeValue VariantAttributeValue @relation(fields: [attributeValueId], references: [id], onDelete: Cascade) - @@unique([cartId, productId], name: "unique_cart_item") + @@unique([cartId, attributeValueId], name: "unique_cart_item") @@map("cart_items") } @@ -123,18 +152,18 @@ model Order { } 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") + attributeValueId Int @map("attribute_value_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) + variantAttributeValue VariantAttributeValue @relation(fields: [attributeValueId], references: [id], onDelete: Cascade) @@map("order_items") } diff --git a/prisma/seed.ts b/prisma/seed.ts index 106da46..4a2b3e5 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,4 +1,4 @@ -import { categories, products } from "./initial_data"; +import { categories, products, variantAttributes, variantAttributeValues } from "./initial_data"; import { PrismaClient } from "../generated/prisma/client"; const prisma = new PrismaClient(); @@ -9,10 +9,23 @@ async function seedDb() { }); console.log("1. Categories successfully inserted"); + await prisma.variantAttribute.createMany({ + data: variantAttributes, + }) + console.log("2. Variant Attributes successfully inserted"); + await prisma.product.createMany({ data: products, + }); - console.log("2. Products successfully inserted"); + console.log("3. Products successfully inserted"); + + await prisma.variantAttributeValue.createMany({ + data: variantAttributeValues, + }) + + console.log("4. Variant Attribute Values successfully inserted"); + } seedDb() diff --git a/src/lib/cart.ts b/src/lib/cart.ts index e0308df..d14534c 100644 --- a/src/lib/cart.ts +++ b/src/lib/cart.ts @@ -1,5 +1,6 @@ -import type { CartItem, CartItemInput } from "@/models/cart.model"; -import { type Product } from "@/models/product.model"; +import type { CartItem, CartItemWithProduct } from "@/models/cart.model"; +// import { type Product } from "@/models/product.model"; +import { type VariantAttributeValue } from "@/models/variant-attribute.model"; import { alterQuantityCartItem, deleteRemoteCartItem, @@ -18,14 +19,14 @@ export async function getCart(userId?: number, sessionCartId?: string) { export async function addToCart( userId: number | undefined, sessionCartId: string | undefined, - productId: Product["id"], + attributeValueId: VariantAttributeValue["id"], quantity: number = 1 ) { try { const updatedCart = await alterQuantityCartItem( userId, sessionCartId, - productId, + attributeValueId, quantity ); return updatedCart; @@ -54,18 +55,30 @@ export async function removeFromCart( } } -export function calculateTotal(items: CartItem[]): number; -export function calculateTotal(items: CartItemInput[]): number; +// Función para CartItem +function calculateCartItemTotal(items: CartItem[]): number { + return items.reduce((total, item) => { + const price = item.variantAttributeValue ? Number(item.variantAttributeValue.price) || 0 : 0; + return total + (price * item.quantity); + }, 0); +} -export function calculateTotal(items: CartItem[] | CartItemInput[]): number { +// Función para CartItemWithProduct +function calculateCartItemWithProductTotal(items: CartItemWithProduct[]): number { return items.reduce((total, item) => { - // 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; - } else { - // CartItemInput - has price directly - return total + item.price * item.quantity; - } + const price = typeof item.product.price === 'number' ? item.product.price : 0; + return total + (price * item.quantity); }, 0); } + +export function calculateTotal(items: CartItem[]): number; +export function calculateTotal(items: CartItemWithProduct[]): number; + +export function calculateTotal(items: CartItem[] | CartItemWithProduct[]): number { + // Verificar si es CartItemWithProduct comprobando la estructura + if (items.length > 0 && 'product' in items[0]) { + return calculateCartItemWithProductTotal(items as CartItemWithProduct[]); + } else { + return calculateCartItemTotal(items as CartItem[]); + } +} diff --git a/src/lib/utils.tests.ts b/src/lib/utils.tests.ts index 1526f23..b7d7472 100644 --- a/src/lib/utils.tests.ts +++ b/src/lib/utils.tests.ts @@ -2,13 +2,15 @@ import { vi } from "vitest"; import type { Category } from "@/models/category.model"; import type { Order, OrderDetails, OrderItem } from "@/models/order.model"; -import type { Product } from "@/models/product.model"; +import type { Product, VariantAttributeValueWithNumber } from "@/models/product.model"; import type { User } from "@/models/user.model"; import type { OrderItem as PrismaOrderItem, Order as PrismaOrder, Product as PrismaProduct, + VariantAttributeValue as PrismaVariantAttributeValue, + } from "@/../generated/prisma/client"; import type { Session } from "react-router"; @@ -53,38 +55,40 @@ export const createMockSession = (userId: number | null): Session => ({ unset: vi.fn(), }); -export const createTestProduct = (overrides?: Partial): Product => ({ +export const createTestDBProduct = ( + overrides?: Partial & { variantAttributeValues?: PrismaVariantAttributeValue[] } +): PrismaProduct & { variantAttributeValues: PrismaVariantAttributeValue[] } => ({ id: 1, title: "Test Product", imgSrc: "/test-image.jpg", alt: "Test alt text", - price: 100, description: "Test description", categoryId: 1, isOnSale: false, features: ["Feature 1", "Feature 2"], createdAt: new Date(), updatedAt: new Date(), + variantAttributeValues: overrides?.variantAttributeValues ?? [createTestDBVariantAttributeValue()], ...overrides, }); -export const createTestDBProduct = ( - overrides?: Partial -): PrismaProduct => ({ +// --- FRONTEND PRODUCT --- +export const createTestProduct = (overrides?: Partial): Product => ({ id: 1, title: "Test Product", imgSrc: "/test-image.jpg", alt: "Test alt text", - price: new Decimal(100), description: "Test description", categoryId: 1, isOnSale: false, features: ["Feature 1", "Feature 2"], createdAt: new Date(), updatedAt: new Date(), + variantAttributeValues: [createTestVariantAttributeValue()], ...overrides, }); + export const createTestCategory = ( overrides?: Partial ): Category => ({ @@ -99,6 +103,32 @@ export const createTestCategory = ( ...overrides, }); +export const createTestDBVariantAttributeValue = ( + overrides?: Partial +): PrismaVariantAttributeValue => ({ + id: 1, + attributeId: 1, + productId: 1, + value: "Default", + price: new Decimal(100), + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, +}); +export const createTestVariantAttributeValue = ( + overrides: Partial = {} +): VariantAttributeValueWithNumber => ({ + id: 1, + attributeId: 1, + productId: 1, + value: "Default", + price: 100, // ya es number + createdAt: new Date(), + updatedAt: new Date(), + variantAttribute: { id: 1, name: "Talla", createdAt: new Date(), updatedAt: new Date() }, + ...overrides, +}); + export const createTestOrderDetails = ( overrides: Partial = {} ): OrderDetails => ({ @@ -121,13 +151,22 @@ export const createTestOrderItem = ( ({ id: 1, orderId: 1, - productId: 1, + attributeValueId: 1, quantity: 1, title: "Test Product", price: 100, imgSrc: "test-image.jpg", createdAt: new Date(), updatedAt: new Date(), + variantAttributeValue: { + id: 1, + attributeId: 1, + productId: 1, + value: "Default", + price: new Decimal(100), + createdAt: new Date(), + updatedAt: new Date(), + }, ...overrides, } satisfies OrderItem); @@ -137,7 +176,7 @@ export const createTestDBOrderItem = ( ({ id: 1, orderId: 1, - productId: 1, + attributeValueId: 1, quantity: 1, title: "Test Product", price: new Decimal(100), diff --git a/src/models/cart.model.ts b/src/models/cart.model.ts index ad4206a..493cd8e 100644 --- a/src/models/cart.model.ts +++ b/src/models/cart.model.ts @@ -3,6 +3,7 @@ import { type Product } from "./product.model"; import type { Cart as PrismaCart, CartItem as PrismaCartItem, + VariantAttributeValue } from "@/../generated/prisma/client"; export type CartItem = PrismaCartItem & { @@ -10,6 +11,7 @@ export type CartItem = PrismaCartItem & { Product, "id" | "title" | "imgSrc" | "alt" | "price" | "isOnSale" >; + variantAttributeValue?: VariantAttributeValue; }; export type Cart = PrismaCart; @@ -23,7 +25,6 @@ export interface CartItemInput { } // Tipo para representar un producto simplificado en el carrito - export type CartProductInfo = Pick< Product, "id" | "title" | "imgSrc" | "alt" | "price" | "isOnSale" @@ -33,6 +34,8 @@ export type CartProductInfo = Pick< export type CartItemWithProduct = { product: CartProductInfo; quantity: number; + attributeValueId: number; + variantAttributeValue?: VariantAttributeValue; }; // Tipo para el carrito con items y productos incluidos diff --git a/src/models/order.model.ts b/src/models/order.model.ts index 3ac10a4..ccaf1dd 100644 --- a/src/models/order.model.ts +++ b/src/models/order.model.ts @@ -1,6 +1,7 @@ import type { Order as PrismaOrder, OrderItem as PrismaOrderItem, + VariantAttributeValue } from "@/../generated/prisma/client"; export type OrderDetails = Pick< @@ -19,6 +20,7 @@ export type OrderDetails = Pick< export type OrderItem = Omit & { price: number; + variantAttributeValue?: VariantAttributeValue; }; export type Order = Omit & { @@ -28,7 +30,7 @@ export type Order = Omit & { }; export interface OrderItemInput { - productId: number; + attributeValueId: number; quantity: number; title: string; price: number; diff --git a/src/models/product.model.ts b/src/models/product.model.ts index 96ba043..3a282e2 100644 --- a/src/models/product.model.ts +++ b/src/models/product.model.ts @@ -1,5 +1,18 @@ -import type { Product as PrismaProduct } from "@/../generated/prisma/client"; +import type { VariantAttributeValue as PrismaVariantAttributeValue, } from "./variant-attribute.model"; +import type { Product as PrismaProduct, VariantAttribute } from "@/../generated/prisma/client"; -export type Product = Omit & { - price: number; +export type Product = PrismaProduct & { + price?: number | null; + minPrice?: number | null; + maxPrice?: number | null; + variantAttributeValues?: VariantAttributeValueWithNumber[]; }; + +export type VariantAttributeValueWithNumber = Omit & { + price: number + variantAttribute: VariantAttribute +} + +export type ProductDTO = PrismaProduct & { + variantAttributeValues: PrismaVariantAttributeValue[]; +}; \ No newline at end of file diff --git a/src/models/variant-attribute.model.ts b/src/models/variant-attribute.model.ts new file mode 100644 index 0000000..acbda1b --- /dev/null +++ b/src/models/variant-attribute.model.ts @@ -0,0 +1,5 @@ +import type { VariantAttributeValue as PrismaVariantAttributeValue, VariantAttribute } from "@/../generated/prisma/client"; + +export type VariantAttributeValue = PrismaVariantAttributeValue & { + variantAttribute?: VariantAttribute; +}; \ No newline at end of file diff --git a/src/routes/account/orders/index.tsx b/src/routes/account/orders/index.tsx index dccd7c0..8d4f9d0 100644 --- a/src/routes/account/orders/index.tsx +++ b/src/routes/account/orders/index.tsx @@ -13,6 +13,9 @@ export async function loader({ request }: Route.LoaderArgs) { try { const orders = await getOrdersByUser(request); + // console.log("ORDERS", orders[0].items[0].variantAttributeValue?.value); + console.log("ORDERS", orders[0].items); + orders.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); return { orders }; @@ -29,7 +32,7 @@ export default function Orders({ loaderData }: Route.ComponentProps) { {orders!.length > 0 ? (
{orders!.map((order) => ( -
+
@@ -87,37 +90,43 @@ export default function Orders({ loaderData }: Route.ComponentProps) { - {order.items.map((item) => ( - - -
-
- {item.title} -
-
-
- {item.title} + {order.items.map((item) => { + const productTitle = item.variantAttributeValue?.value + ? `${item.title} (${item.variantAttributeValue.value})` + : item.title; + + return ( + + +
+
+ {productTitle}
-
- {item.quantity} × S/{item.price.toFixed(2)} +
+
+ {productTitle} +
+
+ {item.quantity} × S/{item.price.toFixed(2)} +
-
- - - S/{item.price.toFixed(2)} - - - {item.quantity} - - - S/{(item.price * item.quantity).toFixed(2)} - - - ))} + + + S/{item.price.toFixed(2)} + + + {item.quantity} + + + S/{(item.price * item.quantity).toFixed(2)} + + + ); + })}
diff --git a/src/routes/cart/add-item/index.tsx b/src/routes/cart/add-item/index.tsx index ac49758..a8adce0 100644 --- a/src/routes/cart/add-item/index.tsx +++ b/src/routes/cart/add-item/index.tsx @@ -7,14 +7,12 @@ import type { Route } from "../+types"; export async function action({ request }: Route.ActionArgs) { const formData = await request.formData(); - const productId = Number(formData.get("productId")); + const attributeValueId = Number(formData.get("attributeValueId")); const quantity = Number(formData.get("quantity")) || 1; 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, attributeValueId, quantity); return redirect(redirectTo || "/cart"); } diff --git a/src/routes/cart/index.tsx b/src/routes/cart/index.tsx index d330cef..5870afd 100644 --- a/src/routes/cart/index.tsx +++ b/src/routes/cart/index.tsx @@ -30,64 +30,71 @@ 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, variantAttributeValue }) => ( +
+
+ {product.alt
-
-

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

-
-
- +
+
+

+ {product.title} ({variantAttributeValue?.value}) +

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

+ ${product.price!.toFixed(2)} +

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

Total

S/{total.toFixed(2)}

@@ -106,3 +113,4 @@ export default function Cart({ loaderData }: Route.ComponentProps) { ); } + diff --git a/src/routes/category/components/price-filter/index.tsx b/src/routes/category/components/price-filter/index.tsx index 5337413..4bc29f5 100644 --- a/src/routes/category/components/price-filter/index.tsx +++ b/src/routes/category/components/price-filter/index.tsx @@ -1,11 +1,11 @@ -import { Form } from "react-router"; +import { useSubmit } from "react-router"; import { Button, Input } from "@/components/ui"; import { cn } from "@/lib/utils"; interface PriceFilterProps { - minPrice: string; - maxPrice: string; + minPrice?: number; + maxPrice?: number; className?: string; } @@ -14,15 +14,24 @@ export function PriceFilter({ maxPrice, className, }: PriceFilterProps) { + const submit = useSubmit() + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + const formData = new FormData(e.currentTarget) + const minPrice = formData.get("minPrice") + const maxPrice = formData.get("maxPrice") + if (!minPrice && !maxPrice) return + if (maxPrice && minPrice && maxPrice < minPrice) return + submit(formData, { method: 'get', action: window.location.pathname }) + } return ( -
+
Precio
Filtrar Productos - + ); } diff --git a/src/routes/category/components/product-card/index.tsx b/src/routes/category/components/product-card/index.tsx index a6abe33..6526ded 100644 --- a/src/routes/category/components/product-card/index.tsx +++ b/src/routes/category/components/product-card/index.tsx @@ -6,8 +6,14 @@ interface ProductCardProps { product: Product; } +const stickerCategoryId = 3; // ID de la categoría "Stickers" + export function ProductCard({ product }: ProductCardProps) { + + const isSticker = product.categoryId === stickerCategoryId; + return ( + <>

{product.title}

{product.description}

-

S/{product.price}

+ {isSticker ? ( +
+

+ Entre +

+

+ S/{product.minPrice} - S/{product.maxPrice} +

+
+ ) : ( +
+

+ Precio +

+

S/{product.price}

+
+ )}
{product.isOnSale && ( @@ -34,5 +56,6 @@ export function ProductCard({ product }: ProductCardProps) { )}
+ ); } diff --git a/src/routes/category/index.tsx b/src/routes/category/index.tsx index 7c0aef5..a25f856 100644 --- a/src/routes/category/index.tsx +++ b/src/routes/category/index.tsx @@ -2,9 +2,8 @@ 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 { getProductsByCategorySlug } from "@/services/product.service"; +import { filterByMinMaxPrice } from "@/services/product.service"; import { PriceFilter } from "./components/price-filter"; import { ProductCard } from "./components/product-card"; @@ -19,38 +18,24 @@ 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 minPrice = url.searchParams.get("minPrice"); + const minValue = minPrice ? parseFloat(minPrice) : undefined; + const maxPrice = url.searchParams.get("maxPrice"); + const maxValue = maxPrice ? parseFloat(maxPrice) : undefined; + try { - const [category, products] = await Promise.all([ + const [category] = await Promise.all([ getCategoryBySlug(categorySlug), - 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 filteredProducts = filterProductsByPrice( - products, - minPrice, - maxPrice - ); + const finalProducts = await filterByMinMaxPrice(categorySlug, minValue, maxValue); return { category, - products: filteredProducts, - minPrice, - maxPrice, + products: finalProducts, + minPrice: minValue, + maxPrice: maxValue, + finalProducts }; } catch (e) { throw new Response("Error loading category: " + e, { status: 500 }); diff --git a/src/routes/checkout/index.tsx b/src/routes/checkout/index.tsx index 1ceb7ae..0f10cd0 100644 --- a/src/routes/checkout/index.tsx +++ b/src/routes/checkout/index.tsx @@ -19,7 +19,7 @@ import { type CulqiInstance, } from "@/hooks/use-culqui"; import { calculateTotal, getCart } from "@/lib/cart"; -import { type CartItem } from "@/models/cart.model"; +import { type CartItem, type CartItemWithProduct } from "@/models/cart.model"; import { getCurrentUser } from "@/services/auth.service"; import { deleteRemoteCart } from "@/services/cart.service"; import { createOrder } from "@/services/order.service"; @@ -103,12 +103,18 @@ export async function action({ request }: Route.ActionArgs) { const chargeData = await response.json(); - const items = cartItems.map((item) => ({ - productId: item.product.id, + const items: CartItemWithProduct[] = cartItems.map((item) => ({ + product: { + id: item.product.id, + title: item.product.title, + imgSrc: item.product.imgSrc, + alt: item.product.alt, + price: item.product.price ?? 0, + isOnSale: item.product.isOnSale, + }, quantity: item.quantity, - title: item.product.title, - price: item.product.price, - imgSrc: item.product.imgSrc, + attributeValueId: item.attributeValueId, + variantAttributeValue: item.variantAttributeValue, })); const { id: orderId } = await createOrder( @@ -249,9 +255,9 @@ export default function Checkout({

Resumen de la orden

- {cart?.items?.map(({ product, quantity }) => ( + {cart?.items?.map(({ product, quantity, variantAttributeValue }) => (
@@ -262,11 +268,11 @@ export default function Checkout({ />
-

{product.title}

+

{product.title} ({variantAttributeValue?.value})

{quantity}

-

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

+

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

@@ -301,56 +307,79 @@ export default function Checkout({ Información de envío
- - - - {errors.company?.message &&

{errors.company?.message}

} - - - - - +
+
+ +
+
+ +
+
+ + + {errors.company?.message &&

{errors.company?.message}

} + + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ (initialVariantId); + const [currentPrice, setCurrentPrice] = useState(0); + + // Verificar si el producto tiene variantes + const hasVariants = product?.variantAttributeValues && product.variantAttributeValues.length > 0; + + // Verificar si debe mostrar selectores (solo polos y stickers) + const shouldShowVariants = hasVariants && (product?.categoryId === shirtId || product?.categoryId === stickerId); + + // Agrupar variantes por atributo + const variantGroups = shouldShowVariants + ? product.variantAttributeValues!.reduce((groups, variant) => { + const attributeName = variant!.variantAttribute!.name; + if (!groups[attributeName]) { + groups[attributeName] = []; + } + groups[attributeName].push(variant); + return groups; + }, {} as Record) + : {}; + + useEffect(() => { + if (!product) return; + + if (hasVariants && product.variantAttributeValues) { + const firstVariant = product.variantAttributeValues[selectedVariant ? product.variantAttributeValues.findIndex(v => v.id === selectedVariant) : 0]; + setSelectedVariant(firstVariant.id); + setCurrentPrice(Number(firstVariant.price)); + } else { + setCurrentPrice(Number(product.price || 0)); + } + }, [product, hasVariants, selectedVariant]); + + // Funciones después de los hooks + const handleVariantChange = (variantId: number) => { + setSelectedVariant(variantId); + const variant = product?.variantAttributeValues?.find(v => v.id === variantId); + if (variant) { + setCurrentPrice(Number(variant.price)); + } + }; if (!product) { return ; @@ -41,28 +91,74 @@ export default function Product({ loaderData }: Route.ComponentProps) {

{product.title}

-

S/{product.price}

+ + {/* Precio dinámico */} +

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

+

{product.description}

+ + {/* Selectores de variantes dinámicos - solo para polos y stickers */} + {shouldShowVariants && ( + <> + {Object.entries(variantGroups).map(([attributeName, variants]) => ( +
+

+ {attributeName.charAt(0).toUpperCase() + attributeName.slice(1)} +

+
+ {variants!.map((variant) => ( + + ))} +
+
+ ))} + + )} + + {/* Formulario actualizado para enviar variante seleccionada */}
+ + {/* Enviar la variante seleccionada si existe y debe mostrar variantes */} + {shouldShowVariants && selectedVariant && ( + + )}
+ +

Características @@ -78,4 +174,4 @@ export default function Product({ loaderData }: Route.ComponentProps) { ); -} +} \ No newline at end of file diff --git a/src/routes/product/product.test.tsx b/src/routes/product/product.test.tsx index f70059d..3c2c5c2 100644 --- a/src/routes/product/product.test.tsx +++ b/src/routes/product/product.test.tsx @@ -1,163 +1,123 @@ -import { render, screen } from "@testing-library/react"; -import { useNavigation } from "react-router"; +import { render, screen, fireEvent } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; -import { createTestProduct } from "@/lib/utils.tests"; -import type { Product as ProductType } from "@/models/product.model"; +import { createTestProduct, createTestVariantAttributeValue } from "@/lib/utils.tests"; +import type { Product as ProductModel, VariantAttributeValueWithNumber } from "@/models/product.model"; import Product from "."; import type { Route } from "./+types"; -// Helper function to create a test navigation object -const createTestNavigation = (overrides = {}) => ({ - state: "idle" as const, - location: undefined, - formMethod: undefined, - formAction: undefined, - formEncType: undefined, - formData: undefined, - json: undefined, - text: undefined, - ...overrides, -}); - // Mock de react-router -vi.mock("react-router", () => ({ - Form: vi.fn(({ children }) =>
{children}
), - useNavigation: vi.fn(() => createTestNavigation()), - Link: vi.fn(({ children, ...props }) => {children}), -})); +vi.mock("react-router", () => { + const actual = vi.importActual("react-router"); // mantener los demás exports reales + return { + ...actual, + Form: vi.fn(({ children }) =>
{children}
), + useNavigation: vi.fn(() => ({ state: "idle" } )), + useSearchParams: vi.fn(() => [new URLSearchParams(), vi.fn()]), + Link: vi.fn(({ children, ...props }) => {children}), + }; +}); const createTestProps = ( - productData: Partial = {} + productData: Partial = {} ): Route.ComponentProps => ({ loaderData: { product: createTestProduct(productData) }, params: { id: "123" }, - // Hack to satisfy type requirements matches: [] as unknown as Route.ComponentProps["matches"], }); describe("Product Component", () => { describe("Rendering with valid product data", () => { it("should render product title correctly", () => { - // Step 1: Setup - Create test props const props = createTestProps({ title: "Awesome Product" }); - // Step 2: Mock - Component mocks already set up above - // Step 3: Call - Render component render(); - // Step 4: Verify - Check title is rendered correctly - const titleElement = screen.getByRole("heading", { level: 1 }); - expect(titleElement).toHaveTextContent("Awesome Product"); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Awesome Product"); }); it("should render product price with correct currency", () => { - // Step 1: Setup - Create test props - const props = createTestProps({ price: 150.99 }); - // Step 2: Mock - Component mocks already set up above - // Step 3: Call - Render component + const props = createTestProps({ + categoryId: 1, // Para que el componente muestre variantes + variantAttributeValues: [ + createTestVariantAttributeValue({ id: 1, value: "S", price: 100 }), + createTestVariantAttributeValue({ id: 2, value: "M", price: 120 }), + ], +}); render(); - // Step 4: Verify - Check price is rendered correctly - expect(screen.queryByText("S/150.99")).toBeInTheDocument(); + expect(screen.getByText("S/100.00")).toBeInTheDocument(); }); it("should render product description", () => { - // Step 1: Setup - Create test props - const props = createTestProps({ - description: "Amazing product", - }); - // Step 2: Mock - Component mocks already set up above - // Step 3: Call - Render component + const props = createTestProps({ description: "Amazing product" }); render(); - // Step 4: Verify - Check description is rendered - expect(screen.queryByText("Amazing product")).toBeInTheDocument(); + expect(screen.getByText("Amazing product")).toBeInTheDocument(); }); - it("should render product image with correct src and alt attributes", () => { - // Step 1: Setup - Create test props + it("should render product image with correct src and alt", () => { const props = createTestProps({ imgSrc: "/test-image.jpg", alt: "Test Product", }); - // Step 2: Mock - Component mocks already set up above - // Step 3: Call - Render component render(); - // Step 4: Verify - Check image attributes const image = screen.getByRole("img"); expect(image).toHaveAttribute("src", "/test-image.jpg"); expect(image).toHaveAttribute("alt", "Test Product"); }); it("should render all product features as list items", () => { - // Step 1: Setup - Create test props const features = ["Feature 1", "Feature 2", "Feature 3"]; const props = createTestProps({ features }); - // Step 2: Mock - Component mocks already set up above - // Step 3: Call - Render component render(); - // Step 4: Verify - Check features are rendered features.forEach((feature) => { - expect(screen.queryByText(feature)).toBeInTheDocument(); + expect(screen.getByText(feature)).toBeInTheDocument(); }); }); it('should render "Agregar al Carrito" button', () => { - // Step 1: Setup - Create test props const props = createTestProps(); - // Step 2: Mock - Component mocks already set up above - // Step 3: Call - Render component render(); - // Step 4: Verify - Check button is present - expect( - screen.queryByRole("button", { name: "Agregar al Carrito" }) - ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Agregar al Carrito" })).toBeInTheDocument(); }); - }); - describe("Form interactions", () => { - it("should include hidden redirectTo input with correct value", () => { - // Step 1: Setup - const productId = 123; - const props = createTestProps({ id: productId }); - // Step 2: Mock - Component mocks already set up above - // Step 3: Call - render(); - // Step 4: Verify - const redirectInput = screen.queryByDisplayValue( - `/products/${productId}` - ); - expect(redirectInput).toBeInTheDocument(); - }); + it("should render variants and update price when variant is selected", () => { + const props = createTestProps({ + categoryId: 1, + variantAttributeValues: [ + { + id: 1, + attributeId: 1, + productId: 1, + value: "S", + price: 100, + createdAt: new Date(), + updatedAt: new Date(), + variantAttribute: { id: 1, name: "Talla" }, + }, + { + id: 2, + attributeId: 1, + productId: 1, + value: "M", + price: 120, + createdAt: new Date(), + updatedAt: new Date(), + variantAttribute: { id: 1, name: "Talla" }, + }, + ] as VariantAttributeValueWithNumber[], + }); - it("should disable button when cart is loading", () => { - // Step 1: Setup - const props = createTestProps(); - const expectedNavigation = createTestNavigation({ state: "submitting" }); - // Step 2: Mock - Override navigation state to simulate loading - vi.mocked(useNavigation).mockReturnValue(expectedNavigation); - // Step 3: Call render(); - // Step 4: Verify - const button = screen.getByRole("button"); - expect(button).toBeDisabled(); - expect(button).toHaveTextContent("Agregando..."); - }); - }); - describe("Error handling", () => { - it("should render NotFound component when product is not provided", () => { - // Step 1: Setup - Create props without product - const props = createTestProps(); - props.loaderData.product = undefined; + const smallBtn = screen.getByRole("button", { name: "S" }); + const mediumBtn = screen.getByRole("button", { name: "M" }); + expect(smallBtn).toBeInTheDocument(); + expect(mediumBtn).toBeInTheDocument(); - // Step 2: Mock - Mock NotFound component - // vi.mock("../not-found", () => ({ - // default: () =>
Not Found Page
, - // })); - // Step 3: Call - render(); - // Step 4: Verify - expect(screen.getByTestId("not-found")).toBeInTheDocument(); + expect(screen.getByText("S/100.00")).toBeInTheDocument(); + + fireEvent.click(mediumBtn); + expect(screen.getByText("S/120.00")).toBeInTheDocument(); }); }); }); diff --git a/src/routes/root/index.tsx b/src/routes/root/index.tsx index f3197d1..b76631d 100644 --- a/src/routes/root/index.tsx +++ b/src/routes/root/index.tsx @@ -68,7 +68,7 @@ export async function loader({ request }: Route.LoaderArgs) { } const totalItems = - cart?.items.reduce((total, item) => total + item.quantity, 0) || 0; + cart?.items?.reduce((total, item) => total + item.quantity, 0) || 0; // Preparar datos de respuesta según estado de autenticación const responseData = user ? { user, totalItems } : { totalItems }; diff --git a/src/services/cart.service.ts b/src/services/cart.service.ts index f742706..417bcbb 100644 --- a/src/services/cart.service.ts +++ b/src/services/cart.service.ts @@ -24,14 +24,17 @@ async function getCart( include: { items: { include: { - product: { - select: { - id: true, - title: true, - imgSrc: true, - alt: true, - price: true, - isOnSale: true, + variantAttributeValue: { + include: { + product: { + select: { + id: true, + title: true, + imgSrc: true, + alt: true, + isOnSale: true, + }, + }, }, }, }, @@ -49,9 +52,10 @@ async function getCart( items: data.items.map((item) => ({ ...item, product: { - ...item.product, - price: item.product.price.toNumber(), + ...item.variantAttributeValue.product, + price: item.variantAttributeValue.price.toNumber(), }, + variantAttributeValue: item.variantAttributeValue, })), }; } @@ -82,14 +86,17 @@ export async function getOrCreateCart( include: { items: { include: { - product: { - select: { - id: true, - title: true, - imgSrc: true, - alt: true, - price: true, - isOnSale: true, + variantAttributeValue: { + include: { + product: { + select: { + id: true, + title: true, + imgSrc: true, + alt: true, + isOnSale: true, + }, + }, }, }, }, @@ -104,9 +111,10 @@ export async function getOrCreateCart( items: newCart.items.map((item) => ({ ...item, product: { - ...item.product, - price: item.product.price.toNumber(), + ...item.variantAttributeValue.product, + price: item.variantAttributeValue.price.toNumber(), }, + variantAttributeValue: item.variantAttributeValue, })), }; } @@ -130,7 +138,7 @@ export async function createRemoteItems( await prisma.cartItem.createMany({ data: items.map((item) => ({ cartId: cart.id, - productId: item.product.id, + attributeValueId: item.attributeValueId, quantity: item.quantity, })), }); @@ -146,12 +154,14 @@ export async function createRemoteItems( export async function alterQuantityCartItem( userId: User["id"] | undefined, sessionCartId: string | undefined, - productId: number, + attributeValueId: number, quantity: number = 1 ): Promise { const cart = await getOrCreateCart(userId, sessionCartId); - const existingItem = cart.items.find((item) => item.product.id === productId); + const existingItem = cart.items.find( + (item) => item.attributeValueId === attributeValueId + ); if (existingItem) { const newQuantity = existingItem.quantity + quantity; @@ -170,7 +180,7 @@ export async function alterQuantityCartItem( await prisma.cartItem.create({ data: { cartId: cart.id, - productId, + attributeValueId: attributeValueId, quantity, }, }); @@ -236,14 +246,17 @@ export async function linkCartToUser( include: { items: { include: { - product: { - select: { - id: true, - title: true, - imgSrc: true, - alt: true, - price: true, - isOnSale: true, + variantAttributeValue: { + include: { + product: { + select: { + id: true, + title: true, + imgSrc: true, + alt: true, + isOnSale: true, + }, + }, }, }, }, @@ -258,9 +271,10 @@ export async function linkCartToUser( items: updatedCart.items.map((item) => ({ ...item, product: { - ...item.product, - price: item.product.price.toNumber(), + ...item.variantAttributeValue.product, + price: item.variantAttributeValue.price.toNumber(), }, + variantAttributeValue: item.variantAttributeValue, })), }; } @@ -285,41 +299,48 @@ export async function mergeGuestCartWithUserCart( include: { items: { include: { - product: { - select: { - id: true, - title: true, - imgSrc: true, - alt: true, - price: true, - isOnSale: true, + variantAttributeValue: { + include: { + product: { + select: { + id: true, + title: true, + imgSrc: true, + alt: true, + isOnSale: true, + }, + }, }, }, }, }, }, }); + return { ...updatedCart, items: updatedCart.items.map((item) => ({ ...item, product: { - ...item.product, - price: item.product.price.toNumber(), + ...item.variantAttributeValue.product, + price: item.variantAttributeValue.price.toNumber(), }, + variantAttributeValue: item.variantAttributeValue, })), }; } // Obtener productos duplicados para eliminarlos del carrito del usuario - const guestProductIds = guestCart.items.map((item) => item.productId); + const guestAttributeValueIds = guestCart.items.map( + (item) => item.attributeValueId + ); // Eliminar productos del carrito usuario que también existan en el carrito invitado await prisma.cartItem.deleteMany({ where: { cartId: userCart.id, - productId: { - in: guestProductIds, + attributeValueId: { + in: guestAttributeValueIds, }, }, }); @@ -328,7 +349,7 @@ export async function mergeGuestCartWithUserCart( await prisma.cartItem.createMany({ data: guestCart.items.map((item) => ({ cartId: userCart.id, - productId: item.productId, + attributeValueId: item.attributeValueId, quantity: item.quantity, })), }); @@ -341,3 +362,4 @@ export async function mergeGuestCartWithUserCart( // Devolver el carrito actualizado del usuario return await getCart(userId); } + diff --git a/src/services/chat-system-prompt.ts b/src/services/chat-system-prompt.ts index 30401b0..0313ad6 100644 --- a/src/services/chat-system-prompt.ts +++ b/src/services/chat-system-prompt.ts @@ -1,6 +1,6 @@ import type { CartWithItems } from "@/models/cart.model"; import type { Category } from "@/models/category.model"; -import type { Product } from "@/models/product.model"; +import type { Product, VariantAttributeValueWithNumber } from "@/models/product.model"; interface SystemPromptConfig { categories: Category[]; @@ -13,32 +13,75 @@ export function generateSystemPrompt({ products, userCart, }: SystemPromptConfig): string { - const onSaleProducts = products.filter((p) => p.isOnSale); - const salesSection = - onSaleProducts.length > 0 - ? ` + + // Procesar productos con información de variantes + const processedProducts = products.map(product => { + const category = categories.find((c) => c.id === product.categoryId); + + // Formatear precio según si tiene variantes o no + let priceDisplay = ""; + if (product.price) { + priceDisplay = `S/${product.price}`; + } else if (product.minPrice && product.maxPrice) { + priceDisplay = `S/${product.minPrice} - S/${product.maxPrice}`; + } + + // Formatear variantes según el tipo + let variantDisplay = ""; + if (product.variantAttributeValues && product.variantAttributeValues.length > 0) { + const variantType = product.variantAttributeValues[0]?.variantAttribute?.name; + + switch (variantType) { + case 'talla': { + const sizes = product.variantAttributeValues.map((v: VariantAttributeValueWithNumber) => v.value).join(", "); + variantDisplay = `\n- 👕 Tallas disponibles: ${sizes}`; + break; + } + case 'dimensión': { + const dimensions = product.variantAttributeValues + .map((v: VariantAttributeValueWithNumber) => `${v.value} (S/${v.price})`) + .join(", "); + variantDisplay = `\n- 📐 Dimensiones: ${dimensions}`; + break; + } + default: { + const options = product.variantAttributeValues + .map((v: VariantAttributeValueWithNumber) => `${v.value} (S/${v.price})`) + .join(", "); + variantDisplay = `\n- ⚙️ Opciones: ${options}`; + } + } + } + + return { + ...product, + categoryTitle: category?.title || "Sin categoría", + priceDisplay, + variantDisplay + }; + }); + + // Procesar productos en oferta + const onSaleProducts = processedProducts.filter((p) => p.isOnSale); + const salesSection = onSaleProducts.length > 0 + ? ` ## 🔥 PRODUCTOS EN OFERTA ESPECIAL: ${onSaleProducts - .map( - (product) => ` -- **${product.title}** - S/${product.price} ⚡ [Ver oferta](/products/${product.id}) -` - ) + .map(product => ` +- **${product.title}** - ${product.priceDisplay} ⚡ [Ver oferta](/products/${product.id})`) .join("")} ` - : ""; + : ""; + // Procesar carrito del usuario const cartSection = userCart?.items?.length ? ` ## 🛒 CARRITO ACTUAL DEL USUARIO: El usuario tiene actualmente ${userCart.items.length} producto(s) en su carrito: ${userCart.items - .map( - (item) => ` + .map(item => ` - **${item.product.title}** (Cantidad: ${item.quantity}) - S/${item.product.price} - Link: [Ver producto](/products/${item.product.id}) -` - ) + Link: [Ver producto](/products/${item.product.id})`) .join("")} **IMPORTANTE**: Usa esta información para hacer recomendaciones inteligentes: @@ -48,9 +91,29 @@ ${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 +- Si en el carrito el usuario tiene un polo, recomienda un producto de la misma categoría y variante (talla) que la del polo presente en su carrito ` : ""; + // Generar categorías + const categoriesSection = categories + .map(cat => ` +**${cat.title}** (${cat.slug}) +- Descripción: ${cat.description} +- Link: [Ver categoría](/category/${cat.slug})`) + .join("\n"); + + // Generar productos + const productsSection = processedProducts + .map(product => ` +**${product.title}** +- 💰 Precio: ${product.priceDisplay}${product.isOnSale ? " ⚡ ¡EN OFERTA!" : ""} +- 📝 Descripción: ${product.description} +- 🏷️ Categoría: ${product.categoryTitle} +- ✨ Características: ${product.features.join(", ")}${product.variantDisplay} +- 🔗 Link: [Ver producto](/products/${product.id})`) + .join("\n"); + return ` # Asistente Virtual de Full Stock @@ -65,44 +128,48 @@ Eres un asistente virtual especializado en **Full Stock**, una tienda de product - Si te preguntan sobre temas completamente no relacionados, redirige brevemente hacia los productos - Usa un lenguaje natural y cercano, pero profesional - Siempre termina con una pregunta directa o llamada a la acción +- **EMPATIZA** con los problemas típicos de developers (debugging, deadlines, stack decisions) +- **TONO**: Casual pero experto - como un desarrollador que entiende a otros developers ## PRODUCTOS DISPONIBLES: ### Categorías: -${categories - .map( - (cat) => ` -**${cat.title}** (${cat.slug}) -- Descripción: ${cat.description} -- Link: [Ver categoría](/category/${cat.slug}) -` - ) - .join("\n")} +${categoriesSection} ### Productos: -${products - .map((product) => { - const category = categories.find((c) => c.id === product.categoryId); - return ` -**${product.title}** -- 💰 Precio: 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}) -`; - }) - .join("\n")} +${productsSection} ${salesSection} ${cartSection} +## MANEJO DE VARIANTES DE PRODUCTOS: +**IMPORTANTE**: Cuando un usuario muestre interés en un producto con variantes: + +### Para POLOS (Tallas): +- Si preguntan por un polo, menciona: "¿Qué talla necesitas: Small, Medium o Large?" +- Ejemplo: "¡El [Polo React](/products/1) está disponible en tallas Small, Medium y Large por S/20! ¿Cuál prefieres?" + +### Para STICKERS (Dimensiones): +- Menciona las opciones con precios, es decir, menciona cada dimensión con su respectivo precio +- Ejemplo: "¡El [Sticker Docker](/products/10) viene en varios tamaños! ¿Prefieres 3x3cm (S/2.99), 5x5cm (S/3.99) o 10x10cm (S/4.99)?" + +### Para PRODUCTOS ÚNICOS (Tazas): +- Procede normal, no menciones variantes +- Ejemplo: "¡La [Taza JavaScript](/products/18) por S/14.99 es perfecta para tu café matutino!" + +### Reglas Generales: +- **SIEMPRE pregunta por la variante** cuando el usuario muestre interés en el producto +- **Incluye precios** solo si varían entre opciones +- **Sé específico** sobre las opciones disponibles +- **Facilita la decisión** con recomendaciones si es necesario + ## INSTRUCCIONES PARA RESPUESTAS: - **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) +- **AL MENCIONAR PRODUCTOS CON VARIANTES**, pregunta inmediatamente por la opción preferida - 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 @@ -123,6 +190,9 @@ ${cartSection} - **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 +- **Variantes como valor**: Destaca las opciones disponibles como ventaja del producto +- **Desinterés**: Si el usuario muestra desinterés, ofrece alternativas o pregunta sobre sus necesidades específicas +- **Regla de variante automática**: Si el usuario muestra interés en un un polo, siempre pregunta automáticamente por la talla (Small, Medium o Large) del usuario ## LÓGICA DE RECOMENDACIONES BASADAS EN CARRITO: **Si el usuario tiene productos en su carrito y pide recomendaciones:** @@ -144,17 +214,19 @@ Cuando te pregunten sobre tecnologías que tenemos en productos (React, Docker, 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?" +- **Tallas**: "Nuestros polos vienen en tallas Small, Medium, Large. ¿Cuál prefieres?" +- **Dimensiones**: "Nuestros stickers vienen en 3 dimensiones distintas, 3x3 cm, 5x5 cm y 10x10 cm. ¿Cuál sería la mejor para ti?" - **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?" -- **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?" +## EJEMPLOS DE RESPUESTAS CORTAS CON VARIANTES: +- "¡Te recomiendo el [Polo React](/products/1) por S/20! 🚀 ¿Qué talla necesitas: Small, Medium o Large?" +- "La [Taza Docker](/products/2) por S/14.99 es ideal para tus momentos de café. ¿La agregamos?" + +- "La [Taza JavaScript](/products/18) por S/14.99 es perfecta para programar. ¿La agregamos?" +- **Ejemplo con carrito (React)**: "Veo que tienes el Polo React! Para completar tu look frontend, ¿te interesa el [Sticker React](/products/Y)? Viene en 3 tamaños diferentes." +- **Ejemplo con carrito (Backend)**: "Perfecto, tienes productos backend. El [Polo Node.js](/products/Z) combinaría genial. ¿Qué talla usas: Small, Medium o Large?" ¿En qué puedo ayudarte hoy a encontrar el producto perfecto para ti? 🛒✨ `; diff --git a/src/services/order.service.test.ts b/src/services/order.service.test.ts index f5ffa51..640ee65 100644 --- a/src/services/order.service.test.ts +++ b/src/services/order.service.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it, vi } from "vitest"; +import { Decimal } from "@prisma/client/runtime/library"; +import { describe, expect, it, vi, beforeEach } from "vitest"; import { prisma as mockPrisma } from "@/db/prisma"; import { calculateTotal } from "@/lib/cart"; @@ -11,7 +12,7 @@ import { createTestRequest, createTestUser, } from "@/lib/utils.tests"; -import type { CartItemInput } from "@/models/cart.model"; +import type { CartItemWithProduct } from "@/models/cart.model"; import { getSession } from "@/session.server"; import { createOrder, getOrdersByUser } from "./order.service"; @@ -31,20 +32,34 @@ vi.mock("@/lib/cart"); vi.mock("@/session.server"); describe("Order Service", () => { - const mockedItems: CartItemInput[] = [ + beforeEach(() => { + vi.clearAllMocks(); + }); + + const mockedItems: CartItemWithProduct[] = [ { - productId: 1, + product: { + id: 1, + title: "Test Product", + price: 19.99, + imgSrc: "test-product.jpg", + alt: "Test Product Alt", + isOnSale: false, + }, quantity: 2, - title: "Test Product", - price: 19.99, - imgSrc: "test-product.jpg", + attributeValueId: 1, }, { - productId: 2, + product: { + id: 2, + title: "Another Product", + price: 29.99, + imgSrc: "another-product.jpg", + alt: "Another Product Alt", + isOnSale: true, + }, quantity: 1, - title: "Another Product", - price: 29.99, - imgSrc: "another-product.jpg", + attributeValueId: 2, }, ]; @@ -53,61 +68,105 @@ describe("Order Service", () => { const mockedTotalAmount = 200; const mockedRequest = createTestRequest(); - it("should create an order", async () => { - const prismaOrder = { - ...createTestDBOrder(), - items: [createTestDBOrderItem()], - }; - - vi.mocked(getOrCreateUser).mockResolvedValue(mockedUser); - vi.mocked(calculateTotal).mockReturnValue(mockedTotalAmount); - - vi.mocked(mockPrisma.order.create).mockResolvedValue(prismaOrder); - - const order = await createOrder(mockedItems, mockedFormData, "payment-id"); - expect(mockPrisma.order.create).toHaveBeenCalledWith({ - data: { - userId: mockedUser.id, - totalAmount: mockedTotalAmount, - email: mockedFormData.email, - firstName: mockedFormData.firstName, - lastName: mockedFormData.lastName, - company: mockedFormData.company, - address: mockedFormData.address, - city: mockedFormData.city, - country: mockedFormData.country, - region: mockedFormData.region, - zip: mockedFormData.zip, - phone: mockedFormData.phone, - items: { - create: mockedItems.map((item) => ({ - productId: item.productId, - quantity: item.quantity, - title: item.title, - price: item.price, - imgSrc: item.imgSrc, - })), + describe("createOrder", () => { + it("should create an order successfully", async () => { + const prismaOrder = { + ...createTestDBOrder(), + items: [ + { + ...createTestDBOrderItem(), + variantAttributeValue: { + id: 1, + value: "Test Value", + price: 19.99, + product: { + id: 1, + title: "Test Product", + imgSrc: "test-product.jpg", + alt: "Test Product Alt", + isOnSale: false, + }, + }, + }, + ], + }; + + vi.mocked(getOrCreateUser).mockResolvedValue(mockedUser); + vi.mocked(calculateTotal).mockReturnValue(mockedTotalAmount); + vi.mocked(mockPrisma.order.create).mockResolvedValue(prismaOrder); + + const order = await createOrder(mockedItems, mockedFormData, "payment-id"); + + expect(getOrCreateUser).toHaveBeenCalledWith(mockedFormData.email); + expect(calculateTotal).toHaveBeenCalledWith(mockedItems); + expect(mockPrisma.order.create).toHaveBeenCalledWith({ + data: { + userId: mockedUser.id, + totalAmount: mockedTotalAmount, + email: mockedFormData.email, + firstName: mockedFormData.firstName, + lastName: mockedFormData.lastName, + company: mockedFormData.company, + address: mockedFormData.address, + city: mockedFormData.city, + country: mockedFormData.country, + region: mockedFormData.region, + zip: mockedFormData.zip, + phone: mockedFormData.phone, + items: { + create: mockedItems.map((item) => ({ + attributeValueId: item.attributeValueId, + quantity: item.quantity, + title: item.product.title, + price: item.product.price ?? 0, + imgSrc: item.product.imgSrc ?? "", + })), + }, + paymentId: "payment-id", + }, + include: { + items: { + include: { + variantAttributeValue: { + include: { + product: { + select: { + id: true, + title: true, + imgSrc: true, + alt: true, + isOnSale: true, + }, + }, + }, + }, + }, + }, + }, + }); + + expect(order).toEqual({ + ...prismaOrder, + totalAmount: Number(prismaOrder.totalAmount), + items: prismaOrder.items.map((item) => ({ + ...item, + price: Number(item.price), + variantAttributeValue: item.variantAttributeValue, + })), + createdAt: prismaOrder.createdAt, + updatedAt: prismaOrder.updatedAt, + details: { + email: prismaOrder.email, + firstName: prismaOrder.firstName, + lastName: prismaOrder.lastName, + company: prismaOrder.company, + address: prismaOrder.address, + city: prismaOrder.city, + country: prismaOrder.country, + region: prismaOrder.region, + zip: prismaOrder.zip, + phone: prismaOrder.phone, }, - paymentId: "payment-id", - }, - include: { - items: true, - }, - }); - expect(order).toEqual({ - ...prismaOrder, - totalAmount: Number(prismaOrder.totalAmount), - items: prismaOrder.items.map((item) => ({ - ...item, - price: Number(item.price), - imgSrc: item.imgSrc ?? "", - productId: item.productId ?? 0, - createdAt: item.createdAt, - updatedAt: item.updatedAt, - })), - createdAt: prismaOrder.createdAt, - updatedAt: prismaOrder.updatedAt, - details: { email: prismaOrder.email, firstName: prismaOrder.firstName, lastName: prismaOrder.lastName, @@ -118,43 +177,141 @@ describe("Order Service", () => { region: prismaOrder.region, zip: prismaOrder.zip, phone: prismaOrder.phone, - }, - paymentId: prismaOrder.paymentId, + paymentId: prismaOrder.paymentId, + }); }); - }); - it("should get orders by user", async () => { - const prismaOrders = [ - { ...createTestDBOrder(), items: [createTestOrderItem()] }, - { - ...createTestDBOrder({ id: 2 }), - items: [createTestOrderItem({ id: 2 })], - }, - ]; - const mockedSession = createMockSession(mockedUser.id); - vi.mocked(getSession).mockResolvedValue(mockedSession); - vi.mocked(mockPrisma.order.findMany).mockResolvedValue(prismaOrders); - const orders = await getOrdersByUser(mockedRequest); - expect(mockPrisma.order.findMany).toHaveBeenCalledWith({ - where: { userId: mockedUser.id }, - include: { items: true }, - orderBy: { createdAt: "desc" }, + it("should throw error if order creation fails", async () => { + vi.mocked(getOrCreateUser).mockResolvedValue(mockedUser); + vi.mocked(calculateTotal).mockReturnValue(mockedTotalAmount); + vi.mocked(mockPrisma.order.create).mockRejectedValue( + new Error("Database error") + ); + + await expect( + createOrder(mockedItems, mockedFormData, "payment-id") + ).rejects.toThrow("Failed to create order"); + + expect(mockPrisma.order.create).toHaveBeenCalled(); }); - expect(orders).toEqual( - prismaOrders.map((order) => ({ - ...order, - totalAmount: Number(order.totalAmount), - items: order.items.map((item) => ({ - ...item, - price: Number(item.price), - imgSrc: item.imgSrc ?? "", - productId: item.productId ?? 0, - createdAt: item.createdAt, - updatedAt: item.updatedAt, - })), - createdAt: order.createdAt, - updatedAt: order.updatedAt, - details: { + + it("should handle empty items array", async () => { + vi.mocked(getOrCreateUser).mockResolvedValue(mockedUser); + vi.mocked(calculateTotal).mockReturnValue(0); + + const prismaOrder = { + ...createTestDBOrder({ totalAmount: new Decimal(0) }), + items: [], + }; + + vi.mocked(mockPrisma.order.create).mockResolvedValue(prismaOrder); + + const order = await createOrder([], mockedFormData, "payment-id"); + + expect(order.items).toEqual([]); + expect(order.totalAmount).toBe(0); + }); + }); + + describe("getOrdersByUser", () => { + it("should get orders by user successfully", async () => { + const prismaOrders = [ + { + ...createTestDBOrder(), + items: [ + { + ...createTestOrderItem(), + variantAttributeValue: { + id: 1, + value: "Test Value", + price: 19.99, + product: { + id: 1, + title: "Test Product", + imgSrc: "test-product.jpg", + alt: "Test Product Alt", + isOnSale: false, + }, + }, + }, + ], + }, + { + ...createTestDBOrder({ id: 2 }), + items: [ + { + ...createTestOrderItem({ id: 2 }), + variantAttributeValue: { + id: 2, + value: "Test Value 2", + price: 29.99, + product: { + id: 2, + title: "Another Product", + imgSrc: "another-product.jpg", + alt: "Another Product Alt", + isOnSale: true, + }, + }, + }, + ], + }, + ]; + const mockedSession = createMockSession(mockedUser.id); + + vi.mocked(getSession).mockResolvedValue(mockedSession); + vi.mocked(mockPrisma.order.findMany).mockResolvedValue(prismaOrders); + + const orders = await getOrdersByUser(mockedRequest); + + expect(getSession).toHaveBeenCalledWith("session=mock-session-id"); + expect(mockPrisma.order.findMany).toHaveBeenCalledWith({ + where: { userId: mockedUser.id }, + include: { + items: { + include: { + variantAttributeValue: { + include: { + product: { + select: { + id: true, + title: true, + imgSrc: true, + alt: true, + isOnSale: true, + }, + }, + }, + }, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + + expect(orders).toEqual( + prismaOrders.map((order) => ({ + ...order, + totalAmount: Number(order.totalAmount), + items: order.items.map((item) => ({ + ...item, + price: Number(item.price), + variantAttributeValue: item.variantAttributeValue, + })), + createdAt: order.createdAt, + updatedAt: order.updatedAt, + details: { + email: order.email, + firstName: order.firstName, + lastName: order.lastName, + company: order.company, + address: order.address, + city: order.city, + country: order.country, + region: order.region, + zip: order.zip, + phone: order.phone, + }, email: order.email, firstName: order.firstName, lastName: order.lastName, @@ -165,34 +322,68 @@ describe("Order Service", () => { region: order.region, zip: order.zip, phone: order.phone, - }, - })) - ); - }); + })) + ); + }); - it("should throw error if user is not authenticated", async () => { - const mockedSession = createMockSession(null); + it("should throw error if user is not authenticated", async () => { + const mockedSession = createMockSession(null); - vi.mocked(getSession).mockResolvedValue(mockedSession); + vi.mocked(getSession).mockResolvedValue(mockedSession); - await expect(getOrdersByUser(mockedRequest)).rejects.toThrow( - "User not authenticated" - ); + await expect(getOrdersByUser(mockedRequest)).rejects.toThrow( + "User not authenticated" + ); - expect(getSession).toHaveBeenCalledWith("session=mock-session-id"); - }); + expect(getSession).toHaveBeenCalledWith("session=mock-session-id"); + expect(mockPrisma.order.findMany).not.toHaveBeenCalled(); + }); + + it("should return empty array if user has no orders", async () => { + const mockedSession = createMockSession(mockedUser.id); - it("should throw error if order creation fails", async () => { - vi.mocked(getOrCreateUser).mockResolvedValue(mockedUser); - vi.mocked(calculateTotal).mockReturnValue(mockedTotalAmount); - vi.mocked(mockPrisma.order.create).mockRejectedValue( - new Error("Database error") - ); + vi.mocked(getSession).mockResolvedValue(mockedSession); + vi.mocked(mockPrisma.order.findMany).mockResolvedValue([]); - await expect( - createOrder(mockedItems, mockedFormData, "payment-id") - ).rejects.toThrow("Failed to create order"); + const orders = await getOrdersByUser(mockedRequest); - expect(mockPrisma.order.create).toHaveBeenCalled(); + expect(orders).toEqual([]); + expect(mockPrisma.order.findMany).toHaveBeenCalledWith({ + where: { userId: mockedUser.id }, + include: { + items: { + include: { + variantAttributeValue: { + include: { + product: { + select: { + id: true, + title: true, + imgSrc: true, + alt: true, + isOnSale: true, + }, + }, + }, + }, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + }); + + it("should handle database errors gracefully", async () => { + const mockedSession = createMockSession(mockedUser.id); + + vi.mocked(getSession).mockResolvedValue(mockedSession); + vi.mocked(mockPrisma.order.findMany).mockRejectedValue( + new Error("Database connection error") + ); + + await expect(getOrdersByUser(mockedRequest)).rejects.toThrow( + "Failed to fetch orders" + ); + }); }); -}); +}); \ No newline at end of file diff --git a/src/services/order.service.ts b/src/services/order.service.ts index 6f5948a..1592c6e 100644 --- a/src/services/order.service.ts +++ b/src/services/order.service.ts @@ -1,13 +1,13 @@ import { prisma } from "@/db/prisma"; import { calculateTotal } from "@/lib/cart"; -import { type CartItemInput } from "@/models/cart.model"; +import { type CartItemWithProduct } from "@/models/cart.model"; import { type Order, type OrderDetails } from "@/models/order.model"; import { getSession } from "@/session.server"; import { getOrCreateUser } from "./user.service"; export async function createOrder( - items: CartItemInput[], + items: CartItemWithProduct[], formData: OrderDetails, paymentId: string ): Promise { @@ -25,17 +25,33 @@ export async function createOrder( ...shippingDetails, items: { create: items.map((item) => ({ - productId: item.productId, + attributeValueId: item.attributeValueId, quantity: item.quantity, - title: item.title, - price: item.price, - imgSrc: item.imgSrc, + title: item.product.title, + price: item.product.price ?? 0, + imgSrc: item.product.imgSrc ?? "", })), }, paymentId: paymentId, }, include: { - items: true, + items: { + include: { + variantAttributeValue: { + include: { + product: { + select: { + id: true, + title: true, + imgSrc: true, + alt: true, + isOnSale: true, + }, + }, + }, + }, + }, + }, }, }); } catch (error) { @@ -54,16 +70,14 @@ export async function createOrder( zip: order.zip, phone: order.phone, }; + return { ...order, totalAmount: Number(order.totalAmount), items: order.items.map((item) => ({ ...item, price: Number(item.price), - imgSrc: item.imgSrc, - productId: item.productId, - createdAt: item.createdAt, - updatedAt: item.updatedAt, + variantAttributeValue: item.variantAttributeValue, })), createdAt: order.createdAt, updatedAt: order.updatedAt, @@ -78,15 +92,39 @@ export async function getOrdersByUser(request: Request): Promise { if (!userId) { throw new Error("User not authenticated"); } - const orders = await prisma.order.findMany({ - where: { userId }, - include: { - items: true, - }, - orderBy: { - createdAt: "desc", - }, - }); + + let orders; + + try { + orders = await prisma.order.findMany({ + where: { userId }, + include: { + items: { + include: { + variantAttributeValue: { + include: { + product: { + select: { + id: true, + title: true, + imgSrc: true, + alt: true, + isOnSale: true, + }, + }, + }, + }, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + } catch (error) { + throw new Error("Failed to fetch orders", { cause: error }); + } + return orders.map((order) => { const details = { email: order.email, @@ -100,14 +138,14 @@ export async function getOrdersByUser(request: Request): Promise { zip: order.zip, phone: order.phone, }; + return { ...order, totalAmount: Number(order.totalAmount), items: order.items.map((item) => ({ ...item, price: Number(item.price), - createdAt: item.createdAt, - updatedAt: item.updatedAt, + variantAttributeValue: item.variantAttributeValue, })), createdAt: order.createdAt, updatedAt: order.updatedAt, @@ -115,4 +153,4 @@ export async function getOrdersByUser(request: Request): Promise { ...details, }; }); -} +} \ No newline at end of file diff --git a/src/services/product.service.test.ts b/src/services/product.service.test.ts index 7bac27a..7da50a3 100644 --- a/src/services/product.service.test.ts +++ b/src/services/product.service.test.ts @@ -1,13 +1,19 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { vi, describe, it, expect, beforeEach } from "vitest"; + import { prisma as mockPrisma } from "@/db/prisma"; -import { createTestCategory, createTestDBProduct } from "@/lib/utils.tests"; -import type { Category } from "@/models/category.model"; +import { + createTestCategory, + createTestDBProduct, + createTestDBVariantAttributeValue, +} from "@/lib/utils.tests"; import { getCategoryBySlug } from "./category.service"; -import { getProductById, getProductsByCategorySlug } from "./product.service"; +import { getProductsByCategorySlug, getProductById, formattedProduct } from "./product.service"; + +import type { Category } from "generated/prisma/client"; -import type { Product as PrismaProduct } from "@/../generated/prisma/client"; +import { Decimal } from "@/../generated/prisma/internal/prismaNamespace"; vi.mock("@/db/prisma", () => ({ prisma: { @@ -18,7 +24,6 @@ vi.mock("@/db/prisma", () => ({ }, })); -// Mock category service vi.mock("./category.service"); describe("Product Service", () => { @@ -27,93 +32,79 @@ describe("Product Service", () => { }); describe("getProductsByCategorySlug", () => { - it("should return products for a valid category slug", async () => { - // Step 1: Setup - Create test data with valid category and products + it("should return products for a valid category slug with prices as numbers", async () => { const testCategory = createTestCategory(); - const mockedProducts: PrismaProduct[] = [ + + const mockedProducts = [ createTestDBProduct({ id: 1, categoryId: testCategory.id }), - createTestDBProduct({ - id: 2, - title: "Test Product 2", - categoryId: testCategory.id, - }), + createTestDBProduct({ id: 2, categoryId: testCategory.id }), ]; - // Step 2: Mock - Configure responses vi.mocked(getCategoryBySlug).mockResolvedValue(testCategory); - vi.mocked(mockPrisma.product.findMany).mockResolvedValue(mockedProducts); - // Step 3: Call service function const products = await getProductsByCategorySlug(testCategory.slug); - // Step 4: Verify expected behavior expect(getCategoryBySlug).toHaveBeenCalledWith(testCategory.slug); expect(mockPrisma.product.findMany).toHaveBeenCalledWith({ where: { categoryId: testCategory.id }, + include: { variantAttributeValues: true }, }); + + // Comprobamos que los precios se transforman a number expect(products).toEqual( - mockedProducts.map((product) => ({ - ...product, - price: product.price.toNumber(), - })) + mockedProducts.map(formattedProduct) ); }); - it("should throw error when category slug does not exist", async () => { - // Step 1: Setup - Create test data for non-existent category const invalidSlug = "invalid-slug"; - - // Step 2: Mock - Configure error response const errorMessage = `Category with slug "${invalidSlug}" not found`; - vi.mocked(getCategoryBySlug).mockRejectedValue(new Error(errorMessage)); - // Step 3: Call service function - const getProducts = getProductsByCategorySlug( - invalidSlug as Category["slug"] - ); + vi.mocked(getCategoryBySlug).mockRejectedValue(new Error(errorMessage)); - // Step 4: Verify expected behavior - await expect(getProducts).rejects.toThrow(errorMessage); + await expect(getProductsByCategorySlug(invalidSlug as Category["slug"])).rejects.toThrow(errorMessage); expect(mockPrisma.product.findMany).not.toHaveBeenCalled(); }); }); describe("getProductById", () => { - it("should return product for valid ID", async () => { - // Step 1: Setup - Create test data for existing product - const testProduct = createTestDBProduct(); + it("should return product for valid ID with prices as numbers", async () => { + const testProduct = createTestDBProduct({ + id: 1, + variantAttributeValues: [ + createTestDBVariantAttributeValue({ price: new Decimal(120) }), + createTestDBVariantAttributeValue({ id: 2, price: new Decimal(150) }), + ], + }); - // Step 2: Mock - Configure Prisma response vi.mocked(mockPrisma.product.findUnique).mockResolvedValue(testProduct); - // Step 3: Call service function const result = await getProductById(testProduct.id); - // Step 4: Verify expected behavior expect(mockPrisma.product.findUnique).toHaveBeenCalledWith({ where: { id: testProduct.id }, + include: { variantAttributeValues: { include: { variantAttribute: true } } }, }); + + // Se espera que el producto devuelto tenga minPrice y maxPrice correctamente calculados expect(result).toEqual({ ...testProduct, - price: testProduct.price.toNumber(), + variantAttributeValues: testProduct.variantAttributeValues.map((v) => ({ + ...v, + price: v.price.toNumber(), + })), }); }); it("should throw error when product does not exist", async () => { - // Step 1: Setup - Configure ID for non-existent product const nonExistentId = 999; - // Step 2: Mock - Configure null response from Prisma vi.mocked(mockPrisma.product.findUnique).mockResolvedValue(null); - // Step 3: Call service function - const productPromise = getProductById(nonExistentId); - - // Step 4: Verify expected behavior - await expect(productPromise).rejects.toThrow("Product not found"); + await expect(getProductById(nonExistentId)).rejects.toThrow("Product not found"); expect(mockPrisma.product.findUnique).toHaveBeenCalledWith({ where: { id: nonExistentId }, + include: { variantAttributeValues: { include: { variantAttribute: true } } }, }); }); }); diff --git a/src/services/product.service.ts b/src/services/product.service.ts index 3406570..983a43d 100644 --- a/src/services/product.service.ts +++ b/src/services/product.service.ts @@ -1,38 +1,107 @@ import { prisma } from "@/db/prisma"; import type { Category } from "@/models/category.model"; -import type { Product } from "@/models/product.model"; +import type { Product, ProductDTO } from "@/models/product.model"; +import type { VariantAttributeValue } from "@/models/variant-attribute.model"; import { getCategoryBySlug } from "./category.service"; +export const formattedProduct = (product: ProductDTO): Product => { + const { variantAttributeValues, ...rest } = product; + const prices = variantAttributeValues.map((v: VariantAttributeValue) => + v.price.toNumber() + ); + const minPrice = Math.min(...prices); + const maxPrice = Math.max(...prices); + if (minPrice === maxPrice) { + return { + ...rest, + price: minPrice, + }; + } + return { + ...rest, + minPrice, + maxPrice, + }; +}; + export async function getProductsByCategorySlug( categorySlug: Category["slug"] ): Promise { const category = await getCategoryBySlug(categorySlug); const products = await prisma.product.findMany({ where: { categoryId: category.id }, + include: { + variantAttributeValues: true, + }, }); - return products.map((product) => ({ - ...product, - price: product.price.toNumber(), - })); + return products.map(formattedProduct); } export async function getProductById(id: number): Promise { const product = await prisma.product.findUnique({ where: { id }, + include: { + variantAttributeValues: { + include: { + variantAttribute: true, + }, + }, + }, }); - if (!product) { throw new Error("Product not found"); } + const productWithParsedPrices = { + ...product, + variantAttributeValues: product.variantAttributeValues.map((variant) => ({ + ...variant, + price: variant.price.toNumber(), + })), + }; - return { ...product, price: product.price.toNumber() }; + return productWithParsedPrices as unknown as Product; } export async function getAllProducts(): Promise { - return (await prisma.product.findMany()).map((p) => ({ - ...p, - price: p.price.toNumber(), - })); + const products = await prisma.product.findMany({ + include: { + variantAttributeValues: true, + }, + }); + return products.map(formattedProduct); } + +export async function filterByMinMaxPrice( + slug: string, + min?: number, + max?: number +): Promise { + const priceFilter: { gte?: number; lte?: number } = {}; + + if (min !== undefined) { + priceFilter.gte = min; + } + if (max !== undefined) { + priceFilter.lte = max; + } + + const result = await prisma.product.findMany({ + where: { + category: { + slug: slug as Category["slug"], // si slug es enum + }, + variantAttributeValues: { + some: { + price: priceFilter, // 👈 el rango se aplica al mismo variant + }, + }, + }, + include: { + variantAttributeValues: true, + }, + }); + + return result.map(formattedProduct); +} \ No newline at end of file