From 8fc9df3b50b2a75728a17be0d0f557e0f0a05c5d Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Tue, 25 Aug 2026 14:33:14 -0500 Subject: [PATCH] feat(ecommerce): mensaje de urgencia por inventario real Expone ProductVariant.inventoryQuantity (ya calculado desde variant.inventory_quantity en Medusa, hoy colapsado al booleano available) y lo usa para mostrar "Quedan N" cuando el stock real es bajo (<=5), tanto en la tarjeta de producto como en la ficha. null indica inventario no gestionado (backorder / manage_inventory=false), donde nunca se muestra un numero. ADR 0005 seccion 3 (patron eBay). Catalogo mock actualizado (data/products.json) con inventoryQuantity=null en todas las variantes para seguir tipando contra Product[] via `satisfies`. --- .../components/product/product-card.tsx | 15 +++++- .../components/product/product-purchase.tsx | 14 +++-- workloads/ecommerce/data/products.json | 51 ++++++++++++------- .../lib/headless/providers/medusa.ts | 8 +++ workloads/ecommerce/lib/headless/types.ts | 1 + workloads/ecommerce/lib/utils.ts | 5 ++ 6 files changed, 72 insertions(+), 22 deletions(-) diff --git a/workloads/ecommerce/components/product/product-card.tsx b/workloads/ecommerce/components/product/product-card.tsx index d7de6d4..b6e1ce9 100644 --- a/workloads/ecommerce/components/product/product-card.tsx +++ b/workloads/ecommerce/components/product/product-card.tsx @@ -2,12 +2,12 @@ import Image from "next/image"; import Link from "next/link"; -import { Heart, ShoppingBag, Sparkles } from "lucide-react"; +import { Heart, ShoppingBag, Sparkles, TriangleAlert } from "lucide-react"; import { useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import type { Product } from "@/lib/headless/types"; -import { formatMoney } from "@/lib/utils"; +import { formatMoney, LOW_STOCK_THRESHOLD } from "@/lib/utils"; import { useCartStore } from "@/store/cart-store"; import { useWishlistStore } from "@/store/wishlist-store"; @@ -23,6 +23,14 @@ export function ProductCard({ product, priority = false }: { product: Product; p : null; const [selectedVariantId, setSelectedVariantId] = useState(product.variants[0]?.id); + const selectedVariant = product.variants.find((variant) => variant.id === selectedVariantId) ?? product.variants[0]; + const lowStock = + selectedVariant?.inventoryQuantity !== null && + selectedVariant?.inventoryQuantity !== undefined && + selectedVariant.inventoryQuantity > 0 && + selectedVariant.inventoryQuantity <= LOW_STOCK_THRESHOLD + ? selectedVariant.inventoryQuantity + : null; return (
@@ -56,6 +64,9 @@ export function ProductCard({ product, priority = false }: { product: Product; p ))} )} + {lowStock !== null && ( + Quedan {lowStock} + )}
{product.compareAtPrice && {formatMoney(product.compareAtPrice, product.currency)}} diff --git a/workloads/ecommerce/components/product/product-purchase.tsx b/workloads/ecommerce/components/product/product-purchase.tsx index 2f7206b..cfd1b0a 100644 --- a/workloads/ecommerce/components/product/product-purchase.tsx +++ b/workloads/ecommerce/components/product/product-purchase.tsx @@ -1,14 +1,22 @@ "use client"; -import { CheckCircle2, ShoppingBag, Truck } from "lucide-react"; +import { CheckCircle2, ShoppingBag, TriangleAlert, Truck } from "lucide-react"; import { useState } from "react"; import { Button } from "@/components/ui/button"; import type { Product } from "@/lib/headless/types"; -import { formatMoney } from "@/lib/utils"; +import { formatMoney, LOW_STOCK_THRESHOLD } from "@/lib/utils"; import { useCartStore } from "@/store/cart-store"; export function ProductPurchase({ product }: { product: Product }) { const [variantId, setVariantId] = useState(product.variants[0]?.id); const addItem = useCartStore((state) => state.addItem); - return
{product.variants.map((variant) => )}
{formatMoney(product.price, product.currency)}{product.compareAtPrice && {formatMoney(product.compareAtPrice, product.currency)}}
Disponibilidad por confirmarEnvíos nacionales
; + const selectedVariant = product.variants.find((variant) => variant.id === variantId) ?? product.variants[0]; + const lowStock = + selectedVariant?.inventoryQuantity !== null && + selectedVariant?.inventoryQuantity !== undefined && + selectedVariant.inventoryQuantity > 0 && + selectedVariant.inventoryQuantity <= LOW_STOCK_THRESHOLD + ? selectedVariant.inventoryQuantity + : null; + return
{product.variants.map((variant) => )}
{formatMoney(product.price, product.currency)}{product.compareAtPrice && {formatMoney(product.compareAtPrice, product.currency)}}
{lowStock !== null ? Quedan {lowStock} unidades : Disponibilidad por confirmar}Envíos nacionales
; } diff --git a/workloads/ecommerce/data/products.json b/workloads/ecommerce/data/products.json index 8ae94bb..0197d05 100644 --- a/workloads/ecommerce/data/products.json +++ b/workloads/ecommerce/data/products.json @@ -30,7 +30,8 @@ "id": "ari-001-toy-story-5-rex-default", "name": "Presentación", "value": "Edición original", - "available": true + "available": true, + "inventoryQuantity": null } ], "thumbnail": "/images/thumbnails/ari-001-toy-story-5-rex.webp" @@ -66,7 +67,8 @@ "id": "ari-002-toy-story-5-buzz-lightyear-default", "name": "Presentación", "value": "Edición original", - "available": true + "available": true, + "inventoryQuantity": null } ], "thumbnail": "/images/thumbnails/ari-002-toy-story-5-buzz-lightyear.webp" @@ -102,7 +104,8 @@ "id": "ari-003-toy-story-5-jessie-default", "name": "Presentación", "value": "Edición original", - "available": true + "available": true, + "inventoryQuantity": null } ], "thumbnail": "/images/thumbnails/ari-003-toy-story-5-jessie.webp" @@ -138,7 +141,8 @@ "id": "ari-004-toy-story-5-woody-default", "name": "Presentación", "value": "Edición original", - "available": true + "available": true, + "inventoryQuantity": null } ], "thumbnail": "/images/thumbnails/ari-004-toy-story-5-woody.webp" @@ -174,7 +178,8 @@ "id": "ari-005-intensamente-furia-default", "name": "Presentación", "value": "Edición original", - "available": true + "available": true, + "inventoryQuantity": null } ], "thumbnail": "/images/thumbnails/ari-005-intensamente-furia.webp" @@ -210,7 +215,8 @@ "id": "ari-006-intensamente-tristeza-default", "name": "Presentación", "value": "Edición original", - "available": true + "available": true, + "inventoryQuantity": null } ], "thumbnail": "/images/thumbnails/ari-006-intensamente-tristeza.webp" @@ -246,7 +252,8 @@ "id": "ari-007-minnie-y-mickey-mouse-default", "name": "Presentación", "value": "Edición original", - "available": true + "available": true, + "inventoryQuantity": null } ], "thumbnail": "/images/thumbnails/ari-007-minnie-y-mickey-mouse.webp" @@ -282,7 +289,8 @@ "id": "ari-008-angel-lilo-y-stitch-default", "name": "Presentación", "value": "Edición original", - "available": true + "available": true, + "inventoryQuantity": null } ], "thumbnail": "/images/thumbnails/ari-008-angel-lilo-y-stitch.webp" @@ -318,7 +326,8 @@ "id": "ari-009-mochilas-disney-coleccion-marvel-default", "name": "Presentación", "value": "Rosa", - "available": true + "available": true, + "inventoryQuantity": null } ], "thumbnail": "/images/thumbnails/ari-009-mochilas-disney-coleccion-marvel.webp" @@ -354,7 +363,8 @@ "id": "ari-010-mochila-ligera-y-resistente-al-agua-de-d-default", "name": "Presentación", "value": "Rosa", - "available": true + "available": true, + "inventoryQuantity": null } ], "thumbnail": "/images/thumbnails/ari-010-mochila-ligera-y-resistente-al-agua-de-d.webp" @@ -390,7 +400,8 @@ "id": "ari-011-mochila-escolar-de-spider-man-de-disney--default", "name": "Presentación", "value": "Rosa", - "available": true + "available": true, + "inventoryQuantity": null } ], "thumbnail": "/images/thumbnails/ari-011-mochila-escolar-de-spider-man-de-disney-.webp" @@ -426,7 +437,8 @@ "id": "ari-012-disney-mochila-de-poliester-ultraligera--default", "name": "Presentación", "value": "Rosa", - "available": true + "available": true, + "inventoryQuantity": null } ], "thumbnail": "/images/thumbnails/ari-012-disney-mochila-de-poliester-ultraligera-.webp" @@ -462,7 +474,8 @@ "id": "ari-013-mochila-pequena-bob-esponja-default", "name": "Presentación", "value": "Rosa", - "available": true + "available": true, + "inventoryQuantity": null } ], "thumbnail": "/images/thumbnails/ari-013-mochila-pequena-bob-esponja.webp" @@ -498,7 +511,8 @@ "id": "ari-014-mochila-casual-de-gran-capacidad-con-est-default", "name": "Presentación", "value": "Rosa", - "available": true + "available": true, + "inventoryQuantity": null } ], "thumbnail": "/images/thumbnails/ari-014-mochila-casual-de-gran-capacidad-con-est.webp" @@ -534,7 +548,8 @@ "id": "ari-015-mochila-escolar-estilo-britanico-de-disn-default", "name": "Presentación", "value": "Rosa", - "available": true + "available": true, + "inventoryQuantity": null } ], "thumbnail": "/images/thumbnails/ari-015-mochila-escolar-estilo-britanico-de-disn.webp" @@ -570,7 +585,8 @@ "id": "ari-016-disney-mochila-iron-man-de-poliester-ult-default", "name": "Presentación", "value": "Rosa", - "available": true + "available": true, + "inventoryQuantity": null } ], "thumbnail": "/images/thumbnails/ari-016-disney-mochila-iron-man-de-poliester-ult.webp" @@ -606,7 +622,8 @@ "id": "ari-017-mochila-de-disney-marvel-capitan-america-default", "name": "Presentación", "value": "Rosa", - "available": true + "available": true, + "inventoryQuantity": null } ], "thumbnail": "/images/thumbnails/ari-017-mochila-de-disney-marvel-capitan-america.webp" diff --git a/workloads/ecommerce/lib/headless/providers/medusa.ts b/workloads/ecommerce/lib/headless/providers/medusa.ts index 2ccfe8b..0b1f1b0 100644 --- a/workloads/ecommerce/lib/headless/providers/medusa.ts +++ b/workloads/ecommerce/lib/headless/providers/medusa.ts @@ -157,11 +157,19 @@ function mapVariant(variant: MedusaVariant): ProductVariant { variant.manage_inventory === false || (variant.inventory_quantity ?? 0) > 0; + // null = inventario no gestionado (backorder o manage_inventory=false), + // no hay un numero real que mostrar como "quedan N". + const inventoryQuantity = + variant.allow_backorder === true || variant.manage_inventory === false + ? null + : (variant.inventory_quantity ?? 0); + return { id: variant.id, name: option?.option?.title || "Presentación", value: option?.value || variant.title || "Estándar", available, + inventoryQuantity, }; } diff --git a/workloads/ecommerce/lib/headless/types.ts b/workloads/ecommerce/lib/headless/types.ts index 9613dd7..5f9cced 100644 --- a/workloads/ecommerce/lib/headless/types.ts +++ b/workloads/ecommerce/lib/headless/types.ts @@ -3,6 +3,7 @@ export interface ProductVariant { name: string; value: string; available: boolean; + inventoryQuantity: number | null; } export interface Product { diff --git a/workloads/ecommerce/lib/utils.ts b/workloads/ecommerce/lib/utils.ts index ae6b58c..69c1919 100644 --- a/workloads/ecommerce/lib/utils.ts +++ b/workloads/ecommerce/lib/utils.ts @@ -13,3 +13,8 @@ export function formatMoney(value: number | null, currency = "COP"): string { maximumFractionDigits: 0, }).format(value); } + +// Umbral desde el cual mostramos "Quedan N" en vez de solo "Disponible". +// inventoryQuantity=null significa inventario no gestionado (backorder / +// manage_inventory=false en Medusa) -- ahi nunca mostramos el mensaje. +export const LOW_STOCK_THRESHOLD = 5; -- 2.54.0