From d8699c5105e5c006bc637421bdf3944fe60ced41 Mon Sep 17 00:00:00 2001 From: Jeffcar1993 Date: Tue, 19 Aug 2025 20:59:46 -0500 Subject: [PATCH 01/11] feat: add product variants and update cart functionality to support size selection --- .react-router/types/+register.ts | 1 + prisma/initial_data.ts | 13 +++ .../migration.sql | 11 ++ .../migration.sql | 56 ++++++++++ prisma/schema.prisma | 44 +++++--- prisma/seed.ts | 35 +++++- src/lib/cart.ts | 6 +- src/models/cart.model.ts | 1 + src/models/product.model.ts | 5 + src/routes/cart/add-item/index.tsx | 26 ++++- src/routes/product/index.tsx | 32 +++++- src/services/cart.service.ts | 102 +++++++++--------- src/services/product.service.ts | 18 ++-- 13 files changed, 269 insertions(+), 81 deletions(-) create mode 100644 prisma/migrations/20250819202839_add_product_variants/migration.sql create mode 100644 prisma/migrations/20250820012221_sync_cartitem_cartid/migration.sql 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/prisma/initial_data.ts b/prisma/initial_data.ts index 0520e9e..6b45206 100644 --- a/prisma/initial_data.ts +++ b/prisma/initial_data.ts @@ -29,6 +29,19 @@ 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 products = [ { title: "Polo React", diff --git a/prisma/migrations/20250819202839_add_product_variants/migration.sql b/prisma/migrations/20250819202839_add_product_variants/migration.sql new file mode 100644 index 0000000..3dac80e --- /dev/null +++ b/prisma/migrations/20250819202839_add_product_variants/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "ProductVariants" ( + "id" SERIAL NOT NULL, + "productId" INTEGER NOT NULL, + "size" TEXT NOT NULL, + + CONSTRAINT "ProductVariant_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "ProductVariants" ADD CONSTRAINT "ProductVariant_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20250820012221_sync_cartitem_cartid/migration.sql b/prisma/migrations/20250820012221_sync_cartitem_cartid/migration.sql new file mode 100644 index 0000000..a6b7016 --- /dev/null +++ b/prisma/migrations/20250820012221_sync_cartitem_cartid/migration.sql @@ -0,0 +1,56 @@ +/* + Warnings: + + - You are about to drop the column `cart_id` on the `cart_items` table. All the data in the column will be lost. + - You are about to drop the column `product_id` on the `cart_items` table. All the data in the column will be lost. + - You are about to drop the `ProductVariants` table. If the table is not empty, all the data it contains will be lost. + - A unique constraint covering the columns `[cartId,productId]` on the table `cart_items` will be added. If there are existing duplicate values, this will fail. + - Added the required column `cartId` to the `cart_items` table without a default value. This is not possible if the table is not empty. + - Added the required column `productId` to the `cart_items` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "ProductVariants" DROP CONSTRAINT "ProductVariant_productId_fkey"; + +-- DropForeignKey +ALTER TABLE "cart_items" DROP CONSTRAINT "cart_items_cart_id_fkey"; + +-- 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 "cart_id", +DROP COLUMN "product_id", +ADD COLUMN "cartId" INTEGER NOT NULL, +ADD COLUMN "productId" INTEGER NOT NULL, +ADD COLUMN "productVariantId" INTEGER; + +-- DropTable +DROP TABLE "ProductVariants"; + +-- CreateTable +CREATE TABLE "ProductVariant" ( + "id" SERIAL NOT NULL, + "productId" INTEGER NOT NULL, + "size" TEXT NOT NULL, + + CONSTRAINT "ProductVariant_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "cart_items_cartId_productId_key" ON "cart_items"("cartId", "productId"); + +-- AddForeignKey +ALTER TABLE "ProductVariant" ADD CONSTRAINT "ProductVariant_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "cart_items" ADD CONSTRAINT "cart_items_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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e0f992b..ae756a8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -51,17 +51,18 @@ 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[] category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) cartItems CartItem[] @@ -70,6 +71,15 @@ 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 Cart { id Int @id @default(autoincrement()) sessionCartId String @unique @default(dbgenerated("gen_random_uuid()")) @map("session_cart_id") @db.Uuid @@ -84,15 +94,17 @@ 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) - - cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade) - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + id Int @id @default(autoincrement()) + cartId Int + productId Int + productVariantId Int? + 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) + productVariant ProductVariant? @relation("CartItemToProductVariant", fields: [productVariantId], references: [id]) @@unique([cartId, productId], name: "unique_cart_item") @@map("cart_items") diff --git a/prisma/seed.ts b/prisma/seed.ts index 106da46..c40dd8c 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -3,16 +3,49 @@ 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; + async function seedDb() { + // Limpia las tablas para evitar duplicados + await prisma.productVariant.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"); + } } seedDb() @@ -22,4 +55,4 @@ seedDb() .finally(async () => { console.log("--- Database seeded successfully. ---"); await prisma.$disconnect(); - }); + }); \ No newline at end of file diff --git a/src/lib/cart.ts b/src/lib/cart.ts index e0308df..d8cb443 100644 --- a/src/lib/cart.ts +++ b/src/lib/cart.ts @@ -19,14 +19,16 @@ export async function addToCart( userId: number | undefined, sessionCartId: string | undefined, productId: Product["id"], - quantity: number = 1 + quantity: number = 1, + productVariantId?: number ) { try { const updatedCart = await alterQuantityCartItem( userId, sessionCartId, productId, - quantity + quantity, + productVariantId ); return updatedCart; } catch (error) { diff --git a/src/models/cart.model.ts b/src/models/cart.model.ts index ad4206a..19629c9 100644 --- a/src/models/cart.model.ts +++ b/src/models/cart.model.ts @@ -33,6 +33,7 @@ export type CartProductInfo = Pick< export type CartItemWithProduct = { product: CartProductInfo; quantity: number; + productVariantId: number | null; }; // Tipo para el carrito con items y productos incluidos diff --git a/src/models/product.model.ts b/src/models/product.model.ts index 96ba043..ca78f1a 100644 --- a/src/models/product.model.ts +++ b/src/models/product.model.ts @@ -2,4 +2,9 @@ import type { Product as PrismaProduct } from "@/../generated/prisma/client"; export type Product = Omit & { price: number; + variants?: ProductVariant[]; }; +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..4504a4e 100644 --- a/src/routes/cart/add-item/index.tsx +++ b/src/routes/cart/add-item/index.tsx @@ -1,5 +1,5 @@ import { redirect } from "react-router"; - +import { prisma } from "@/db/prisma"; import { addToCart } from "@/lib/cart"; import { getSession } from "@/session.server"; @@ -9,12 +9,32 @@ 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 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; + + // 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; + } + + await addToCart(userId, sessionCartId, productId, quantity, productVariantId); return redirect(redirectTo || "/cart"); -} +} \ No newline at end of file diff --git a/src/routes/product/index.tsx b/src/routes/product/index.tsx index f444f0b..7ed5c53 100644 --- a/src/routes/product/index.tsx +++ b/src/routes/product/index.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { Form, useNavigation } from "react-router"; import { Button, Container, Separator } from "@/components/ui"; @@ -22,6 +23,11 @@ export default function Product({ loaderData }: Route.ComponentProps) { const navigation = useNavigation(); const cartLoading = navigation.state === "submitting"; + // Si el producto tiene variantes, selecciona la primera por defecto + const [selectedSize, setSelectedSize] = useState( + product?.variants?.[0]?.size ?? "" + ); + if (!product) { return ; } @@ -51,6 +57,30 @@ 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 && ( +
+ +
+ {product.variants.map(variant => ( + + ))} +
+ {/* input oculto para enviar la talla seleccionada */} + +
+ )} + + + ); +}; \ No newline at end of file diff --git a/src/routes/product/index.tsx b/src/routes/product/index.tsx index 7ed5c53..1d2394e 100644 --- a/src/routes/product/index.tsx +++ b/src/routes/product/index.tsx @@ -24,9 +24,9 @@ export default function Product({ loaderData }: Route.ComponentProps) { const cartLoading = navigation.state === "submitting"; // Si el producto tiene variantes, selecciona la primera por defecto - const [selectedSize, setSelectedSize] = useState( - product?.variants?.[0]?.size ?? "" - ); + const [selectedVariantId, setSelectedVariantId] = useState( + product?.variants?.[0]?.id ?? "" +); if (!product) { return ; @@ -59,28 +59,28 @@ export default function Product({ loaderData }: Route.ComponentProps) { /> {/* Botones de talla si hay variantes */} {product.variants && product.variants.length > 0 && ( -
- -
- {product.variants.map(variant => ( - - ))} -
- {/* input oculto para enviar la talla seleccionada */} - -
- )} +
+ +
+ {product.variants.map(variant => ( + + ))} +
+ {/* input oculto para enviar el id del variant seleccionado */} + +
+)} + ))} + + {/* input oculto para enviar la opción seleccionada */} + + + ); +} + diff --git a/src/lib/cart.ts b/src/lib/cart.ts index d8cb443..60828db 100644 --- a/src/lib/cart.ts +++ b/src/lib/cart.ts @@ -20,7 +20,8 @@ export async function addToCart( sessionCartId: string | undefined, productId: Product["id"], quantity: number = 1, - productVariantId?: number + productVariantId?: number, + stickersVariantId?: number ) { try { const updatedCart = await alterQuantityCartItem( @@ -28,7 +29,8 @@ export async function addToCart( sessionCartId, productId, quantity, - productVariantId + productVariantId, + stickersVariantId ); return updatedCart; } catch (error) { diff --git a/src/models/cart.model.ts b/src/models/cart.model.ts index 5905251..0a01af7 100644 --- a/src/models/cart.model.ts +++ b/src/models/cart.model.ts @@ -11,6 +11,10 @@ export type CartItem = PrismaCartItem & { id: number; size: "small" | "medium" | "large"; } | null; + stickersVariant?: { + id: number; + measure: "3*3" | "5*5" | "10*10"; + } | null; }; export type Cart = PrismaCart; @@ -35,6 +39,8 @@ export type CartItemWithProduct = { product: CartProductInfo; quantity: number; productVariantId: number | null; + stickersVariantId: number | null; + price: number; }; // Tipo para el carrito con items y productos incluidos diff --git a/src/models/product.model.ts b/src/models/product.model.ts index ca78f1a..0b9ee87 100644 --- a/src/models/product.model.ts +++ b/src/models/product.model.ts @@ -3,6 +3,13 @@ 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; diff --git a/src/routes/cart/add-item/index.tsx b/src/routes/cart/add-item/index.tsx index 4504a4e..139dd09 100644 --- a/src/routes/cart/add-item/index.tsx +++ b/src/routes/cart/add-item/index.tsx @@ -10,12 +10,14 @@ export async function action({ request }: Route.ActionArgs) { 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"); let productVariantId: number | undefined = undefined; + let stickersVariantId: number | undefined = undefined; // Si hay talla, busca el variant correspondiente if (size) { @@ -34,7 +36,24 @@ export async function action({ request }: Route.ActionArgs) { productVariantId = variant.id; } - await addToCart(userId, sessionCartId, productId, quantity, productVariantId); + // 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 a54f8f4..cdc2c7c 100644 --- a/src/routes/cart/index.tsx +++ b/src/routes/cart/index.tsx @@ -31,7 +31,8 @@ export default function Cart({ loaderData }: Route.ComponentProps) { Carrito de compras
- {cart?.items?.map(({ product, quantity, id, productVariant }) => ( + {cart?.items?.map( + ({ product, quantity, id, productVariant, stickersVariant }) => (
)} + {stickersVariant && ( + + ({stickersVariant.measure}) + + )}
- ))} + ) + )}

