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) => (
+ handleVariantChange(variant.id)}
+ >
+ {variant.value}
+
+ ))}
+
+
+ ))}
+ >
+)}
+```
+
+#### 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}
+ {order.items.map((item) => {
+ const productTitle = item.variantAttributeValue?.value
+ ? `${item.title} (${item.variantAttributeValue.value})`
+ : item.title;
+
+ return (
+
+
+
+
+
-
- {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.title}
-
+ {cart?.items?.map(
+ ({ product, quantity, id, variantAttributeValue }) => (
+
+
+
-
-
- ${product.price.toFixed(2)}
-
-
-
- ))}
+ )
+ )}
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
Min
Max
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) => (
+ handleVariantChange(variant.id)}
+ >
+ {variant.value}
+
+ ))}
+
+
+ ))}
+ >
+ )}
+
+ {/* Formulario actualizado para enviar variante seleccionada */}
+
+ {/* Enviar la variante seleccionada si existe y debe mostrar variantes */}
+ {shouldShowVariants && selectedVariant && (
+
+ )}
{cartLoading ? "Agregando..." : "Agregar al Carrito"}
+
+
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