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/api/gemini-chat.js b/api/gemini-chat.js new file mode 100644 index 0000000..7661506 --- /dev/null +++ b/api/gemini-chat.js @@ -0,0 +1,18 @@ +import express from "express"; +import { GoogleGenAI } from "@google/genai"; + +const router = express.Router(); +const ai = new GoogleGenAI({ apiKey: process.env.GOOGLE_API_KEY || "" }); + +router.post("/", async (req, res) => { + const { message } = req.body; + try { + const chat = ai.chats.create({ model: "gemini-2.5-flash", history: [] }); + const response = await chat.sendMessage({ message }); + res.json({ text: response.text }); + } catch (err) { + res.status(500).json({ error: "Error al contactar a Gemini" }); + } +}); + +export default router; \ No newline at end of file diff --git a/api/index.js b/api/index.js new file mode 100644 index 0000000..9b84e44 --- /dev/null +++ b/api/index.js @@ -0,0 +1,12 @@ +import express from "express"; +import cors from "cors"; +import geminiChatRouter from "./gemini-chat.js"; + +const app = express(); +app.use(express.json()); +app.use("/api/gemini-chat", geminiChatRouter); +app.use(cors()); + +app.listen(3001, () => { + console.log("API server listening on http://localhost:3001"); +}); \ No newline at end of file diff --git a/prisma/initial_data.ts b/prisma/initial_data.ts index 0520e9e..1e8b763 100644 --- a/prisma/initial_data.ts +++ b/prisma/initial_data.ts @@ -29,6 +29,30 @@ export const categories = [ }, ]; +export const productVariant = [ + // Ejemplo para un producto Polo + { productTitle: "Polo React", sizes: ["small", "medium", "large"] }, + { productTitle: "Polo JavaScript", sizes: ["small", "medium", "large"] }, + { productTitle: "Polo Node.js", sizes: ["small", "medium", "large"] }, + { productTitle: "Polo TypeScript", sizes: ["small", "medium", "large"] }, + { productTitle: "Polo Backend Developer", sizes: ["small", "medium", "large"] }, + { productTitle: "Polo Frontend Developer", sizes: ["small", "medium", "large"] }, + { productTitle: "Polo Full-Stack Developer", sizes: ["small", "medium", "large"] }, + { productTitle: "Polo It's A Feature", sizes: ["small", "medium", "large"] }, + { productTitle: "Polo It Works On My Machine", sizes: ["small", "medium", "large"] }, +]; + +export const stickersVariant = [ + { productTitle: "Sticker JavaScript", measure: ["3*3", "5*5", "10*10"] }, + { productTitle: "Sticker React", measure: ["3*3", "5*5", "10*10"] }, + { productTitle: "Sticker Git", measure: ["3*3", "5*5", "10*10"] }, + { productTitle: "Sticker Docker", measure: ["3*3", "5*5", "10*10"] }, + { productTitle: "Sticker Linux", measure: ["3*3", "5*5", "10*10"] }, + { productTitle: "Sticker VS Code", measure: ["3*3", "5*5", "10*10"] }, + { productTitle: "Sticker GitHub", measure: ["3*3", "5*5", "10*10"] }, + { productTitle: "Sticker HTML", measure: ["3*3", "5*5", "10*10"] }, + ]; + export const products = [ { title: "Polo React", diff --git a/prisma/migrations/20250621010244_create_user_table/migration.sql b/prisma/migrations/20250621010244_create_user_table/migration.sql deleted file mode 100644 index 8aec2e6..0000000 --- a/prisma/migrations/20250621010244_create_user_table/migration.sql +++ /dev/null @@ -1,15 +0,0 @@ --- CreateTable -CREATE TABLE "users" ( - "id" SERIAL NOT NULL, - "email" TEXT NOT NULL, - "name" TEXT, - "password" TEXT, - "is_guest" BOOLEAN NOT NULL DEFAULT true, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "users_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); diff --git a/prisma/migrations/20250621010843_update_ts_on_users/migration.sql b/prisma/migrations/20250621010843_update_ts_on_users/migration.sql deleted file mode 100644 index 75dae24..0000000 --- a/prisma/migrations/20250621010843_update_ts_on_users/migration.sql +++ /dev/null @@ -1,3 +0,0 @@ --- AlterTable -ALTER TABLE "users" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(0), -ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMP(0); diff --git a/prisma/migrations/20250625005548_convert_category_slug_to_enum/migration.sql b/prisma/migrations/20250625005548_convert_category_slug_to_enum/migration.sql deleted file mode 100644 index bb2264d..0000000 --- a/prisma/migrations/20250625005548_convert_category_slug_to_enum/migration.sql +++ /dev/null @@ -1,26 +0,0 @@ --- CreateEnum -CREATE TYPE "CategorySlug" AS ENUM ('polos', 'tazas', 'stickers'); - --- AlterTable: Add temporary column -ALTER TABLE "categories" ADD COLUMN "slug_new" "CategorySlug"; - --- Update data: Convert existing string values to enum -UPDATE "categories" SET "slug_new" = - CASE - WHEN "slug" = 'polos' THEN 'polos'::"CategorySlug" - WHEN "slug" = 'tazas' THEN 'tazas'::"CategorySlug" - WHEN "slug" = 'stickers' THEN 'stickers'::"CategorySlug" - ELSE 'polos'::"CategorySlug" -- default fallback - END; - --- Make the new column NOT NULL -ALTER TABLE "categories" ALTER COLUMN "slug_new" SET NOT NULL; - --- Drop the old column -ALTER TABLE "categories" DROP COLUMN "slug"; - --- Rename the new column -ALTER TABLE "categories" RENAME COLUMN "slug_new" TO "slug"; - --- Add unique constraint -ALTER TABLE "categories" ADD CONSTRAINT "categories_slug_key" UNIQUE ("slug"); \ No newline at end of file diff --git a/prisma/migrations/20250806155625_add_payment_id_to_order/migration.sql b/prisma/migrations/20250806155625_add_payment_id_to_order/migration.sql deleted file mode 100644 index 1b545cf..0000000 --- a/prisma/migrations/20250806155625_add_payment_id_to_order/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "orders" ADD COLUMN "payment_id" TEXT; diff --git a/prisma/migrations/20250621053111_proposed_schema/migration.sql b/prisma/migrations/20250826163128_init/migration.sql similarity index 59% rename from prisma/migrations/20250621053111_proposed_schema/migration.sql rename to prisma/migrations/20250826163128_init/migration.sql index 71e0b5b..18beb94 100644 --- a/prisma/migrations/20250621053111_proposed_schema/migration.sql +++ b/prisma/migrations/20250826163128_init/migration.sql @@ -1,8 +1,24 @@ +-- CreateEnum +CREATE TYPE "CategorySlug" AS ENUM ('polos', 'tazas', 'stickers'); + +-- CreateTable +CREATE TABLE "users" ( + "id" SERIAL NOT NULL, + "email" TEXT NOT NULL, + "name" TEXT, + "password" TEXT, + "is_guest" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + -- CreateTable CREATE TABLE "categories" ( "id" SERIAL NOT NULL, "title" TEXT NOT NULL, - "slug" TEXT NOT NULL, + "slug" "CategorySlug" NOT NULL, "img_src" TEXT, "alt" TEXT, "description" TEXT, @@ -29,6 +45,25 @@ CREATE TABLE "products" ( CONSTRAINT "products_pkey" PRIMARY KEY ("id") ); +-- CreateTable +CREATE TABLE "ProductVariant" ( + "id" SERIAL NOT NULL, + "productId" INTEGER NOT NULL, + "size" TEXT NOT NULL, + + CONSTRAINT "ProductVariant_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "stickersVariant" ( + "id" SERIAL NOT NULL, + "productId" INTEGER NOT NULL, + "measure" TEXT NOT NULL, + "price" DECIMAL(10,2) NOT NULL, + + CONSTRAINT "stickersVariant_pkey" PRIMARY KEY ("id") +); + -- CreateTable CREATE TABLE "carts" ( "id" SERIAL NOT NULL, @@ -43,8 +78,11 @@ CREATE TABLE "carts" ( -- CreateTable CREATE TABLE "cart_items" ( "id" SERIAL NOT NULL, - "cart_id" INTEGER NOT NULL, - "product_id" INTEGER NOT NULL, + "cartId" INTEGER NOT NULL, + "productId" INTEGER NOT NULL, + "productVariantId" INTEGER, + "stickersVariantId" INTEGER, + "price" DECIMAL(10,2) NOT NULL, "quantity" INTEGER NOT NULL, "created_at" TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -58,6 +96,7 @@ CREATE TABLE "orders" ( "user_id" INTEGER NOT NULL, "total_amount" DECIMAL(10,2) NOT NULL, "email" TEXT NOT NULL, + "payment_id" TEXT, "first_name" TEXT NOT NULL, "last_name" TEXT NOT NULL, "company" TEXT, @@ -88,6 +127,9 @@ CREATE TABLE "order_items" ( CONSTRAINT "order_items_pkey" PRIMARY KEY ("id") ); +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + -- CreateIndex CREATE UNIQUE INDEX "categories_slug_key" ON "categories"("slug"); @@ -95,19 +137,31 @@ CREATE UNIQUE INDEX "categories_slug_key" ON "categories"("slug"); CREATE UNIQUE INDEX "carts_session_cart_id_key" ON "carts"("session_cart_id"); -- CreateIndex -CREATE UNIQUE INDEX "cart_items_cart_id_product_id_key" ON "cart_items"("cart_id", "product_id"); +CREATE UNIQUE INDEX "cart_items_cartId_productId_productVariantId_stickersVarian_key" ON "cart_items"("cartId", "productId", "productVariantId", "stickersVariantId"); -- AddForeignKey ALTER TABLE "products" ADD CONSTRAINT "products_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE SET NULL ON UPDATE CASCADE; +-- AddForeignKey +ALTER TABLE "ProductVariant" ADD CONSTRAINT "ProductVariant_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "stickersVariant" ADD CONSTRAINT "stickersVariant_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + -- AddForeignKey ALTER TABLE "carts" ADD CONSTRAINT "carts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "cart_items" ADD CONSTRAINT "cart_items_cart_id_fkey" FOREIGN KEY ("cart_id") REFERENCES "carts"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "cart_items" ADD CONSTRAINT "cart_items_cartId_fkey" FOREIGN KEY ("cartId") REFERENCES "carts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "cart_items" ADD CONSTRAINT "cart_items_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "cart_items" ADD CONSTRAINT "cart_items_productVariantId_fkey" FOREIGN KEY ("productVariantId") REFERENCES "ProductVariant"("id") ON DELETE SET NULL ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "cart_items" ADD CONSTRAINT "cart_items_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "cart_items" ADD CONSTRAINT "cart_items_stickersVariantId_fkey" FOREIGN KEY ("stickersVariantId") REFERENCES "stickersVariant"("id") ON DELETE SET NULL ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "orders" ADD CONSTRAINT "orders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e0f992b..541249e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -51,17 +51,19 @@ model Category { } model Product { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) title String - imgSrc String @map("img_src") + imgSrc String @map("img_src") alt String? - price Decimal @db.Decimal(10, 2) + price Decimal @db.Decimal(10, 2) description String? - categoryId Int? @map("category_id") - isOnSale Boolean @default(false) @map("is_on_sale") + categoryId Int? @map("category_id") + isOnSale Boolean @default(false) @map("is_on_sale") features String[] - createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) - updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) + variants ProductVariant[] + stickersVariants stickersVariant[] category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) cartItems CartItem[] @@ -70,6 +72,25 @@ model Product { @@map("products") } +model ProductVariant { + id Int @id @default(autoincrement()) + product Product @relation(fields: [productId], references: [id]) + productId Int + size String // 'small', 'medium', 'large' + + cartItems CartItem[] @relation("CartItemToProductVariant") +} + +model stickersVariant { + id Int @id @default(autoincrement()) + product Product @relation(fields: [productId], references: [id]) + productId Int + measure String // '3*3', '5*5', '10*10' + price Decimal @db.Decimal(10, 2) + + cartItems CartItem[] @relation("CartItemTostickersVariant") +} + model Cart { id Int @id @default(autoincrement()) sessionCartId String @unique @default(dbgenerated("gen_random_uuid()")) @map("session_cart_id") @db.Uuid @@ -84,17 +105,22 @@ model Cart { } 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) + id Int @id @default(autoincrement()) + cartId Int + productId Int + productVariantId Int? + stickersVariantId Int? + price Decimal @db.Decimal(10, 2) + 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) + cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + productVariant ProductVariant? @relation("CartItemToProductVariant", fields: [productVariantId], references: [id]) + stickersVariant stickersVariant? @relation("CartItemTostickersVariant", fields: [stickersVariantId], references: [id]) - @@unique([cartId, productId], name: "unique_cart_item") + @@unique([cartId, productId, productVariantId, stickersVariantId], name: "unique_cart_item") @@map("cart_items") } diff --git a/prisma/seed.ts b/prisma/seed.ts index 106da46..414c9f5 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -3,16 +3,81 @@ import { PrismaClient } from "../generated/prisma/client"; const prisma = new PrismaClient(); +// Define las tallas para los productos tipo "Polo" +const poloSizes = ["small", "medium", "large"] as const; +// Define el tamaño para los productos tipo "Stickers" +const stickerMeasures = ["3*3", "5*5", "10*10"] as const; + async function seedDb() { + // Limpia las tablas para evitar duplicados + await prisma.productVariant.deleteMany(); + await prisma.stickersVariant.deleteMany(); + await prisma.product.deleteMany(); + await prisma.category.deleteMany(); + + // Inserta categorías await prisma.category.createMany({ data: categories, }); console.log("1. Categories successfully inserted"); + // Inserta productos await prisma.product.createMany({ data: products, }); console.log("2. Products successfully inserted"); + + // Obtiene los productos tipo "Polo" para agregar variantes + const polosCategory = await prisma.category.findUnique({ + where: { slug: "polos" }, + }); + + if (polosCategory) { + const polos = await prisma.product.findMany({ + where: { categoryId: polosCategory.id }, + }); + + for (const polo of polos) { + for (const size of poloSizes) { + await prisma.productVariant.create({ + data: { + productId: polo.id, + size, + }, + }); + } + } + console.log("3. Polo variants successfully inserted"); + } + + // Obtiene los productos tipo "Stickers" para agregar variantes + const stickersCategory = await prisma.category.findUnique({ + where: { slug: "stickers" }, + }); + if (stickersCategory) { + const stickers = await prisma.product.findMany({ + where: { categoryId: stickersCategory.id }, + }); + + const stickerPrices: Record = { + "3*3": 2.99, + "5*5": 5.99, + "10*10": 8.99, + }; + + for (const sticker of stickers) { + for (const measure of stickerMeasures) { + await prisma.stickersVariant.create({ + data: { + productId: sticker.id, + measure, + price: stickerPrices[measure], // Asigna el precio según la medida + }, + }); + } + } + console.log("4. Stickers variants successfully inserted"); + } } seedDb() @@ -22,4 +87,4 @@ seedDb() .finally(async () => { console.log("--- Database seeded successfully. ---"); await prisma.$disconnect(); - }); + }); \ No newline at end of file diff --git a/src/components/product/VariantSelector.tsx b/src/components/product/VariantSelector.tsx new file mode 100644 index 0000000..880c39e --- /dev/null +++ b/src/components/product/VariantSelector.tsx @@ -0,0 +1,46 @@ +type VariantOption = { + id: number | string; + label: string; + value: string; +}; + +type VariantSelectorProps = { + label: string; + name: string; + options: VariantOption[]; + selectedValue: string; + onSelect: (value: string) => void; +}; + +export function VariantSelector({ + label, + name, + options, + selectedValue, + onSelect, +}: VariantSelectorProps) { + return ( +
+ +
+ {options.map(option => ( + + ))} +
+ {/* input oculto para enviar la opción seleccionada */} + +
+ ); +} + diff --git a/src/lib/cart.ts b/src/lib/cart.ts index e0308df..723e27e 100644 --- a/src/lib/cart.ts +++ b/src/lib/cart.ts @@ -19,14 +19,18 @@ export async function addToCart( userId: number | undefined, sessionCartId: string | undefined, productId: Product["id"], - quantity: number = 1 + quantity: number = 1, + productVariantId?: number, + stickersVariantId?: number ) { try { const updatedCart = await alterQuantityCartItem( userId, sessionCartId, productId, - quantity + quantity, + productVariantId, + stickersVariantId ); return updatedCart; } catch (error) { @@ -41,7 +45,7 @@ export async function removeFromCart( itemId: CartItem["id"] ) { try { - // El backend determinará si es un usuario autenticado o invitado + // La parte del backend determina si es un usuario autenticado o invitado const updatedCart = await deleteRemoteCartItem( userId, sessionCartId, @@ -59,12 +63,12 @@ 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) { - // CartItem - has a product property - return total + item.product.price * item.quantity; + + return total + item.price * item.quantity; } else { - // CartItemInput - has price directly + return total + item.price * item.quantity; } }, 0); diff --git a/src/lib/utils.tests.ts b/src/lib/utils.tests.ts index 1526f23..1ff1527 100644 --- a/src/lib/utils.tests.ts +++ b/src/lib/utils.tests.ts @@ -63,6 +63,8 @@ export const createTestProduct = (overrides?: Partial): Product => ({ categoryId: 1, isOnSale: false, features: ["Feature 1", "Feature 2"], + variants: [], + stickersVariants: [], createdAt: new Date(), updatedAt: new Date(), ...overrides, diff --git a/src/models/cart.model.ts b/src/models/cart.model.ts index ad4206a..083d45d 100644 --- a/src/models/cart.model.ts +++ b/src/models/cart.model.ts @@ -1,18 +1,26 @@ -import { type Product } from "./product.model"; +import type { Product, ProductVariant, StickersVariant } 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" - >; +export type Cart = PrismaCart; + +// Este es el tipo para un item del carrito en nuestra aplicación. +// Extiende el tipo base de Prisma, pero asegura que los precios sean `number` +// y que las relaciones (product, variants) usen los tipos de nuestra aplicación. +export type CartItem = Omit & { + product: Product; + productVariant: ProductVariant | null; + stickersVariant: StickersVariant | null; + price: number; // El precio final del item en el carrito (puede ser de una variante) }; -export type Cart = PrismaCart; +// Este es el tipo principal para el Carrito en la aplicación. +export type CartWithItems = Omit & { + items: CartItem[]; +}; export interface CartItemInput { productId: Product["id"]; @@ -22,20 +30,4 @@ export interface CartItemInput { 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; - quantity: number; -}; - -// Tipo para el carrito con items y productos incluidos -export type CartWithItems = Cart & { - items: CartItem[]; -}; +export type CartItemWithProduct = CartItem; diff --git a/src/models/product.model.ts b/src/models/product.model.ts index 96ba043..0b9ee87 100644 --- a/src/models/product.model.ts +++ b/src/models/product.model.ts @@ -2,4 +2,16 @@ import type { Product as PrismaProduct } from "@/../generated/prisma/client"; export type Product = Omit & { price: number; + variants?: ProductVariant[]; + stickersVariants?: StickersVariant[]; }; + +export type StickersVariant = { + id: number; + measure: "3*3" | "5*5" | "10*10"; + price: number; +}; +export interface ProductVariant { + id: number; + size: "small" | "medium" | "large"; +} diff --git a/src/routes/cart/add-item/index.tsx b/src/routes/cart/add-item/index.tsx index ac49758..ab440a0 100644 --- a/src/routes/cart/add-item/index.tsx +++ b/src/routes/cart/add-item/index.tsx @@ -1,5 +1,6 @@ import { redirect } from "react-router"; +import { prisma } from "@/db/prisma"; import { addToCart } from "@/lib/cart"; import { getSession } from "@/session.server"; @@ -9,12 +10,51 @@ export async function action({ request }: Route.ActionArgs) { const formData = await request.formData(); const productId = Number(formData.get("productId")); const quantity = Number(formData.get("quantity")) || 1; + const size = formData.get("size") as string | undefined; + const measure = formData.get("measure") as string | undefined; const redirectTo = formData.get("redirectTo") as string | null; const session = await getSession(request.headers.get("Cookie")); const sessionCartId = session.get("sessionCartId"); const userId = session.get("userId"); - await addToCart(userId, sessionCartId, productId, quantity); + let productVariantId: number | undefined = undefined; + let stickersVariantId: number | undefined = undefined; + + // Si hay talla, busca el variant correspondiente + if (size) { + const variant = await prisma.productVariant.findFirst({ + where: { + productId, + size, + }, + }); + if (!variant) { + return new Response( + JSON.stringify({ error: "No se encontró la variante seleccionada." }), + { status: 400, headers: { "Content-Type": "application/json" } } +); + } + productVariantId = variant.id; + } + + // Si hay medida, busca el variant correspondiente + if (measure) { + const variantMeasure = await prisma.stickersVariant.findFirst({ + where: { + productId, + measure, + }, + }); + if (!variantMeasure) { + return new Response( + JSON.stringify({ error: "No se encontró la variante de stickers seleccionada." }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + stickersVariantId = variantMeasure.id; + } + + await addToCart(userId, sessionCartId, productId, quantity, productVariantId, stickersVariantId); return redirect(redirectTo || "/cart"); -} +} \ No newline at end of file diff --git a/src/routes/cart/index.tsx b/src/routes/cart/index.tsx index d330cef..acc66c9 100644 --- a/src/routes/cart/index.tsx +++ b/src/routes/cart/index.tsx @@ -30,8 +30,9 @@ export default function Cart({ loaderData }: Route.ComponentProps) { Carrito de compras
- {cart?.items?.map(({ product, quantity, id }) => ( -
+ {cart?.items?.map( + ({ product, quantity, id, productVariant, stickersVariant, price }) => ( +
-

{product.title}

+

+ {product.title} + {productVariant && ( + + ({productVariant.size}) + + )} + {stickersVariant && ( + + ({stickersVariant.measure}) + + )} +

- ))} + ) + )}

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..25ad1b8 100644 --- a/src/routes/category/components/price-filter/index.tsx +++ b/src/routes/category/components/price-filter/index.tsx @@ -2,6 +2,7 @@ import { Form } from "react-router"; import { Button, Input } from "@/components/ui"; import { cn } from "@/lib/utils"; +import { useState } from "react"; interface PriceFilterProps { minPrice: string; diff --git a/src/routes/category/components/product-card/index.tsx b/src/routes/category/components/product-card/index.tsx index a6abe33..08ee48f 100644 --- a/src/routes/category/components/product-card/index.tsx +++ b/src/routes/category/components/product-card/index.tsx @@ -1,12 +1,112 @@ -import { Link } from "react-router"; +import { useState } from "react"; +import { Link, useNavigate } from "react-router"; +import { Button } from "@/components/ui"; import type { Product } from "@/models/product.model"; interface ProductCardProps { product: Product; + categorySlug: string; + minPrice?: string; + maxPrice?: string; } -export function ProductCard({ product }: ProductCardProps) { +export function ProductCard({ + product, + categorySlug, + minPrice, + maxPrice, +}: ProductCardProps) { + const navigate = useNavigate(); + const [hoveredPrice, setHoveredPrice] = useState(null); + let variantTitle: string | null = null; + let variants: string[] = []; + let variantParamName: "size" | "measure" | null = null; + + // Obtener el precio base para stickers para la variante 3*3 + const getBasePrice = () => { + if (categorySlug === "stickers" && product.stickersVariants?.length) { + const baseVariant = product.stickersVariants.find( + (variant) => variant.measure === "3*3" + ); + return baseVariant ? baseVariant.price : product.price; + } + return product.price; + }; + + // Obtener rango de precios para las variantes filtradas + const getPriceRange = () => { + if (categorySlug === "stickers" && product.stickersVariants?.length && variants.length > 0) { + const filteredVariants = product.stickersVariants.filter(variant => + variants.includes(variant.measure) + ); + + if (filteredVariants.length > 0) { + const minPrice = Math.min(...filteredVariants.map(v => v.price)); + const maxPrice = Math.max(...filteredVariants.map(v => v.price)); + + if (minPrice === maxPrice) { + return `S/${minPrice}`; + } + return `S/${minPrice} - S/${maxPrice}`; + } + } + return null; + }; + + if (categorySlug === "polos") { + variantTitle = "Elige la talla"; + variants = ["Small", "Medium", "Large"]; + variantParamName = "size"; + } else if (categorySlug === "stickers") { + variantTitle = "Elige la medida"; + + const min = minPrice ? parseFloat(minPrice) : 0; + const max = maxPrice ? parseFloat(maxPrice) : Infinity; + + if (product.stickersVariants?.length) { + variants = product.stickersVariants + .filter((variant) => variant.price >= min && variant.price <= max) + .map((variant) => variant.measure); + } else { + variants = ["3*3", "5*5", "10*10"]; + } + variantParamName = "measure"; + } + + const basePrice = getBasePrice(); + const priceRange = getPriceRange(); + + const handleVariantClick = ( + e: React.MouseEvent, + variant: string + ) => { + e.preventDefault(); + e.stopPropagation(); + if (variantParamName) { + const paramValue = + variantParamName === "size" ? variant.toLowerCase() : variant; + navigate(`/products/${product.id}?${variantParamName}=${paramValue}`); + } + }; + + const hoverVariantClick = (variant: string) => { + if (variantParamName === "measure") { + if (product.stickersVariants && product.stickersVariants.length > 0) { + const variantPrice = product.stickersVariants.find( + (v) => v.measure === variant + )?.price; + setHoveredPrice(variantPrice || null); + } else { + setHoveredPrice(null); + } + } + }; + + const handleMouseLeave = () => { + setHoveredPrice(null); + }; + return (
-
+
{product.title} + {variantTitle && ( +
+

{variantTitle}

+
+ {variants.map((variant) => ( + + ))} +
+
+ )}

{product.title}

{product.description}

-

S/{product.price}

+

+ {hoveredPrice !== null ? `S/${hoveredPrice}` : (priceRange ? priceRange : `S/${basePrice}`)} +

{product.isOnSale && ( diff --git a/src/routes/category/index.tsx b/src/routes/category/index.tsx index 7c0aef5..0258f62 100644 --- a/src/routes/category/index.tsx +++ b/src/routes/category/index.tsx @@ -31,19 +31,29 @@ export async function loader({ params, request }: Route.LoaderArgs) { const filterProductsByPrice = ( products: Product[], minPrice: string, - maxPrice: string + maxPrice: string, + categorySlug: string ) => { const min = minPrice ? parseFloat(minPrice) : 0; const max = maxPrice ? parseFloat(maxPrice) : Infinity; - return products.filter( - (product) => product.price >= min && product.price <= max - ); + return products.filter((product) => { + if ( + categorySlug === "stickers" && + product.stickersVariants?.length + ) { + return product.stickersVariants.some( + (variant) => variant.price >= min && variant.price <= max + ); + } + return product.price >= min && product.price <= max; + }); }; const filteredProducts = filterProductsByPrice( products, minPrice, - maxPrice + maxPrice, + categorySlug ); return { @@ -82,7 +92,13 @@ export default function Category({ loaderData }: Route.ComponentProps) { />
{products.map((product) => ( - + ))}
diff --git a/src/routes/checkout/index.tsx b/src/routes/checkout/index.tsx index 1ceb7ae..1004462 100644 --- a/src/routes/checkout/index.tsx +++ b/src/routes/checkout/index.tsx @@ -107,8 +107,9 @@ export async function action({ request }: Route.ActionArgs) { productId: item.product.id, quantity: item.quantity, title: item.product.title, - price: item.product.price, + price: item.price, // Usar el precio del item (que puede ser de la variante) imgSrc: item.product.imgSrc, + productVariantTitle: item.productVariant?.size || null, })); const { id: orderId } = await createOrder( @@ -249,9 +250,10 @@ export default function Checkout({

Resumen de la orden

- {cart?.items?.map(({ product, quantity }) => ( + {cart?.items?.map( + ({ product, quantity, id, productVariant, stickersVariant, price }) => (
@@ -262,15 +264,28 @@ export default function Checkout({ />
-

{product.title}

+

+ {product.title} + {productVariant && ( + + ({productVariant.size}) + + )} + {stickersVariant && ( + + ({stickersVariant.measure}) + + )} +

{quantity}

-

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

+

S/{price.toFixed(2)}

- ))} + ) + )}

Total

S/{total.toFixed(2)}

diff --git a/src/routes/product/index.tsx b/src/routes/product/index.tsx index f444f0b..adab178 100644 --- a/src/routes/product/index.tsx +++ b/src/routes/product/index.tsx @@ -1,7 +1,9 @@ -import { Form, useNavigation } from "react-router"; +import { useCallback, useEffect, useState } from "react"; +import { Form, useNavigation, useSearchParams } from "react-router"; +import { VariantSelector } from "@/components/product/VariantSelector"; import { Button, Container, Separator } from "@/components/ui"; -import { type Product } from "@/models/product.model"; +import { capitalize } from "@/lib/utils"; import { getProductById } from "@/services/product.service"; import NotFound from "../not-found"; @@ -20,12 +22,59 @@ export async function loader({ params }: Route.LoaderArgs) { export default function Product({ loaderData }: Route.ComponentProps) { const { product } = loaderData; const navigation = useNavigation(); + const [searchParams] = useSearchParams(); const cartLoading = navigation.state === "submitting"; + const getInitialSize = useCallback(() => { + const isValidSize = ( + size: string | null + ): size is "small" | "medium" | "large" => { + return size === "small" || size === "medium" || size === "large"; + }; + const sizeFromUrl = searchParams.get("size"); + const availableSizes = product?.variants?.map((v) => v.size) || []; + if (isValidSize(sizeFromUrl) && availableSizes.includes(sizeFromUrl)) { + return sizeFromUrl; + } + return product?.variants?.[0]?.size ?? ""; + }, [product?.variants, searchParams]); + + const getInitialMeasure = useCallback(() => { + const isValidMeasure = ( + measure: string | null + ): measure is "3*3" | "5*5" | "10*10" => { + return measure === "3*3" || measure === "5*5" || measure === "10*10"; + }; + const measureFromUrl = searchParams.get("measure"); + const availableMeasures = + product?.stickersVariants?.map((v) => v.measure) || []; + if ( + isValidMeasure(measureFromUrl) && + availableMeasures.includes(measureFromUrl) + ) { + return measureFromUrl; + } + return product?.stickersVariants?.[0]?.measure ?? ""; + }, [product?.stickersVariants, searchParams]); + + const [selectedSize, setSelectedSize] = useState(getInitialSize); + const [selectedMeasure, setSelectedMeasure] = useState(getInitialMeasure); + + useEffect(() => { + setSelectedSize(getInitialSize()); + setSelectedMeasure(getInitialMeasure()); + }, [getInitialMeasure, getInitialSize]); + if (!product) { return ; } + let displayedPrice = product.price; + + if (selectedMeasure) { + displayedPrice = product.stickersVariants?.find(v => v.measure === selectedMeasure)?.price || product.price; + } + return ( <>
@@ -41,7 +90,7 @@ export default function Product({ loaderData }: Route.ComponentProps) {

{product.title}

-

S/{product.price}

+

S/{displayedPrice}

{product.description}

@@ -51,6 +100,35 @@ export default function Product({ loaderData }: Route.ComponentProps) { name="redirectTo" value={`/products/${product.id}`} /> + {/* Botones de talla si hay variantes */} + {product.variants && product.variants.length > 0 && ( + ({ + id: variant.id, + label: capitalize(variant.size), + value: variant.size , + }))} + selectedValue={selectedSize} + onSelect={setSelectedSize} + /> + )} + {/* Botones de medida si hay variantes de stickers */} + {product.stickersVariants && product.stickersVariants.length > 0 && ( + ({ + id: variant.id, + label: variant.measure, + value: variant.measure , + }))} + selectedValue={selectedMeasure} + onSelect={setSelectedMeasure} + /> + )} +