Total

S/{total.toFixed(2)}

diff --git a/src/routes/checkout/index.tsx b/src/routes/checkout/index.tsx index b4e1fff..25ad4bd 100644 --- a/src/routes/checkout/index.tsx +++ b/src/routes/checkout/index.tsx @@ -250,7 +250,8 @@ export default function Checkout({

Resumen de la orden

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

{quantity}

@@ -278,7 +284,8 @@ export default function Checkout({
- ))} + ) + )}

Total

S/{total.toFixed(2)}

diff --git a/src/routes/product/index.tsx b/src/routes/product/index.tsx index 7ed5c53..88bbffd 100644 --- a/src/routes/product/index.tsx +++ b/src/routes/product/index.tsx @@ -1,8 +1,9 @@ import { useState } from "react"; import { Form, useNavigation } 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"; @@ -28,10 +29,21 @@ export default function Product({ loaderData }: Route.ComponentProps) { product?.variants?.[0]?.size ?? "" ); + // Si el producto tiene variantes de stickers, selecciona la primera por defecto + const [selectedMeasure, setSelectedMeasure] = useState( + product?.stickersVariants?.[0]?.measure ?? "" + ); + if (!product) { return ; } + let displayedPrice = product.price; + + if (selectedMeasure) { + displayedPrice = product.stickersVariants?.find(v => v.measure === selectedMeasure)?.price || product.price; + } + return ( <>
@@ -47,7 +59,7 @@ export default function Product({ loaderData }: Route.ComponentProps) {

{product.title}

-

S/{product.price}

+

S/{displayedPrice}

{product.description}

@@ -59,28 +71,33 @@ export default function Product({ loaderData }: Route.ComponentProps) { /> {/* Botones de talla si hay variantes */} {product.variants && product.variants.length > 0 && ( -
- -
- {product.variants.map(variant => ( - - ))} -
- {/* input oculto para enviar la talla seleccionada */} - -
+ ({ + 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} + /> )} + {/* Botón de agregar al carrito */} + ))} +
+
+ )}

