Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fc9df3b50 | ||
|
|
707a05cc6e | ||
|
|
e20f3e1ddf | ||
|
|
5b03066ed2 | ||
|
|
78cc5ef93b | ||
|
|
9a8e12fa8b | ||
|
|
e36525e0d8 | ||
|
|
b00dd14f76 |
No files matched your search
@@ -2,11 +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";
|
||||
|
||||
@@ -16,19 +17,63 @@ export function ProductCard({ product, priority = false }: { product: Product; p
|
||||
const wishlistHydrated = useWishlistStore((state) => state.hasHydrated);
|
||||
const toggleProduct = useWishlistStore((state) => state.toggleProduct);
|
||||
const isFavorite = wishlistHydrated && wishlistProducts.some((item) => item.id === product.id);
|
||||
const discountPercent =
|
||||
product.compareAtPrice && product.price
|
||||
? Math.round((1 - product.price / product.compareAtPrice) * 100)
|
||||
: 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 (
|
||||
<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">
|
||||
<div className="relative aspect-square overflow-hidden bg-slate-50">
|
||||
<Link href={`/product/${product.slug}`} prefetch={false}><Image src={product.thumbnail} alt={product.name} fill priority={priority} sizes="(max-width: 640px) 82vw, (max-width: 1024px) 44vw, 280px" className="object-cover transition duration-500 group-hover:scale-[1.035]" /></Link>
|
||||
<div className="absolute left-3 top-3 flex gap-2">{product.isNew && <Badge className="bg-fuchsia-500 text-white">Nuevo</Badge>}{product.featured && <Badge className="bg-yellow-300 text-slate-950"><Sparkles className="mr-1 size-3" />Top</Badge>}</div>
|
||||
<div className="absolute left-3 top-3 flex gap-2">{discountPercent && discountPercent > 0 && <Badge className="bg-rose-600 text-white">-{discountPercent}%</Badge>}{product.isNew && <Badge className="bg-fuchsia-500 text-white">Nuevo</Badge>}{product.featured && <Badge className="bg-yellow-300 text-slate-950"><Sparkles className="mr-1 size-3" />Top</Badge>}</div>
|
||||
<button type="button" aria-label={isFavorite ? "Quitar de favoritos" : "Agregar a favoritos"} aria-pressed={isFavorite} className={`absolute right-3 top-3 rounded-full p-2.5 shadow-lg backdrop-blur transition ${isFavorite ? "bg-fuchsia-500 text-white" : "bg-white/95 text-slate-900 hover:text-fuchsia-500"}`} onClick={() => toggleProduct(product)}><Heart className={`size-4 ${isFavorite ? "fill-current" : ""}`} /></button>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col space-y-3 p-5">
|
||||
<div className="flex items-center justify-between gap-2 text-xs font-bold text-slate-500"><span>{product.category}</span><span className="rounded-full bg-cyan-50 px-2 py-1 text-cyan-700">{product.collection}</span></div>
|
||||
<Link href={`/product/${product.slug}`} prefetch={false}><h3 className="line-clamp-2 min-h-12 text-lg font-black leading-6 text-slate-950 group-hover:text-fuchsia-600">{product.name}</h3></Link>
|
||||
<p className="line-clamp-2 min-h-10 text-sm leading-5 text-slate-500">{product.shortDescription}</p>
|
||||
<div className="mt-auto flex items-center justify-between gap-3 pt-1"><strong className="text-sm font-black">{formatMoney(product.price, product.currency)}</strong><Button variant="magenta" size="sm" onClick={() => addItem(product, product.variants[0]?.id)}><ShoppingBag className="size-4" />Agregar</Button></div>
|
||||
{product.variants.length > 1 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{product.variants.map((variant) => (
|
||||
<button
|
||||
key={variant.id}
|
||||
type="button"
|
||||
disabled={!variant.available}
|
||||
aria-pressed={selectedVariantId === variant.id}
|
||||
aria-label={`${variant.name}: ${variant.value}`}
|
||||
onClick={() => setSelectedVariantId(variant.id)}
|
||||
className={`rounded-full border px-2.5 py-1 text-xs font-bold transition ${
|
||||
selectedVariantId === variant.id
|
||||
? "border-fuchsia-500 bg-fuchsia-50 text-fuchsia-700"
|
||||
: "border-slate-200 text-slate-600 hover:border-slate-400"
|
||||
} ${!variant.available ? "cursor-not-allowed opacity-40" : ""}`}
|
||||
>
|
||||
{variant.value}
|
||||
</button>
|
||||
))}
|
||||
</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="flex flex-col">
|
||||
{product.compareAtPrice && <span className="text-xs font-bold text-slate-400 line-through">{formatMoney(product.compareAtPrice, product.currency)}</span>}
|
||||
<strong className="text-sm font-black">{formatMoney(product.price, product.currency)}</strong>
|
||||
</div>
|
||||
<Button variant="magenta" size="sm" onClick={() => addItem(product, selectedVariantId)}><ShoppingBag className="size-4" />Agregar</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
|
||||
@@ -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 <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>;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -25,7 +25,7 @@ spec:
|
||||
runAsUser: 1001
|
||||
containers:
|
||||
- name: app
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.143
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.147
|
||||
env:
|
||||
- name: HEADLESS_PROVIDER
|
||||
value: medusa
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ export interface ProductVariant {
|
||||
name: string;
|
||||
value: string;
|
||||
available: boolean;
|
||||
inventoryQuantity: number | null;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user