Compare commits

...
Author SHA1 Message Date
devops 8fc9df3b50 feat(ecommerce): mensaje de urgencia por inventario real
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 26s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
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`.
2026-08-25 14:33:14 -05:00
devops 707a05cc6e Merge pull request 'fix(ecommerce): selector de variante inline en tarjeta de producto' (#38) from fix/etapa2-subfase3-swatches-inline into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 25s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 10m37s
2026-08-25 19:31:15 +00:00
6 changed files with 72 additions and 22 deletions

No files matched your search

@@ -2,12 +2,12 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; 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 { useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { Product } from "@/lib/headless/types"; 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 { useCartStore } from "@/store/cart-store";
import { useWishlistStore } from "@/store/wishlist-store"; import { useWishlistStore } from "@/store/wishlist-store";
@@ -23,6 +23,14 @@ export function ProductCard({ product, priority = false }: { product: Product; p
: null; : null;
const [selectedVariantId, setSelectedVariantId] = useState(product.variants[0]?.id); 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 ( return (
<article className="group flex h-full flex-col overflow-hidden rounded-3xl border border-slate-100 bg-white shadow-lg shadow-slate-900/5 transition duration-300 hover:-translate-y-1.5 hover:shadow-2xl hover:shadow-violet-500/10"> <article className="group flex h-full flex-col overflow-hidden rounded-3xl border border-slate-100 bg-white shadow-lg shadow-slate-900/5 transition duration-300 hover:-translate-y-1.5 hover:shadow-2xl hover:shadow-violet-500/10">
@@ -56,6 +64,9 @@ export function ProductCard({ product, priority = false }: { product: Product; p
))} ))}
</div> </div>
)} )}
{lowStock !== null && (
<span className="flex items-center gap-1.5 text-xs font-bold text-amber-600"><TriangleAlert className="size-3.5" />Quedan {lowStock}</span>
)}
<div className="mt-auto flex items-center justify-between gap-3 pt-1"> <div className="mt-auto flex items-center justify-between gap-3 pt-1">
<div className="flex flex-col"> <div className="flex flex-col">
{product.compareAtPrice && <span className="text-xs font-bold text-slate-400 line-through">{formatMoney(product.compareAtPrice, product.currency)}</span>} {product.compareAtPrice && <span className="text-xs font-bold text-slate-400 line-through">{formatMoney(product.compareAtPrice, product.currency)}</span>}
@@ -1,14 +1,22 @@
"use client"; "use client";
import { CheckCircle2, ShoppingBag, Truck } from "lucide-react"; import { CheckCircle2, ShoppingBag, TriangleAlert, Truck } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { Product } from "@/lib/headless/types"; 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 { useCartStore } from "@/store/cart-store";
export function ProductPurchase({ product }: { product: Product }) { export function ProductPurchase({ product }: { product: Product }) {
const [variantId, setVariantId] = useState(product.variants[0]?.id); const [variantId, setVariantId] = useState(product.variants[0]?.id);
const addItem = useCartStore((state) => state.addItem); const addItem = useCartStore((state) => state.addItem);
return <div><div className="mb-5 flex flex-wrap gap-2">{product.variants.map((variant) => <button key={variant.id} onClick={() => setVariantId(variant.id)} className={`rounded-full border px-4 py-2 text-sm font-bold ${variantId === variant.id ? "border-fuchsia-500 bg-fuchsia-50 text-fuchsia-700" : "border-slate-200"}`}>{variant.value}</button>)}</div><div className="mb-6 flex items-end gap-3"><strong className="text-3xl font-black">{formatMoney(product.price, product.currency)}</strong>{product.compareAtPrice && <del className="text-slate-400">{formatMoney(product.compareAtPrice, product.currency)}</del>}</div><Button variant="magenta" size="lg" className="w-full" onClick={() => addItem(product, variantId)}><ShoppingBag />Añadir al carrito</Button><div className="mt-5 grid gap-3 rounded-2xl bg-slate-50 p-4 text-sm"><span className="flex items-center gap-2 font-bold"><CheckCircle2 className="size-4 text-emerald-500" />Disponibilidad por confirmar</span><span className="flex items-center gap-2 font-bold"><Truck className="size-4 text-cyan-600" />Envíos nacionales</span></div></div>; 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 <div><div className="mb-5 flex flex-wrap gap-2">{product.variants.map((variant) => <button key={variant.id} onClick={() => setVariantId(variant.id)} className={`rounded-full border px-4 py-2 text-sm font-bold ${variantId === variant.id ? "border-fuchsia-500 bg-fuchsia-50 text-fuchsia-700" : "border-slate-200"}`}>{variant.value}</button>)}</div><div className="mb-6 flex items-end gap-3"><strong className="text-3xl font-black">{formatMoney(product.price, product.currency)}</strong>{product.compareAtPrice && <del className="text-slate-400">{formatMoney(product.compareAtPrice, product.currency)}</del>}</div><Button variant="magenta" size="lg" className="w-full" onClick={() => addItem(product, variantId)}><ShoppingBag />Añadir al carrito</Button><div className="mt-5 grid gap-3 rounded-2xl bg-slate-50 p-4 text-sm">{lowStock !== null ? <span className="flex items-center gap-2 font-bold text-amber-600"><TriangleAlert className="size-4" />Quedan {lowStock} unidades</span> : <span className="flex items-center gap-2 font-bold"><CheckCircle2 className="size-4 text-emerald-500" />Disponibilidad por confirmar</span>}<span className="flex items-center gap-2 font-bold"><Truck className="size-4 text-cyan-600" />Envíos nacionales</span></div></div>;
} }
+34 -17
View File
@@ -30,7 +30,8 @@
"id": "ari-001-toy-story-5-rex-default", "id": "ari-001-toy-story-5-rex-default",
"name": "Presentación", "name": "Presentación",
"value": "Edición original", "value": "Edición original",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-001-toy-story-5-rex.webp" "thumbnail": "/images/thumbnails/ari-001-toy-story-5-rex.webp"
@@ -66,7 +67,8 @@
"id": "ari-002-toy-story-5-buzz-lightyear-default", "id": "ari-002-toy-story-5-buzz-lightyear-default",
"name": "Presentación", "name": "Presentación",
"value": "Edición original", "value": "Edición original",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-002-toy-story-5-buzz-lightyear.webp" "thumbnail": "/images/thumbnails/ari-002-toy-story-5-buzz-lightyear.webp"
@@ -102,7 +104,8 @@
"id": "ari-003-toy-story-5-jessie-default", "id": "ari-003-toy-story-5-jessie-default",
"name": "Presentación", "name": "Presentación",
"value": "Edición original", "value": "Edición original",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-003-toy-story-5-jessie.webp" "thumbnail": "/images/thumbnails/ari-003-toy-story-5-jessie.webp"
@@ -138,7 +141,8 @@
"id": "ari-004-toy-story-5-woody-default", "id": "ari-004-toy-story-5-woody-default",
"name": "Presentación", "name": "Presentación",
"value": "Edición original", "value": "Edición original",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-004-toy-story-5-woody.webp" "thumbnail": "/images/thumbnails/ari-004-toy-story-5-woody.webp"
@@ -174,7 +178,8 @@
"id": "ari-005-intensamente-furia-default", "id": "ari-005-intensamente-furia-default",
"name": "Presentación", "name": "Presentación",
"value": "Edición original", "value": "Edición original",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-005-intensamente-furia.webp" "thumbnail": "/images/thumbnails/ari-005-intensamente-furia.webp"
@@ -210,7 +215,8 @@
"id": "ari-006-intensamente-tristeza-default", "id": "ari-006-intensamente-tristeza-default",
"name": "Presentación", "name": "Presentación",
"value": "Edición original", "value": "Edición original",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-006-intensamente-tristeza.webp" "thumbnail": "/images/thumbnails/ari-006-intensamente-tristeza.webp"
@@ -246,7 +252,8 @@
"id": "ari-007-minnie-y-mickey-mouse-default", "id": "ari-007-minnie-y-mickey-mouse-default",
"name": "Presentación", "name": "Presentación",
"value": "Edición original", "value": "Edición original",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-007-minnie-y-mickey-mouse.webp" "thumbnail": "/images/thumbnails/ari-007-minnie-y-mickey-mouse.webp"
@@ -282,7 +289,8 @@
"id": "ari-008-angel-lilo-y-stitch-default", "id": "ari-008-angel-lilo-y-stitch-default",
"name": "Presentación", "name": "Presentación",
"value": "Edición original", "value": "Edición original",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-008-angel-lilo-y-stitch.webp" "thumbnail": "/images/thumbnails/ari-008-angel-lilo-y-stitch.webp"
@@ -318,7 +326,8 @@
"id": "ari-009-mochilas-disney-coleccion-marvel-default", "id": "ari-009-mochilas-disney-coleccion-marvel-default",
"name": "Presentación", "name": "Presentación",
"value": "Rosa", "value": "Rosa",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-009-mochilas-disney-coleccion-marvel.webp" "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", "id": "ari-010-mochila-ligera-y-resistente-al-agua-de-d-default",
"name": "Presentación", "name": "Presentación",
"value": "Rosa", "value": "Rosa",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-010-mochila-ligera-y-resistente-al-agua-de-d.webp" "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", "id": "ari-011-mochila-escolar-de-spider-man-de-disney--default",
"name": "Presentación", "name": "Presentación",
"value": "Rosa", "value": "Rosa",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-011-mochila-escolar-de-spider-man-de-disney-.webp" "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", "id": "ari-012-disney-mochila-de-poliester-ultraligera--default",
"name": "Presentación", "name": "Presentación",
"value": "Rosa", "value": "Rosa",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-012-disney-mochila-de-poliester-ultraligera-.webp" "thumbnail": "/images/thumbnails/ari-012-disney-mochila-de-poliester-ultraligera-.webp"
@@ -462,7 +474,8 @@
"id": "ari-013-mochila-pequena-bob-esponja-default", "id": "ari-013-mochila-pequena-bob-esponja-default",
"name": "Presentación", "name": "Presentación",
"value": "Rosa", "value": "Rosa",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-013-mochila-pequena-bob-esponja.webp" "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", "id": "ari-014-mochila-casual-de-gran-capacidad-con-est-default",
"name": "Presentación", "name": "Presentación",
"value": "Rosa", "value": "Rosa",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-014-mochila-casual-de-gran-capacidad-con-est.webp" "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", "id": "ari-015-mochila-escolar-estilo-britanico-de-disn-default",
"name": "Presentación", "name": "Presentación",
"value": "Rosa", "value": "Rosa",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-015-mochila-escolar-estilo-britanico-de-disn.webp" "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", "id": "ari-016-disney-mochila-iron-man-de-poliester-ult-default",
"name": "Presentación", "name": "Presentación",
"value": "Rosa", "value": "Rosa",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-016-disney-mochila-iron-man-de-poliester-ult.webp" "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", "id": "ari-017-mochila-de-disney-marvel-capitan-america-default",
"name": "Presentación", "name": "Presentación",
"value": "Rosa", "value": "Rosa",
"available": true "available": true,
"inventoryQuantity": null
} }
], ],
"thumbnail": "/images/thumbnails/ari-017-mochila-de-disney-marvel-capitan-america.webp" "thumbnail": "/images/thumbnails/ari-017-mochila-de-disney-marvel-capitan-america.webp"
@@ -157,11 +157,19 @@ function mapVariant(variant: MedusaVariant): ProductVariant {
variant.manage_inventory === false || variant.manage_inventory === false ||
(variant.inventory_quantity ?? 0) > 0; (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 { return {
id: variant.id, id: variant.id,
name: option?.option?.title || "Presentación", name: option?.option?.title || "Presentación",
value: option?.value || variant.title || "Estándar", value: option?.value || variant.title || "Estándar",
available, available,
inventoryQuantity,
}; };
} }
@@ -3,6 +3,7 @@ export interface ProductVariant {
name: string; name: string;
value: string; value: string;
available: boolean; available: boolean;
inventoryQuantity: number | null;
} }
export interface Product { export interface Product {
+5
View File
@@ -13,3 +13,8 @@ export function formatMoney(value: number | null, currency = "COP"): string {
maximumFractionDigits: 0, maximumFractionDigits: 0,
}).format(value); }).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;