Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cd1078cdf | ||
|
|
1dccd0aa6a | ||
|
|
474ac3795b | ||
|
|
bb2ad3c317 | ||
|
|
578f085334 | ||
|
|
ce4842f375 |
No files matched your search
@@ -0,0 +1,145 @@
|
||||
import { defineRouteConfig } from "@medusajs/admin-sdk";
|
||||
import { ArrowDownTray, ArrowUpTray, CheckCircleSolid, XCircleSolid } from "@medusajs/icons";
|
||||
import { Badge, Button, Container, Heading, Text, toast } from "@medusajs/ui";
|
||||
import { useRef, useState } from "react";
|
||||
import { sdk } from "../../lib/sdk";
|
||||
|
||||
// Importador masivo via CSV -- pensado para cargar varios productos de
|
||||
// una sola vez (comprados en Amazon/eBay/etc, no dropshipping
|
||||
// automatizado -- ver conversacion sobre por que se descarto raspar
|
||||
// tiendas de terceros). Las fotos se descargan del origen y se re-alojan
|
||||
// en el mismo MinIO/S3 del NAS que ya usa el resto del catalogo.
|
||||
|
||||
type RowResult = {
|
||||
row: number;
|
||||
title: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function downloadTemplate() {
|
||||
window.open("/admin/product-import", "_blank");
|
||||
}
|
||||
|
||||
const ProductImportPanel = () => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [results, setResults] = useState<RowResult[] | null>(null);
|
||||
|
||||
async function handleFile(file: File) {
|
||||
const csv = await file.text();
|
||||
setLoading(true);
|
||||
setResults(null);
|
||||
try {
|
||||
const data = await sdk.client.fetch<{ results?: RowResult[]; error?: string }>("/admin/product-import", {
|
||||
method: "post",
|
||||
body: { csv },
|
||||
});
|
||||
if (data.error) {
|
||||
toast.error("No se pudo procesar el archivo", { description: data.error });
|
||||
return;
|
||||
}
|
||||
setResults(data.results ?? []);
|
||||
const okCount = data.results?.filter((r) => r.ok).length ?? 0;
|
||||
const failCount = (data.results?.length ?? 0) - okCount;
|
||||
if (failCount === 0) {
|
||||
toast.success(`${okCount} productos importados`);
|
||||
} else {
|
||||
toast.warning(`${okCount} importados, ${failCount} con error`, {
|
||||
description: "Revisa el detalle abajo.",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("No se pudo procesar el archivo", { description: (error as Error).message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<div>
|
||||
<Heading level="h2">Importación de productos</Heading>
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
Carga varios productos a la vez desde un archivo CSV, con fotos, descripción y precio.
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="small" variant="secondary" onClick={downloadTemplate}>
|
||||
<ArrowDownTray />
|
||||
Descargar plantilla
|
||||
</Button>
|
||||
<Button size="small" variant="primary" isLoading={loading} onClick={() => inputRef.current?.click()}>
|
||||
<ArrowUpTray />
|
||||
Subir CSV
|
||||
</Button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4">
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
Columnas: <code>titulo, descripcion, precio, categoria, coleccion, cantidad, fotos</code>. Varias fotos se
|
||||
separan con <code>;</code> dentro de la misma celda. <code>categoria</code>/<code>coleccion</code> deben
|
||||
escribirse igual que en la tienda o se dejan vacías. Cada foto se descarga y se guarda en el almacenamiento
|
||||
propio de la tienda (no queda enlazada al sitio de origen).
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{results && (
|
||||
<div className="px-6 py-4">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-ui-fg-subtle">
|
||||
<th className="w-10 py-2" />
|
||||
<th className="py-2 font-medium">Fila</th>
|
||||
<th className="py-2 font-medium">Producto</th>
|
||||
<th className="py-2 font-medium">Detalle</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((result) => (
|
||||
<tr key={result.row} className="border-b last:border-0">
|
||||
<td className="py-2">
|
||||
{result.ok ? (
|
||||
<CheckCircleSolid className="text-ui-tag-green-icon" />
|
||||
) : (
|
||||
<XCircleSolid className="text-ui-tag-red-icon" />
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2">{result.row}</td>
|
||||
<td className="py-2">{result.title}</td>
|
||||
<td className="py-2">
|
||||
{result.ok ? (
|
||||
<Badge color="green">Importado</Badge>
|
||||
) : (
|
||||
<span className="text-rose-600">{result.error}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export const config = defineRouteConfig({
|
||||
label: "Importación",
|
||||
icon: ArrowUpTray,
|
||||
});
|
||||
|
||||
export default ProductImportPanel;
|
||||
@@ -49,7 +49,7 @@ export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||
const variant = product.variants?.[0];
|
||||
const inventoryItem = variant?.inventory_items?.[0];
|
||||
const level = inventoryItem?.inventory?.location_levels?.find(
|
||||
(level: { location_id: string }) => level.location_id === locationId,
|
||||
(candidate) => candidate?.location_id === locationId,
|
||||
);
|
||||
return {
|
||||
productId: product.id,
|
||||
@@ -131,7 +131,7 @@ export async function POST(req: MedusaRequest<PanelUpdateBody>, res: MedusaRespo
|
||||
}
|
||||
if (typeof quantity === "number" && inventoryItemId) {
|
||||
const level = inventoryItem?.inventory?.location_levels?.find(
|
||||
(level: { location_id: string }) => level.location_id === locationId,
|
||||
(candidate) => candidate?.location_id === locationId,
|
||||
);
|
||||
await updateInventoryLevelsWorkflow(req.scope).run({
|
||||
input: {
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
|
||||
import { Modules } from "@medusajs/framework/utils";
|
||||
import type {
|
||||
IFulfillmentModuleService,
|
||||
IProductModuleService,
|
||||
ISalesChannelModuleService,
|
||||
IStockLocationService,
|
||||
} from "@medusajs/framework/types";
|
||||
import {
|
||||
createInventoryItemsWorkflow,
|
||||
createProductsWorkflow,
|
||||
uploadFilesWorkflow,
|
||||
} from "@medusajs/medusa/core-flows";
|
||||
import { csvRowsToObjects, parseCsv } from "../../../lib/csv";
|
||||
|
||||
// Importador masivo de productos via CSV -- para alguien que compra
|
||||
// producto fisico en Amazon/eBay/etc. y quiere cargarlo rapido a la
|
||||
// tienda, no para dropshipping automatizado (ver conversacion: se evaluo
|
||||
// y se descarto raspar catalogos de terceros sin autorizacion). Cada
|
||||
// fila se procesa independiente (una foto rota o categoria mal escrita
|
||||
// no tumba el resto del archivo).
|
||||
//
|
||||
// Las fotos se descargan del origen y se re-alojan en el mismo
|
||||
// MinIO/S3 que ya usa el resto del catalogo (uploadFilesWorkflow ->
|
||||
// mismo File Module Provider de medusa-config.ts, ya corre en el NAS),
|
||||
// bajo el prefijo import-products/ para separarlas de ari-products/
|
||||
// (subidas manuales desde el Admin).
|
||||
|
||||
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
|
||||
const IMAGE_FETCH_TIMEOUT_MS = 15000;
|
||||
const OPTION_TITLE = "Presentación";
|
||||
const DEFAULT_VARIANT_VALUE = "Estandar";
|
||||
|
||||
type ImportRow = {
|
||||
titulo: string;
|
||||
descripcion: string;
|
||||
precio: string;
|
||||
categoria: string;
|
||||
coleccion: string;
|
||||
cantidad: string;
|
||||
fotos: string;
|
||||
};
|
||||
|
||||
type RowResult = {
|
||||
row: number;
|
||||
title: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function extensionFromContentType(contentType: string | null, url: string): string {
|
||||
if (contentType?.includes("png")) return "png";
|
||||
if (contentType?.includes("webp")) return "webp";
|
||||
if (contentType?.includes("gif")) return "gif";
|
||||
if (contentType?.includes("jpeg") || contentType?.includes("jpg")) return "jpg";
|
||||
const match = url.match(/\.(jpe?g|png|webp|gif)(?:\?|$)/i);
|
||||
return match ? match[1].toLowerCase().replace("jpeg", "jpg") : "jpg";
|
||||
}
|
||||
|
||||
async function downloadImageAsBase64(url: string): Promise<{ content: string; mimeType: string; extension: string }> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), IMAGE_FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`No se pudo descargar la imagen (${response.status})`);
|
||||
}
|
||||
const contentType = response.headers.get("content-type");
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.byteLength > MAX_IMAGE_BYTES) {
|
||||
throw new Error(`Imagen supera ${MAX_IMAGE_BYTES / 1024 / 1024}MB`);
|
||||
}
|
||||
const extension = extensionFromContentType(contentType, url);
|
||||
return {
|
||||
content: buffer.toString("base64"),
|
||||
mimeType: contentType?.split(";")[0] ?? `image/${extension === "jpg" ? "jpeg" : extension}`,
|
||||
extension,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||
// Plantilla descargable -- misma fuente de verdad que valida el POST,
|
||||
// asi que las columnas nunca quedan desincronizadas entre lo que se
|
||||
// ofrece descargar y lo que realmente se procesa.
|
||||
const header = "titulo,descripcion,precio,categoria,coleccion,cantidad,fotos";
|
||||
const example =
|
||||
'"Muñeco Buzz Lightyear","Figura articulada de 30cm, edición coleccionista.",89900,"Figuras de accion","Toy Story",3,"https://ejemplo.com/foto1.jpg;https://ejemplo.com/foto2.jpg"';
|
||||
const csv = `${header}\n${example}\n`;
|
||||
res.setHeader("Content-Type", "text/csv; charset=utf-8");
|
||||
res.setHeader("Content-Disposition", 'attachment; filename="plantilla-importacion.csv"');
|
||||
res.send(csv);
|
||||
}
|
||||
|
||||
export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaResponse) {
|
||||
const csvText = req.body?.csv;
|
||||
if (!csvText || typeof csvText !== "string") {
|
||||
res.status(400).json({ error: "Falta el contenido CSV" });
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = csvRowsToObjects(parseCsv(csvText)) as unknown as ImportRow[];
|
||||
if (rows.length === 0) {
|
||||
res.status(400).json({ error: "El archivo no tiene filas de datos" });
|
||||
return;
|
||||
}
|
||||
|
||||
const stockLocationService = req.scope.resolve<IStockLocationService>(Modules.STOCK_LOCATION);
|
||||
const fulfillmentService = req.scope.resolve<IFulfillmentModuleService>(Modules.FULFILLMENT);
|
||||
const salesChannelService = req.scope.resolve<ISalesChannelModuleService>(Modules.SALES_CHANNEL);
|
||||
const productService = req.scope.resolve<IProductModuleService>(Modules.PRODUCT);
|
||||
|
||||
const [locations, shippingProfiles, salesChannels, categories, collections] = await Promise.all([
|
||||
stockLocationService.listStockLocations({}),
|
||||
fulfillmentService.listShippingProfiles({}),
|
||||
salesChannelService.listSalesChannels({}),
|
||||
productService.listProductCategories({}),
|
||||
productService.listProductCollections({}),
|
||||
]);
|
||||
|
||||
const locationId = locations[0]?.id;
|
||||
const shippingProfileId = shippingProfiles.find((p) => p.type === "default")?.id ?? shippingProfiles[0]?.id;
|
||||
const salesChannelId = salesChannels[0]?.id;
|
||||
|
||||
if (!locationId || !shippingProfileId || !salesChannelId) {
|
||||
res.status(400).json({
|
||||
error: "Faltan datos base de la tienda (bodega, perfil de envío o canal de venta)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const categoryByName = new Map(categories.map((c) => [c.name.toLowerCase().trim(), c.id]));
|
||||
const collectionByName = new Map(collections.map((c) => [c.title.toLowerCase().trim(), c.id]));
|
||||
|
||||
const results: RowResult[] = [];
|
||||
|
||||
for (let index = 0; index < rows.length; index++) {
|
||||
const rowNumber = index + 2; // +1 header, +1 base 1
|
||||
const row = rows[index];
|
||||
const title = row.titulo?.trim();
|
||||
try {
|
||||
if (!title) throw new Error("Falta el título");
|
||||
const price = Number(row.precio);
|
||||
if (!Number.isFinite(price) || price <= 0) throw new Error("Precio inválido");
|
||||
|
||||
const imageUrls = (row.fotos ?? "")
|
||||
.split(";")
|
||||
.map((u) => u.trim())
|
||||
.filter(Boolean);
|
||||
if (imageUrls.length === 0) throw new Error("Falta al menos una foto");
|
||||
|
||||
const downloaded = await Promise.all(imageUrls.map((url) => downloadImageAsBase64(url)));
|
||||
const { result: uploadedFiles } = await uploadFilesWorkflow(req.scope).run({
|
||||
input: {
|
||||
files: downloaded.map((file, i) => ({
|
||||
filename: `import-products/${Date.now()}-${index}-${i}.${file.extension}`,
|
||||
mimeType: file.mimeType,
|
||||
content: file.content,
|
||||
access: "public" as const,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const quantity = Number(row.cantidad);
|
||||
const { result: inventoryItems } = await createInventoryItemsWorkflow(req.scope).run({
|
||||
input: {
|
||||
items: [
|
||||
{
|
||||
title,
|
||||
location_levels: [
|
||||
{ location_id: locationId, stocked_quantity: Number.isFinite(quantity) && quantity >= 0 ? quantity : 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const inventoryItemId = inventoryItems[0]?.id;
|
||||
if (!inventoryItemId) throw new Error("No se pudo crear el registro de inventario");
|
||||
|
||||
const categoryId = row.categoria ? categoryByName.get(row.categoria.toLowerCase().trim()) : undefined;
|
||||
const collectionId = row.coleccion ? collectionByName.get(row.coleccion.toLowerCase().trim()) : undefined;
|
||||
|
||||
await createProductsWorkflow(req.scope).run({
|
||||
input: {
|
||||
products: [
|
||||
{
|
||||
title,
|
||||
description: row.descripcion || undefined,
|
||||
status: "published",
|
||||
thumbnail: uploadedFiles[0]?.url,
|
||||
images: uploadedFiles.map((f) => ({ url: f.url })),
|
||||
category_ids: categoryId ? [categoryId] : undefined,
|
||||
collection_id: collectionId,
|
||||
shipping_profile_id: shippingProfileId,
|
||||
sales_channels: [{ id: salesChannelId }],
|
||||
options: [{ title: OPTION_TITLE, values: [DEFAULT_VARIANT_VALUE] }],
|
||||
variants: [
|
||||
{
|
||||
title: DEFAULT_VARIANT_VALUE,
|
||||
options: { [OPTION_TITLE]: DEFAULT_VARIANT_VALUE },
|
||||
manage_inventory: true,
|
||||
inventory_items: [{ inventory_item_id: inventoryItemId }],
|
||||
prices: [{ amount: price, currency_code: "cop" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
results.push({ row: rowNumber, title, ok: true });
|
||||
} catch (error) {
|
||||
results.push({ row: rowNumber, title: title || `Fila ${rowNumber}`, ok: false, error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ results });
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Parser CSV minimo (RFC4180: campos entre comillas, comillas escapadas
|
||||
// como "", comas y saltos de linea dentro de campos citados) -- se evita
|
||||
// sumar una dependencia nueva (papaparse/csv-parse) para un formato que
|
||||
// nosotros mismos controlamos via la plantilla descargable.
|
||||
export function parseCsv(text: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let field = "";
|
||||
let inQuotes = false;
|
||||
let i = 0;
|
||||
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
|
||||
while (i < normalized.length) {
|
||||
const char = normalized[i];
|
||||
if (inQuotes) {
|
||||
if (char === '"') {
|
||||
if (normalized[i + 1] === '"') {
|
||||
field += '"';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
inQuotes = false;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
field += char;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') {
|
||||
inQuotes = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === ",") {
|
||||
row.push(field);
|
||||
field = "";
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "\n") {
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
row = [];
|
||||
field = "";
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
field += char;
|
||||
i += 1;
|
||||
}
|
||||
if (field.length > 0 || row.length > 0) {
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
}
|
||||
return rows.filter((r) => r.some((cell) => cell.trim().length > 0));
|
||||
}
|
||||
|
||||
export function csvRowsToObjects(rows: string[][]): Record<string, string>[] {
|
||||
const [header, ...body] = rows;
|
||||
if (!header) return [];
|
||||
const keys = header.map((h) => h.trim().toLowerCase());
|
||||
return body.map((cols) => {
|
||||
const obj: Record<string, string> = {};
|
||||
keys.forEach((key, index) => {
|
||||
obj[key] = (cols[index] ?? "").trim();
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
}
|
||||
@@ -65,7 +65,7 @@ spec:
|
||||
memory: 64Mi
|
||||
|
||||
- name: migrations
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.215
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.218
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- node
|
||||
@@ -86,7 +86,7 @@ spec:
|
||||
|
||||
containers:
|
||||
- name: app
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.215
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.218
|
||||
imagePullPolicy: IfNotPresent
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
|
||||
@@ -7,5 +7,19 @@ import { useCartStore } from "@/store/cart-store";
|
||||
|
||||
export function MobileAddToCart({ product }: { product: Product }) {
|
||||
const addItem = useCartStore((state) => state.addItem);
|
||||
return <div className="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200 bg-white/95 p-3 shadow-2xl backdrop-blur lg:hidden"><Button variant="primary" size="lg" className="w-full" onClick={() => addItem(product, product.variants[0]?.id)}><ShoppingBag />Añadir al carrito</Button></div>;
|
||||
const outOfStock = product.variants[0]?.available === false;
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200 bg-white/95 p-3 shadow-2xl backdrop-blur lg:hidden">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
disabled={outOfStock}
|
||||
onClick={() => addItem(product, product.variants[0]?.id)}
|
||||
>
|
||||
<ShoppingBag />
|
||||
{outOfStock ? "Agotado" : "Añadir al carrito"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,9 @@ export function ProductCard({ product, priority = false }: { product: Product; p
|
||||
|
||||
const [selectedVariantId, setSelectedVariantId] = useState(product.variants[0]?.id);
|
||||
const selectedVariant = product.variants.find((variant) => variant.id === selectedVariantId) ?? product.variants[0];
|
||||
const outOfStock = selectedVariant?.available === false;
|
||||
const lowStock =
|
||||
!outOfStock &&
|
||||
selectedVariant?.inventoryQuantity !== null &&
|
||||
selectedVariant?.inventoryQuantity !== undefined &&
|
||||
selectedVariant.inventoryQuantity > 0 &&
|
||||
@@ -36,7 +38,8 @@ export function ProductCard({ product, priority = false }: { product: Product; p
|
||||
<article className="group flex h-full flex-col overflow-hidden rounded-lg border border-slate-100 bg-white shadow-lg shadow-ari-marino/5 transition duration-300 hover:-translate-y-1.5 hover:shadow-2xl hover:shadow-ari-coral/10">
|
||||
<div className="relative aspect-square overflow-hidden bg-white">
|
||||
<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">{discountPercent && discountPercent > 0 && <Badge className="bg-ari-coral-claro text-ari-marino">-{discountPercent}%</Badge>}{product.isNew && <Badge className="bg-ari-marino text-white">Nuevo</Badge>}{product.featured && <Badge className="bg-ari-coral-claro text-ari-marino"><Sparkles className="mr-1 size-3" />Top</Badge>}</div>
|
||||
<div className="absolute left-3 top-3 flex gap-2">{outOfStock && <Badge className="bg-rose-600 text-white">Agotado</Badge>}{discountPercent && discountPercent > 0 && <Badge className="bg-ari-coral-claro text-ari-marino">-{discountPercent}%</Badge>}{product.isNew && <Badge className="bg-ari-marino text-white">Nuevo</Badge>}{product.featured && <Badge className="bg-ari-coral-claro text-ari-marino"><Sparkles className="mr-1 size-3" />Top</Badge>}</div>
|
||||
{outOfStock && <div className="pointer-events-none absolute inset-0 bg-white/40" />}
|
||||
<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-ari-coral text-white" : "bg-white/95 text-slate-900 hover:text-ari-coral"}`} onClick={() => toggleProduct(product)}><Heart className={`size-4 ${isFavorite ? "fill-current" : ""}`} /></button>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col space-y-3 p-5">
|
||||
@@ -67,12 +70,15 @@ export function ProductCard({ product, priority = false }: { product: Product; p
|
||||
{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>
|
||||
)}
|
||||
{outOfStock && (
|
||||
<span className="flex items-center gap-1.5 text-xs font-bold text-rose-600"><TriangleAlert className="size-3.5" />Sin stock</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="font-display text-base font-bold text-ari-marino">{formatMoney(product.price, product.currency)}</strong>
|
||||
</div>
|
||||
<Button variant="primary" size="sm" onClick={() => addItem(product, selectedVariantId)}><ShoppingBag className="size-4" />Agregar</Button>
|
||||
<Button variant="primary" size="sm" disabled={outOfStock} onClick={() => addItem(product, selectedVariantId)}><ShoppingBag className="size-4" />{outOfStock ? "Agotado" : "Agregar"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle2, ShoppingBag, TriangleAlert, Truck } from "lucide-react";
|
||||
import { CheckCircle2, ShoppingBag, TriangleAlert, Truck, XCircle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Product } from "@/lib/headless/types";
|
||||
@@ -11,12 +11,68 @@ export function ProductPurchase({ product }: { product: Product }) {
|
||||
const [variantId, setVariantId] = useState(product.variants[0]?.id);
|
||||
const addItem = useCartStore((state) => state.addItem);
|
||||
const selectedVariant = product.variants.find((variant) => variant.id === variantId) ?? product.variants[0];
|
||||
const outOfStock = selectedVariant?.available === false;
|
||||
const lowStock =
|
||||
!outOfStock &&
|
||||
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-ari-marino bg-ari-marino text-white" : "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>{/* oculto en mobile: MobileAddToCart (barra fija abajo, lg:hidden) ya cubre esa misma accion ahi -- sin el hidden aqui, ambos se mostraban a la vez. */}<Button variant="primary" size="lg" className="hidden w-full lg:inline-flex" 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>;
|
||||
|
||||
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-ari-marino bg-ari-marino text-white" : "border-slate-200"
|
||||
} ${variant.available === false ? "opacity-40" : ""}`}
|
||||
>
|
||||
{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>
|
||||
{/* oculto en mobile: MobileAddToCart (barra fija abajo, lg:hidden) ya cubre esa misma accion ahi -- sin el hidden aqui, ambos se mostraban a la vez. */}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="hidden w-full lg:inline-flex"
|
||||
disabled={outOfStock}
|
||||
onClick={() => addItem(product, variantId)}
|
||||
>
|
||||
<ShoppingBag />
|
||||
{outOfStock ? "Agotado" : "Añadir al carrito"}
|
||||
</Button>
|
||||
<div className="mt-5 grid gap-3 rounded-2xl bg-slate-50 p-4 text-sm">
|
||||
{outOfStock ? (
|
||||
<span className="flex items-center gap-2 font-bold text-rose-600">
|
||||
<XCircle className="size-4" />
|
||||
Agotado
|
||||
</span>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user