{product.title}

diff --git a/src/routes/category/index.tsx b/src/routes/category/index.tsx index 7c0aef5..a165661 100644 --- a/src/routes/category/index.tsx +++ b/src/routes/category/index.tsx @@ -82,7 +82,11 @@ export default function Category({ loaderData }: Route.ComponentProps) { />
{products.map((product) => ( - + ))}
diff --git a/src/routes/product/index.tsx b/src/routes/product/index.tsx index 88bbffd..106efe2 100644 --- a/src/routes/product/index.tsx +++ b/src/routes/product/index.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { Form, useNavigation } from "react-router"; +import { useEffect, useState } from "react"; +import { Form, useNavigation, useSearchParams } from "react-router"; import { VariantSelector } from "@/components/product/VariantSelector"; import { Button, Container, Separator } from "@/components/ui"; @@ -22,17 +22,41 @@ 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"; - // Si el producto tiene variantes, selecciona la primera por defecto - const [selectedSize, setSelectedSize] = useState( - product?.variants?.[0]?.size ?? "" - ); + const getInitialSize = () => { + const isValidSize = (size: string | null) => { + 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 ?? ""; + }; - // Si el producto tiene variantes de stickers, selecciona la primera por defecto - const [selectedMeasure, setSelectedMeasure] = useState( - product?.stickersVariants?.[0]?.measure ?? "" - ); + const getInitialMeasure = () => { + const isValidMeasure = (measure: string | null) => { + 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 ?? ""; + }; + + const [selectedSize, setSelectedSize] = useState(getInitialSize); + const [selectedMeasure, setSelectedMeasure] = useState(getInitialMeasure); + + useEffect(() => { + setSelectedSize(getInitialSize); + setSelectedMeasure(getInitialMeasure); + }, [searchParams, product?.id]); if (!product) { return ; From b09d189d36989f2eaa37917537e5e38f9760fb6a Mon Sep 17 00:00:00 2001 From: Kellyarias02 Date: Wed, 27 Aug 2025 20:23:46 -0500 Subject: [PATCH 07/11] Refactor: to add interactivity hover category stickers and to improve the user experiencie. --- .../components/product-card/index.tsx | 33 ++++++++++++++- src/routes/category/index.tsx | 4 +- src/services/product.service.ts | 40 +++++++++++++------ 3 files changed, 60 insertions(+), 17 deletions(-) diff --git a/src/routes/category/components/product-card/index.tsx b/src/routes/category/components/product-card/index.tsx index 6b83d98..58b40a2 100644 --- a/src/routes/category/components/product-card/index.tsx +++ b/src/routes/category/components/product-card/index.tsx @@ -1,4 +1,5 @@ import { Link, useNavigate } from "react-router"; +import { useState } from "react"; import type { Product } from "@/models/product.model"; import { Button } from "@/components/ui"; @@ -10,17 +11,24 @@ interface ProductCardProps { export function ProductCard({ product, categorySlug }: ProductCardProps) { const navigate = useNavigate(); + const [hoveredPrice, setHoveredPrice] = useState(null); let variantTitle: string | null = null; let variants: string[] = []; let variantParamName: "size" | "measure" | null = null; + const variantMap: Record = { + "3*3": "3*3", + "5*5": "5*5", + "10*10": "10*10" + }; + if (categorySlug === "polos") { variantTitle = "Elige la talla"; variants = ["Small", "Medium", "Large"]; variantParamName = "size"; } else if (categorySlug === "stickers") { variantTitle = "Elige la medida"; - variants = ["3*3", "5*5", "10*10"]; + variants = ["3*3", "5*5", "10*10"]; variantParamName = "measure"; } @@ -37,6 +45,23 @@ export function ProductCard({ product, categorySlug }: ProductCardProps) { } }; + 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 ( handleVariantClick(e, variant)} + onMouseEnter={() => hoverVariantClick(variant)} + onMouseLeave={handleMouseLeave} > {variant} @@ -70,7 +97,9 @@ export function ProductCard({ product, categorySlug }: ProductCardProps) {

{product.title}

{product.description}

-

S/{product.price}

+

+ S/{hoveredPrice !== null ? hoveredPrice : product.price} +

{product.isOnSale && ( diff --git a/src/routes/category/index.tsx b/src/routes/category/index.tsx index a165661..b6b36b8 100644 --- a/src/routes/category/index.tsx +++ b/src/routes/category/index.tsx @@ -92,6 +92,6 @@ export default function Category({ loaderData }: Route.ComponentProps) {
- - ); +     +  ); } diff --git a/src/services/product.service.ts b/src/services/product.service.ts index 65f729c..a7a35ca 100644 --- a/src/services/product.service.ts +++ b/src/services/product.service.ts @@ -4,19 +4,6 @@ import type { Product } from "@/models/product.model"; import { getCategoryBySlug } from "./category.service"; -export async function getProductsByCategorySlug( - categorySlug: Category["slug"] -): Promise { - const category = await getCategoryBySlug(categorySlug); - const products = await prisma.product.findMany({ - where: { categoryId: category.id }, - }); - - return products.map((product) => ({ - ...product, - price: product.price.toNumber(), - })); -} export async function getProductById(id: number): Promise { const product = await prisma.product.findUnique({ @@ -48,3 +35,30 @@ export async function getAllProducts(): Promise { price: p.price.toNumber(), })); } + +export async function getProductsByCategorySlug( + categorySlug: Category["slug"] +): Promise { + const category = await getCategoryBySlug(categorySlug); + const products = await prisma.product.findMany({ + where: { categoryId: category.id }, + include: { + stickersVariants: true, + variants: true + }, + }); + + return products.map((product) => ({ + ...product, + price: product.price.toNumber(), + variants: product.variants?.map(v => ({ + id: v.id, + size: v.size as "small" | "medium" | "large", + })), + stickersVariants: product.stickersVariants?.map(s => ({ + id: s.id, + measure: s.measure as "3*3" | "5*5" | "10*10", + price: s.price.toNumber(), + })), + })); +} From 4dec2c465f7a5b06bc9b3ffbf0da6ba7c3f248d2 Mon Sep 17 00:00:00 2001 From: Jeffcar1993 Date: Thu, 28 Aug 2025 16:36:28 -0500 Subject: [PATCH 08/11] Refactor: To add functions about as filters for stickers category --- .../components/product-card/index.tsx | 25 ++++++++++++++++--- src/routes/category/index.tsx | 22 ++++++++++++---- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/routes/category/components/product-card/index.tsx b/src/routes/category/components/product-card/index.tsx index 58b40a2..9d48246 100644 --- a/src/routes/category/components/product-card/index.tsx +++ b/src/routes/category/components/product-card/index.tsx @@ -7,9 +7,16 @@ import { Button } from "@/components/ui"; interface ProductCardProps { product: Product; categorySlug: string; + minPrice?: string; + maxPrice?: string; } -export function ProductCard({ product, categorySlug }: ProductCardProps) { +export function ProductCard({ + product, + categorySlug, + minPrice, + maxPrice, +}: ProductCardProps) { const navigate = useNavigate(); const [hoveredPrice, setHoveredPrice] = useState(null); let variantTitle: string | null = null; @@ -18,8 +25,8 @@ export function ProductCard({ product, categorySlug }: ProductCardProps) { const variantMap: Record = { "3*3": "3*3", - "5*5": "5*5", - "10*10": "10*10" + "5*5": "5*5", + "10*10": "10*10", }; if (categorySlug === "polos") { @@ -28,7 +35,17 @@ export function ProductCard({ product, categorySlug }: ProductCardProps) { variantParamName = "size"; } else if (categorySlug === "stickers") { variantTitle = "Elige la medida"; - variants = ["3*3", "5*5", "10*10"]; + + 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"; } diff --git a/src/routes/category/index.tsx b/src/routes/category/index.tsx index b6b36b8..b12ed7f 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 { @@ -86,6 +96,8 @@ export default function Category({ loaderData }: Route.ComponentProps) { product={product} key={product.id} categorySlug={category.slug} + minPrice={minPrice} + maxPrice={maxPrice} /> ))} From 59d6a55ecee680f76739f370fc77d2d32735ef43 Mon Sep 17 00:00:00 2001 From: Kellyarias02 Date: Thu, 28 Aug 2025 19:34:56 -0500 Subject: [PATCH 09/11] Fix: Display the dynamic prices on the sticker category --- .../components/price-filter/index.tsx | 3 +- .../components/product-card/index.tsx | 36 ++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/routes/category/components/price-filter/index.tsx b/src/routes/category/components/price-filter/index.tsx index 5337413..17f47d4 100644 --- a/src/routes/category/components/price-filter/index.tsx +++ b/src/routes/category/components/price-filter/index.tsx @@ -1,7 +1,8 @@ import { Form } from "react-router"; -import { Button, Input } from "@/components/ui"; +import { Button, Container, 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 9d48246..6a1eafa 100644 --- a/src/routes/category/components/product-card/index.tsx +++ b/src/routes/category/components/product-card/index.tsx @@ -29,6 +29,37 @@ export function ProductCard({ "10*10": "10*10", }; + // 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"]; @@ -49,6 +80,9 @@ export function ProductCard({ variantParamName = "measure"; } + const basePrice = getBasePrice(); + const priceRange = getPriceRange(); + const handleVariantClick = ( e: React.MouseEvent, variant: string @@ -115,7 +149,7 @@ export function ProductCard({

{product.title}

{product.description}

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

{product.isOnSale && ( From c4e4e939a2ece1d827be7f19acdd1734c691292a Mon Sep 17 00:00:00 2001 From: Kellyarias02 Date: Sat, 30 Aug 2025 11:07:36 -0500 Subject: [PATCH 10/11] Refactor: to add test about as variants of polos and stickers --- src/lib/utils.tests.ts | 2 + src/routes/product/index.tsx | 2 +- src/routes/product/product.test.tsx | 58 +++++++++++++++++++++++++++- src/services/product.service.test.ts | 18 +++++++-- 4 files changed, 75 insertions(+), 5 deletions(-) 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/routes/product/index.tsx b/src/routes/product/index.tsx index 106efe2..791d2ed 100644 --- a/src/routes/product/index.tsx +++ b/src/routes/product/index.tsx @@ -121,7 +121,7 @@ export default function Product({ loaderData }: Route.ComponentProps) { onSelect={setSelectedMeasure} /> )} - {/* Botón de agregar al carrito */} + - - - ); -}; \ No newline at end of file diff --git a/src/routes/product/index.tsx b/src/routes/product/index.tsx index 791d2ed..adab178 100644 --- a/src/routes/product/index.tsx +++ b/src/routes/product/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Form, useNavigation, useSearchParams } from "react-router"; import { VariantSelector } from "@/components/product/VariantSelector"; @@ -25,38 +25,45 @@ export default function Product({ loaderData }: Route.ComponentProps) { const [searchParams] = useSearchParams(); const cartLoading = navigation.state === "submitting"; - const getInitialSize = () => { - const isValidSize = (size: string | null) => { + 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 sizeFromUrl; + } return product?.variants?.[0]?.size ?? ""; - }; + }, [product?.variants, searchParams]); - const getInitialMeasure = () => { - const isValidMeasure = (measure: string | null) => { + 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)) { + 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); - }, [searchParams, product?.id]); + setSelectedSize(getInitialSize()); + setSelectedMeasure(getInitialMeasure()); + }, [getInitialMeasure, getInitialSize]); if (!product) { return ; diff --git a/src/services/cart.service.ts b/src/services/cart.service.ts index f8ee66f..3286c1a 100644 --- a/src/services/cart.service.ts +++ b/src/services/cart.service.ts @@ -1,8 +1,80 @@ import { prisma } from "@/db/prisma"; -import type { CartItemWithProduct, CartWithItems } from "@/models/cart.model"; +import type { + CartItem, + CartItemWithProduct, + CartWithItems, +} from "@/models/cart.model"; import type { User } from "@/models/user.model"; import { getSession } from "@/session.server"; +import type { + Cart as PrismaCart, + CartItem as PrismaCartItem, + Product as PrismaProduct, + ProductVariant as PrismaProductVariant, + stickersVariant as PrismaStickersVariant, +} from "@/../generated/prisma/client"; + +// Este tipo representa la estructura de datos que devuelve Prisma +// cuando incluimos todas las relaciones de los items del carrito. +type PrismaCartItemWithDetails = PrismaCartItem & { + product: PrismaProduct; + productVariant: PrismaProductVariant | null; + stickersVariant: PrismaStickersVariant | null; +}; + +type PrismaCartWithDetails = PrismaCart & { + items: PrismaCartItemWithDetails[]; +}; + +/** + * Mapea un objeto de carrito de Prisma al modelo CartWithItems de la aplicación. + * Esta función convierte los tipos Decimal a numbers para los precios. + * @param prismaCart - El objeto de carrito obtenido de Prisma. + * @returns El objeto de carrito mapeado, o null si la entrada es null. + */ +function mapPrismaCartToAppCart( + prismaCart: PrismaCartWithDetails | null +): CartWithItems | null { + if (!prismaCart) { + return null; + } + + const items: CartItem[] = prismaCart.items.map((item) => { + const itemPrice = + typeof item.price === "object" ? item.price.toNumber() : item.price; + + const productPrice = + typeof item.product.price === "object" + ? item.product.price.toNumber() + : item.product.price; + + const stickerVariantPrice = item.stickersVariant?.price + ? typeof item.stickersVariant.price === "object" + ? item.stickersVariant.price.toNumber() + : item.stickersVariant.price + : undefined; + + return { + ...item, + price: itemPrice, + product: { + ...item.product, + price: productPrice, + }, + productVariant: item.productVariant, + stickersVariant: item.stickersVariant + ? { ...item.stickersVariant, price: stickerVariantPrice! } + : null, + }; + }); + + return { + ...prismaCart, + items, + }; +} + // Función para obtener un carrito con sus ítems async function getCart( userId?: number, @@ -37,41 +109,7 @@ async function getCart( if (!data) return null; - return { - ...data, - items: data.items.map((item: any) => ({ - ...item, - price: - typeof item.price === "object" - ? item.price.toNumber() - : item.price, - product: { - ...item.product, - price: - typeof item.product.price === "object" - ? item.product.price.toNumber() - : item.product.price, - }, - productVariant: item.productVariant - ? { - id: item.productVariant.id, - size: item.productVariant.size as "small" | "medium" | "large", - } - : null, - productVariantId: item.productVariantId ?? null, - stickersVariant: item.stickersVariant - ? { - id: item.stickersVariant.id, - measure: item.stickersVariant.measure as "3*3" | "5*5" | "10*10", - price: - typeof item.stickersVariant.price === "object" - ? item.stickersVariant.price.toNumber() - : item.stickersVariant.price, - } - : null, - stickersVariantId: item.stickersVariantId ?? null, - })), - }; + return mapPrismaCartToAppCart(data); } export async function getRemoteCart( @@ -115,21 +153,11 @@ export async function getOrCreateCart( if (!newCart) throw new Error("Failed to create cart"); - return { - ...newCart, - items: newCart.items.map((item: any) => ({ - ...item, - product: { - ...item.product, - price: - typeof item.product.price === "object" - ? item.product.price.toNumber() - : item.product.price, - } as any, - productVariant: item.productVariant ?? null, - productVariantId: item.productVariantId ?? null, - })), - }; + const mappedCart = mapPrismaCartToAppCart(newCart); + if (!mappedCart) { + throw new Error("Failed to map newly created cart"); + } + return mappedCart; } export async function createRemoteItems( @@ -320,21 +348,13 @@ export async function linkCartToUser( if (!updatedCart) throw new Error("Cart not found after linking"); - return { - ...updatedCart, - items: updatedCart.items.map((item: any) => ({ - ...item, - product: { - ...item.product, - price: - typeof item.product.price === "object" - ? item.product.price.toNumber() - : item.product.price, - } as any, - productVariant: item.productVariant ?? null, - productVariantId: item.productVariantId ?? null, - })), - }; + const mappedCart = mapPrismaCartToAppCart(updatedCart); + + if (!mappedCart) { + throw new Error("Failed to map linked cart"); + } + + return mappedCart; } export async function mergeGuestCartWithUserCart( @@ -364,23 +384,7 @@ export async function mergeGuestCartWithUserCart( }, }, }); - return { - ...updatedCart, - items: updatedCart.items.map((item: any) => ({ - - ...item, - product: { - ...item.product, - price: - typeof item.product.price === "object" - ? item.product.price.toNumber() - : item.product.price, - } as any, - productVariant: item.productVariant ?? null, - productVariantId: item.productVariantId ?? null, - stickersVariantId: item.stickersVariantId ?? null, - })), - }; + return mapPrismaCartToAppCart(updatedCart); } // Obtener productos duplicados para eliminarlos del carrito del usuario diff --git a/src/services/gemini.service.ts b/src/services/gemini.service.ts deleted file mode 100644 index 617311c..0000000 --- a/src/services/gemini.service.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { GoogleGenAI } from "@google/genai"; - -const ai = new GoogleGenAI({ - apiKey: import.meta.env.VITE_GOOGLE_API_KEY || "", -}); - -let chatInstance: any = null; - -export function getChatInstance() { - if (!chatInstance) { - chatInstance = ai.chats.create({ - model: "gemini-2.5-flash", - history: [], - }); - } - return chatInstance; -} - -export async function sendGeminiMessage(message: string) { - const res = await fetch("/api/gemini-chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message }), -}); - if (!res.ok) throw new Error("Error al contactar a Gemini"); - const data = await res.json(); - return data.text; -} \ No newline at end of file