diff --git a/README.md b/README.md index 74872fd..5992c20 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,264 @@ -# React + TypeScript + Vite +# 🛒 Full Stock Frontend -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +> Tienda online especilizada en productos como polos, tazas y stickers con tematica para desarrolladores, incluye chatbot AI integrado. -Currently, two official plugins are available: +### ✨ Características principales -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh +- 🛍️ **Catálogo de productos** con variantes (tallas, tamaños) y precios dinámicos +- 🤖 **Chatbot AI** que conoce inventario, precios y puede recomendar productos +- 🛒 **Carrito de compras** persistente con gestión de sesiones +- 📱 **Diseño responsive** optimizado para móviles y desktop +- ⚡ **Server-Side Rendering** con React Router v7 +- 🔍 **Filtros avanzados** por categoría, precio y variantes +- 💳 **Gestión de precios** con modificadores por variante -## Expanding the ESLint configuration +## 🛠️ Stack Tecnológico -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: +### **Frontend** -- Configure the top-level `parserOptions` property like this: +- **Framework**: React 19.1 con TypeScript +- **Routing**: React Router v7 (con SSR) +- **Styling**: Tailwind CSS + shadcn/ui +- **Build Tool**: Vite 6.0.1 +- **State**: React useState/useEffect + URL state + +### **Backend** + +- **Runtime**: Node.js (integrado con React Router) +- **Database**: PostgreSQL con Prisma ORM +- **AI**: Google Gemini AI para chatbot +- **Session**: Cookie-based sessions + +### **Dev Tools** + +- **Testing**: Vitest + React Testing Library +- **Linting**: ESLint + TypeScript ESLint +- **Formatting**: Prettier +- **Git Hooks**: Husky + lint-staged + +## 🚀 Instalación + +### **Prerrequisitos** + +- Node.js 18+ +- npm/yarn/pnpm +- PostgreSQL database +- Google AI API Key + +### **1. Clonar repositorio** + +```bash +git clone git@github.com:codeableorg/fullstock-frontend.git +cd fullstock-frontend +``` + +### **2. Instalar dependencias** + +```bash +npm install +# o +yarn install +# o +pnpm install +``` + +### **3. Configurar variables de entorno** + +```bash +# Crear archivo .env +cp .env.example .env +``` + +Completar con tus valores: + +```env +# Database +DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?schema=public" + +# Google AI +GOOGLE_API_KEY="tu_google_ai_api_key" +``` + +### **4. Configurar base de datos** + +```bash +# Generar cliente Prisma +npx prisma generate + +# Ejecutar migraciones +npx prisma migrate dev + +# (Opcional) Seed con datos de ejemplo +npx prisma db seed +``` + +### **5. Ejecutar en desarrollo** + +```bash +npm run dev +# o +yarn dev +# o +pnpm dev +``` + +🎉 **¡Listo!** Abre [http://localhost:5173](http://localhost:5173) + +## 📁 Estructura del Proyecto + +``` +fullstock-frontend/ +├── 📁 prisma/ # Esquemas y migraciones de DB +│ ├── schema.prisma # Modelo de datos +│ └── migrations/ # Historial de migraciones +├── 📁 src/ +│ ├── 📁 components/ # Componentes reutilizables +│ │ ├── ui/ # Componentes base (shadcn/ui) +│ │ └── layout/ # Layouts y navegación +│ ├── 📁 routes/ # Páginas y rutas (React Router v7) +│ │ ├── _index.tsx # Homepage +│ │ ├── products/ # Páginas de productos +│ │ ├── category/ # Páginas de categorías +│ │ └── cart/ # Carrito de compras +│ ├── 📁 services/ # Lógica de negocio +│ │ ├── product.service.ts # Gestión de productos +│ │ ├── cart.service.ts # Gestión de carrito +│ │ └── chat.service.ts # Chatbot AI +│ ├── 📁 models/ # Tipos TypeScript +│ ├── 📁 lib/ # Utilidades +│ └── 📁 db/ # Configuración de Prisma +├── 📁 public/ # Assets estáticos +├── 📄 package.json # Dependencias y scripts +├── 📄 tailwind.config.js # Configuración de Tailwind +├── 📄 vite.config.ts # Configuración de Vite +└── 📄 README.md # Este archivo +``` + +## 🎮 Scripts Disponibles + +```bash +# Desarrollo +npm run dev # Servidor de desarrollo +npm run build # Build para producción +npm run start # Servidor de producción +npm run preview # Preview del build + +# Base de datos +npm run db:generate # Generar cliente Prisma +npm run db:migrate # Ejecutar migraciones +npm run db:seed # Llenar con datos de ejemplo +npm run db:studio # Abrir Prisma Studio + +# Testing +npm run test # Ejecutar tests +npm run test:watch # Tests en modo watch +npm run test:coverage # Coverage report + +# Code Quality +npm run lint # Ejecutar ESLint +npm run lint:fix # Arreglar errores de lint +npm run type-check # Verificar tipos TypeScript +``` + +## 🔧 Configuración Adicional + +### **Google AI Setup** + +1. Ve a [Google AI Studio](https://aistudio.google.com/) +2. Crea un nuevo proyecto +3. Genera una API Key +4. Agrégala a tu `.env` como `GOOGLE_API_KEY` + +## 🤖 Chatbot Features + +El chatbot AI tiene conocimiento completo de: + +- **Inventario**: Productos disponibles, precios, variantes +- **Categorías**: Polos, Stickers, Tazas, etc. +- **Variantes**: Tallas (S, M, L), Tamaños (3×3, 5×5, 10×10 cm) +- **Precios**: Precios base + modificadores por variante +- **Carrito**: Productos que ya tiene el usuario + +### Ejemplos de interacción: -```js -export default tseslint.config({ - languageOptions: { - // other options... - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - }, -}) ``` +Usuario: "¿Tienen stickers de 10×10 cm?" +Bot: "¡Sí! Tenemos Stickers JavaScript en 10×10 cm por S/8.00. ¿Te interesa?" + +Usuario: "¿Qué tallas tienen en polos?" +Bot: "Nuestros polos vienen en S (S/20.00), M (S/22.00) y L (S/23.00)." +``` + +## 🧪 Testing -- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` -- Optionally add `...tseslint.configs.stylisticTypeChecked` -- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: +```bash +# Ejecutar todos los tests +npm run test -```js -// eslint.config.js -import react from 'eslint-plugin-react' +# Tests específicos +npm run test src/services/chat.service.test.ts +npm run test src/routes/product/ -export default tseslint.config({ - // Set the react version - settings: { react: { version: '18.3' } }, - plugins: { - // Add the react plugin - react, - }, - rules: { - // other rules... - // Enable its recommended rules - ...react.configs.recommended.rules, - ...react.configs['jsx-runtime'].rules, - }, -}) +# Coverage +npm run test:coverage ``` + +### **Estructura de tests** + +- Unit tests para services +- Integration tests para loaders +- Component tests para UI +- E2E tests con Playwright + +## 🚀 Deployment + +### **Docker** + +```dockerfile +# Dockerfile incluido en el proyecto +docker build -t fullstock . +docker run -p 3000:3000 fullstock +``` + +## 🤝 Contribuir + +1. **Fork** el repositorio +2. **Crea** una branch: `git checkout -b feature/nueva-funcionalidad` +3. **Commit** tus cambios: `git commit -m 'feat: add nueva funcionalidad'` +4. **Push** a la branch: `git push origin feature/nueva-funcionalidad` +5. **Abre** un Pull Request + +### **Convenciones de commits** + +Usamos [Conventional Commits](https://www.conventionalcommits.org/): + +```bash +feat(chat): add variant knowledge to system prompt +fix(product): resolve price calculation for variants +docs(readme): update installation instructions +``` + +## 📝 Roadmap + +- [ ] 🔐 Autenticación de usuarios +- [ ] 💳 Integración con pasarelas de pago Culqi +- [ ] 🌙 Modo oscuro + +## 📄 Licencia + +Este proyecto está bajo la licencia [MIT](./LICENSE). + +## 👨‍💻 Autor + +**Jota(Jhonattan Saldaña Camacho)** + +- GitHub: [@jhonattan](https://github.com/jhonattan) +- LinkedIn: [jhonattansaldana](https://www.linkedin.com/in/jhonattansaldana/) +- Email: jsaldana999@gmail.com + +## 🙏 Agradecimientos + +- [shadcn/ui](https://ui.shadcn.com/) por los componentes base +- [React Router](https://reactrouter.com/) por el framework fullstack +- [Google AI](https://ai.google.com/) por la API de Gemini +- [Vercel](https://vercel.com/) por el hosting gratuito diff --git a/prisma/initial_data.ts b/prisma/initial_data.ts index 0520e9e..88a35f1 100644 --- a/prisma/initial_data.ts +++ b/prisma/initial_data.ts @@ -10,6 +10,7 @@ export const categories = [ alt: "Hombre luciendo polo azul", description: "Polos exclusivos con diseños que todo desarrollador querrá lucir. Ideales para llevar el código a donde vayas.", + hasVariants: true, // Los polos tienen variantes de talla }, { title: "Tazas", @@ -18,6 +19,7 @@ export const categories = [ alt: "Tazas con diseño de código", description: "Tazas que combinan perfectamente con tu café matutino y tu pasión por la programación. ¡Empieza el día con estilo!", + hasVariants: false, // Las tazas NO tienen variantes }, { title: "Stickers", @@ -26,6 +28,44 @@ export const categories = [ alt: "Stickers de desarrollo web", description: "Personaliza tu espacio de trabajo con nuestros stickers únicos y muestra tu amor por el desarrollo web.", + hasVariants: true, // Los stickers tienen variantes de tamaño + }, +]; + +// Variantes por categoría +export const categoryVariants = [ + // Variantes para Polos (categoryId: 1) - sin modificador de precio + { categoryId: 1, value: "small", label: "S", priceModifier: 0, sortOrder: 1 }, + { + categoryId: 1, + value: "medium", + label: "M", + priceModifier: 0, + sortOrder: 2, + }, + { categoryId: 1, value: "large", label: "L", priceModifier: 0, sortOrder: 3 }, + + // Variantes para Stickers (categoryId: 3) - con modificador de precio + { + categoryId: 3, + value: "3x3", + label: "3×3 cm", + priceModifier: 0, + sortOrder: 1, + }, + { + categoryId: 3, + value: "5x5", + label: "5×5 cm", + priceModifier: 1.0, + sortOrder: 2, + }, + { + categoryId: 3, + value: "10x10", + label: "10×10 cm", + priceModifier: 3.0, + sortOrder: 3, }, ]; diff --git a/prisma/migrations/20250821150826_add_category_variants/migration.sql b/prisma/migrations/20250821150826_add_category_variants/migration.sql new file mode 100644 index 0000000..52b106e --- /dev/null +++ b/prisma/migrations/20250821150826_add_category_variants/migration.sql @@ -0,0 +1,47 @@ +/* + Warnings: + + - A unique constraint covering the columns `[cart_id,product_id,category_variant_id]` on the table `cart_items` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropIndex +DROP INDEX "cart_items_cart_id_product_id_key"; + +-- AlterTable +ALTER TABLE "cart_items" ADD COLUMN "category_variant_id" INTEGER; + +-- AlterTable +ALTER TABLE "categories" ADD COLUMN "has_variants" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "order_items" ADD COLUMN "category_variant_id" INTEGER, +ADD COLUMN "variant_info" TEXT; + +-- CreateTable +CREATE TABLE "category_variants" ( + "id" SERIAL NOT NULL, + "category_id" INTEGER NOT NULL, + "value" TEXT NOT NULL, + "label" TEXT NOT NULL, + "price_modifier" DECIMAL(10,2) NOT NULL DEFAULT 0, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "category_variants_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "category_variants_category_id_value_key" ON "category_variants"("category_id", "value"); + +-- CreateIndex +CREATE UNIQUE INDEX "cart_items_cart_id_product_id_category_variant_id_key" ON "cart_items"("cart_id", "product_id", "category_variant_id"); + +-- AddForeignKey +ALTER TABLE "category_variants" ADD CONSTRAINT "category_variants_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "cart_items" ADD CONSTRAINT "cart_items_category_variant_id_fkey" FOREIGN KEY ("category_variant_id") REFERENCES "category_variants"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "order_items" ADD CONSTRAINT "order_items_category_variant_id_fkey" FOREIGN KEY ("category_variant_id") REFERENCES "category_variants"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20250822161758_add_final_price_to_cart_items/migration.sql b/prisma/migrations/20250822161758_add_final_price_to_cart_items/migration.sql new file mode 100644 index 0000000..9367f96 --- /dev/null +++ b/prisma/migrations/20250822161758_add_final_price_to_cart_items/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `final_price` to the `cart_items` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "cart_items" ADD COLUMN "final_price" DECIMAL(10,2) NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e0f992b..abe3292 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -42,14 +42,35 @@ model Category { imgSrc String? @map("img_src") alt String? description String? + hasVariants Boolean @default(false) @map("has_variants") // Indica si esta categoría maneja variantes createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) - products Product[] + products Product[] + categoryVariants CategoryVariant[] @@map("categories") } +// Variantes disponibles para cada categoría +model CategoryVariant { + id Int @id @default(autoincrement()) + categoryId Int @map("category_id") + value String // "small", "medium", "large", "3x3", "5x5", "10x10" + label String // "S", "M", "L", "3×3 cm", "5×5 cm", "10×10 cm" + priceModifier Decimal @default(0) @map("price_modifier") @db.Decimal(10, 2) + sortOrder Int @default(0) @map("sort_order") // Para ordenar las opciones + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) + + category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade) + cartItems CartItem[] + orderItems OrderItem[] + + @@unique([categoryId, value]) + @@map("category_variants") +} + model Product { id Int @id @default(autoincrement()) title String @@ -83,18 +104,22 @@ model Cart { @@map("carts") } +// Actualizar CartItem para variantes de categoría model CartItem { - id Int @id @default(autoincrement()) - cartId Int @map("cart_id") - productId Int @map("product_id") - quantity Int - createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) - updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) - - cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade) - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) - - @@unique([cartId, productId], name: "unique_cart_item") + id Int @id @default(autoincrement()) + cartId Int @map("cart_id") + productId Int @map("product_id") + categoryVariantId Int? @map("category_variant_id") // Variante seleccionada + quantity Int + finalPrice Decimal @map("final_price") @db.Decimal(10, 2)// Price final + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) + + cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + categoryVariant CategoryVariant? @relation(fields: [categoryVariantId], references: [id], onDelete: SetNull) + + @@unique([cartId, productId, categoryVariantId], name: "unique_cart_item_variant") @@map("cart_items") } @@ -122,19 +147,23 @@ model Order { @@map("orders") } +// Actualizar OrderItem model OrderItem { - id Int @id @default(autoincrement()) - orderId Int @map("order_id") - productId Int? @map("product_id") - quantity Int - title String - price Decimal @db.Decimal(10, 2) - imgSrc String? @map("img_src") - createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) - updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) - - order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) - product Product? @relation(fields: [productId], references: [id], onDelete: SetNull) + id Int @id @default(autoincrement()) + orderId Int @map("order_id") + productId Int? @map("product_id") + categoryVariantId Int? @map("category_variant_id") + quantity Int + title String + variantInfo String? @map("variant_info") // "Talla: M" o "Tamaño: 5×5 cm" + price Decimal @db.Decimal(10, 2) // Precio final (base + modificador) + imgSrc String? @map("img_src") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) + + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + product Product? @relation(fields: [productId], references: [id], onDelete: SetNull) + categoryVariant CategoryVariant? @relation(fields: [categoryVariantId], references: [id], onDelete: SetNull) @@map("order_items") -} +} \ No newline at end of file diff --git a/prisma/seed.ts b/prisma/seed.ts index 106da46..827a800 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,18 +1,41 @@ -import { categories, products } from "./initial_data"; +import { categories, categoryVariants, products } from "./initial_data"; import { PrismaClient } from "../generated/prisma/client"; const prisma = new PrismaClient(); async function seedDb() { + await prisma.orderItem.deleteMany(); + console.log(" ✅ OrderItems eliminados"); + + await prisma.cart.deleteMany(); + console.log(" ✅ Carts eliminados"); + + await prisma.cartItem.deleteMany(); + console.log(" ✅ CartItems eliminados"); + + await prisma.categoryVariant.deleteMany(); + console.log(" ✅ CategoryVariants eliminados"); + + await prisma.product.deleteMany(); + console.log(" ✅ Products eliminados"); + + await prisma.category.deleteMany(); + console.log(" ✅ Categories eliminadas"); + await prisma.category.createMany({ data: categories, }); console.log("1. Categories successfully inserted"); + await prisma.categoryVariant.createMany({ + data: categoryVariants, + }); + console.log("2. Category variants successfully inserted"); + await prisma.product.createMany({ data: products, }); - console.log("2. Products successfully inserted"); + console.log("3. Products successfully inserted"); } seedDb() diff --git a/src/lib/cart.ts b/src/lib/cart.ts index e0308df..a11ba06 100644 --- a/src/lib/cart.ts +++ b/src/lib/cart.ts @@ -19,14 +19,16 @@ export async function addToCart( userId: number | undefined, sessionCartId: string | undefined, productId: Product["id"], - quantity: number = 1 + quantity: number = 1, + categoryVariantId: number | null = null ) { try { const updatedCart = await alterQuantityCartItem( userId, sessionCartId, productId, - quantity + quantity, + categoryVariantId ); return updatedCart; } catch (error) { @@ -60,9 +62,9 @@ export function calculateTotal(items: CartItemInput[]): number; export function calculateTotal(items: CartItem[] | CartItemInput[]): number { return items.reduce((total, item) => { // Type guard to determine which type we're working with - if ("product" in item) { + if ("finalPrice" in item) { // CartItem - has a product property - return total + item.product.price * item.quantity; + return total + item.finalPrice * item.quantity; } else { // CartItemInput - has price directly return total + item.price * item.quantity; diff --git a/src/lib/utils.tests.ts b/src/lib/utils.tests.ts index 1526f23..c7eb3cf 100644 --- a/src/lib/utils.tests.ts +++ b/src/lib/utils.tests.ts @@ -94,6 +94,7 @@ export const createTestCategory = ( imgSrc: "/images/polos.jpg", alt: "Colección de polos para programadores", description: "Explora nuestra colección de polos para programadores", + hasVariants: true, createdAt: new Date(), updatedAt: new Date(), ...overrides, @@ -126,6 +127,8 @@ export const createTestOrderItem = ( title: "Test Product", price: 100, imgSrc: "test-image.jpg", + variantInfo: "Tamaño: L", + categoryVariantId: 1, createdAt: new Date(), updatedAt: new Date(), ...overrides, @@ -142,6 +145,8 @@ export const createTestDBOrderItem = ( title: "Test Product", price: new Decimal(100), imgSrc: "test-image.jpg", + variantInfo: "Tamaño: L", + categoryVariantId: 1, createdAt: new Date(), updatedAt: new Date(), ...overrides, diff --git a/src/models/cart.model.ts b/src/models/cart.model.ts index ad4206a..3e497de 100644 --- a/src/models/cart.model.ts +++ b/src/models/cart.model.ts @@ -1,38 +1,45 @@ import { type Product } from "./product.model"; -import type { - Cart as PrismaCart, - CartItem as PrismaCartItem, -} from "@/../generated/prisma/client"; - -export type CartItem = PrismaCartItem & { - product: Pick< - Product, - "id" | "title" | "imgSrc" | "alt" | "price" | "isOnSale" - >; -}; +import type { CategoryVariant } from "./category.model"; +import type { Cart as PrismaCart } from "@/../generated/prisma/client"; +import type { Nullable } from "./utils.model"; export type Cart = PrismaCart; +type productInfo = Pick< + Product, + "id" | "title" | "imgSrc" | "alt" | "price" | "isOnSale" +>; + +export type CartItem = { + id: number; + cartId: number; + productId: number; + categoryVariantId: Nullable; + quantity: number; + finalPrice: number; + createdAt: Date; + updatedAt: Date; + product: productInfo; + categoryVariant?: Nullable; +}; + export interface CartItemInput { productId: Product["id"]; quantity: number; + categoryVariantId: Nullable; + variantInfo: Nullable; title: Product["title"]; price: Product["price"]; imgSrc: Product["imgSrc"]; } -// Tipo para representar un producto simplificado en el carrito - -export type CartProductInfo = Pick< - Product, - "id" | "title" | "imgSrc" | "alt" | "price" | "isOnSale" ->; - // Tipo para representar un item de carrito con su producto export type CartItemWithProduct = { - product: CartProductInfo; + product: productInfo; quantity: number; + categoryVariantId: Nullable; + finalPrice: number; }; // Tipo para el carrito con items y productos incluidos diff --git a/src/models/category.model.ts b/src/models/category.model.ts index 9c05ae1..598a234 100644 --- a/src/models/category.model.ts +++ b/src/models/category.model.ts @@ -1,9 +1,14 @@ import type { Category as PrismaCategory } from "@/../generated/prisma/client"; +import type { CategoryVariant as PrismaCategoryVariant } from "@/../generated/prisma/client"; export const VALID_SLUGS = ["polos", "stickers", "tazas"] as const; export type Category = PrismaCategory; +export type CategoryVariant = Omit & { + priceModifier: number; +}; + export function isValidCategorySlug( categorySlug: unknown ): categorySlug is Category["slug"] { diff --git a/src/models/order.model.ts b/src/models/order.model.ts index 3ac10a4..5b8942f 100644 --- a/src/models/order.model.ts +++ b/src/models/order.model.ts @@ -2,6 +2,7 @@ import type { Order as PrismaOrder, OrderItem as PrismaOrderItem, } from "@/../generated/prisma/client"; +import type { Nullable } from "./utils.model"; export type OrderDetails = Pick< PrismaOrder, @@ -29,8 +30,10 @@ export type Order = Omit & { export interface OrderItemInput { productId: number; + categoryVariantId?: Nullable; quantity: number; title: string; + variantInfo?: Nullable; price: number; imgSrc: string; } diff --git a/src/models/utils.model.ts b/src/models/utils.model.ts new file mode 100644 index 0000000..26102a1 --- /dev/null +++ b/src/models/utils.model.ts @@ -0,0 +1,3 @@ +export type Nullable = T | null; +export type Optional = T | undefined; +export type Maybe = T | null | undefined; diff --git a/src/routes/cart/add-item/index.tsx b/src/routes/cart/add-item/index.tsx index ac49758..607c586 100644 --- a/src/routes/cart/add-item/index.tsx +++ b/src/routes/cart/add-item/index.tsx @@ -8,13 +8,22 @@ import type { Route } from "../+types"; export async function action({ request }: Route.ActionArgs) { const formData = await request.formData(); const productId = Number(formData.get("productId")); + const categoryVariantId = formData.get("categoryVariantId") + ? Number(formData.get("categoryVariantId")) + : null; 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, + productId, + quantity, + categoryVariantId + ); return redirect(redirectTo || "/cart"); } diff --git a/src/routes/cart/index.tsx b/src/routes/cart/index.tsx index d330cef..7cdde70 100644 --- a/src/routes/cart/index.tsx +++ b/src/routes/cart/index.tsx @@ -23,6 +23,25 @@ export async function loader({ request }: Route.LoaderArgs) { export default function Cart({ loaderData }: Route.ComponentProps) { const { cart, total } = loaderData; + // ✅ AGREGAR: Verificación para carrito vacío + if (!cart || !cart.items || cart.items.length === 0) { + return ( +
+ +

