From 2466cf65190e666f1aaf6fa699e63fb6c2cf6fcf Mon Sep 17 00:00:00 2001 From: Sebas Vallejo Date: Thu, 21 Aug 2025 20:16:00 -0500 Subject: [PATCH 01/25] feat: update product variant UI --- .../components/product-card/index.tsx | 13 +- src/routes/checkout/index.tsx | 123 +++++++++++------- src/routes/product/index.tsx | 59 ++++++++- 3 files changed, 139 insertions(+), 56 deletions(-) diff --git a/src/routes/category/components/product-card/index.tsx b/src/routes/category/components/product-card/index.tsx index a6abe33..c4130e6 100644 --- a/src/routes/category/components/product-card/index.tsx +++ b/src/routes/category/components/product-card/index.tsx @@ -1,5 +1,4 @@ import { Link } from "react-router"; - import type { Product } from "@/models/product.model"; interface ProductCardProps { @@ -7,6 +6,8 @@ interface ProductCardProps { } export function ProductCard({ product }: ProductCardProps) { + const isSticker = product.categoryId === 3; + return (

{product.title}

{product.description}

-

S/{product.price}

+ {isSticker && ( +
+

Entre

+

2.59 - 5.30

+
+ )} + {!isSticker && ( +

S/{product.price}

+ )} {product.isOnSale && ( diff --git a/src/routes/checkout/index.tsx b/src/routes/checkout/index.tsx index 1ceb7ae..5bfbbde 100644 --- a/src/routes/checkout/index.tsx +++ b/src/routes/checkout/index.tsx @@ -301,56 +301,79 @@ export default function Checkout({ Información de envío
- - - - {errors.company?.message &&

{errors.company?.message}

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

{errors.company?.message}

} + + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ ("Medium"); if (!product) { return ; } + const showSizeSelector = product.categoryId === 1 || product.categoryId === 3; + + const getSizeOptions = () => { + if (product.categoryId === 3) { + return { + label: "Dimensiones", + options: [ + { value: "Small", label: "3x3 cm" }, + { value: "Medium", label: "5x5 cm" }, + { value: "Large", label: "10x10 cm" } + ] + }; + } else { + return { + label: "Talla", + options: [ + { value: "Small", label: "Small" }, + { value: "Medium", label: "Medium" }, + { value: "Large", label: "Large" } + ] + }; + } + }; + + const sizeOptions = getSizeOptions(); + return ( <>
@@ -40,11 +65,35 @@ export default function Product({ loaderData }: Route.ComponentProps) {

{product.title} + {showSizeSelector && ( + + {" "}({sizeOptions.options.find(option => option.value === selectedSize)?.label}) + + )}

S/{product.price}

{product.description}

+ + {showSizeSelector && ( +
+

{sizeOptions.label}

+
+ {sizeOptions.options.map((option) => ( + + ))} +
+
+ )} +
+ +

Características @@ -78,4 +129,4 @@ export default function Product({ loaderData }: Route.ComponentProps) {

); -} +} \ No newline at end of file From 58cb9d0e8bec297e25e1d5cfe9ca783646bb1ad8 Mon Sep 17 00:00:00 2001 From: Janet Huacahuasi Date: Fri, 22 Aug 2025 11:56:45 -0500 Subject: [PATCH 02/25] feat: update inital data and update product service to get price --- prisma/initial_data.ts | 32 ++++----- src/models/product.model.ts | 11 ++- src/models/variant-attribute.model.ts | 2 + .../components/product-card/index.tsx | 9 +++ src/routes/category/index.tsx | 20 +++++- src/routes/root/index.tsx | 2 +- src/services/cart.service.ts | 70 +++++++++++-------- src/services/product.service.ts | 48 ++++++++++--- 8 files changed, 133 insertions(+), 61 deletions(-) create mode 100644 src/models/variant-attribute.model.ts diff --git a/prisma/initial_data.ts b/prisma/initial_data.ts index e8aee38..3ab5380 100644 --- a/prisma/initial_data.ts +++ b/prisma/initial_data.ts @@ -395,36 +395,36 @@ export const variantAttributeValues = [ // --- STICKERS (dimensiones: 3x3, 6x6, 9x9) --- { attributeId: 2, productId: 10, value: "3x3", price: 2.99 }, - { attributeId: 2, productId: 10, value: "6x6", price: 3.99 }, - { attributeId: 2, productId: 10, value: "9x9", price: 4.99 }, + { attributeId: 2, productId: 10, value: "5x5", price: 3.99 }, + { attributeId: 2, productId: 10, value: "10x10", price: 4.99 }, { attributeId: 2, productId: 11, value: "3x3", price: 2.49 }, - { attributeId: 2, productId: 11, value: "6x6", price: 3.49 }, - { attributeId: 2, productId: 11, value: "9x9", price: 4.49 }, + { attributeId: 2, productId: 11, value: "5x5", price: 3.49 }, + { attributeId: 2, productId: 11, value: "10x10", price: 4.49 }, { attributeId: 2, productId: 12, value: "3x3", price: 3.99 }, - { attributeId: 2, productId: 12, value: "6x6", price: 4.99 }, - { attributeId: 2, productId: 12, value: "9x9", price: 5.99 }, + { attributeId: 2, productId: 12, value: "5x5", price: 4.99 }, + { attributeId: 2, productId: 12, value: "10x10", price: 5.99 }, { attributeId: 2, productId: 13, value: "3x3", price: 2.99 }, - { attributeId: 2, productId: 13, value: "6x6", price: 3.99 }, - { attributeId: 2, productId: 13, value: "9x9", price: 4.99 }, + { attributeId: 2, productId: 13, value: "5x5", price: 3.99 }, + { attributeId: 2, productId: 13, value: "10x10", price: 4.99 }, { attributeId: 2, productId: 14, value: "3x3", price: 2.49 }, - { attributeId: 2, productId: 14, value: "6x6", price: 3.49 }, - { attributeId: 2, productId: 14, value: "9x9", price: 4.49 }, + { attributeId: 2, productId: 14, value: "5x5", price: 3.49 }, + { attributeId: 2, productId: 14, value: "10x10", price: 4.49 }, { attributeId: 2, productId: 15, value: "3x3", price: 2.49 }, - { attributeId: 2, productId: 15, value: "6x6", price: 3.49 }, - { attributeId: 2, productId: 15, value: "9x9", price: 4.49 }, + { attributeId: 2, productId: 15, value: "5x5", price: 3.49 }, + { attributeId: 2, productId: 15, value: "10x10", price: 4.49 }, { attributeId: 2, productId: 16, value: "3x3", price: 2.99 }, - { attributeId: 2, productId: 16, value: "6x6", price: 3.99 }, - { attributeId: 2, productId: 16, value: "9x9", price: 4.99 }, + { attributeId: 2, productId: 16, value: "5x5", price: 3.99 }, + { attributeId: 2, productId: 16, value: "10x10", price: 4.99 }, { attributeId: 2, productId: 17, value: "3x3", price: 2.99 }, - { attributeId: 2, productId: 17, value: "6x6", price: 3.99 }, - { attributeId: 2, productId: 17, value: "9x9", price: .99 }, + { attributeId: 2, productId: 17, value: "5x5", price: 3.99 }, + { attributeId: 2, productId: 17, value: "10x10", price: .99 }, // --- TAZAS (no aplica: Único) --- { attributeId: 3, productId: 18, value: "Único", price: 14.99 }, diff --git a/src/models/product.model.ts b/src/models/product.model.ts index 96ba043..489125f 100644 --- a/src/models/product.model.ts +++ b/src/models/product.model.ts @@ -1,5 +1,12 @@ +import type { VariantAttributeValue } from "./variant-attribute.model"; import type { Product as PrismaProduct } from "@/../generated/prisma/client"; -export type Product = Omit & { - price: number; +export type Product = PrismaProduct & { + price?: number | null; + minPrice?: number | null; + maxPrice?: number | null; }; + +export type ProductVariantValue = PrismaProduct & { + variantAttributeValues: VariantAttributeValue[]; +} \ No newline at end of file diff --git a/src/models/variant-attribute.model.ts b/src/models/variant-attribute.model.ts new file mode 100644 index 0000000..2c91027 --- /dev/null +++ b/src/models/variant-attribute.model.ts @@ -0,0 +1,2 @@ +import type { VariantAttributeValue as PrismaVariantAttributeValue } from "@/../generated/prisma/client"; +export type VariantAttributeValue= PrismaVariantAttributeValue \ No newline at end of file diff --git a/src/routes/category/components/product-card/index.tsx b/src/routes/category/components/product-card/index.tsx index a6abe33..7805793 100644 --- a/src/routes/category/components/product-card/index.tsx +++ b/src/routes/category/components/product-card/index.tsx @@ -8,6 +8,7 @@ interface ProductCardProps { export function ProductCard({ product }: ProductCardProps) { return ( + <>

{product.title}

{product.description}

+ { + product?.price &&

S/{product.price}

+ } + { + product?.minPrice && +

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

+ }
{product.isOnSale && ( @@ -34,5 +42,6 @@ export function ProductCard({ product }: ProductCardProps) { )} + ); } diff --git a/src/routes/category/index.tsx b/src/routes/category/index.tsx index 7c0aef5..e4f83a0 100644 --- a/src/routes/category/index.tsx +++ b/src/routes/category/index.tsx @@ -36,8 +36,24 @@ export async function loader({ params, request }: Route.LoaderArgs) { const min = minPrice ? parseFloat(minPrice) : 0; const max = maxPrice ? parseFloat(maxPrice) : Infinity; return products.filter( - (product) => product.price >= min && product.price <= max - ); + (product) => { + const minProductPrice = product.minPrice||0 + const maxProductPrice = product.maxPrice ||0 + const productPrice = product.price || 0 + + if (min && max) { + return ((productPrice||minProductPrice) >= min) && ((productPrice||maxProductPrice) <= max) + } + + if (min) { + return (productPrice||minProductPrice) >= min + } + if (max) { + return (productPrice||maxProductPrice) <= max + + } + return true + }); }; const filteredProducts = filterProductsByPrice( diff --git a/src/routes/root/index.tsx b/src/routes/root/index.tsx index f3197d1..b76631d 100644 --- a/src/routes/root/index.tsx +++ b/src/routes/root/index.tsx @@ -68,7 +68,7 @@ export async function loader({ request }: Route.LoaderArgs) { } const totalItems = - cart?.items.reduce((total, item) => total + item.quantity, 0) || 0; + cart?.items?.reduce((total, item) => total + item.quantity, 0) || 0; // Preparar datos de respuesta según estado de autenticación const responseData = user ? { user, totalItems } : { totalItems }; diff --git a/src/services/cart.service.ts b/src/services/cart.service.ts index f742706..74cbdfc 100644 --- a/src/services/cart.service.ts +++ b/src/services/cart.service.ts @@ -18,42 +18,52 @@ async function getCart( : undefined; if (!whereCondition) return null; + try { - const data = await prisma.cart.findFirst({ - where: whereCondition, - include: { - items: { - include: { - product: { - select: { - id: true, - title: true, - imgSrc: true, - alt: true, - price: true, - isOnSale: true, + const data = await prisma.cart.findFirst({ + where: whereCondition, + include: { + items: { + include: { + product: { + select: { + id: true, + title: true, + imgSrc: true, + alt: true, + price: true, + isOnSale: true, + }, }, }, - }, - orderBy: { - id: "asc", + orderBy: { + id: "asc", + }, }, }, - }, - }); - - if (!data) return null; + }); + + if (!data) return null; + + return { + ...data, + items: data.items.map((item) => ({ + ...item, + product: { + ...item.product, + price: item.product.price.toNumber(), + }, + })), + }; + }catch(e) { + console.log(e) + return { + error: true, + status: 500, + message: "Error al obtener el carrito. Verifica el modelo Product.", + }; + } - return { - ...data, - items: data.items.map((item) => ({ - ...item, - product: { - ...item.product, - price: item.product.price.toNumber(), - }, - })), - }; } export async function getRemoteCart( diff --git a/src/services/product.service.ts b/src/services/product.service.ts index 3406570..add02b7 100644 --- a/src/services/product.service.ts +++ b/src/services/product.service.ts @@ -1,38 +1,66 @@ import { prisma } from "@/db/prisma"; import type { Category } from "@/models/category.model"; -import type { Product } from "@/models/product.model"; +import type { Product, ProductVariantValue } from "@/models/product.model"; +import type { VariantAttributeValue } from "@/models/variant-attribute.model"; import { getCategoryBySlug } from "./category.service"; +const formattedProduct = (product: ProductVariantValue) => { + const {variantAttributeValues, ...rest} = product + const prices = variantAttributeValues.map((v: VariantAttributeValue) => Number(v.price)) + const minPrice = Math.min(...prices) + const maxPrice = Math.max(...prices) + if (minPrice === maxPrice) { + return { + ...rest, + price: minPrice + } + } + return { + ...rest, + minPrice, + maxPrice + } +} + export async function getProductsByCategorySlug( categorySlug: Category["slug"] ): Promise { const category = await getCategoryBySlug(categorySlug); const products = await prisma.product.findMany({ where: { categoryId: category.id }, + include: { + variantAttributeValues: true + } }); - return products.map((product) => ({ - ...product, - price: product.price.toNumber(), - })); + return products.map(formattedProduct) } export async function getProductById(id: number): Promise { const product = await prisma.product.findUnique({ where: { id }, + include: { + variantAttributeValues: true + } }); if (!product) { throw new Error("Product not found"); } + const variants = product.variantAttributeValues.map((variant)=> ({ + ...variant, + price: Number(variant.price) + })) - return { ...product, price: product.price.toNumber() }; +return {...product, variantAttributeValues: variants } as Product } export async function getAllProducts(): Promise { - return (await prisma.product.findMany()).map((p) => ({ - ...p, - price: p.price.toNumber(), - })); + const products = await prisma.product.findMany({ + include: { + variantAttributeValues: true + } + }); + return products.map(formattedProduct) } From f448d4b22da92ac1dc92f236fc6b1ba11243ea25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Vallejo?= Date: Fri, 22 Aug 2025 20:11:01 -0500 Subject: [PATCH 03/25] Refactor: sticker UI update --- src/routes/category/components/product-card/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/category/components/product-card/index.tsx b/src/routes/category/components/product-card/index.tsx index c4130e6..2265f4f 100644 --- a/src/routes/category/components/product-card/index.tsx +++ b/src/routes/category/components/product-card/index.tsx @@ -28,8 +28,8 @@ export function ProductCard({ product }: ProductCardProps) {

{product.description}

{isSticker && (
-

Entre

-

2.59 - 5.30

+

Desde

+

S/2.59

)} {!isSticker && ( From ef1925ad7e5f69888b0cf26efa37a6a34e4a0cea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Vallejo?= Date: Fri, 22 Aug 2025 20:45:03 -0500 Subject: [PATCH 04/25] Fix: Product-card --- .../category/components/product-card/index.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/routes/category/components/product-card/index.tsx b/src/routes/category/components/product-card/index.tsx index faf51e6..05d36bd 100644 --- a/src/routes/category/components/product-card/index.tsx +++ b/src/routes/category/components/product-card/index.tsx @@ -5,8 +5,11 @@ interface ProductCardProps { product: Product; } +const stickerCategoryId = 3; + export function ProductCard({ product }: ProductCardProps) { - const isSticker = product.categoryId === 3; + + const isSticker = stickerCategoryId; return ( <> @@ -27,13 +30,16 @@ export function ProductCard({ product }: ProductCardProps) {

{product.title}

{product.description}

- {isSticker && ( + {isSticker ? (
-

Desde

-

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

+ Desde +

+

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

- )} - {!isSticker && ( + ) : (

S/{product.price}

)}
From ca8e64fcd784d599a64056d1273ba4ea92bb97db Mon Sep 17 00:00:00 2001 From: mike Date: Wed, 27 Aug 2025 21:45:37 -0500 Subject: [PATCH 05/25] fix: correct sticker category check and improve price filter logic --- src/routes/category/components/product-card/index.tsx | 9 +++++---- src/routes/category/index.tsx | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/routes/category/components/product-card/index.tsx b/src/routes/category/components/product-card/index.tsx index 05d36bd..23c402b 100644 --- a/src/routes/category/components/product-card/index.tsx +++ b/src/routes/category/components/product-card/index.tsx @@ -1,16 +1,17 @@ import { Link } from "react-router"; + import type { Product } from "@/models/product.model"; interface ProductCardProps { product: Product; } -const stickerCategoryId = 3; +const stickerCategoryId = 3; // ID de la categoría "Stickers" export function ProductCard({ product }: ProductCardProps) { - - const isSticker = stickerCategoryId; - + + const isSticker = product.categoryId === stickerCategoryId; + return ( <> = min } + if (max) { return (productPrice||maxProductPrice) <= max - } return true }); From ccc1059598bb5fe7d570b5d13eba2ae7de4fe8d1 Mon Sep 17 00:00:00 2001 From: mike Date: Fri, 29 Aug 2025 00:49:04 -0500 Subject: [PATCH 06/25] feat: refactor cart and product models to use attributeValueId, update related functions and components --- src/lib/cart.ts | 11 +++--- src/models/cart.model.ts | 2 +- src/models/product.model.ts | 1 + src/routes/cart/add-item/index.tsx | 4 +-- src/routes/cart/index.tsx | 12 +++---- src/routes/checkout/index.tsx | 2 +- src/routes/product/index.tsx | 58 ++++++++++++++++++++++-------- src/services/cart.service.ts | 8 ++--- 8 files changed, 65 insertions(+), 33 deletions(-) diff --git a/src/lib/cart.ts b/src/lib/cart.ts index e0308df..537a129 100644 --- a/src/lib/cart.ts +++ b/src/lib/cart.ts @@ -1,5 +1,6 @@ import type { CartItem, CartItemInput } from "@/models/cart.model"; -import { type Product } from "@/models/product.model"; +// import { type Product } from "@/models/product.model"; +import { type VariantAttributeValue } from "@/models/variant-attribute.model"; import { alterQuantityCartItem, deleteRemoteCartItem, @@ -18,14 +19,14 @@ export async function getCart(userId?: number, sessionCartId?: string) { export async function addToCart( userId: number | undefined, sessionCartId: string | undefined, - productId: Product["id"], + attributeValueId: VariantAttributeValue["id"], quantity: number = 1 ) { try { const updatedCart = await alterQuantityCartItem( userId, sessionCartId, - productId, + attributeValueId, quantity ); return updatedCart; @@ -62,10 +63,10 @@ export function calculateTotal(items: CartItem[] | CartItemInput[]): number { // 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 + Number(item.product.price) * item.quantity; } else { // CartItemInput - has price directly - return total + item.price * item.quantity; + return total + Number(item.price) * item.quantity; } }, 0); } diff --git a/src/models/cart.model.ts b/src/models/cart.model.ts index 8550190..53333ce 100644 --- a/src/models/cart.model.ts +++ b/src/models/cart.model.ts @@ -33,7 +33,7 @@ export type CartProductInfo = Pick< export type CartItemWithProduct = { product: CartProductInfo; quantity: number; - attributeId: number; + attributeValueId: 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 489125f..47259f5 100644 --- a/src/models/product.model.ts +++ b/src/models/product.model.ts @@ -5,6 +5,7 @@ export type Product = PrismaProduct & { price?: number | null; minPrice?: number | null; maxPrice?: number | null; + variantAttributeValues?: VariantAttributeValue[]; }; export type ProductVariantValue = PrismaProduct & { diff --git a/src/routes/cart/add-item/index.tsx b/src/routes/cart/add-item/index.tsx index ac49758..06745d1 100644 --- a/src/routes/cart/add-item/index.tsx +++ b/src/routes/cart/add-item/index.tsx @@ -7,14 +7,14 @@ import type { Route } from "../+types"; export async function action({ request }: Route.ActionArgs) { const formData = await request.formData(); - const productId = Number(formData.get("productId")); + const attributeValueId = Number(formData.get("attributeValueId")); const quantity = Number(formData.get("quantity")) || 1; const redirectTo = formData.get("redirectTo") as string | null; const session = await getSession(request.headers.get("Cookie")); const sessionCartId = session.get("sessionCartId"); const userId = session.get("userId"); - await addToCart(userId, sessionCartId, productId, quantity); + await addToCart(userId, sessionCartId, attributeValueId, quantity); return redirect(redirectTo || "/cart"); } diff --git a/src/routes/cart/index.tsx b/src/routes/cart/index.tsx index d330cef..e80a88b 100644 --- a/src/routes/cart/index.tsx +++ b/src/routes/cart/index.tsx @@ -30,7 +30,7 @@ export default function Cart({ loaderData }: Route.ComponentProps) { Carrito de compras
- {cart?.items?.map(({ product, quantity, id }) => ( + {cart?.items?.map(({ product, quantity, id, attributeValueId }) => (

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

diff --git a/src/routes/checkout/index.tsx b/src/routes/checkout/index.tsx index 5bfbbde..8b23f33 100644 --- a/src/routes/checkout/index.tsx +++ b/src/routes/checkout/index.tsx @@ -266,7 +266,7 @@ export default function Checkout({

{quantity}

-

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

+

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

diff --git a/src/routes/product/index.tsx b/src/routes/product/index.tsx index da547f3..545544c 100644 --- a/src/routes/product/index.tsx +++ b/src/routes/product/index.tsx @@ -1,9 +1,12 @@ -import { Form, useNavigation } from "react-router"; import { useState } from "react"; +import { Form, useNavigation } from "react-router"; + import { Button, Container, Separator } from "@/components/ui"; import { type Product } from "@/models/product.model"; import { getProductById } from "@/services/product.service"; + import NotFound from "../not-found"; + import type { Route } from "./+types"; export async function loader({ params }: Route.LoaderArgs) { @@ -26,7 +29,18 @@ export default function Product({ loaderData }: Route.ComponentProps) { } const showSizeSelector = product.categoryId === 1 || product.categoryId === 3; - + + const getAttributeValueId = () => { // AQUI TRAER EL AttributeValueId con el cambio de SEBAS + if ( + !product.variantAttributeValues || + product.variantAttributeValues.length === 0 + ) { + return undefined; + } + // Devuelve el attributeId de la posición 0 + return product.variantAttributeValues[0].id; + }; + const getSizeOptions = () => { if (product.categoryId === 3) { return { @@ -34,8 +48,8 @@ export default function Product({ loaderData }: Route.ComponentProps) { options: [ { value: "Small", label: "3x3 cm" }, { value: "Medium", label: "5x5 cm" }, - { value: "Large", label: "10x10 cm" } - ] + { value: "Large", label: "10x10 cm" }, + ], }; } else { return { @@ -43,8 +57,8 @@ export default function Product({ loaderData }: Route.ComponentProps) { options: [ { value: "Small", label: "Small" }, { value: "Medium", label: "Medium" }, - { value: "Large", label: "Large" } - ] + { value: "Large", label: "Large" }, + ], }; } }; @@ -67,7 +81,14 @@ export default function Product({ loaderData }: Route.ComponentProps) { {product.title} {showSizeSelector && ( - {" "}({sizeOptions.options.find(option => option.value === selectedSize)?.label}) + {" "} + ( + { + sizeOptions.options.find( + (option) => option.value === selectedSize + )?.label + } + ) )} @@ -78,12 +99,16 @@ export default function Product({ loaderData }: Route.ComponentProps) { {showSizeSelector && (
-

{sizeOptions.label}

+

+ {sizeOptions.label} +

{sizeOptions.options.map((option) => ( - + - +

Características @@ -129,4 +159,4 @@ export default function Product({ loaderData }: Route.ComponentProps) { ); -} \ No newline at end of file +} diff --git a/src/services/cart.service.ts b/src/services/cart.service.ts index af05fa3..417bcbb 100644 --- a/src/services/cart.service.ts +++ b/src/services/cart.service.ts @@ -138,7 +138,7 @@ export async function createRemoteItems( await prisma.cartItem.createMany({ data: items.map((item) => ({ cartId: cart.id, - attributeValueId: item.attributeId, // modificar + attributeValueId: item.attributeValueId, quantity: item.quantity, })), }); @@ -154,13 +154,13 @@ export async function createRemoteItems( export async function alterQuantityCartItem( userId: User["id"] | undefined, sessionCartId: string | undefined, - attributeId: number, + attributeValueId: number, quantity: number = 1 ): Promise { const cart = await getOrCreateCart(userId, sessionCartId); const existingItem = cart.items.find( - (item) => item.attributeValueId === attributeId + (item) => item.attributeValueId === attributeValueId ); if (existingItem) { @@ -180,7 +180,7 @@ export async function alterQuantityCartItem( await prisma.cartItem.create({ data: { cartId: cart.id, - attributeValueId: attributeId, + attributeValueId: attributeValueId, quantity, }, }); From 819617ea8abc2c3d647600f2c23364d8fc378cd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Vallejo?= Date: Fri, 29 Aug 2025 19:15:08 -0500 Subject: [PATCH 07/25] refactor: product.service & UI and chatbot update --- prisma/initial_data.ts | 104 ++++++------ .../components/product-card/index.tsx | 29 ++-- src/routes/product/index.tsx | 132 ++++++++++------ src/services/chat-system-prompt.ts | 149 ++++++++++++------ src/services/product.service.ts | 53 +++++-- 5 files changed, 296 insertions(+), 171 deletions(-) diff --git a/prisma/initial_data.ts b/prisma/initial_data.ts index 3ab5380..1c3bcf9 100644 --- a/prisma/initial_data.ts +++ b/prisma/initial_data.ts @@ -30,9 +30,9 @@ export const categories = [ ]; export const variantAttributes = [ - { name: "no aplica" }, { name: "talla" }, { name: "dimensiones" }, + { name: "no aplica" }, ] export const products = [ @@ -357,74 +357,74 @@ export const products = [ export const variantAttributeValues = [ // --- POLOS (talla: S, M, L) --- - { attributeId: 1, productId: 1, value: "S", price: 20.0 }, - { attributeId: 1, productId: 1, value: "M", price: 20.0 }, - { attributeId: 1, productId: 1, value: "L", price: 20.0 }, + { attributeId: 1, productId: 1, value: "Small", price: 20.0 }, + { attributeId: 1, productId: 1, value: "Medium", price: 20.0 }, + { attributeId: 1, productId: 1, value: "Large", price: 20.0 }, - { attributeId: 1, productId: 2, value: "S", price: 20.0 }, - { attributeId: 1, productId: 2, value: "M", price: 20.0 }, - { attributeId: 1, productId: 2, value: "L", price: 20.0 }, + { attributeId: 1, productId: 2, value: "Small", price: 20.0 }, + { attributeId: 1, productId: 2, value: "Medium", price: 20.0 }, + { attributeId: 1, productId: 2, value: "Large", price: 20.0 }, - { attributeId: 1, productId: 3, value: "S", price: 20.0 }, - { attributeId: 1, productId: 3, value: "M", price: 20.0 }, - { attributeId: 1, productId: 3, value: "L", price: 20.0 }, + { attributeId: 1, productId: 3, value: "Small", price: 20.0 }, + { attributeId: 1, productId: 3, value: "Medium", price: 20.0 }, + { attributeId: 1, productId: 3, value: "Large", price: 20.0 }, - { attributeId: 1, productId: 4, value: "S", price: 20.0 }, - { attributeId: 1, productId: 4, value: "M", price: 20.0 }, - { attributeId: 1, productId: 4, value: "L", price: 20.0 }, + { attributeId: 1, productId: 4, value: "Small", price: 20.0 }, + { attributeId: 1, productId: 4, value: "Medium", price: 20.0 }, + { attributeId: 1, productId: 4, value: "Large", price: 20.0 }, - { attributeId: 1, productId: 5, value: "S", price: 25.0 }, - { attributeId: 1, productId: 5, value: "M", price: 25.0 }, - { attributeId: 1, productId: 5, value: "L", price: 25.0 }, + { attributeId: 1, productId: 5, value: "Small", price: 25.0 }, + { attributeId: 1, productId: 5, value: "Medium", price: 25.0 }, + { attributeId: 1, productId: 5, value: "Large", price: 25.0 }, - { attributeId: 1, productId: 6, value: "S", price: 25.0 }, - { attributeId: 1, productId: 6, value: "M", price: 25.0 }, - { attributeId: 1, productId: 6, value: "L", price: 25.0 }, + { attributeId: 1, productId: 6, value: "Small", price: 25.0 }, + { attributeId: 1, productId: 6, value: "Medium", price: 25.0 }, + { attributeId: 1, productId: 6, value: "Large", price: 25.0 }, - { attributeId: 1, productId: 7, value: "S", price: 25.0 }, - { attributeId: 1, productId: 7, value: "M", price: 25.0 }, - { attributeId: 1, productId: 7, value: "L", price: 25.0 }, + { attributeId: 1, productId: 7, value: "Small", price: 25.0 }, + { attributeId: 1, productId: 7, value: "Medium", price: 25.0 }, + { attributeId: 1, productId: 7, value: "Large", price: 25.0 }, - { attributeId: 1, productId: 8, value: "S", price: 15.0 }, - { attributeId: 1, productId: 8, value: "M", price: 15.0 }, - { attributeId: 1, productId: 8, value: "L", price: 15.0 }, + { attributeId: 1, productId: 8, value: "Small", price: 15.0 }, + { attributeId: 1, productId: 8, value: "Medium", price: 15.0 }, + { attributeId: 1, productId: 8, value: "Large", price: 15.0 }, - { attributeId: 1, productId: 9, value: "S", price: 15.0 }, - { attributeId: 1, productId: 9, value: "M", price: 15.0 }, - { attributeId: 1, productId: 9, value: "L", price: 15.0 }, + { attributeId: 1, productId: 9, value: "Small", price: 15.0 }, + { attributeId: 1, productId: 9, value: "Medium", price: 15.0 }, + { attributeId: 1, productId: 9, value: "Large", price: 15.0 }, // --- STICKERS (dimensiones: 3x3, 6x6, 9x9) --- - { attributeId: 2, productId: 10, value: "3x3", price: 2.99 }, - { attributeId: 2, productId: 10, value: "5x5", price: 3.99 }, - { attributeId: 2, productId: 10, value: "10x10", price: 4.99 }, + { attributeId: 2, productId: 10, value: "3x3 cm", price: 2.99 }, + { attributeId: 2, productId: 10, value: "5x5 cm", price: 3.99 }, + { attributeId: 2, productId: 10, value: "10x10 cm", price: 4.99 }, - { attributeId: 2, productId: 11, value: "3x3", price: 2.49 }, - { attributeId: 2, productId: 11, value: "5x5", price: 3.49 }, - { attributeId: 2, productId: 11, value: "10x10", price: 4.49 }, + { attributeId: 2, productId: 11, value: "3x3 cm", price: 2.49 }, + { attributeId: 2, productId: 11, value: "5x5 cm", price: 3.49 }, + { attributeId: 2, productId: 11, value: "10x10 cm", price: 4.49 }, - { attributeId: 2, productId: 12, value: "3x3", price: 3.99 }, - { attributeId: 2, productId: 12, value: "5x5", price: 4.99 }, - { attributeId: 2, productId: 12, value: "10x10", price: 5.99 }, + { attributeId: 2, productId: 12, value: "3x3 cm", price: 3.99 }, + { attributeId: 2, productId: 12, value: "5x5 cm", price: 4.99 }, + { attributeId: 2, productId: 12, value: "10x10 cm", price: 5.99 }, - { attributeId: 2, productId: 13, value: "3x3", price: 2.99 }, - { attributeId: 2, productId: 13, value: "5x5", price: 3.99 }, - { attributeId: 2, productId: 13, value: "10x10", price: 4.99 }, + { attributeId: 2, productId: 13, value: "3x3 cm", price: 2.99 }, + { attributeId: 2, productId: 13, value: "5x5 cm", price: 3.99 }, + { attributeId: 2, productId: 13, value: "10x10 cm", price: 4.99 }, - { attributeId: 2, productId: 14, value: "3x3", price: 2.49 }, - { attributeId: 2, productId: 14, value: "5x5", price: 3.49 }, - { attributeId: 2, productId: 14, value: "10x10", price: 4.49 }, + { attributeId: 2, productId: 14, value: "3x3 cm", price: 2.49 }, + { attributeId: 2, productId: 14, value: "5x5 cm", price: 3.49 }, + { attributeId: 2, productId: 14, value: "10x10 cm", price: 4.49 }, - { attributeId: 2, productId: 15, value: "3x3", price: 2.49 }, - { attributeId: 2, productId: 15, value: "5x5", price: 3.49 }, - { attributeId: 2, productId: 15, value: "10x10", price: 4.49 }, + { attributeId: 2, productId: 15, value: "3x3 cm", price: 2.49 }, + { attributeId: 2, productId: 15, value: "5x5 cm", price: 3.49 }, + { attributeId: 2, productId: 15, value: "10x10 cm", price: 4.49 }, - { attributeId: 2, productId: 16, value: "3x3", price: 2.99 }, - { attributeId: 2, productId: 16, value: "5x5", price: 3.99 }, - { attributeId: 2, productId: 16, value: "10x10", price: 4.99 }, + { attributeId: 2, productId: 16, value: "3x3 cm", price: 2.99 }, + { attributeId: 2, productId: 16, value: "5x5 cm", price: 3.99 }, + { attributeId: 2, productId: 16, value: "10x10 cm", price: 4.99 }, - { attributeId: 2, productId: 17, value: "3x3", price: 2.99 }, - { attributeId: 2, productId: 17, value: "5x5", price: 3.99 }, - { attributeId: 2, productId: 17, value: "10x10", price: .99 }, + { attributeId: 2, productId: 17, value: "3x3 cm", price: 2.99 }, + { attributeId: 2, productId: 17, value: "5x5 cm", price: 3.99 }, + { attributeId: 2, productId: 17, value: "10x10 cm", price: 4.99 }, // --- TAZAS (no aplica: Único) --- { attributeId: 3, productId: 18, value: "Único", price: 14.99 }, diff --git a/src/routes/category/components/product-card/index.tsx b/src/routes/category/components/product-card/index.tsx index 05d36bd..8e87062 100644 --- a/src/routes/category/components/product-card/index.tsx +++ b/src/routes/category/components/product-card/index.tsx @@ -30,18 +30,25 @@ export function ProductCard({ product }: ProductCardProps) {

{product.title}

{product.description}

- {isSticker ? ( -
-

- Desde -

-

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

-
+ {product.categoryId === 3 ? ( +
+

+ Desde +

+

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

+
) : ( -

S/{product.price}

- )} +
+

+ Precio +

+

+ S/{product.price} +

+
+ )}
{product.isOnSale && ( diff --git a/src/routes/product/index.tsx b/src/routes/product/index.tsx index da547f3..b6a04b8 100644 --- a/src/routes/product/index.tsx +++ b/src/routes/product/index.tsx @@ -1,5 +1,5 @@ import { Form, useNavigation } from "react-router"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Button, Container, Separator } from "@/components/ui"; import { type Product } from "@/models/product.model"; import { getProductById } from "@/services/product.service"; @@ -19,37 +19,54 @@ export default function Product({ loaderData }: Route.ComponentProps) { const { product } = loaderData; const navigation = useNavigation(); const cartLoading = navigation.state === "submitting"; - const [selectedSize, setSelectedSize] = useState("Medium"); + + // Estados para manejar variantes + const [selectedVariant, setSelectedVariant] = useState(null); + const [currentPrice, setCurrentPrice] = useState(0); if (!product) { return ; } - const showSizeSelector = product.categoryId === 1 || product.categoryId === 3; + // Verificar si el producto tiene variantes + const hasVariants = product.variantAttributeValues && product.variantAttributeValues.length > 0; + + // Verificar si debe mostrar selectores (solo polos y stickers) + const shouldShowVariants = hasVariants && (product.categoryId === 1 || product.categoryId === 3); - const getSizeOptions = () => { - if (product.categoryId === 3) { - return { - label: "Dimensiones", - options: [ - { value: "Small", label: "3x3 cm" }, - { value: "Medium", label: "5x5 cm" }, - { value: "Large", label: "10x10 cm" } - ] - }; + // Agrupar variantes por atributo (en caso de que un producto tenga múltiples tipos de atributos) + const variantGroups = shouldShowVariants + ? product.variantAttributeValues.reduce((groups, variant) => { + const attributeName = variant.variantAttribute.name; + if (!groups[attributeName]) { + groups[attributeName] = []; + } + groups[attributeName].push(variant); + return groups; + }, {} as Record) + : {}; + + // Inicializar precio y variante seleccionada + useEffect(() => { + if (hasVariants) { + // Seleccionar la primera variante por defecto + const firstVariant = product.variantAttributeValues[0]; + setSelectedVariant(firstVariant.id); + setCurrentPrice(firstVariant.price); } else { - return { - label: "Talla", - options: [ - { value: "Small", label: "Small" }, - { value: "Medium", label: "Medium" }, - { value: "Large", label: "Large" } - ] - }; + // Si no hay variantes, usar el precio base del producto (asumiendo que existe) + setCurrentPrice(product.price || 0); } - }; + }, [product]); - const sizeOptions = getSizeOptions(); + // Manejar cambio de variante + const handleVariantChange = (variantId: number) => { + setSelectedVariant(variantId); + const variant = product.variantAttributeValues.find(v => v.id === variantId); + if (variant) { + setCurrentPrice(variant.price); + } + }; return ( <> @@ -65,48 +82,67 @@ export default function Product({ loaderData }: Route.ComponentProps) {

{product.title} - {showSizeSelector && ( - - {" "}({sizeOptions.options.find(option => option.value === selectedSize)?.label}) - - )}

-

S/{product.price}

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

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

+

{product.description}

- {showSizeSelector && ( -
-

{sizeOptions.label}

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

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

+
+ {variants.map((variant) => ( + + ))} +
+
+ ))} + )} + {/* Formulario actualizado para enviar variante seleccionada */}
+ + {/* Enviar la variante seleccionada si existe y debe mostrar variantes */} + {shouldShowVariants && selectedVariant && ( + + )} diff --git a/src/services/chat-system-prompt.ts b/src/services/chat-system-prompt.ts index 30401b0..38426db 100644 --- a/src/services/chat-system-prompt.ts +++ b/src/services/chat-system-prompt.ts @@ -13,32 +13,70 @@ export function generateSystemPrompt({ products, userCart, }: SystemPromptConfig): string { - const onSaleProducts = products.filter((p) => p.isOnSale); - const salesSection = - onSaleProducts.length > 0 - ? ` + + // Procesar productos con información de variantes + const processedProducts = products.map(product => { + const category = categories.find((c) => c.id === product.categoryId); + + // Formatear precio según si tiene variantes o no + let priceDisplay = ""; + if (product.price) { + priceDisplay = `S/${product.price}`; + } else if (product.minPrice && product.maxPrice) { + priceDisplay = `S/${product.minPrice} - S/${product.maxPrice}`; + } + + // Formatear variantes según el tipo + let variantDisplay = ""; + if (product.variants && product.variants.length > 0 && product.variantType !== 'único') { + switch (product.variantType) { + case 'talla': + const sizes = product.variants.map(v => v.value).join(", "); + variantDisplay = `\n- 👕 Tallas disponibles: ${sizes}`; + break; + case 'dimensión': + const dimensions = product.variants + .map(v => `${v.value} (S/${v.price})`) + .join(", "); + variantDisplay = `\n- 📐 Dimensiones: ${dimensions}`; + break; + default: + const options = product.variants + .map(v => `${v.value} (S/${v.price})`) + .join(", "); + variantDisplay = `\n- ⚙️ Opciones: ${options}`; + } + } + + return { + ...product, + categoryTitle: category?.title || "Sin categoría", + priceDisplay, + variantDisplay + }; + }); + + // Procesar productos en oferta + const onSaleProducts = processedProducts.filter((p) => p.isOnSale); + const salesSection = onSaleProducts.length > 0 + ? ` ## 🔥 PRODUCTOS EN OFERTA ESPECIAL: ${onSaleProducts - .map( - (product) => ` -- **${product.title}** - S/${product.price} ⚡ [Ver oferta](/products/${product.id}) -` - ) + .map(product => ` +- **${product.title}** - ${product.priceDisplay} ⚡ [Ver oferta](/products/${product.id})`) .join("")} ` - : ""; + : ""; + // Procesar carrito del usuario const cartSection = userCart?.items?.length ? ` ## 🛒 CARRITO ACTUAL DEL USUARIO: El usuario tiene actualmente ${userCart.items.length} producto(s) en su carrito: ${userCart.items - .map( - (item) => ` + .map(item => ` - **${item.product.title}** (Cantidad: ${item.quantity}) - S/${item.product.price} - Link: [Ver producto](/products/${item.product.id}) -` - ) + Link: [Ver producto](/products/${item.product.id})`) .join("")} **IMPORTANTE**: Usa esta información para hacer recomendaciones inteligentes: @@ -51,6 +89,25 @@ ${userCart.items ` : ""; + // Generar categorías + const categoriesSection = categories + .map(cat => ` +**${cat.title}** (${cat.slug}) +- Descripción: ${cat.description} +- Link: [Ver categoría](/category/${cat.slug})`) + .join("\n"); + + // Generar productos + const productsSection = processedProducts + .map(product => ` +**${product.title}** +- 💰 Precio: ${product.priceDisplay}${product.isOnSale ? " ⚡ ¡EN OFERTA!" : ""} +- 📝 Descripción: ${product.description} +- 🏷️ Categoría: ${product.categoryTitle} +- ✨ Características: ${product.features.join(", ")}${product.variantDisplay} +- 🔗 Link: [Ver producto](/products/${product.id})`) + .join("\n"); + return ` # Asistente Virtual de Full Stock @@ -69,40 +126,42 @@ Eres un asistente virtual especializado en **Full Stock**, una tienda de product ## PRODUCTOS DISPONIBLES: ### Categorías: -${categories - .map( - (cat) => ` -**${cat.title}** (${cat.slug}) -- Descripción: ${cat.description} -- Link: [Ver categoría](/category/${cat.slug}) -` - ) - .join("\n")} +${categoriesSection} ### Productos: -${products - .map((product) => { - const category = categories.find((c) => c.id === product.categoryId); - return ` -**${product.title}** -- 💰 Precio: S/${product.price}${product.isOnSale ? " ⚡ ¡EN OFERTA!" : ""} -- 📝 Descripción: ${product.description} -- 🏷️ Categoría: ${category?.title || "Sin categoría"} -- ✨ Características: ${product.features.join(", ")} -- 🔗 Link: [Ver producto](/products/${product.id}) -`; - }) - .join("\n")} +${productsSection} ${salesSection} ${cartSection} +## MANEJO DE VARIANTES DE PRODUCTOS: +**IMPORTANTE**: Cuando un usuario muestre interés en un producto con variantes: + +### Para POLOS (Tallas): +- Si preguntan por un polo, menciona: "¿Qué talla necesitas: Small, Medium o Large?" +- Ejemplo: "¡El [Polo React](/products/1) está disponible en tallas S, M y L por S/20! ¿Cuál prefieres?" + +### Para STICKERS (Dimensiones): +- Menciona las opciones con precios: "Tenemos 3 tamaños: 3x3cm (S/2.99), 5x5cm (S/3.99) o 10x10cm (S/4.99)" +- Ejemplo: "¡El [Sticker Docker](/products/10) viene en varios tamaños! ¿Prefieres 3x3cm (S/2.99), 5x5cm (S/3.99) o 10x10cm (S/4.99)?" + +### Para PRODUCTOS ÚNICOS (Tazas): +- Procede normal, no menciones variantes +- Ejemplo: "¡La [Taza JavaScript](/products/18) por S/14.99 es perfecta para tu café matutino!" + +### Reglas Generales: +- **SIEMPRE pregunta por la variante** cuando el usuario muestre interés en el producto +- **Incluye precios** solo si varían entre opciones +- **Sé específico** sobre las opciones disponibles +- **Facilita la decisión** con recomendaciones si es necesario + ## INSTRUCCIONES PARA RESPUESTAS: - **MANTÉN LAS RESPUESTAS BREVES Y DIRECTAS** (máximo 2-3 oraciones) - Ve directo al punto, sin explicaciones largas - Cuando recomiendes productos, SIEMPRE incluye el link en formato: [Nombre del Producto](/products/ID) - Para categorías, usa links como: [Categoría](/category/slug) +- **AL MENCIONAR PRODUCTOS CON VARIANTES**, pregunta inmediatamente por la opción preferida - Responde en **Markdown** para dar formato atractivo - Sé específico sobre precios, características y beneficios - Si hay productos en oferta, destácalos con emojis y texto llamativo @@ -123,6 +182,7 @@ ${cartSection} - **Personalización**: Adapta según el nivel o tecnología mencionada - **Storytelling**: Usa curiosidades técnicas o historias para conectar emocionalmente con productos - **Oportunidades educativas**: Si preguntan sobre tecnologías que tienes en productos, educa brevemente y conecta con la venta +- **Variantes como valor**: Destaca las opciones disponibles como ventaja del producto ## LÓGICA DE RECOMENDACIONES BASADAS EN CARRITO: **Si el usuario tiene productos en su carrito y pide recomendaciones:** @@ -144,17 +204,18 @@ Cuando te pregunten sobre tecnologías que tenemos en productos (React, Docker, 4. **Ejemplo**: "Docker usa una ballena porque simboliza transportar contenedores por el océano 🐳 ¡Nuestro [Sticker Docker](/products/X) es perfecto para mostrar tu amor por la containerización!" ## RESPUESTAS A PREGUNTAS COMUNES: -- **Tallas**: "Nuestros polos vienen en tallas S, M, L, XL. ¿Cuál prefieres?" +- **Tallas**: "Nuestros polos vienen en tallas S, M, L. ¿Cuál prefieres?" + - **Envío**: "Manejamos envío a todo el país. ¿A qué ciudad lo necesitas?" - **Materiales**: "Usamos algodón 100% de alta calidad para máxima comodidad" - **Cuidado**: "Para que dure más, lava en agua fría y evita la secadora" -## EJEMPLOS DE RESPUESTAS CORTAS: -- "¡Te recomiendo el [Polo React](/products/1) por S/20.00! 🚀 ¿Qué talla necesitas?" -- "Perfecto para backend: [Polo Backend Developer](/products/3) ⚡ **EN OFERTA** por S/25.00. ¿Te animas?" -- **Ejemplo de pregunta técnica relacionada**: "¡La ballena de Docker representa la facilidad de transportar aplicaciones! 🐳 Nuestro [Sticker Docker](/products/X) captura perfectamente esa filosofía. ¿Te gusta coleccionar stickers de tecnología?" -- **Ejemplo con carrito (React)**: "Veo que tienes el Polo React en tu carrito! Para completar tu look frontend, te recomiendo la [Taza React](/products/Y). ¿Te interesa?" -- **Ejemplo con carrito (Backend)**: "Perfecto, tienes productos backend en tu carrito. El [Sticker Node.js](/products/Z) combinaría genial. ¿Lo agregamos?" +## EJEMPLOS DE RESPUESTAS CORTAS CON VARIANTES: +- "¡Te recomiendo el [Polo React](/products/1) por S/20! 🚀 ¿Qué talla necesitas: S, M o L?" + +- "La [Taza JavaScript](/products/18) por S/14.99 es perfecta para programar. ¿La agregamos?" +- **Ejemplo con carrito (React)**: "Veo que tienes el Polo React! Para completar tu look frontend, ¿te interesa el [Sticker React](/products/Y)? Viene en 3 tamaños diferentes." +- **Ejemplo con carrito (Backend)**: "Perfecto, tienes productos backend. El [Polo Node.js](/products/Z) combinaría genial. ¿Qué talla usas: S, M o L?" ¿En qué puedo ayudarte hoy a encontrar el producto perfecto para ti? 🛒✨ `; diff --git a/src/services/product.service.ts b/src/services/product.service.ts index add02b7..c79e857 100644 --- a/src/services/product.service.ts +++ b/src/services/product.service.ts @@ -5,22 +5,39 @@ import type { VariantAttributeValue } from "@/models/variant-attribute.model"; import { getCategoryBySlug } from "./category.service"; -const formattedProduct = (product: ProductVariantValue) => { - const {variantAttributeValues, ...rest} = product - const prices = variantAttributeValues.map((v: VariantAttributeValue) => Number(v.price)) - const minPrice = Math.min(...prices) - const maxPrice = Math.max(...prices) - if (minPrice === maxPrice) { - return { - ...rest, - price: minPrice - } - } - return { - ...rest, - minPrice, - maxPrice +const formattedProduct = (product: ProductVariantValue) => { + const {variantAttributeValues, ...rest} = product + + const prices = variantAttributeValues.map((v: VariantAttributeValue) => Number(v.price)) + const minPrice = Math.min(...prices) + const maxPrice = Math.max(...prices) + + // Agrupar y formatear variantes + const variants = variantAttributeValues.map(v => ({ + id: v.id, + attributeId: v.attributeId, + value: v.value, + price: Number(v.price) + })) + + // Determinar tipo de variante basado en attributeId + const getVariantType = (attributeId: number) => { + switch (attributeId) { + case 1: return 'talla' + case 2: return 'dimensión' + case 3: return 'único' + default: return 'variante' } + } + + const variantType = variants.length > 0 ? getVariantType(variants[0].attributeId) : 'único' + + return { + ...rest, + variants, + variantType, + ...(minPrice === maxPrice ? { price: minPrice } : { minPrice, maxPrice }) + } } export async function getProductsByCategorySlug( @@ -41,7 +58,11 @@ export async function getProductById(id: number): Promise { const product = await prisma.product.findUnique({ where: { id }, include: { - variantAttributeValues: true + variantAttributeValues: { + include: { + variantAttribute: true + } + } } }); From 6da9f5b2457be52a3c953c5ab9b8baeef0e0f2e0 Mon Sep 17 00:00:00 2001 From: Janet Huacahuasi Date: Fri, 29 Aug 2025 18:32:49 -0500 Subject: [PATCH 08/25] feat: integration product detail with cart service --- .react-router/types/+register.ts | 1 + prisma/initial_data.ts | 2 +- src/routes/cart/add-item/index.tsx | 2 - src/routes/product/index.tsx | 110 ++++++++++++++++++----------- 4 files changed, 69 insertions(+), 46 deletions(-) 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 3ab5380..7796283 100644 --- a/prisma/initial_data.ts +++ b/prisma/initial_data.ts @@ -30,9 +30,9 @@ export const categories = [ ]; export const variantAttributes = [ - { name: "no aplica" }, { name: "talla" }, { name: "dimensiones" }, + { name: "no aplica" }, ] export const products = [ diff --git a/src/routes/cart/add-item/index.tsx b/src/routes/cart/add-item/index.tsx index 06745d1..a8adce0 100644 --- a/src/routes/cart/add-item/index.tsx +++ b/src/routes/cart/add-item/index.tsx @@ -13,8 +13,6 @@ export async function action({ request }: Route.ActionArgs) { const session = await getSession(request.headers.get("Cookie")); const sessionCartId = session.get("sessionCartId"); const userId = session.get("userId"); - await addToCart(userId, sessionCartId, attributeValueId, quantity); - return redirect(redirectTo || "/cart"); } diff --git a/src/routes/product/index.tsx b/src/routes/product/index.tsx index 545544c..b8691c1 100644 --- a/src/routes/product/index.tsx +++ b/src/routes/product/index.tsx @@ -8,6 +8,21 @@ import { getProductById } from "@/services/product.service"; import NotFound from "../not-found"; import type { Route } from "./+types"; +const categoryStickerId =3 +const categoryTazaId = 2 +const initialVariantPosition = 1 + +const variantGroupLabel: { [key: number]: string } = { + 3: 'No aplica', + 1: 'Talla', + 2: 'Dimensiones' +} +const displayVariantSize: { [key: string]: string } = { + 'S': 'Small', + 'M': 'Medium', + 'L': 'Large' +} + export async function loader({ params }: Route.LoaderArgs) { try { @@ -20,50 +35,59 @@ export async function loader({ params }: Route.LoaderArgs) { export default function Product({ loaderData }: Route.ComponentProps) { const { product } = loaderData; - const navigation = useNavigation(); - const cartLoading = navigation.state === "submitting"; - const [selectedSize, setSelectedSize] = useState("Medium"); - - if (!product) { - return ; - } - - const showSizeSelector = product.categoryId === 1 || product.categoryId === 3; - - const getAttributeValueId = () => { // AQUI TRAER EL AttributeValueId con el cambio de SEBAS - if ( - !product.variantAttributeValues || - product.variantAttributeValues.length === 0 - ) { - return undefined; - } - // Devuelve el attributeId de la posición 0 - return product.variantAttributeValues[0].id; - }; const getSizeOptions = () => { - if (product.categoryId === 3) { - return { - label: "Dimensiones", - options: [ - { value: "Small", label: "3x3 cm" }, - { value: "Medium", label: "5x5 cm" }, - { value: "Large", label: "10x10 cm" }, - ], - }; - } else { + const options = product?.variantAttributeValues?.map((variantAttribute, index) => ({ + value: variantAttribute.id, + label: product.categoryId === categoryStickerId ? `${variantAttribute.value} cm` : displayVariantSize[variantAttribute.value], + price: variantAttribute.price, + selected: index===initialVariantPosition?true:false + })); + if (product?.categoryId === categoryTazaId) { return { - label: "Talla", - options: [ - { value: "Small", label: "Small" }, - { value: "Medium", label: "Medium" }, - { value: "Large", label: "Large" }, - ], - }; + label: '', + options: [{ + value: product?.variantAttributeValues?.[0].id, + label: product?.variantAttributeValues?.[0].value, + price: product?.variantAttributeValues?.[0].price, + selected: true + }] + } + } + return { + label: variantGroupLabel[product?.variantAttributeValues?.[0].attributeId as number], + options: options || [], } }; const sizeOptions = getSizeOptions(); + const navigation = useNavigation(); + const cartLoading = navigation.state === "submitting"; + const [data, setData] = useState(sizeOptions) + const showSizeSelector = product?.categoryId === 1 || product?.categoryId === 3; + const selectedDisplay = data.options.find((option) => option.selected === true); + + const onSelectedVariant = (id: number) => { + setData({ + ...data, + options: data.options.map((v) => { + if (v.value === id) { + return ({ + ...v, + selected: true + }) + } + return ({ + ...v, + selected: false + }) + }) + }) + } + + if (!product) { + return ; + } return ( <> @@ -84,15 +108,15 @@ export default function Product({ loaderData }: Route.ComponentProps) { {" "} ( { - sizeOptions.options.find( - (option) => option.value === selectedSize + data.options.find( + (option) => option.selected === true )?.label } ) )}

-

S/{product.price}

+

S/ {selectedDisplay?.price}

{product.description}

@@ -107,10 +131,10 @@ export default function Product({ loaderData }: Route.ComponentProps) { @@ -128,7 +152,7 @@ export default function Product({ loaderData }: Route.ComponentProps) { - + ); } diff --git a/src/routes/category/index.tsx b/src/routes/category/index.tsx index 65ad74c..5066e1a 100644 --- a/src/routes/category/index.tsx +++ b/src/routes/category/index.tsx @@ -2,9 +2,8 @@ import { redirect } from "react-router"; import { Container } from "@/components/ui"; import { isValidCategorySlug, type Category } from "@/models/category.model"; -import type { Product } from "@/models/product.model"; import { getCategoryBySlug } from "@/services/category.service"; -import { getProductsByCategorySlug } from "@/services/product.service"; +import { filterByMinMaxPrice } from "@/services/product.service"; import { PriceFilter } from "./components/price-filter"; import { ProductCard } from "./components/product-card"; @@ -19,54 +18,25 @@ export async function loader({ params, request }: Route.LoaderArgs) { } const url = new URL(request.url); - const minPrice = url.searchParams.get("minPrice") || ""; - const maxPrice = url.searchParams.get("maxPrice") || ""; + const minPrice = url.searchParams.get("minPrice"); + const minValue = minPrice ? parseFloat(minPrice) : undefined; + const maxPrice = url.searchParams.get("maxPrice"); + const maxValue = maxPrice ? parseFloat(maxPrice) : undefined; + try { - const [category, products] = await Promise.all([ + const [category] = await Promise.all([ getCategoryBySlug(categorySlug), - getProductsByCategorySlug(categorySlug), ]); - - const filterProductsByPrice = ( - products: Product[], - minPrice: string, - maxPrice: string - ) => { - const min = minPrice ? parseFloat(minPrice) : 0; - const max = maxPrice ? parseFloat(maxPrice) : Infinity; - return products.filter( - (product) => { - const minProductPrice = product.minPrice||0 - const maxProductPrice = product.maxPrice ||0 - const productPrice = product.price || 0 - - if (min && max) { - return ((productPrice||minProductPrice) >= min) && ((productPrice||maxProductPrice) <= max) - } - - if (min) { - return (productPrice||minProductPrice) >= min - } - - if (max) { - return (productPrice||maxProductPrice) <= max - } - return true - }); - }; - - const filteredProducts = filterProductsByPrice( - products, - minPrice, - maxPrice - ); + console.log({categorySlug, minValue, maxValue}) + const finalProducts = await filterByMinMaxPrice(categorySlug, minValue, maxValue); return { category, - products: filteredProducts, - minPrice, - maxPrice, + products: finalProducts, + minPrice: minValue, + maxPrice: maxValue, + finalProducts }; } catch (e) { throw new Response("Error loading category: " + e, { status: 500 }); diff --git a/src/services/product.service.ts b/src/services/product.service.ts index add02b7..d4434a4 100644 --- a/src/services/product.service.ts +++ b/src/services/product.service.ts @@ -5,9 +5,9 @@ import type { VariantAttributeValue } from "@/models/variant-attribute.model"; import { getCategoryBySlug } from "./category.service"; -const formattedProduct = (product: ProductVariantValue) => { +const formattedProduct = (product:any ): ProductVariantValue => { const {variantAttributeValues, ...rest} = product - const prices = variantAttributeValues.map((v: VariantAttributeValue) => Number(v.price)) + const prices = variantAttributeValues.map((v: VariantAttributeValue) => v.price.toNumber()) const minPrice = Math.min(...prices) const maxPrice = Math.max(...prices) if (minPrice === maxPrice) { @@ -19,7 +19,7 @@ const formattedProduct = (product: ProductVariantValue) => { return { ...rest, minPrice, - maxPrice + maxPrice, } } @@ -37,7 +37,7 @@ export async function getProductsByCategorySlug( return products.map(formattedProduct) } -export async function getProductById(id: number): Promise { +export async function getProductById(id: number): Promise { const product = await prisma.product.findUnique({ where: { id }, include: { @@ -50,10 +50,10 @@ export async function getProductById(id: number): Promise { } const variants = product.variantAttributeValues.map((variant)=> ({ ...variant, - price: Number(variant.price) + price: variant.price.toNumber() })) -return {...product, variantAttributeValues: variants } as Product +return {...product, variantAttributeValues: variants } } export async function getAllProducts(): Promise { @@ -64,3 +64,36 @@ export async function getAllProducts(): Promise { }); return products.map(formattedProduct) } + +export async function filterByMinMaxPrice( + slug: string, + min?: number, + max?: number +): Promise { + const priceFilter: any = {}; + + if (min !== undefined) { + priceFilter.gte = min; + } + if (max !== undefined) { + priceFilter.lte = max; + } + + const result = await prisma.product.findMany({ + where: { + category: { + slug: slug as any, // si slug es enum + }, + variantAttributeValues: { + some: { + price: priceFilter, // 👈 el rango se aplica al mismo variant + }, + }, + }, + include: { + variantAttributeValues: true, + }, + }); + + return result.map(formattedProduct); +} From 027de23e6d4ae2c3044d7194176ba60b499fbc26 Mon Sep 17 00:00:00 2001 From: mike Date: Sun, 31 Aug 2025 22:24:07 -0500 Subject: [PATCH 11/25] feat: update variant attribute values and improve product pricing logic --- prisma/initial_data.ts | 102 ++++----- src/models/cart.model.ts | 3 + src/models/variant-attribute.model.ts | 7 +- .../components/product-card/index.tsx | 9 +- src/routes/product/index.tsx | 205 ++++++++---------- src/services/cart.service.ts | 2 + src/services/product.service.ts | 69 +++--- 7 files changed, 196 insertions(+), 201 deletions(-) diff --git a/prisma/initial_data.ts b/prisma/initial_data.ts index 7796283..a46b79b 100644 --- a/prisma/initial_data.ts +++ b/prisma/initial_data.ts @@ -357,74 +357,74 @@ export const products = [ export const variantAttributeValues = [ // --- POLOS (talla: S, M, L) --- - { attributeId: 1, productId: 1, value: "S", price: 20.0 }, - { attributeId: 1, productId: 1, value: "M", price: 20.0 }, - { attributeId: 1, productId: 1, value: "L", price: 20.0 }, + { attributeId: 1, productId: 1, value: "Small", price: 20.0 }, + { attributeId: 1, productId: 1, value: "Medium", price: 20.0 }, + { attributeId: 1, productId: 1, value: "Large", price: 20.0 }, - { attributeId: 1, productId: 2, value: "S", price: 20.0 }, - { attributeId: 1, productId: 2, value: "M", price: 20.0 }, - { attributeId: 1, productId: 2, value: "L", price: 20.0 }, + { attributeId: 1, productId: 2, value: "Small", price: 20.0 }, + { attributeId: 1, productId: 2, value: "Medium", price: 20.0 }, + { attributeId: 1, productId: 2, value: "Large", price: 20.0 }, - { attributeId: 1, productId: 3, value: "S", price: 20.0 }, - { attributeId: 1, productId: 3, value: "M", price: 20.0 }, - { attributeId: 1, productId: 3, value: "L", price: 20.0 }, + { attributeId: 1, productId: 3, value: "Small", price: 20.0 }, + { attributeId: 1, productId: 3, value: "Medium", price: 20.0 }, + { attributeId: 1, productId: 3, value: "Large", price: 20.0 }, - { attributeId: 1, productId: 4, value: "S", price: 20.0 }, - { attributeId: 1, productId: 4, value: "M", price: 20.0 }, - { attributeId: 1, productId: 4, value: "L", price: 20.0 }, + { attributeId: 1, productId: 4, value: "Small", price: 20.0 }, + { attributeId: 1, productId: 4, value: "Medium", price: 20.0 }, + { attributeId: 1, productId: 4, value: "Large", price: 20.0 }, - { attributeId: 1, productId: 5, value: "S", price: 25.0 }, - { attributeId: 1, productId: 5, value: "M", price: 25.0 }, - { attributeId: 1, productId: 5, value: "L", price: 25.0 }, + { attributeId: 1, productId: 5, value: "Small", price: 25.0 }, + { attributeId: 1, productId: 5, value: "Medium", price: 25.0 }, + { attributeId: 1, productId: 5, value: "Large", price: 25.0 }, - { attributeId: 1, productId: 6, value: "S", price: 25.0 }, - { attributeId: 1, productId: 6, value: "M", price: 25.0 }, - { attributeId: 1, productId: 6, value: "L", price: 25.0 }, + { attributeId: 1, productId: 6, value: "Small", price: 25.0 }, + { attributeId: 1, productId: 6, value: "Medium", price: 25.0 }, + { attributeId: 1, productId: 6, value: "Large", price: 25.0 }, - { attributeId: 1, productId: 7, value: "S", price: 25.0 }, - { attributeId: 1, productId: 7, value: "M", price: 25.0 }, - { attributeId: 1, productId: 7, value: "L", price: 25.0 }, + { attributeId: 1, productId: 7, value: "Small", price: 25.0 }, + { attributeId: 1, productId: 7, value: "Medium", price: 25.0 }, + { attributeId: 1, productId: 7, value: "Large", price: 25.0 }, - { attributeId: 1, productId: 8, value: "S", price: 15.0 }, - { attributeId: 1, productId: 8, value: "M", price: 15.0 }, - { attributeId: 1, productId: 8, value: "L", price: 15.0 }, + { attributeId: 1, productId: 8, value: "Small", price: 15.0 }, + { attributeId: 1, productId: 8, value: "Medium", price: 15.0 }, + { attributeId: 1, productId: 8, value: "Large", price: 15.0 }, - { attributeId: 1, productId: 9, value: "S", price: 15.0 }, - { attributeId: 1, productId: 9, value: "M", price: 15.0 }, - { attributeId: 1, productId: 9, value: "L", price: 15.0 }, + { attributeId: 1, productId: 9, value: "Small", price: 15.0 }, + { attributeId: 1, productId: 9, value: "Medium", price: 15.0 }, + { attributeId: 1, productId: 9, value: "Large", price: 15.0 }, // --- STICKERS (dimensiones: 3x3, 6x6, 9x9) --- - { attributeId: 2, productId: 10, value: "3x3", price: 2.99 }, - { attributeId: 2, productId: 10, value: "5x5", price: 3.99 }, - { attributeId: 2, productId: 10, value: "10x10", price: 4.99 }, + { attributeId: 2, productId: 10, value: "3x3 cm", price: 2.99 }, + { attributeId: 2, productId: 10, value: "5x5 cm", price: 3.99 }, + { attributeId: 2, productId: 10, value: "10x10 cm", price: 4.99 }, - { attributeId: 2, productId: 11, value: "3x3", price: 2.49 }, - { attributeId: 2, productId: 11, value: "5x5", price: 3.49 }, - { attributeId: 2, productId: 11, value: "10x10", price: 4.49 }, + { attributeId: 2, productId: 11, value: "3x3 cm", price: 2.49 }, + { attributeId: 2, productId: 11, value: "5x5 cm", price: 3.49 }, + { attributeId: 2, productId: 11, value: "10x10 cm", price: 4.49 }, - { attributeId: 2, productId: 12, value: "3x3", price: 3.99 }, - { attributeId: 2, productId: 12, value: "5x5", price: 4.99 }, - { attributeId: 2, productId: 12, value: "10x10", price: 5.99 }, + { attributeId: 2, productId: 12, value: "3x3 cm", price: 3.99 }, + { attributeId: 2, productId: 12, value: "5x5 cm", price: 4.99 }, + { attributeId: 2, productId: 12, value: "10x10 cm", price: 5.99 }, - { attributeId: 2, productId: 13, value: "3x3", price: 2.99 }, - { attributeId: 2, productId: 13, value: "5x5", price: 3.99 }, - { attributeId: 2, productId: 13, value: "10x10", price: 4.99 }, + { attributeId: 2, productId: 13, value: "3x3 cm", price: 2.99 }, + { attributeId: 2, productId: 13, value: "5x5 cm", price: 3.99 }, + { attributeId: 2, productId: 13, value: "10x10 cm", price: 4.99 }, - { attributeId: 2, productId: 14, value: "3x3", price: 2.49 }, - { attributeId: 2, productId: 14, value: "5x5", price: 3.49 }, - { attributeId: 2, productId: 14, value: "10x10", price: 4.49 }, + { attributeId: 2, productId: 14, value: "3x3 cm", price: 2.49 }, + { attributeId: 2, productId: 14, value: "5x5 cm", price: 3.49 }, + { attributeId: 2, productId: 14, value: "10x10 cm", price: 4.49 }, - { attributeId: 2, productId: 15, value: "3x3", price: 2.49 }, - { attributeId: 2, productId: 15, value: "5x5", price: 3.49 }, - { attributeId: 2, productId: 15, value: "10x10", price: 4.49 }, + { attributeId: 2, productId: 15, value: "3x3 cm", price: 2.49 }, + { attributeId: 2, productId: 15, value: "5x5 cm", price: 3.49 }, + { attributeId: 2, productId: 15, value: "10x10 cm", price: 4.49 }, - { attributeId: 2, productId: 16, value: "3x3", price: 2.99 }, - { attributeId: 2, productId: 16, value: "5x5", price: 3.99 }, - { attributeId: 2, productId: 16, value: "10x10", price: 4.99 }, + { attributeId: 2, productId: 16, value: "3x3 cm", price: 2.99 }, + { attributeId: 2, productId: 16, value: "5x5 cm", price: 3.99 }, + { attributeId: 2, productId: 16, value: "10x10 cm", price: 4.99 }, - { attributeId: 2, productId: 17, value: "3x3", price: 2.99 }, - { attributeId: 2, productId: 17, value: "5x5", price: 3.99 }, - { attributeId: 2, productId: 17, value: "10x10", price: .99 }, + { attributeId: 2, productId: 17, value: "3x3 cm", price: 2.99 }, + { attributeId: 2, productId: 17, value: "5x5 cm", price: 3.99 }, + { attributeId: 2, productId: 17, value: "10x10 cm", price: .99 }, // --- TAZAS (no aplica: Único) --- { attributeId: 3, productId: 18, value: "Único", price: 14.99 }, diff --git a/src/models/cart.model.ts b/src/models/cart.model.ts index 53333ce..5128ebd 100644 --- a/src/models/cart.model.ts +++ b/src/models/cart.model.ts @@ -3,6 +3,7 @@ import { type Product } from "./product.model"; import type { Cart as PrismaCart, CartItem as PrismaCartItem, + VariantAttributeValue } from "@/../generated/prisma/client"; export type CartItem = PrismaCartItem & { @@ -10,6 +11,7 @@ export type CartItem = PrismaCartItem & { Product, "id" | "title" | "imgSrc" | "alt" | "price" | "isOnSale" >; + variantAttributeValue?: VariantAttributeValue; }; export type Cart = PrismaCart; @@ -34,6 +36,7 @@ export type CartItemWithProduct = { product: CartProductInfo; quantity: number; attributeValueId: number; + variantAttributeValue?: VariantAttributeValue; }; // Tipo para el carrito con items y productos incluidos diff --git a/src/models/variant-attribute.model.ts b/src/models/variant-attribute.model.ts index 2c91027..acbda1b 100644 --- a/src/models/variant-attribute.model.ts +++ b/src/models/variant-attribute.model.ts @@ -1,2 +1,5 @@ -import type { VariantAttributeValue as PrismaVariantAttributeValue } from "@/../generated/prisma/client"; -export type VariantAttributeValue= PrismaVariantAttributeValue \ No newline at end of file +import type { VariantAttributeValue as PrismaVariantAttributeValue, VariantAttribute } from "@/../generated/prisma/client"; + +export type VariantAttributeValue = PrismaVariantAttributeValue & { + variantAttribute?: VariantAttribute; +}; \ No newline at end of file diff --git a/src/routes/category/components/product-card/index.tsx b/src/routes/category/components/product-card/index.tsx index 23c402b..6526ded 100644 --- a/src/routes/category/components/product-card/index.tsx +++ b/src/routes/category/components/product-card/index.tsx @@ -34,14 +34,19 @@ export function ProductCard({ product }: ProductCardProps) { {isSticker ? (

- Desde + Entre

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

) : ( -

S/{product.price}

+
+

+ Precio +

+

S/{product.price}

+
)}
{product.isOnSale && ( diff --git a/src/routes/product/index.tsx b/src/routes/product/index.tsx index 0713b03..b17ab76 100644 --- a/src/routes/product/index.tsx +++ b/src/routes/product/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useState, useEffect } from "react"; import { Form, useNavigation, useSearchParams } from "react-router"; import { Button, Container, Separator } from "@/components/ui"; @@ -8,21 +8,6 @@ import { getProductById } from "@/services/product.service"; import NotFound from "../not-found"; import type { Route } from "./+types"; -const categoryStickerId =3 -const categoryTazaId = 2 -const initialVariantPosition = 1 - -const variantGroupLabel: { [key: number]: string } = { - 3: 'No aplica', - 1: 'Talla', - 2: 'Dimensiones' -} -const displayVariantSize: { [key: string]: string } = { - 'S': 'Small', - 'M': 'Medium', - 'L': 'Large' -} - export async function loader({ params }: Route.LoaderArgs) { try { @@ -34,71 +19,55 @@ export async function loader({ params }: Route.LoaderArgs) { } export default function Product({ loaderData }: Route.ComponentProps) { -const [searchParams] = useSearchParams(); -const initialVariantId = searchParams.get("variantId") ? Number(searchParams.get("variantId")) : null; const { product } = loaderData; - - const getSizeOptions = () => { - const options = product?.variantAttributeValues?.map((variantAttribute, index) => ({ - value: variantAttribute.id, - label: product.categoryId === categoryStickerId ? `${variantAttribute.value} cm` : displayVariantSize[variantAttribute.value], - price: variantAttribute.price, - selected: index===initialVariantPosition?true:false - })); - if (product?.categoryId === categoryTazaId) { - return { - label: '', - options: [{ - value: product?.variantAttributeValues?.[0].id, - label: product?.variantAttributeValues?.[0].value, - price: product?.variantAttributeValues?.[0].price, - selected: true - }] - } - } - return { - label: variantGroupLabel[product?.variantAttributeValues?.[0].attributeId as number], - options: options || [], - } - }; - - const sizeOptions = getSizeOptions(); const navigation = useNavigation(); const cartLoading = navigation.state === "submitting"; - const [data, setData] = useState(sizeOptions) - const showSizeSelector = product?.categoryId === 1 || product?.categoryId === 3; - const selectedDisplay = data.options.find((option) => option.selected === true); - useEffect(() => { - if (initialVariantId) { - setData((prevData) => ({ - ...prevData, - options: prevData.options.map((option) => - option.value === initialVariantId - ? { ...option, selected: true } - : { ...option, selected: false } - ), - })); - } - }, [initialVariantId]); + const [searchParams] = useSearchParams(); + const initialVariantId = searchParams.get("variantId") ? Number(searchParams.get("variantId")) : null; - const onSelectedVariant = (id: number) => { - setData({ - ...data, - options: data.options.map((v) => { - if (v.value === id) { - return ({ - ...v, - selected: true - }) + // Estados para manejar variantes + const [selectedVariant, setSelectedVariant] = useState(initialVariantId); + const [currentPrice, setCurrentPrice] = useState(0); + + // Verificar si el producto tiene variantes + const hasVariants = product?.variantAttributeValues && product.variantAttributeValues.length > 0; + + // Verificar si debe mostrar selectores (solo polos y stickers) + const shouldShowVariants = hasVariants && (product?.categoryId === 1 || product?.categoryId === 3); + + // Agrupar variantes por atributo + const variantGroups = shouldShowVariants + ? product.variantAttributeValues!.reduce((groups, variant) => { + const attributeName = variant!.variantAttribute!.name; + if (!groups[attributeName]) { + groups[attributeName] = []; } - return ({ - ...v, - selected: false - }) - }) - }) - } + groups[attributeName].push(variant); + return groups; + }, {} as Record) + : {}; + + useEffect(() => { + if (!product) return; + + if (hasVariants && product.variantAttributeValues) { + const firstVariant = product.variantAttributeValues[selectedVariant ? product.variantAttributeValues.findIndex(v => v.id === selectedVariant) : 0]; + setSelectedVariant(firstVariant.id); + setCurrentPrice(Number(firstVariant.price)); + } else { + setCurrentPrice(Number(product.price || 0)); + } + }, [product, hasVariants, selectedVariant]); + + // Funciones después de los hooks + const handleVariantChange = (variantId: number) => { + setSelectedVariant(variantId); + const variant = product?.variantAttributeValues?.find(v => v.id === variantId); + if (variant) { + setCurrentPrice(Number(variant.price)); + } + }; if (!product) { return ; @@ -118,71 +87,75 @@ const initialVariantId = searchParams.get("variantId") ? Number(searchParams.get

{product.title} - {showSizeSelector && ( - - {" "} - ( - { - data.options.find( - (option) => option.selected === true - )?.label - } - ) - - )}

-

S/ {selectedDisplay?.price}

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

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

+

{product.description}

- {showSizeSelector && ( -
-

- {sizeOptions.label} -

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

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

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

Características @@ -198,4 +171,4 @@ const initialVariantId = searchParams.get("variantId") ? Number(searchParams.get ); -} +} \ No newline at end of file diff --git a/src/services/cart.service.ts b/src/services/cart.service.ts index 417bcbb..a63a665 100644 --- a/src/services/cart.service.ts +++ b/src/services/cart.service.ts @@ -45,6 +45,8 @@ async function getCart( }, }); + console.log("DATA CART SERVICE", data?.items); + if (!data) return null; return { diff --git a/src/services/product.service.ts b/src/services/product.service.ts index d4434a4..bc58616 100644 --- a/src/services/product.service.ts +++ b/src/services/product.service.ts @@ -5,23 +5,25 @@ import type { VariantAttributeValue } from "@/models/variant-attribute.model"; import { getCategoryBySlug } from "./category.service"; -const formattedProduct = (product:any ): ProductVariantValue => { - const {variantAttributeValues, ...rest} = product - const prices = variantAttributeValues.map((v: VariantAttributeValue) => v.price.toNumber()) - const minPrice = Math.min(...prices) - const maxPrice = Math.max(...prices) - if (minPrice === maxPrice) { - return { - ...rest, - price: minPrice - } - } +const formattedProduct = (product: any): ProductVariantValue => { + const { variantAttributeValues, ...rest } = product; + const prices = variantAttributeValues.map((v: VariantAttributeValue) => + v.price.toNumber() + ); + const minPrice = Math.min(...prices); + const maxPrice = Math.max(...prices); + if (minPrice === maxPrice) { return { ...rest, - minPrice, - maxPrice, - } -} + price: minPrice, + }; + } + return { + ...rest, + minPrice, + maxPrice, + }; +}; export async function getProductsByCategorySlug( categorySlug: Category["slug"] @@ -30,39 +32,45 @@ export async function getProductsByCategorySlug( const products = await prisma.product.findMany({ where: { categoryId: category.id }, include: { - variantAttributeValues: true - } + variantAttributeValues: true, + }, }); - return products.map(formattedProduct) + return products.map(formattedProduct); } -export async function getProductById(id: number): Promise { +export async function getProductById(id: number): Promise { const product = await prisma.product.findUnique({ where: { id }, include: { - variantAttributeValues: true - } + variantAttributeValues: { + include: { + variantAttribute: true, + }, + }, + }, }); - if (!product) { throw new Error("Product not found"); } - const variants = product.variantAttributeValues.map((variant)=> ({ - ...variant, - price: variant.price.toNumber() - })) + const productWithParsedPrices = { + ...product, + variantAttributeValues: product.variantAttributeValues.map((variant) => ({ + ...variant, + price: variant.price.toNumber(), + })), + }; -return {...product, variantAttributeValues: variants } + return productWithParsedPrices as unknown as ProductVariantValue; } export async function getAllProducts(): Promise { const products = await prisma.product.findMany({ include: { - variantAttributeValues: true - } + variantAttributeValues: true, + }, }); - return products.map(formattedProduct) + return products.map(formattedProduct); } export async function filterByMinMaxPrice( @@ -97,3 +105,4 @@ export async function filterByMinMaxPrice( return result.map(formattedProduct); } + From 63d4785483c593fe3a0a520f769201452d579cd6 Mon Sep 17 00:00:00 2001 From: mike Date: Sun, 31 Aug 2025 23:21:12 -0500 Subject: [PATCH 12/25] feat: enhance cart and checkout components to display variant attribute values --- src/routes/account/orders/index.tsx | 4 +++- src/routes/cart/index.tsx | 11 ++++++----- src/routes/category/index.tsx | 1 - src/routes/checkout/index.tsx | 6 +++--- src/services/cart.service.ts | 2 -- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/routes/account/orders/index.tsx b/src/routes/account/orders/index.tsx index dccd7c0..20f1ab7 100644 --- a/src/routes/account/orders/index.tsx +++ b/src/routes/account/orders/index.tsx @@ -13,6 +13,8 @@ export async function loader({ request }: Route.LoaderArgs) { try { const orders = await getOrdersByUser(request); + console.log("ORDERS", orders[0].items); + orders.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); return { orders }; @@ -29,7 +31,7 @@ export default function Orders({ loaderData }: Route.ComponentProps) { {orders!.length > 0 ? (
{orders!.map((order) => ( -
+
diff --git a/src/routes/cart/index.tsx b/src/routes/cart/index.tsx index e80a88b..db8b1ee 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, attributeValueId }) => ( -
+ {cart?.items?.map(({ product, quantity, id, variantAttributeValue + }) => ( +
-

{product.title}

+

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

diff --git a/src/routes/category/index.tsx b/src/routes/category/index.tsx index 5066e1a..a25f856 100644 --- a/src/routes/category/index.tsx +++ b/src/routes/category/index.tsx @@ -28,7 +28,6 @@ export async function loader({ params, request }: Route.LoaderArgs) { const [category] = await Promise.all([ getCategoryBySlug(categorySlug), ]); - console.log({categorySlug, minValue, maxValue}) const finalProducts = await filterByMinMaxPrice(categorySlug, minValue, maxValue); return { diff --git a/src/routes/checkout/index.tsx b/src/routes/checkout/index.tsx index 8b23f33..27c4a3e 100644 --- a/src/routes/checkout/index.tsx +++ b/src/routes/checkout/index.tsx @@ -249,9 +249,9 @@ export default function Checkout({

Resumen de la orden

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

{product.title}

+

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

{quantity}

diff --git a/src/services/cart.service.ts b/src/services/cart.service.ts index a63a665..417bcbb 100644 --- a/src/services/cart.service.ts +++ b/src/services/cart.service.ts @@ -45,8 +45,6 @@ async function getCart( }, }); - console.log("DATA CART SERVICE", data?.items); - if (!data) return null; return { From 45c98262f31362490e548dcbad3831c8a4b7e347 Mon Sep 17 00:00:00 2001 From: mike Date: Mon, 1 Sep 2025 00:43:58 -0500 Subject: [PATCH 13/25] feat: overhaul README to document fullstack e-commerce features and variant management --- README.md | 387 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 337 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 74872fd..459b976 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,337 @@ -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: - -- Configure the top-level `parserOptions` property like this: - -```js -export default tseslint.config({ - languageOptions: { - // other options... - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - }, -}) -``` - -- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` -- Optionally add `...tseslint.configs.stylisticTypeChecked` -- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: - -```js -// eslint.config.js -import react from 'eslint-plugin-react' - -export default tseslint.config({ - // Set the react version - settings: { react: { version: '18.3' } }, - plugins: { - // Add the react plugin - react, - }, - rules: { - // other rules... - // Enable its recommended rules - ...react.configs.recommended.rules, - ...react.configs['jsx-runtime'].rules, - }, -}) -``` +# FullStack E-commerce Frontend + +Una aplicación de e-commerce moderna construida con React Router, TypeScript, Prisma y diseñada específicamente para desarrolladores web. La aplicación incluye un sistema completo de variantes de producto que permite diferentes opciones como tallas y dimensiones. + +## 🚀 Características Principales + +- **Sistema de Variantes de Producto**: Soporte completo para productos con múltiples opciones (tallas para polos, dimensiones por stickers) +- **Carrito de Compras Inteligente**: Gestión de productos con sus variantes específicas +- **Checkout Seguro**: Integración con Culqi para pagos seguros +- **Chat Bot AI**: Asistente virtual powered by Gemini para recomendaciones de productos +- **Autenticación Completa**: Sistema de login/registro con gestión de sesiones +- **Responsive Design**: Interfaz adaptativa con Tailwind CSS + +## 📋 Implementación de Variantes de Producto + +### 🗄️ Modificaciones en la Base de Datos + +El sistema de variantes se implementó mediante las siguientes entidades en [prisma/schema.prisma](prisma/schema.prisma): + +```prisma +model VariantAttribute { + id Int @id @default(autoincrement()) + name String // "talla", "dimensiones", "color" + + variantAttributeValues VariantAttributeValue[] +} + +model VariantAttributeValue { + id Int @id @default(autoincrement()) + variantAttributeId Int @map("variant_attribute_id") + productId Int @map("product_id") + value String // "Small", "Medium", "Large", "3x3cm" + price Decimal @db.Decimal(10, 2) + + variantAttribute VariantAttribute @relation(fields: [variantAttributeId], references: [id]) + product Product @relation(fields: [productId], references: [id]) + cartItems CartItem[] +} +``` + +### 🎨 Interfaz de Usuario para Variantes + +#### Página de Producto ([src/routes/product/index.tsx](src/routes/product/index.tsx)) + +La página de producto fue actualizada para mostrar selectores de variantes dinámicos: + +- **Detección Automática**: Solo muestra selectores para productos con variantes (polos y stickers) +- **Agrupación por Atributo**: Las variantes se agrupan por tipo (talla, dimensión) +- **Actualización de Precio**: El precio se actualiza automáticamente al seleccionar una variante + +```tsx +// Ejemplo de selector de variantes +{shouldShowVariants && ( + <> + {Object.entries(variantGroups).map(([attributeName, variants]) => ( +
+

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

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

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

+

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

+``` + +#### Checkout ([src/routes/checkout/index.tsx](src/routes/checkout/index.tsx)) + +El proceso de checkout mantiene la información de variantes: + +- **Resumen Detallado**: Muestra cada producto con su variante específica +- **Cálculo Correcto**: Los totales reflejan los precios exactos de cada variante +- **Información Completa**: Se preserva toda la información para la orden final + +### 🛒 Lógica de Negocio + +#### Gestión de Carrito ([src/services/cart.service.ts](src/services/cart.service.ts)) + +```typescript +// Agregar producto con variante específica +export async function addToCart( + userId: number | undefined, + sessionCartId: string | undefined, + attributeValueId: number +): Promise +``` + +### 🔍 Lógica de Filtrado por Precios con Variantes + +El sistema de filtros implementa una lógica inteligente para productos con variantes: + +#### Comportamiento del Filtro +- **Evaluación por Variante**: El filtro analiza cada variante de precio individualmente +- **Inclusión Condicional**: Un producto se incluye si AL MENOS UNA de sus variantes está dentro del rango seleccionado +- **Visualización Resumida**: En las tarjetas se muestra el rango completo (precio mínimo - precio máximo) + +#### Ejemplo Práctico +``` +Producto: Sticker JavaScript +Variantes: S/2.99, S/3.99, S/4.99 +Rango mostrado: S/2.99 - S/4.99 + +Filtro S/1 - S/3: +✅ Incluido (porque S/2.99 está en el rango) + +Filtro S/5 - S/10: +❌ Excluido (ninguna variante está en el rango) +``` + + + +### 🤖 Integración con Chat Bot + +El chat bot fue actualizado para manejar preguntas sobre variantes ([src/services/chat-system-prompt.ts](src/services/chat-system-prompt.ts)): + +#### Manejo Inteligente de Variantes + +```typescript +// Formatear variantes según el tipo +switch (product.variantType) { + case 'talla': + const sizes = product.variants.map(v => v.value).join(", "); + variantDisplay = `\n- 👕 Tallas disponibles: ${sizes}`; + break; + case 'dimensión': + const dimensions = product.variants + .map(v => `${v.value} (S/${v.price})`) + .join(", "); + variantDisplay = `\n- 📐 Dimensiones: ${dimensions}`; + break; +} +``` + +#### Respuestas Contextuales + +- **Polos**: "¿Qué talla necesitas: Small, Medium o Large?" +- **Stickers**: "Tenemos 3 tamaños: 3x3cm (S/2.99), 5x5cm (S/3.99) o 10x10cm (S/4.99)" +- **Tazas**: Procede normal sin mencionar variantes + + + +## 📱 Consideraciones de UX/UI + +### Decisiones de Diseño para Variantes + +1. **Mostrar Solo Cuando Necesario**: Los selectores de variantes solo aparecen para productos que las tienen +2. **Agrupación Clara**: Las variantes se agrupan por tipo de atributo (talla, dimensión) +3. **Feedback Visual**: El botón seleccionado tiene un estilo diferente +4. **Precio Dinámico**: El precio se actualiza inmediatamente al cambiar variantes +5. **Validación**: No se puede agregar al carrito sin seleccionar una variante + +### Manejo de Precios + +- **Productos Sin Variantes**: Muestran precio fijo +- **Productos Con Variantes**: Muestran rango de precios, es decir el precio mínimo y máximo +- **Carrito y Checkout**: Siempre muestran precios específicos de la variante + + +## 🔧 Instalación y Configuración + +```bash +# Instalar dependencias +npm install + +# Configurar base de datos +npm run prisma:generate +npm run prisma:migrate:dev + +# Poblar con datos iniciales +npm run prisma:seed + +# Ejecutar en desarrollo +npm run dev + +# Ejecutar tests +npm run test +npm run test:e2e +``` + +### Productos con Variantes + +```typescript +// Polos - Variantes por talla +{ + title: "Polo React", + variantAttributeValues: [ + { value: "Small", price: 20.0, variantAttribute: { name: "talla" } }, + { value: "Medium", price: 20.0, variantAttribute: { name: "talla" } }, + { value: "Large", price: 20.0, variantAttribute: { name: "talla" } } + ] +} + +// Stickers - Variantes por dimensión +{ + title: "Sticker Docker", + variantAttributeValues: [ + { value: "3x3cm", price: 2.99, variantAttribute: { name: "dimensiones" } }, + { value: "5x5cm", price: 3.99, variantAttribute: { name: "dimensiones" } }, + { value: "10x10cm", price: 4.99, variantAttribute: { name: "dimensiones" } } + ] +} +``` + +## 🚀 Tecnologías Utilizadas + +- **Frontend**: React Router v7, TypeScript, Tailwind CSS +- **Backend**: Node.js con React Router Server Functions +- **Base de Datos**: PostgreSQL con Prisma ORM +- **Pagos**: Culqi Payment Gateway +- **AI**: Google Gemini para el chat bot +- **Testing**: Vitest, Playwright, React Testing Library +- **Deployment**: Docker, Railway + +## 📈 Tareas realizadas por cada integrante + +### 👨‍💻 Mike Vera +- **Sistema de Variantes de Producto**: Implementación completa del sistema de variantes con base de datos (Prisma schema) +- **Carrito con Variantes**: Actualización del carrito para mostrar información detallada de variantes seleccionadas + +### 👨‍💻 Sebastian +- **UI/UX Design & Responsive Design**: Diseño e implementación de la interfaz adaptativa con Tailwind CSS +- **Chat Bot AI**: Integración con Google Gemini para el asistente virtual y sistema de recomendaciones + +### 👩‍💻 Janet +- **Modificacion del archivo de data inicial**: Modificación del archivo 'initial_data.ts' para las diferentes variables de productos +- **Actualización del servicio de productos**: Modificación de las funciones para integrar variantes de productos +- **Filtros de Precio Inteligentes**: Implementación de la lógica de filtrado que considera todas las variantes de precio + + + +### 🤝 Tareas Colaborativas +- **Arquitectura del Proyecto**: Definición conjunta de la estructura para contemplar variables de productos +- **Code Reviews**: Revisión cruzada de código y merge de pull requests entre los 3 integrantes + +- **Integración de Features**: Trabajo conjunto para integrar variantes, en los diferente modulos, cart, + +### 🔧 Metodología de Trabajo +- **Control de Versiones**: Git con GitHub Flow y branches por feature +- **Gestión de Proyecto**: Kanban board para tracking de tareas y sprints semanales +- **Comunicación**: Daily standups virtuales y sesiones de pair programming +- **Calidad de Código**: ESLint, Prettier y Husky configurados en conjunto + +--- + +*Este proyecto fue desarrollado como parte del bootcamp de Codeable, implementando un sistema completo de e-commerce con variantes de producto para una experiencia de usuario optimizada.* \ No newline at end of file From a0d3f93f98f6040f6935e8280eb3b6c1a6db5d88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Vallejo?= Date: Mon, 1 Sep 2025 02:21:08 -0500 Subject: [PATCH 14/25] feat: update chatbot prompts and add context for new product variants --- src/services/chat-system-prompt.ts | 56 ++++++++++++++++++------------ src/services/product.service.ts | 3 +- 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/src/services/chat-system-prompt.ts b/src/services/chat-system-prompt.ts index 38426db..26f826c 100644 --- a/src/services/chat-system-prompt.ts +++ b/src/services/chat-system-prompt.ts @@ -28,23 +28,27 @@ export function generateSystemPrompt({ // Formatear variantes según el tipo let variantDisplay = ""; - if (product.variants && product.variants.length > 0 && product.variantType !== 'único') { - switch (product.variantType) { - case 'talla': - const sizes = product.variants.map(v => v.value).join(", "); - variantDisplay = `\n- 👕 Tallas disponibles: ${sizes}`; - break; - case 'dimensión': - const dimensions = product.variants - .map(v => `${v.value} (S/${v.price})`) - .join(", "); - variantDisplay = `\n- 📐 Dimensiones: ${dimensions}`; - break; - default: - const options = product.variants - .map(v => `${v.value} (S/${v.price})`) - .join(", "); - variantDisplay = `\n- ⚙️ Opciones: ${options}`; + if (product.variantAttributeValues && product.variantAttributeValues.length > 0) { + const variantType = product.variantAttributeValues[0]?.variantAttribute?.name; + + if (variantType && variantType !== 'único') { + switch (variantType) { + case 'talla': + const sizes = product.variantAttributeValues.map((v: any) => v.value).join(", "); + variantDisplay = `\n- 👕 Tallas disponibles: ${sizes}`; + break; + case 'dimensión': + const dimensions = product.variantAttributeValues + .map((v: any) => `${v.value} (S/${v.price})`) + .join(", "); + variantDisplay = `\n- 📐 Dimensiones: ${dimensions}`; + break; + default: + const options = product.variantAttributeValues + .map((v: any) => `${v.value} (S/${v.price})`) + .join(", "); + variantDisplay = `\n- ⚙️ Opciones: ${options}`; + } } } @@ -86,6 +90,7 @@ ${userCart.items - Evita recomendar productos que ya están en el carrito - Ofrece bundles o combos cuando sea apropiado - Menciona que puedes ver lo que ya tienen seleccionado y personalizar las sugerencias +- Si en el carrito el usuario tiene un polo, recomienda un producto de la misma categoría y variante (talla) que la del polo presente en su carrito ` : ""; @@ -122,6 +127,8 @@ Eres un asistente virtual especializado en **Full Stock**, una tienda de product - Si te preguntan sobre temas completamente no relacionados, redirige brevemente hacia los productos - Usa un lenguaje natural y cercano, pero profesional - Siempre termina con una pregunta directa o llamada a la acción +- **EMPATIZA** con los problemas típicos de developers (debugging, deadlines, stack decisions) +- **TONO**: Casual pero experto - como un desarrollador que entiende a otros developers ## PRODUCTOS DISPONIBLES: @@ -140,10 +147,10 @@ ${cartSection} ### Para POLOS (Tallas): - Si preguntan por un polo, menciona: "¿Qué talla necesitas: Small, Medium o Large?" -- Ejemplo: "¡El [Polo React](/products/1) está disponible en tallas S, M y L por S/20! ¿Cuál prefieres?" +- Ejemplo: "¡El [Polo React](/products/1) está disponible en tallas Small, Medium y Large por S/20! ¿Cuál prefieres?" ### Para STICKERS (Dimensiones): -- Menciona las opciones con precios: "Tenemos 3 tamaños: 3x3cm (S/2.99), 5x5cm (S/3.99) o 10x10cm (S/4.99)" +- Menciona las opciones con precios, es decir, menciona cada dimensión con su respectivo precio - Ejemplo: "¡El [Sticker Docker](/products/10) viene en varios tamaños! ¿Prefieres 3x3cm (S/2.99), 5x5cm (S/3.99) o 10x10cm (S/4.99)?" ### Para PRODUCTOS ÚNICOS (Tazas): @@ -183,6 +190,8 @@ ${cartSection} - **Storytelling**: Usa curiosidades técnicas o historias para conectar emocionalmente con productos - **Oportunidades educativas**: Si preguntan sobre tecnologías que tienes en productos, educa brevemente y conecta con la venta - **Variantes como valor**: Destaca las opciones disponibles como ventaja del producto +- **Desinterés**: Si el usuario muestra desinterés, ofrece alternativas o pregunta sobre sus necesidades específicas +- **Regla de variante automática**: Si el usuario muestra interés en un un polo, siempre pregunta automáticamente por la talla (Small, Medium o Large) del usuario ## LÓGICA DE RECOMENDACIONES BASADAS EN CARRITO: **Si el usuario tiene productos en su carrito y pide recomendaciones:** @@ -204,18 +213,19 @@ Cuando te pregunten sobre tecnologías que tenemos en productos (React, Docker, 4. **Ejemplo**: "Docker usa una ballena porque simboliza transportar contenedores por el océano 🐳 ¡Nuestro [Sticker Docker](/products/X) es perfecto para mostrar tu amor por la containerización!" ## RESPUESTAS A PREGUNTAS COMUNES: -- **Tallas**: "Nuestros polos vienen en tallas S, M, L. ¿Cuál prefieres?" - +- **Tallas**: "Nuestros polos vienen en tallas Small, Medium, Large. ¿Cuál prefieres?" +- **Dimensiones**: "Nuestros stickers vienen en 3 dimensiones distintas, 3x3 cm, 5x5 cm y 10x10 cm. ¿Cuál sería la mejor para ti?" - **Envío**: "Manejamos envío a todo el país. ¿A qué ciudad lo necesitas?" - **Materiales**: "Usamos algodón 100% de alta calidad para máxima comodidad" - **Cuidado**: "Para que dure más, lava en agua fría y evita la secadora" ## EJEMPLOS DE RESPUESTAS CORTAS CON VARIANTES: -- "¡Te recomiendo el [Polo React](/products/1) por S/20! 🚀 ¿Qué talla necesitas: S, M o L?" +- "¡Te recomiendo el [Polo React](/products/1) por S/20! 🚀 ¿Qué talla necesitas: Small, Medium o Large?" +- "La [Taza Docker](/products/2) por S/14.99 es ideal para tus momentos de café. ¿La agregamos?" - "La [Taza JavaScript](/products/18) por S/14.99 es perfecta para programar. ¿La agregamos?" - **Ejemplo con carrito (React)**: "Veo que tienes el Polo React! Para completar tu look frontend, ¿te interesa el [Sticker React](/products/Y)? Viene en 3 tamaños diferentes." -- **Ejemplo con carrito (Backend)**: "Perfecto, tienes productos backend. El [Polo Node.js](/products/Z) combinaría genial. ¿Qué talla usas: S, M o L?" +- **Ejemplo con carrito (Backend)**: "Perfecto, tienes productos backend. El [Polo Node.js](/products/Z) combinaría genial. ¿Qué talla usas: Small, Medium o Large?" ¿En qué puedo ayudarte hoy a encontrar el producto perfecto para ti? 🛒✨ `; diff --git a/src/services/product.service.ts b/src/services/product.service.ts index bc58616..10eafb8 100644 --- a/src/services/product.service.ts +++ b/src/services/product.service.ts @@ -104,5 +104,4 @@ export async function filterByMinMaxPrice( }); return result.map(formattedProduct); -} - +} \ No newline at end of file From e7ad0bfac8206a5ee05408fa3c8f0bb6a7a08535 Mon Sep 17 00:00:00 2001 From: mike Date: Mon, 1 Sep 2025 02:30:04 -0500 Subject: [PATCH 15/25] feat: update data models and cart logic to support variant attribute values in orders and cart items --- .../migration.sql | 16 +++ prisma/schema.prisma | 28 ++--- src/lib/cart.ts | 36 ++++-- src/models/cart.model.ts | 1 - src/models/order.model.ts | 4 +- src/routes/account/orders/index.tsx | 63 ++++++----- src/routes/cart/index.tsx | 105 ++++++++++-------- src/routes/checkout/index.tsx | 18 ++- src/services/order.service.ts | 62 ++++++++--- 9 files changed, 207 insertions(+), 126 deletions(-) create mode 100644 prisma/migrations/20250901061035_update_table_order_item/migration.sql diff --git a/prisma/migrations/20250901061035_update_table_order_item/migration.sql b/prisma/migrations/20250901061035_update_table_order_item/migration.sql new file mode 100644 index 0000000..02e9b86 --- /dev/null +++ b/prisma/migrations/20250901061035_update_table_order_item/migration.sql @@ -0,0 +1,16 @@ +/* + Warnings: + + - You are about to drop the column `product_id` on the `order_items` table. All the data in the column will be lost. + - Added the required column `attribute_value_id` to the `order_items` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "order_items" DROP CONSTRAINT "order_items_product_id_fkey"; + +-- AlterTable +ALTER TABLE "order_items" DROP COLUMN "product_id", +ADD COLUMN "attribute_value_id" INTEGER NOT NULL; + +-- AddForeignKey +ALTER TABLE "order_items" ADD CONSTRAINT "order_items_attribute_value_id_fkey" FOREIGN KEY ("attribute_value_id") REFERENCES "variants_attributes_values"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b8df94a..2a77ce9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -64,7 +64,6 @@ model Product { category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) - orderItems OrderItem[] variantAttributeValues VariantAttributeValue[] @@map("products") @@ -93,7 +92,8 @@ model VariantAttributeValue { variantAttribute VariantAttribute @relation(fields: [attributeId], references: [id]) product Product @relation(fields: [productId], references: [id]) - CartItem CartItem[] + CartItem CartItem[] + OrderItem OrderItem[] @@unique([attributeId, productId, value], name: "unique_attribute_product_value") @@map("variants_attributes_values") @@ -152,18 +152,18 @@ model Order { } model OrderItem { - id Int @id @default(autoincrement()) - orderId Int @map("order_id") - productId Int? @map("product_id") - quantity Int - title String - price Decimal @db.Decimal(10, 2) - imgSrc String? @map("img_src") - createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) - updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) - - order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) - product Product? @relation(fields: [productId], references: [id], onDelete: SetNull) + id Int @id @default(autoincrement()) + orderId Int @map("order_id") + attributeValueId Int @map("attribute_value_id") + quantity Int + title String + price Decimal @db.Decimal(10, 2) + imgSrc String? @map("img_src") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(0) + + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + variantAttributeValue VariantAttributeValue @relation(fields: [attributeValueId], references: [id], onDelete: Cascade) @@map("order_items") } diff --git a/src/lib/cart.ts b/src/lib/cart.ts index 537a129..d14534c 100644 --- a/src/lib/cart.ts +++ b/src/lib/cart.ts @@ -1,4 +1,4 @@ -import type { CartItem, CartItemInput } from "@/models/cart.model"; +import type { CartItem, CartItemWithProduct } from "@/models/cart.model"; // import { type Product } from "@/models/product.model"; import { type VariantAttributeValue } from "@/models/variant-attribute.model"; import { @@ -55,18 +55,30 @@ export async function removeFromCart( } } -export function calculateTotal(items: CartItem[]): number; -export function calculateTotal(items: CartItemInput[]): number; +// Función para CartItem +function calculateCartItemTotal(items: CartItem[]): number { + return items.reduce((total, item) => { + const price = item.variantAttributeValue ? Number(item.variantAttributeValue.price) || 0 : 0; + return total + (price * item.quantity); + }, 0); +} -export function calculateTotal(items: CartItem[] | CartItemInput[]): number { +// Función para CartItemWithProduct +function calculateCartItemWithProductTotal(items: CartItemWithProduct[]): number { return items.reduce((total, item) => { - // Type guard to determine which type we're working with - if ("product" in item) { - // CartItem - has a product property - return total + Number(item.product.price) * item.quantity; - } else { - // CartItemInput - has price directly - return total + Number(item.price) * item.quantity; - } + const price = typeof item.product.price === 'number' ? item.product.price : 0; + return total + (price * item.quantity); }, 0); } + +export function calculateTotal(items: CartItem[]): number; +export function calculateTotal(items: CartItemWithProduct[]): number; + +export function calculateTotal(items: CartItem[] | CartItemWithProduct[]): number { + // Verificar si es CartItemWithProduct comprobando la estructura + if (items.length > 0 && 'product' in items[0]) { + return calculateCartItemWithProductTotal(items as CartItemWithProduct[]); + } else { + return calculateCartItemTotal(items as CartItem[]); + } +} diff --git a/src/models/cart.model.ts b/src/models/cart.model.ts index 5128ebd..493cd8e 100644 --- a/src/models/cart.model.ts +++ b/src/models/cart.model.ts @@ -25,7 +25,6 @@ export interface CartItemInput { } // Tipo para representar un producto simplificado en el carrito - export type CartProductInfo = Pick< Product, "id" | "title" | "imgSrc" | "alt" | "price" | "isOnSale" diff --git a/src/models/order.model.ts b/src/models/order.model.ts index 3ac10a4..ccaf1dd 100644 --- a/src/models/order.model.ts +++ b/src/models/order.model.ts @@ -1,6 +1,7 @@ import type { Order as PrismaOrder, OrderItem as PrismaOrderItem, + VariantAttributeValue } from "@/../generated/prisma/client"; export type OrderDetails = Pick< @@ -19,6 +20,7 @@ export type OrderDetails = Pick< export type OrderItem = Omit & { price: number; + variantAttributeValue?: VariantAttributeValue; }; export type Order = Omit & { @@ -28,7 +30,7 @@ export type Order = Omit & { }; export interface OrderItemInput { - productId: number; + attributeValueId: number; quantity: number; title: string; price: number; diff --git a/src/routes/account/orders/index.tsx b/src/routes/account/orders/index.tsx index 20f1ab7..8d4f9d0 100644 --- a/src/routes/account/orders/index.tsx +++ b/src/routes/account/orders/index.tsx @@ -13,6 +13,7 @@ export async function loader({ request }: Route.LoaderArgs) { try { const orders = await getOrdersByUser(request); + // console.log("ORDERS", orders[0].items[0].variantAttributeValue?.value); console.log("ORDERS", orders[0].items); orders.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); @@ -89,37 +90,43 @@ export default function Orders({ loaderData }: Route.ComponentProps) { - {order.items.map((item) => ( - - -
-
- {item.title} -
-
-
- {item.title} + {order.items.map((item) => { + const productTitle = item.variantAttributeValue?.value + ? `${item.title} (${item.variantAttributeValue.value})` + : item.title; + + return ( + + +
+
+ {productTitle}
-
- {item.quantity} × S/{item.price.toFixed(2)} +
+
+ {productTitle} +
+
+ {item.quantity} × S/{item.price.toFixed(2)} +
-
- - - S/{item.price.toFixed(2)} - - - {item.quantity} - - - S/{(item.price * item.quantity).toFixed(2)} - - - ))} + + + S/{item.price.toFixed(2)} + + + {item.quantity} + + + S/{(item.price * item.quantity).toFixed(2)} + + + ); + })}
diff --git a/src/routes/cart/index.tsx b/src/routes/cart/index.tsx index db8b1ee..5870afd 100644 --- a/src/routes/cart/index.tsx +++ b/src/routes/cart/index.tsx @@ -30,65 +30,71 @@ export default function Cart({ loaderData }: Route.ComponentProps) { Carrito de compras
- {cart?.items?.map(({ product, quantity, id, variantAttributeValue - }) => ( -
-
- {product.alt -
-
-
-

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

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

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

-
-
- +
+
+

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

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

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

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

Total

S/{total.toFixed(2)}

@@ -107,3 +113,4 @@ export default function Cart({ loaderData }: Route.ComponentProps) { ); } + diff --git a/src/routes/checkout/index.tsx b/src/routes/checkout/index.tsx index 27c4a3e..0f10cd0 100644 --- a/src/routes/checkout/index.tsx +++ b/src/routes/checkout/index.tsx @@ -19,7 +19,7 @@ import { type CulqiInstance, } from "@/hooks/use-culqui"; import { calculateTotal, getCart } from "@/lib/cart"; -import { type CartItem } from "@/models/cart.model"; +import { type CartItem, type CartItemWithProduct } from "@/models/cart.model"; import { getCurrentUser } from "@/services/auth.service"; import { deleteRemoteCart } from "@/services/cart.service"; import { createOrder } from "@/services/order.service"; @@ -103,12 +103,18 @@ export async function action({ request }: Route.ActionArgs) { const chargeData = await response.json(); - const items = cartItems.map((item) => ({ - productId: item.product.id, + const items: CartItemWithProduct[] = cartItems.map((item) => ({ + product: { + id: item.product.id, + title: item.product.title, + imgSrc: item.product.imgSrc, + alt: item.product.alt, + price: item.product.price ?? 0, + isOnSale: item.product.isOnSale, + }, quantity: item.quantity, - title: item.product.title, - price: item.product.price, - imgSrc: item.product.imgSrc, + attributeValueId: item.attributeValueId, + variantAttributeValue: item.variantAttributeValue, })); const { id: orderId } = await createOrder( diff --git a/src/services/order.service.ts b/src/services/order.service.ts index 6f5948a..efdd63a 100644 --- a/src/services/order.service.ts +++ b/src/services/order.service.ts @@ -1,13 +1,13 @@ import { prisma } from "@/db/prisma"; import { calculateTotal } from "@/lib/cart"; -import { type CartItemInput } from "@/models/cart.model"; +import { type CartItemWithProduct } from "@/models/cart.model"; import { type Order, type OrderDetails } from "@/models/order.model"; import { getSession } from "@/session.server"; import { getOrCreateUser } from "./user.service"; export async function createOrder( - items: CartItemInput[], + items: CartItemWithProduct[], formData: OrderDetails, paymentId: string ): Promise { @@ -25,17 +25,33 @@ export async function createOrder( ...shippingDetails, items: { create: items.map((item) => ({ - productId: item.productId, + attributeValueId: item.attributeValueId, quantity: item.quantity, - title: item.title, - price: item.price, - imgSrc: item.imgSrc, + title: item.product.title, + price: item.product.price ?? 0, + imgSrc: item.product.imgSrc ?? "", })), }, paymentId: paymentId, }, include: { - items: true, + items: { + include: { + variantAttributeValue: { + include: { + product: { + select: { + id: true, + title: true, + imgSrc: true, + alt: true, + isOnSale: true, + }, + }, + }, + }, + }, + }, }, }); } catch (error) { @@ -54,16 +70,14 @@ export async function createOrder( zip: order.zip, phone: order.phone, }; + return { ...order, totalAmount: Number(order.totalAmount), items: order.items.map((item) => ({ ...item, price: Number(item.price), - imgSrc: item.imgSrc, - productId: item.productId, - createdAt: item.createdAt, - updatedAt: item.updatedAt, + variantAttributeValue: item.variantAttributeValue, })), createdAt: order.createdAt, updatedAt: order.updatedAt, @@ -78,15 +92,33 @@ export async function getOrdersByUser(request: Request): Promise { if (!userId) { throw new Error("User not authenticated"); } + const orders = await prisma.order.findMany({ where: { userId }, include: { - items: true, + items: { + include: { + variantAttributeValue: { + include: { + product: { + select: { + id: true, + title: true, + imgSrc: true, + alt: true, + isOnSale: true, + }, + }, + }, + }, + }, + }, }, orderBy: { createdAt: "desc", }, }); + return orders.map((order) => { const details = { email: order.email, @@ -100,14 +132,14 @@ export async function getOrdersByUser(request: Request): Promise { zip: order.zip, phone: order.phone, }; + return { ...order, totalAmount: Number(order.totalAmount), items: order.items.map((item) => ({ ...item, price: Number(item.price), - createdAt: item.createdAt, - updatedAt: item.updatedAt, + variantAttributeValue: item.variantAttributeValue, })), createdAt: order.createdAt, updatedAt: order.updatedAt, @@ -115,4 +147,4 @@ export async function getOrdersByUser(request: Request): Promise { ...details, }; }); -} +} \ No newline at end of file From 80cfcaed1f5cbd08a05782067e6e0a7487d23b69 Mon Sep 17 00:00:00 2001 From: mike Date: Mon, 1 Sep 2025 03:18:55 -0500 Subject: [PATCH 16/25] feat: enhance order service tests and improve error handling in getOrdersByUser function --- src/services/order.service.test.ts | 435 +++++++++++++++++++++-------- src/services/order.service.ts | 44 +-- 2 files changed, 338 insertions(+), 141 deletions(-) diff --git a/src/services/order.service.test.ts b/src/services/order.service.test.ts index f5ffa51..640ee65 100644 --- a/src/services/order.service.test.ts +++ b/src/services/order.service.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it, vi } from "vitest"; +import { Decimal } from "@prisma/client/runtime/library"; +import { describe, expect, it, vi, beforeEach } from "vitest"; import { prisma as mockPrisma } from "@/db/prisma"; import { calculateTotal } from "@/lib/cart"; @@ -11,7 +12,7 @@ import { createTestRequest, createTestUser, } from "@/lib/utils.tests"; -import type { CartItemInput } from "@/models/cart.model"; +import type { CartItemWithProduct } from "@/models/cart.model"; import { getSession } from "@/session.server"; import { createOrder, getOrdersByUser } from "./order.service"; @@ -31,20 +32,34 @@ vi.mock("@/lib/cart"); vi.mock("@/session.server"); describe("Order Service", () => { - const mockedItems: CartItemInput[] = [ + beforeEach(() => { + vi.clearAllMocks(); + }); + + const mockedItems: CartItemWithProduct[] = [ { - productId: 1, + product: { + id: 1, + title: "Test Product", + price: 19.99, + imgSrc: "test-product.jpg", + alt: "Test Product Alt", + isOnSale: false, + }, quantity: 2, - title: "Test Product", - price: 19.99, - imgSrc: "test-product.jpg", + attributeValueId: 1, }, { - productId: 2, + product: { + id: 2, + title: "Another Product", + price: 29.99, + imgSrc: "another-product.jpg", + alt: "Another Product Alt", + isOnSale: true, + }, quantity: 1, - title: "Another Product", - price: 29.99, - imgSrc: "another-product.jpg", + attributeValueId: 2, }, ]; @@ -53,61 +68,105 @@ describe("Order Service", () => { const mockedTotalAmount = 200; const mockedRequest = createTestRequest(); - it("should create an order", async () => { - const prismaOrder = { - ...createTestDBOrder(), - items: [createTestDBOrderItem()], - }; - - vi.mocked(getOrCreateUser).mockResolvedValue(mockedUser); - vi.mocked(calculateTotal).mockReturnValue(mockedTotalAmount); - - vi.mocked(mockPrisma.order.create).mockResolvedValue(prismaOrder); - - const order = await createOrder(mockedItems, mockedFormData, "payment-id"); - expect(mockPrisma.order.create).toHaveBeenCalledWith({ - data: { - userId: mockedUser.id, - totalAmount: mockedTotalAmount, - email: mockedFormData.email, - firstName: mockedFormData.firstName, - lastName: mockedFormData.lastName, - company: mockedFormData.company, - address: mockedFormData.address, - city: mockedFormData.city, - country: mockedFormData.country, - region: mockedFormData.region, - zip: mockedFormData.zip, - phone: mockedFormData.phone, - items: { - create: mockedItems.map((item) => ({ - productId: item.productId, - quantity: item.quantity, - title: item.title, - price: item.price, - imgSrc: item.imgSrc, - })), + describe("createOrder", () => { + it("should create an order successfully", async () => { + const prismaOrder = { + ...createTestDBOrder(), + items: [ + { + ...createTestDBOrderItem(), + variantAttributeValue: { + id: 1, + value: "Test Value", + price: 19.99, + product: { + id: 1, + title: "Test Product", + imgSrc: "test-product.jpg", + alt: "Test Product Alt", + isOnSale: false, + }, + }, + }, + ], + }; + + vi.mocked(getOrCreateUser).mockResolvedValue(mockedUser); + vi.mocked(calculateTotal).mockReturnValue(mockedTotalAmount); + vi.mocked(mockPrisma.order.create).mockResolvedValue(prismaOrder); + + const order = await createOrder(mockedItems, mockedFormData, "payment-id"); + + expect(getOrCreateUser).toHaveBeenCalledWith(mockedFormData.email); + expect(calculateTotal).toHaveBeenCalledWith(mockedItems); + expect(mockPrisma.order.create).toHaveBeenCalledWith({ + data: { + userId: mockedUser.id, + totalAmount: mockedTotalAmount, + email: mockedFormData.email, + firstName: mockedFormData.firstName, + lastName: mockedFormData.lastName, + company: mockedFormData.company, + address: mockedFormData.address, + city: mockedFormData.city, + country: mockedFormData.country, + region: mockedFormData.region, + zip: mockedFormData.zip, + phone: mockedFormData.phone, + items: { + create: mockedItems.map((item) => ({ + attributeValueId: item.attributeValueId, + quantity: item.quantity, + title: item.product.title, + price: item.product.price ?? 0, + imgSrc: item.product.imgSrc ?? "", + })), + }, + paymentId: "payment-id", + }, + include: { + items: { + include: { + variantAttributeValue: { + include: { + product: { + select: { + id: true, + title: true, + imgSrc: true, + alt: true, + isOnSale: true, + }, + }, + }, + }, + }, + }, + }, + }); + + expect(order).toEqual({ + ...prismaOrder, + totalAmount: Number(prismaOrder.totalAmount), + items: prismaOrder.items.map((item) => ({ + ...item, + price: Number(item.price), + variantAttributeValue: item.variantAttributeValue, + })), + createdAt: prismaOrder.createdAt, + updatedAt: prismaOrder.updatedAt, + details: { + email: prismaOrder.email, + firstName: prismaOrder.firstName, + lastName: prismaOrder.lastName, + company: prismaOrder.company, + address: prismaOrder.address, + city: prismaOrder.city, + country: prismaOrder.country, + region: prismaOrder.region, + zip: prismaOrder.zip, + phone: prismaOrder.phone, }, - paymentId: "payment-id", - }, - include: { - items: true, - }, - }); - expect(order).toEqual({ - ...prismaOrder, - totalAmount: Number(prismaOrder.totalAmount), - items: prismaOrder.items.map((item) => ({ - ...item, - price: Number(item.price), - imgSrc: item.imgSrc ?? "", - productId: item.productId ?? 0, - createdAt: item.createdAt, - updatedAt: item.updatedAt, - })), - createdAt: prismaOrder.createdAt, - updatedAt: prismaOrder.updatedAt, - details: { email: prismaOrder.email, firstName: prismaOrder.firstName, lastName: prismaOrder.lastName, @@ -118,43 +177,141 @@ describe("Order Service", () => { region: prismaOrder.region, zip: prismaOrder.zip, phone: prismaOrder.phone, - }, - paymentId: prismaOrder.paymentId, + paymentId: prismaOrder.paymentId, + }); }); - }); - it("should get orders by user", async () => { - const prismaOrders = [ - { ...createTestDBOrder(), items: [createTestOrderItem()] }, - { - ...createTestDBOrder({ id: 2 }), - items: [createTestOrderItem({ id: 2 })], - }, - ]; - const mockedSession = createMockSession(mockedUser.id); - vi.mocked(getSession).mockResolvedValue(mockedSession); - vi.mocked(mockPrisma.order.findMany).mockResolvedValue(prismaOrders); - const orders = await getOrdersByUser(mockedRequest); - expect(mockPrisma.order.findMany).toHaveBeenCalledWith({ - where: { userId: mockedUser.id }, - include: { items: true }, - orderBy: { createdAt: "desc" }, + it("should throw error if order creation fails", async () => { + vi.mocked(getOrCreateUser).mockResolvedValue(mockedUser); + vi.mocked(calculateTotal).mockReturnValue(mockedTotalAmount); + vi.mocked(mockPrisma.order.create).mockRejectedValue( + new Error("Database error") + ); + + await expect( + createOrder(mockedItems, mockedFormData, "payment-id") + ).rejects.toThrow("Failed to create order"); + + expect(mockPrisma.order.create).toHaveBeenCalled(); }); - expect(orders).toEqual( - prismaOrders.map((order) => ({ - ...order, - totalAmount: Number(order.totalAmount), - items: order.items.map((item) => ({ - ...item, - price: Number(item.price), - imgSrc: item.imgSrc ?? "", - productId: item.productId ?? 0, - createdAt: item.createdAt, - updatedAt: item.updatedAt, - })), - createdAt: order.createdAt, - updatedAt: order.updatedAt, - details: { + + it("should handle empty items array", async () => { + vi.mocked(getOrCreateUser).mockResolvedValue(mockedUser); + vi.mocked(calculateTotal).mockReturnValue(0); + + const prismaOrder = { + ...createTestDBOrder({ totalAmount: new Decimal(0) }), + items: [], + }; + + vi.mocked(mockPrisma.order.create).mockResolvedValue(prismaOrder); + + const order = await createOrder([], mockedFormData, "payment-id"); + + expect(order.items).toEqual([]); + expect(order.totalAmount).toBe(0); + }); + }); + + describe("getOrdersByUser", () => { + it("should get orders by user successfully", async () => { + const prismaOrders = [ + { + ...createTestDBOrder(), + items: [ + { + ...createTestOrderItem(), + variantAttributeValue: { + id: 1, + value: "Test Value", + price: 19.99, + product: { + id: 1, + title: "Test Product", + imgSrc: "test-product.jpg", + alt: "Test Product Alt", + isOnSale: false, + }, + }, + }, + ], + }, + { + ...createTestDBOrder({ id: 2 }), + items: [ + { + ...createTestOrderItem({ id: 2 }), + variantAttributeValue: { + id: 2, + value: "Test Value 2", + price: 29.99, + product: { + id: 2, + title: "Another Product", + imgSrc: "another-product.jpg", + alt: "Another Product Alt", + isOnSale: true, + }, + }, + }, + ], + }, + ]; + const mockedSession = createMockSession(mockedUser.id); + + vi.mocked(getSession).mockResolvedValue(mockedSession); + vi.mocked(mockPrisma.order.findMany).mockResolvedValue(prismaOrders); + + const orders = await getOrdersByUser(mockedRequest); + + expect(getSession).toHaveBeenCalledWith("session=mock-session-id"); + expect(mockPrisma.order.findMany).toHaveBeenCalledWith({ + where: { userId: mockedUser.id }, + include: { + items: { + include: { + variantAttributeValue: { + include: { + product: { + select: { + id: true, + title: true, + imgSrc: true, + alt: true, + isOnSale: true, + }, + }, + }, + }, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + + expect(orders).toEqual( + prismaOrders.map((order) => ({ + ...order, + totalAmount: Number(order.totalAmount), + items: order.items.map((item) => ({ + ...item, + price: Number(item.price), + variantAttributeValue: item.variantAttributeValue, + })), + createdAt: order.createdAt, + updatedAt: order.updatedAt, + details: { + email: order.email, + firstName: order.firstName, + lastName: order.lastName, + company: order.company, + address: order.address, + city: order.city, + country: order.country, + region: order.region, + zip: order.zip, + phone: order.phone, + }, email: order.email, firstName: order.firstName, lastName: order.lastName, @@ -165,34 +322,68 @@ describe("Order Service", () => { region: order.region, zip: order.zip, phone: order.phone, - }, - })) - ); - }); + })) + ); + }); - it("should throw error if user is not authenticated", async () => { - const mockedSession = createMockSession(null); + it("should throw error if user is not authenticated", async () => { + const mockedSession = createMockSession(null); - vi.mocked(getSession).mockResolvedValue(mockedSession); + vi.mocked(getSession).mockResolvedValue(mockedSession); - await expect(getOrdersByUser(mockedRequest)).rejects.toThrow( - "User not authenticated" - ); + await expect(getOrdersByUser(mockedRequest)).rejects.toThrow( + "User not authenticated" + ); - expect(getSession).toHaveBeenCalledWith("session=mock-session-id"); - }); + expect(getSession).toHaveBeenCalledWith("session=mock-session-id"); + expect(mockPrisma.order.findMany).not.toHaveBeenCalled(); + }); + + it("should return empty array if user has no orders", async () => { + const mockedSession = createMockSession(mockedUser.id); - it("should throw error if order creation fails", async () => { - vi.mocked(getOrCreateUser).mockResolvedValue(mockedUser); - vi.mocked(calculateTotal).mockReturnValue(mockedTotalAmount); - vi.mocked(mockPrisma.order.create).mockRejectedValue( - new Error("Database error") - ); + vi.mocked(getSession).mockResolvedValue(mockedSession); + vi.mocked(mockPrisma.order.findMany).mockResolvedValue([]); - await expect( - createOrder(mockedItems, mockedFormData, "payment-id") - ).rejects.toThrow("Failed to create order"); + const orders = await getOrdersByUser(mockedRequest); - expect(mockPrisma.order.create).toHaveBeenCalled(); + expect(orders).toEqual([]); + expect(mockPrisma.order.findMany).toHaveBeenCalledWith({ + where: { userId: mockedUser.id }, + include: { + items: { + include: { + variantAttributeValue: { + include: { + product: { + select: { + id: true, + title: true, + imgSrc: true, + alt: true, + isOnSale: true, + }, + }, + }, + }, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + }); + + it("should handle database errors gracefully", async () => { + const mockedSession = createMockSession(mockedUser.id); + + vi.mocked(getSession).mockResolvedValue(mockedSession); + vi.mocked(mockPrisma.order.findMany).mockRejectedValue( + new Error("Database connection error") + ); + + await expect(getOrdersByUser(mockedRequest)).rejects.toThrow( + "Failed to fetch orders" + ); + }); }); -}); +}); \ No newline at end of file diff --git a/src/services/order.service.ts b/src/services/order.service.ts index efdd63a..1592c6e 100644 --- a/src/services/order.service.ts +++ b/src/services/order.service.ts @@ -93,31 +93,37 @@ export async function getOrdersByUser(request: Request): Promise { throw new Error("User not authenticated"); } - const orders = await prisma.order.findMany({ - where: { userId }, - include: { - items: { - include: { - variantAttributeValue: { - include: { - product: { - select: { - id: true, - title: true, - imgSrc: true, - alt: true, - isOnSale: true, + let orders; + + try { + orders = await prisma.order.findMany({ + where: { userId }, + include: { + items: { + include: { + variantAttributeValue: { + include: { + product: { + select: { + id: true, + title: true, + imgSrc: true, + alt: true, + isOnSale: true, + }, }, }, }, }, }, }, - }, - orderBy: { - createdAt: "desc", - }, - }); + orderBy: { + createdAt: "desc", + }, + }); + } catch (error) { + throw new Error("Failed to fetch orders", { cause: error }); + } return orders.map((order) => { const details = { From b7bc308ee7b1548068b6fbea8a632b6bb536f0f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Vallejo?= Date: Mon, 1 Sep 2025 03:21:46 -0500 Subject: [PATCH 17/25] Fix: UI and README.md update --- README.md | 7 ++++--- src/routes/product/index.tsx | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 459b976..b85b871 100644 --- a/README.md +++ b/README.md @@ -309,9 +309,10 @@ npm run test:e2e - **Sistema de Variantes de Producto**: Implementación completa del sistema de variantes con base de datos (Prisma schema) - **Carrito con Variantes**: Actualización del carrito para mostrar información detallada de variantes seleccionadas -### 👨‍💻 Sebastian -- **UI/UX Design & Responsive Design**: Diseño e implementación de la interfaz adaptativa con Tailwind CSS -- **Chat Bot AI**: Integración con Google Gemini para el asistente virtual y sistema de recomendaciones +### 👨‍💻 Sebastián +- **UI/UX Design & Responsive Design**: Implementación con Tailwind CSS y ajustes ligeros en 'initial_data.ts' y servicios para la correcta comunicación backend–frontend. +- **Actualización del ChatBot**: Actualización con nuevo contexto, prompts de comportamiento, estrategia de ventas, ejemplos de respuesta y lógica de recomendaciones. + ### 👩‍💻 Janet - **Modificacion del archivo de data inicial**: Modificación del archivo 'initial_data.ts' para las diferentes variables de productos diff --git a/src/routes/product/index.tsx b/src/routes/product/index.tsx index b17ab76..906ac90 100644 --- a/src/routes/product/index.tsx +++ b/src/routes/product/index.tsx @@ -106,13 +106,13 @@ export default function Product({ loaderData }: Route.ComponentProps) {

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

-
+
{variants!.map((variant) => (