+ Carrito de compras +

+
+

Tu carrito está vacío

+ +
+
+
+ ); + } + return (
@@ -30,75 +49,101 @@ export default function Cart({ loaderData }: Route.ComponentProps) { Carrito de compras
- {cart?.items?.map(({ product, quantity, id }) => ( -
-
- {product.alt -
-
-
-

{product.title}

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

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

-
-
- +
+
+
+

{product.title}

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

({categoryVariant.label})

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

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

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

Total

S/{total.toFixed(2)}

diff --git a/src/routes/category/components/price-filter/index.tsx b/src/routes/category/components/price-filter/index.tsx index 5337413..4418d2f 100644 --- a/src/routes/category/components/price-filter/index.tsx +++ b/src/routes/category/components/price-filter/index.tsx @@ -1,47 +1,145 @@ -import { Form } from "react-router"; +import { useState } from "react"; +import { Form, useSearchParams } from "react-router"; -import { Button, Input } from "@/components/ui"; -import { cn } from "@/lib/utils"; +import { Button, InputField, Label } from "@/components/ui"; + +interface CategoryVariant { + id: number; + label: string; + value: string; +} interface PriceFilterProps { minPrice: string; maxPrice: string; + categoryVariants: CategoryVariant[]; + selectedVariants: string[]; className?: string; } export function PriceFilter({ minPrice, maxPrice, + categoryVariants, + selectedVariants, className, }: PriceFilterProps) { + const [searchParams] = useSearchParams(); + const [localMinPrice, setLocalMinPrice] = useState(minPrice); + const [localMaxPrice, setLocalMaxPrice] = useState(maxPrice); + const [localSelectedVariants, setLocalSelectedVariants] = + useState(selectedVariants); + + const handleVariantChange = (variantId: string, checked: boolean) => { + if (checked) { + setLocalSelectedVariants((prev) => [...prev, variantId]); + } else { + setLocalSelectedVariants((prev) => prev.filter((id) => id !== variantId)); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const form = e.target as HTMLFormElement; + const formData = new FormData(form); + + // Agregar variantes seleccionadas al FormData + localSelectedVariants.forEach((variantId) => { + formData.append("variants", variantId); + }); + + // Crear nueva URL con parámetros + const newSearchParams = new URLSearchParams(); + for (const [key, value] of formData.entries()) { + newSearchParams.append(key, value.toString()); + } + + // Mantener otros parámetros existentes + for (const [key, value] of searchParams.entries()) { + if (!["minPrice", "maxPrice", "variants"].includes(key)) { + newSearchParams.append(key, value); + } + } + + window.location.search = newSearchParams.toString(); + }; + return ( -
-
- Precio -
-
- - + + {/* ✅ EXISTENTE: Filtro por precio */} +
+ +
+ -
-
- - setLocalMinPrice(e.target.value)} + /> + setLocalMaxPrice(e.target.value)} />
-
- -
+ + {/* ✅ NUEVO: Filtro por variantes */} + {categoryVariants.length > 0 && ( +
+ +
+ {categoryVariants.map((variant) => ( + + ))} +
+
+ )} + +
+ + +
+ +
); } diff --git a/src/routes/category/components/product-card/index.tsx b/src/routes/category/components/product-card/index.tsx index a6abe33..be163f7 100644 --- a/src/routes/category/components/product-card/index.tsx +++ b/src/routes/category/components/product-card/index.tsx @@ -2,11 +2,58 @@ import { Link } from "react-router"; import type { Product } from "@/models/product.model"; +interface CategoryVariant { + id: number; + label: string; + value: string; +} + interface ProductCardProps { - product: Product; + product: Product & { + minPricexProduct?: number; + maxPricexProduct?: number; + hasVariants?: boolean; + categoryVariants?: CategoryVariant[]; + }; } export function ProductCard({ product }: ProductCardProps) { + // ✅ NUEVO: Calcular display de precio + const renderPrice = () => { + if ( + product.hasVariants && + product.minPricexProduct && + product.maxPricexProduct + ) { + if (product.minPricexProduct === product.maxPricexProduct) { + return `S/${product.minPricexProduct.toFixed(2)}`; + } + return `S/${product.minPricexProduct.toFixed( + 2 + )} - S/${product.maxPricexProduct.toFixed(2)}`; + } + return `S/${product.price.toFixed(2)}`; + }; + + const renderVariants = () => { + if (!product.hasVariants || !product.categoryVariants?.length) { + return null; + } + + return ( +
+ {product.categoryVariants?.slice(0, 3).map((variant) => ( + + {variant.label} + + ))} +
+ ); + }; + return (

{product.title}

{product.description}

-

S/{product.price}

+ +
+
+

{renderPrice()}

+ {renderVariants()} +
+
{product.isOnSale && ( diff --git a/src/routes/category/index.tsx b/src/routes/category/index.tsx index 7c0aef5..beca7d9 100644 --- a/src/routes/category/index.tsx +++ b/src/routes/category/index.tsx @@ -3,7 +3,11 @@ import { redirect } from "react-router"; import { Container } from "@/components/ui"; import { isValidCategorySlug, type Category } from "@/models/category.model"; import type { Product } from "@/models/product.model"; -import { getCategoryBySlug } from "@/services/category.service"; +import { calculateFinalPrice } from "@/services/cart.service"; +import { + getCategoryBySlug, + getCategoryWithVariants, +} from "@/services/category.service"; import { getProductsByCategorySlug } from "@/services/product.service"; import { PriceFilter } from "./components/price-filter"; @@ -21,6 +25,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { const url = new URL(request.url); const minPrice = url.searchParams.get("minPrice") || ""; const maxPrice = url.searchParams.get("maxPrice") || ""; + const selectedVariants = url.searchParams.getAll("variants") || []; try { const [category, products] = await Promise.all([ @@ -28,37 +33,140 @@ export async function loader({ params, request }: Route.LoaderArgs) { getProductsByCategorySlug(categorySlug), ]); - const filterProductsByPrice = ( - products: Product[], - minPrice: string, - maxPrice: string - ) => { - const min = minPrice ? parseFloat(minPrice) : 0; - const max = maxPrice ? parseFloat(maxPrice) : Infinity; - return products.filter( - (product) => product.price >= min && product.price <= max - ); - }; + const categoryWithVariants = await getCategoryWithVariants(category.id); + const categoryVariants = categoryWithVariants?.categoryVariants || []; + + const productxVariants = await Promise.all( + products.flatMap(async (product) => { + if (categoryVariants.length === 0) { + // Si no hay variantes, solo precio base + return [ + { + ...product, + categoryVariant: null, + finalPrice: product.price, + minPricexProduct: product.price, + maxPricexProduct: product.price, + }, + ]; + } + + // Crear un producto por cada variante + const productVariants = await Promise.all( + categoryVariants.map(async (variant) => { + const finalPrice = await calculateFinalPrice( + product.id, + variant.id + ); + return { + ...product, + categoryVariant: variant, + finalPrice, + minPricexProduct: finalPrice, + maxPricexProduct: finalPrice, + }; + }) + ); + + // Calcular rango de precios por producto + const prices = productVariants.map((pv) => pv.finalPrice); + const minPricexProduct = Math.min(...prices); + const maxPricexProduct = Math.max(...prices); - const filteredProducts = filterProductsByPrice( - products, + return productVariants.map((pv) => ({ + ...pv, + minPricexProduct, + maxPricexProduct, + })); + }) + ); + + // ✅ ACTUALIZADO: Filtrar por precio y variantes + const filteredProducts = filterProductsByPriceAndVariants( + productxVariants.flat(), minPrice, - maxPrice + maxPrice, + selectedVariants ); + // ✅ NUEVO: Agrupar productos únicos con sus rangos de precio + const uniqueProducts = products + .map((product) => { + const productVariants = filteredProducts.filter( + (pv) => pv.id === product.id + ); + if (productVariants.length === 0) return null; + + const prices = productVariants.map((pv) => pv.finalPrice); + const minPricexProduct = Math.min(...prices); + const maxPricexProduct = Math.max(...prices); + + return { + ...product, + minPricexProduct, + maxPricexProduct, + hasVariants: categoryVariants.length > 0, + categoryVariants: productVariants + .map((pv) => pv.categoryVariant) + .filter( + (v): v is { id: number; label: string; value: string } => + v !== null + ), + }; + }) + .filter(Boolean); + return { category, - products: filteredProducts, + products: uniqueProducts, + categoryVariants, minPrice, maxPrice, + selectedVariants, }; } catch (e) { throw new Response("Error loading category: " + e, { status: 500 }); } } +function filterProductsByPriceAndVariants( + productxVariants: (Product & { + categoryVariant: { id: number; label: string; value: string } | null; + finalPrice: number; + minPricexProduct: number; + maxPricexProduct: number; + })[], + minPrice: string, + maxPrice: string, + selectedVariants: string[] +) { + const min = minPrice ? parseFloat(minPrice) : 0; + const max = maxPrice ? parseFloat(maxPrice) : Infinity; + + return productxVariants.filter((productVariant) => { + // Filtro por precio + const priceInRange = + productVariant.finalPrice >= min && productVariant.finalPrice <= max; + + // Filtro por variantes (si hay variantes seleccionadas) + const variantMatch = + selectedVariants.length === 0 || + !productVariant.categoryVariant || + selectedVariants.includes(productVariant.categoryVariant.id.toString()); + + return priceInRange && variantMatch; + }); +} + export default function Category({ loaderData }: Route.ComponentProps) { - const { category, products, minPrice, maxPrice } = loaderData; + const { + category, + products, + categoryVariants, + minPrice, + maxPrice, + selectedVariants, + } = loaderData; return ( <> @@ -78,12 +186,16 @@ export default function Category({ loaderData }: Route.ComponentProps) {
- {products.map((product) => ( - - ))} + {products + .filter((product) => product !== null) + .map((product) => ( + + ))}
diff --git a/src/routes/checkout/index.tsx b/src/routes/checkout/index.tsx index 1ceb7ae..c13df7c 100644 --- a/src/routes/checkout/index.tsx +++ b/src/routes/checkout/index.tsx @@ -20,6 +20,7 @@ import { } from "@/hooks/use-culqui"; import { calculateTotal, getCart } from "@/lib/cart"; import { type CartItem } from "@/models/cart.model"; +import type { CategoryVariant } from "@/models/category.model"; import { getCurrentUser } from "@/services/auth.service"; import { deleteRemoteCart } from "@/services/cart.service"; import { createOrder } from "@/services/order.service"; @@ -105,9 +106,13 @@ export async function action({ request }: Route.ActionArgs) { const items = cartItems.map((item) => ({ productId: item.product.id, + categoryVariantId: item.categoryVariantId, // ← NUEVO quantity: item.quantity, title: item.product.title, - price: item.product.price, + variantInfo: item.categoryVariant + ? getVariantInfoText(item.categoryVariant) + : null, + price: item.finalPrice, imgSrc: item.product.imgSrc, })); @@ -130,6 +135,12 @@ export async function action({ request }: Route.ActionArgs) { }); } +function getVariantInfoText(categoryVariant: CategoryVariant): string { + if (categoryVariant.id === 1) return `Talla: ${categoryVariant.label}`; + if (categoryVariant.id === 3) return `Tamaño: ${categoryVariant.label}`; + return `Opción: ${categoryVariant.label}`; +} + export async function loader({ request }: Route.LoaderArgs) { const session = await getSession(request.headers.get("Cookie")); const sessionCartId = session.get("sessionCartId"); @@ -249,28 +260,37 @@ export default function Checkout({

Resumen de la orden

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

{product.title}

-
-

{quantity}

- -

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

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

{product.title}

+ {categoryVariant && ( +

+ ({categoryVariant.label}) +

+ )} +
+
+

{quantity}

+ +

S/{finalPrice.toFixed(2)}

+
-
- ))} + ) + )}

Total

S/{total.toFixed(2)}

diff --git a/src/routes/product/index.tsx b/src/routes/product/index.tsx index f444f0b..394c3f0 100644 --- a/src/routes/product/index.tsx +++ b/src/routes/product/index.tsx @@ -1,31 +1,101 @@ +import { useEffect, useState } from "react"; import { Form, useNavigation } from "react-router"; import { Button, Container, Separator } from "@/components/ui"; +import { cn } from "@/lib/utils"; import { type Product } from "@/models/product.model"; +import { getCategoryWithVariants } from "@/services/category.service"; import { getProductById } from "@/services/product.service"; import NotFound from "../not-found"; import type { Route } from "./+types"; +interface CategoryVariant { + id: number; + value: string; + label: string; + priceModifier: number; +} + export async function loader({ params }: Route.LoaderArgs) { try { const product = await getProductById(parseInt(params.id)); - return { product }; + const categoryWithVariants = product.categoryId + ? await getCategoryWithVariants(product.categoryId) + : null; + return { product, categoryWithVariants }; } catch { return {}; } } export default function Product({ loaderData }: Route.ComponentProps) { - const { product } = loaderData; + const { product, categoryWithVariants } = loaderData; const navigation = useNavigation(); const cartLoading = navigation.state === "submitting"; + // Estado simple para variantes + const [variants, setVariants] = useState([]); + const [selectedVariant, setSelectedVariant] = + useState(null); + const [finalPrice, setFinalPrice] = useState(0); + + const calculateDisplayPrice = (variant: CategoryVariant | null): number => { + const basePrice = product?.price || 0; + const modifier = variant?.priceModifier || 0; + return basePrice + modifier; + }; + + // Cargar variantes si la categoría las tiene + useEffect(() => { + if (!product) return; + + // Establecer precio inicial + setFinalPrice(product.price); + + if ( + !categoryWithVariants?.hasVariants || + !categoryWithVariants.categoryVariants.length + ) { + setVariants([]); + setSelectedVariant(null); + return; + } + + const mappedVariants: CategoryVariant[] = + categoryWithVariants.categoryVariants.map((variant) => ({ + id: variant.id, + value: variant.value, + label: variant.label, + priceModifier: variant.priceModifier, + })); + + setVariants(mappedVariants); + + const firstVariant = mappedVariants[0] || null; + setSelectedVariant(firstVariant); + setFinalPrice(calculateDisplayPrice(firstVariant)); + }, [categoryWithVariants, product]); + + const handleVariantChange = (variant: CategoryVariant) => { + setSelectedVariant(variant); + setFinalPrice(calculateDisplayPrice(variant)); + }; + if (!product) { return ; } + const hasVariants = categoryWithVariants?.hasVariants && variants.length > 0; + + // Helper para obtener el label de la variante + const getVariantLabel = () => { + if (product.categoryId === 1) return "Talla"; + if (product.categoryId === 3) return "Tamaño"; + return "Opciones"; + }; + return ( <>
@@ -41,16 +111,58 @@ export default function Product({ loaderData }: Route.ComponentProps) {

{product.title}

-

S/{product.price}

+

S/{finalPrice.toFixed(2)}

{product.description}

+ + {/* Toggle Button Group para Variantes - Implementación directa */} + {hasVariants && ( +
+
+ +
+ {variants.map((variant) => ( + + ))} +
+
+
+ )} +
+ {selectedVariant && ( + + )}