Compare commits
42
Commits
d9c2037c59
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55fbf3524b | ||
|
|
17dc9a5133 | ||
|
|
ba96a50a42 | ||
|
|
91335ce023 | ||
|
|
43c47d99fb | ||
|
|
4baabf36ce | ||
|
|
3cc0a7c861 | ||
|
|
a3e9580348 | ||
|
|
aaf6e14742 | ||
|
|
c23803656e | ||
|
|
e86a0ebc96 | ||
|
|
b02a5e8b5f | ||
|
|
ca10ef6e9a | ||
|
|
f6918b9eab | ||
|
|
8e4f3515fe | ||
|
|
e7d7fcdb00 | ||
|
|
8f9df266d3 | ||
|
|
ac7f55eab3 | ||
|
|
f3f6fb7166 | ||
|
|
619228168d | ||
|
|
7f0c1571d9 | ||
|
|
cfd9a7ec96 | ||
|
|
472c12ec0a | ||
|
|
271ea30f44 | ||
|
|
d1fa58bf24 | ||
|
|
8145e90441 | ||
|
|
e6f031a366 | ||
|
|
503f56977d | ||
|
|
f8304633c1 | ||
|
|
a25cad6da3 | ||
|
|
cbdb44c7dc | ||
|
|
09c756942b | ||
|
|
a9ef0ed00a | ||
|
|
da7f316eb1 | ||
|
|
123cbbd87a | ||
|
|
496ad982f3 | ||
|
|
fa183a7115 | ||
|
|
4146245ae9 | ||
|
|
a38edfecc8 | ||
|
|
2566e06abd | ||
|
|
c7ac1b52be | ||
|
|
2e7a3f3d24 |
No files matched your search
@@ -234,11 +234,17 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
# --timeout 30m (no 15m): medido en vivo 2026-09-27, el analisis de
|
||||||
|
# capas de esta imagen (node_modules de Medusa) toma ~14 min en
|
||||||
|
# el runner del NAS -- con 15m hizo timeout una vez. El paso HIGH
|
||||||
|
# de abajo reusa ese analisis cacheado (~2 s), por eso no lo
|
||||||
|
# necesita. No cambia que revisa ni que bloquea.
|
||||||
/tmp/trivy image \
|
/tmp/trivy image \
|
||||||
|
--scanners vuln \
|
||||||
--severity CRITICAL \
|
--severity CRITICAL \
|
||||||
--exit-code 1 \
|
--exit-code 1 \
|
||||||
--ignore-unfixed \
|
--ignore-unfixed \
|
||||||
--timeout 15m0s \
|
--timeout 30m0s \
|
||||||
--ignorefile "${APP_DIR}/.trivyignore" \
|
--ignorefile "${APP_DIR}/.trivyignore" \
|
||||||
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
||||||
|
|
||||||
@@ -248,6 +254,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
/tmp/trivy image \
|
/tmp/trivy image \
|
||||||
|
--scanners vuln \
|
||||||
--severity HIGH \
|
--severity HIGH \
|
||||||
--exit-code 0 \
|
--exit-code 0 \
|
||||||
--ignore-unfixed \
|
--ignore-unfixed \
|
||||||
|
|||||||
@@ -273,11 +273,20 @@ jobs:
|
|||||||
|
|
||||||
echo "Validando remontaje por parámetros: ${PAGE_FILE}"
|
echo "Validando remontaje por parámetros: ${PAGE_FILE}"
|
||||||
|
|
||||||
grep -q 'key={`${category}:${collection}:${query}`}' "${PAGE_FILE}" || {
|
# Prefijo, no igualdad exacta: desde franquicias/personajes
|
||||||
|
# (2026-09-27) la key tambien incluye ?franquicia/?saga/?personaje
|
||||||
|
# -- lo que importa es que siga remontando por categoria,
|
||||||
|
# coleccion y busqueda, y ahora tambien por la taxonomia.
|
||||||
|
grep -q 'key={`${category}:${collection}:${query}' "${PAGE_FILE}" || {
|
||||||
echo "ERROR: app/catalog/page.tsx no remonta CatalogClient al cambiar la URL."
|
echo "ERROR: app/catalog/page.tsx no remonta CatalogClient al cambiar la URL."
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
grep -q 'taxonomy.franquicia}:${taxonomy.saga}:${taxonomy.personaje}' "${PAGE_FILE}" || {
|
||||||
|
echo "ERROR: app/catalog/page.tsx no remonta CatalogClient al cambiar franquicia/saga/personaje."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
echo "OK: navegación real del catálogo validada."
|
echo "OK: navegación real del catálogo validada."
|
||||||
|
|
||||||
- name: Instalar Trivy
|
- name: Instalar Trivy
|
||||||
@@ -420,6 +429,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
/tmp/trivy image \
|
/tmp/trivy image \
|
||||||
|
--scanners vuln \
|
||||||
--severity CRITICAL \
|
--severity CRITICAL \
|
||||||
--exit-code 1 \
|
--exit-code 1 \
|
||||||
--ignore-unfixed \
|
--ignore-unfixed \
|
||||||
@@ -433,6 +443,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
/tmp/trivy image \
|
/tmp/trivy image \
|
||||||
|
--scanners vuln \
|
||||||
--severity HIGH \
|
--severity HIGH \
|
||||||
--exit-code 0 \
|
--exit-code 0 \
|
||||||
--ignore-unfixed \
|
--ignore-unfixed \
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
/tmp/trivy image \
|
/tmp/trivy image \
|
||||||
|
--scanners vuln \
|
||||||
--severity CRITICAL \
|
--severity CRITICAL \
|
||||||
--exit-code 1 \
|
--exit-code 1 \
|
||||||
--ignore-unfixed \
|
--ignore-unfixed \
|
||||||
@@ -243,6 +244,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
/tmp/trivy image \
|
/tmp/trivy image \
|
||||||
|
--scanners vuln \
|
||||||
--severity HIGH \
|
--severity HIGH \
|
||||||
--exit-code 0 \
|
--exit-code 0 \
|
||||||
--ignore-unfixed \
|
--ignore-unfixed \
|
||||||
|
|||||||
@@ -133,6 +133,11 @@ module.exports = defineConfig({
|
|||||||
},
|
},
|
||||||
|
|
||||||
modules: [
|
modules: [
|
||||||
|
// Reseñas de productos (compradores verificados + moderacion del
|
||||||
|
// dueño): src/modules/product-review, rutas en src/api/*/reviews.
|
||||||
|
{
|
||||||
|
resolve: "./src/modules/product-review",
|
||||||
|
},
|
||||||
// ADR 0009 §2 / EnvíoClick: defineConfig() ya registra por defecto
|
// ADR 0009 §2 / EnvíoClick: defineConfig() ya registra por defecto
|
||||||
// un modulo de fulfillment con el provider manual_manual
|
// un modulo de fulfillment con el provider manual_manual
|
||||||
// (confirmado leyendo @medusajs/utils/dist/common/define-config.js
|
// (confirmado leyendo @medusajs/utils/dist/common/define-config.js
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { defineRouteConfig } from "@medusajs/admin-sdk";
|
||||||
|
import { Star } from "@medusajs/icons";
|
||||||
|
import { Badge, Button, Container, Heading, Text, toast } from "@medusajs/ui";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { sdk } from "../../lib/sdk";
|
||||||
|
|
||||||
|
// Moderacion de reseñas: todas nacen "pendiente" (solo compradores
|
||||||
|
// verificados pueden dejarlas, ver src/api/store/reviews/route.ts) y
|
||||||
|
// recien se publican en la tienda cuando se aprueban aca.
|
||||||
|
|
||||||
|
type Status = "pending" | "approved" | "rejected";
|
||||||
|
|
||||||
|
type Review = {
|
||||||
|
id: string;
|
||||||
|
product_title: string;
|
||||||
|
author_name: string;
|
||||||
|
rating: number;
|
||||||
|
title: string | null;
|
||||||
|
content: string;
|
||||||
|
status: Status;
|
||||||
|
order_id: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TABS: { status: Status; label: string }[] = [
|
||||||
|
{ status: "pending", label: "Pendientes" },
|
||||||
|
{ status: "approved", label: "Publicadas" },
|
||||||
|
{ status: "rejected", label: "Rechazadas" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const BADGE: Record<Status, "orange" | "green" | "red"> = { pending: "orange", approved: "green", rejected: "red" };
|
||||||
|
const STATUS_LABEL: Record<Status, string> = { pending: "Pendiente", approved: "Publicada", rejected: "Rechazada" };
|
||||||
|
|
||||||
|
function stars(rating: number): string {
|
||||||
|
return "★".repeat(rating) + "☆".repeat(5 - rating);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReviewsPage = () => {
|
||||||
|
const [tab, setTab] = useState<Status>("pending");
|
||||||
|
const [reviews, setReviews] = useState<Review[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async (status: Status) => {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await sdk.client.fetch<{ reviews: Review[] }>(`/admin/reviews?status=${status}`);
|
||||||
|
setReviews(data.reviews);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load(tab);
|
||||||
|
}, [load, tab]);
|
||||||
|
|
||||||
|
async function setStatus(review: Review, status: Status) {
|
||||||
|
setBusyId(review.id);
|
||||||
|
try {
|
||||||
|
await sdk.client.fetch(`/admin/reviews/${review.id}`, { method: "post", body: { status } });
|
||||||
|
toast.success(status === "approved" ? "Reseña publicada en la tienda" : status === "rejected" ? "Reseña rechazada" : "Reseña devuelta a pendientes");
|
||||||
|
await load(tab);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error((error as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(review: Review) {
|
||||||
|
if (!window.confirm(`¿Borrar la reseña de ${review.author_name}? No se puede deshacer.`)) return;
|
||||||
|
setBusyId(review.id);
|
||||||
|
try {
|
||||||
|
await sdk.client.fetch(`/admin/reviews/${review.id}`, { method: "delete" });
|
||||||
|
toast.success("Reseña borrada");
|
||||||
|
await load(tab);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error((error as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container className="divide-y p-0">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<Heading level="h2">Reseñas</Heading>
|
||||||
|
<Text size="small" className="text-ui-fg-subtle">
|
||||||
|
Solo compradores verificados pueden reseñar. Nada se publica sin tu aprobación.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{TABS.map((item) => (
|
||||||
|
<Button key={item.status} size="small" variant={tab === item.status ? "primary" : "secondary"} onClick={() => setTab(item.status)}>
|
||||||
|
{item.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="px-6 py-8"><Text>Cargando…</Text></div>
|
||||||
|
) : reviews.length === 0 ? (
|
||||||
|
<div className="px-6 py-8"><Text className="text-ui-fg-subtle">No hay reseñas en esta pestaña.</Text></div>
|
||||||
|
) : (
|
||||||
|
reviews.map((review) => (
|
||||||
|
<div key={review.id} className="flex flex-col gap-3 px-6 py-4 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Text weight="plus">{review.product_title}</Text>
|
||||||
|
<Badge color={BADGE[review.status]} size="2xsmall">{STATUS_LABEL[review.status]}</Badge>
|
||||||
|
</div>
|
||||||
|
<Text size="small" className="mt-1 text-ui-tag-orange-icon" aria-label={`${review.rating} de 5 estrellas`}>{stars(review.rating)}</Text>
|
||||||
|
{review.title && <Text weight="plus" className="mt-1">{review.title}</Text>}
|
||||||
|
<Text className="mt-1 whitespace-pre-line">{review.content}</Text>
|
||||||
|
<Text size="small" className="mt-2 text-ui-fg-subtle">
|
||||||
|
{review.author_name} · {new Date(review.created_at).toLocaleDateString("es-CO")} · pedido {review.order_id}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
{review.status !== "approved" && (
|
||||||
|
<Button size="small" isLoading={busyId === review.id} onClick={() => setStatus(review, "approved")}>Aprobar</Button>
|
||||||
|
)}
|
||||||
|
{review.status !== "rejected" && (
|
||||||
|
<Button size="small" variant="secondary" disabled={busyId === review.id} onClick={() => setStatus(review, "rejected")}>Rechazar</Button>
|
||||||
|
)}
|
||||||
|
<Button size="small" variant="danger" disabled={busyId === review.id} onClick={() => remove(review)}>Borrar</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const config = defineRouteConfig({
|
||||||
|
label: "Reseñas",
|
||||||
|
icon: Star,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ReviewsPage;
|
||||||
@@ -115,6 +115,15 @@ function normalizeTitleForMatch(title: string): string {
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Categorias/colecciones: ademas de lo anterior, ignora tildes y eñes
|
||||||
|
// ("Figuras de accion" == "Figuras de acción"). Desde 2026-09-27 los
|
||||||
|
// nombres reales en Medusa llevan tildes; sin esto, un CSV escrito sin
|
||||||
|
// tildes (o viejo) crearia una categoria DUPLICADA en vez de reusar la
|
||||||
|
// existente. Los titulos de producto siguen con normalizeTitleForMatch.
|
||||||
|
function normalizeNameForMatch(name: string): string {
|
||||||
|
return normalizeTitleForMatch(name).normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
function slugify(title: string, uniquePart: string): string {
|
function slugify(title: string, uniquePart: string): string {
|
||||||
const base = title
|
const base = title
|
||||||
.normalize("NFD")
|
.normalize("NFD")
|
||||||
@@ -166,7 +175,7 @@ export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
|||||||
// ofrece descargar y lo que realmente se procesa.
|
// ofrece descargar y lo que realmente se procesa.
|
||||||
const header = "titulo,descripcion,precio,categoria,coleccion,cantidad,fotos";
|
const header = "titulo,descripcion,precio,categoria,coleccion,cantidad,fotos";
|
||||||
const example =
|
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"';
|
'"Muñeco Buzz Lightyear","Figura articulada de 30cm, edición coleccionista.",89900,"Figuras de acción","Toy Story",3,"https://ejemplo.com/foto1.jpg;https://ejemplo.com/foto2.jpg"';
|
||||||
const csv = `${header}\n${example}\n`;
|
const csv = `${header}\n${example}\n`;
|
||||||
res.setHeader("Content-Type", "text/csv; charset=utf-8");
|
res.setHeader("Content-Type", "text/csv; charset=utf-8");
|
||||||
res.setHeader("Content-Disposition", 'attachment; filename="plantilla-importacion.csv"');
|
res.setHeader("Content-Disposition", 'attachment; filename="plantilla-importacion.csv"');
|
||||||
@@ -217,10 +226,10 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
|
|||||||
}
|
}
|
||||||
|
|
||||||
const categoryByName = new Map(
|
const categoryByName = new Map(
|
||||||
categories.filter((c) => c.name).map((c) => [normalizeTitleForMatch(c.name), c.id]),
|
categories.filter((c) => c.name).map((c) => [normalizeNameForMatch(c.name), c.id]),
|
||||||
);
|
);
|
||||||
const collectionByName = new Map(
|
const collectionByName = new Map(
|
||||||
collections.filter((c) => c.title).map((c) => [normalizeTitleForMatch(c.title), c.id]),
|
collections.filter((c) => c.title).map((c) => [normalizeNameForMatch(c.title), c.id]),
|
||||||
);
|
);
|
||||||
const productByTitle = new Map(
|
const productByTitle = new Map(
|
||||||
existingProducts.filter((p) => p.title).map((p) => [normalizeTitleForMatch(p.title), p]),
|
existingProducts.filter((p) => p.title).map((p) => [normalizeTitleForMatch(p.title), p]),
|
||||||
@@ -289,7 +298,7 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
|
|||||||
if (!inventoryItemId) throw new Error("No se pudo crear el registro de inventario");
|
if (!inventoryItemId) throw new Error("No se pudo crear el registro de inventario");
|
||||||
|
|
||||||
const categoryName = row.categoria?.trim();
|
const categoryName = row.categoria?.trim();
|
||||||
let categoryId = categoryName ? categoryByName.get(normalizeTitleForMatch(categoryName)) : undefined;
|
let categoryId = categoryName ? categoryByName.get(normalizeNameForMatch(categoryName)) : undefined;
|
||||||
if (categoryName && !categoryId) {
|
if (categoryName && !categoryId) {
|
||||||
const { result: createdCategories } = await createProductCategoriesWorkflow(req.scope).run({
|
const { result: createdCategories } = await createProductCategoriesWorkflow(req.scope).run({
|
||||||
input: {
|
input: {
|
||||||
@@ -299,11 +308,11 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
categoryId = createdCategories[0].id;
|
categoryId = createdCategories[0].id;
|
||||||
categoryByName.set(normalizeTitleForMatch(categoryName), categoryId);
|
categoryByName.set(normalizeNameForMatch(categoryName), categoryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const collectionName = row.coleccion?.trim();
|
const collectionName = row.coleccion?.trim();
|
||||||
let collectionId = collectionName ? collectionByName.get(normalizeTitleForMatch(collectionName)) : undefined;
|
let collectionId = collectionName ? collectionByName.get(normalizeNameForMatch(collectionName)) : undefined;
|
||||||
if (collectionName && !collectionId) {
|
if (collectionName && !collectionId) {
|
||||||
const { result: createdCollections } = await createCollectionsWorkflow(req.scope).run({
|
const { result: createdCollections } = await createCollectionsWorkflow(req.scope).run({
|
||||||
input: {
|
input: {
|
||||||
@@ -313,7 +322,7 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
collectionId = createdCollections[0].id;
|
collectionId = createdCollections[0].id;
|
||||||
collectionByName.set(normalizeTitleForMatch(collectionName), collectionId);
|
collectionByName.set(normalizeNameForMatch(collectionName), collectionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await createProductsWorkflow(req.scope).run({
|
await createProductsWorkflow(req.scope).run({
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
|
||||||
|
import { MedusaError } from "@medusajs/framework/utils";
|
||||||
|
import { PRODUCT_REVIEW_MODULE } from "../../../../modules/product-review";
|
||||||
|
import type ProductReviewModuleService from "../../../../modules/product-review/service";
|
||||||
|
|
||||||
|
const STATUSES = ["pending", "approved", "rejected"] as const;
|
||||||
|
|
||||||
|
// POST /admin/reviews/:id { status } -- aprobar / rechazar / volver a
|
||||||
|
// pendiente. DELETE /admin/reviews/:id -- borrar (ej. reseñas de prueba).
|
||||||
|
export async function POST(req: MedusaRequest<{ status?: string }>, res: MedusaResponse) {
|
||||||
|
const status = req.body?.status;
|
||||||
|
if (!status || !STATUSES.includes(status as (typeof STATUSES)[number])) {
|
||||||
|
throw new MedusaError(MedusaError.Types.INVALID_DATA, `status debe ser uno de: ${STATUSES.join(", ")}`);
|
||||||
|
}
|
||||||
|
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
|
||||||
|
const review = await reviews.updateProductReviews({ id: req.params.id, status: status as (typeof STATUSES)[number] });
|
||||||
|
res.json({ review });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: MedusaRequest, res: MedusaResponse) {
|
||||||
|
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
|
||||||
|
await reviews.deleteProductReviews(req.params.id);
|
||||||
|
res.json({ id: req.params.id, deleted: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
|
||||||
|
import { ContainerRegistrationKeys } from "@medusajs/framework/utils";
|
||||||
|
import { PRODUCT_REVIEW_MODULE } from "../../../modules/product-review";
|
||||||
|
import type ProductReviewModuleService from "../../../modules/product-review/service";
|
||||||
|
|
||||||
|
// GET /admin/reviews?status=pending -- cola de moderacion para la
|
||||||
|
// pagina "Reseñas" del admin, con el titulo del producto resuelto.
|
||||||
|
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||||
|
const status = typeof req.query.status === "string" ? req.query.status : undefined;
|
||||||
|
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
|
||||||
|
const list = await reviews.listProductReviews(status ? { status: status as "pending" | "approved" | "rejected" } : {}, {
|
||||||
|
order: { created_at: "DESC" },
|
||||||
|
take: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
const productIds = [...new Set(list.map((review) => review.product_id))];
|
||||||
|
const titles: Record<string, string> = {};
|
||||||
|
if (productIds.length) {
|
||||||
|
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
|
||||||
|
const { data: products } = await query.graph({ entity: "product", fields: ["id", "title"], filters: { id: productIds } });
|
||||||
|
for (const product of products) titles[product.id] = product.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ reviews: list.map((review) => ({ ...review, product_title: titles[review.product_id] ?? review.product_id })) });
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { defineMiddlewares } from "@medusajs/medusa"
|
import { authenticate, defineMiddlewares } from "@medusajs/framework/http"
|
||||||
|
|
||||||
// GET /store/products descarta en silencio la relacion `categories`
|
// GET /store/products descarta en silencio la relacion `categories`
|
||||||
// aunque `/admin/products` si la expone -- confirmado en vivo en la
|
// aunque `/admin/products` si la expone -- confirmado en vivo en la
|
||||||
@@ -9,6 +9,20 @@ import { defineMiddlewares } from "@medusajs/medusa"
|
|||||||
// https://docs.medusajs.com/learn/fundamentals/api-routes/retrieve-custom-links
|
// https://docs.medusajs.com/learn/fundamentals/api-routes/retrieve-custom-links
|
||||||
export default defineMiddlewares({
|
export default defineMiddlewares({
|
||||||
routes: [
|
routes: [
|
||||||
|
// Reseñas: crear una y ver las propias exige cliente autenticado --
|
||||||
|
// la verificacion de compra usa auth_context.actor_id (ver
|
||||||
|
// src/api/store/reviews/route.ts). GET /store/reviews y /summary
|
||||||
|
// siguen publicos.
|
||||||
|
{
|
||||||
|
matcher: "/store/reviews",
|
||||||
|
method: ["POST"],
|
||||||
|
middlewares: [authenticate("customer", ["session", "bearer"])],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
matcher: "/store/reviews/me",
|
||||||
|
method: ["GET"],
|
||||||
|
middlewares: [authenticate("customer", ["session", "bearer"])],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
matcher: "/store/products",
|
matcher: "/store/products",
|
||||||
middlewares: [
|
middlewares: [
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework";
|
||||||
|
import { PRODUCT_REVIEW_MODULE } from "../../../../modules/product-review";
|
||||||
|
import type ProductReviewModuleService from "../../../../modules/product-review/service";
|
||||||
|
|
||||||
|
// GET /store/reviews/me -- reseñas del cliente autenticado (cualquier
|
||||||
|
// estado), para que "Mis pedidos" sepa que productos ya califico y en
|
||||||
|
// que estado de moderacion quedo cada una.
|
||||||
|
export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
|
||||||
|
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
|
||||||
|
const mine = await reviews.listProductReviews({ customer_id: req.auth_context.actor_id }, { order: { created_at: "DESC" } });
|
||||||
|
res.json({
|
||||||
|
reviews: mine.map((review) => ({
|
||||||
|
id: review.id,
|
||||||
|
product_id: review.product_id,
|
||||||
|
order_id: review.order_id,
|
||||||
|
rating: review.rating,
|
||||||
|
title: review.title,
|
||||||
|
content: review.content,
|
||||||
|
status: review.status,
|
||||||
|
created_at: review.created_at,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { AuthenticatedMedusaRequest, MedusaRequest, MedusaResponse } from "@medusajs/framework";
|
||||||
|
import { ContainerRegistrationKeys, MedusaError } from "@medusajs/framework/utils";
|
||||||
|
import { PRODUCT_REVIEW_MODULE } from "../../../modules/product-review";
|
||||||
|
import type ProductReviewModuleService from "../../../modules/product-review/service";
|
||||||
|
|
||||||
|
// GET /store/reviews?product_id=... -- reseñas APROBADAS de un producto,
|
||||||
|
// mas promedio y cantidad. Publica (solo publishable key).
|
||||||
|
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||||
|
const productId = typeof req.query.product_id === "string" ? req.query.product_id : "";
|
||||||
|
if (!productId) {
|
||||||
|
throw new MedusaError(MedusaError.Types.INVALID_DATA, "product_id es obligatorio");
|
||||||
|
}
|
||||||
|
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
|
||||||
|
const list = await reviews.listProductReviews(
|
||||||
|
{ product_id: productId, status: "approved" },
|
||||||
|
{ order: { created_at: "DESC" }, take: 50 },
|
||||||
|
);
|
||||||
|
const count = list.length;
|
||||||
|
const average = count ? list.reduce((sum, review) => sum + review.rating, 0) / count : 0;
|
||||||
|
res.json({
|
||||||
|
reviews: list.map((review) => ({
|
||||||
|
id: review.id,
|
||||||
|
author_name: review.author_name,
|
||||||
|
rating: review.rating,
|
||||||
|
title: review.title,
|
||||||
|
content: review.content,
|
||||||
|
created_at: review.created_at,
|
||||||
|
verified_purchase: true,
|
||||||
|
})),
|
||||||
|
average: Math.round(average * 10) / 10,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateReviewBody = {
|
||||||
|
product_id?: string;
|
||||||
|
order_id?: string;
|
||||||
|
rating?: number;
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// POST /store/reviews -- solo clientes autenticados (middleware) y solo
|
||||||
|
// si el pedido indicado es SUYO, no esta cancelado y contiene el
|
||||||
|
// producto: eso es lo que respalda el sello "Compra verificada". Nace
|
||||||
|
// "pending" hasta que el dueño la apruebe en el admin.
|
||||||
|
export async function POST(req: AuthenticatedMedusaRequest<CreateReviewBody>, res: MedusaResponse) {
|
||||||
|
const customerId = req.auth_context.actor_id;
|
||||||
|
const { product_id, order_id, title } = req.body ?? {};
|
||||||
|
const rating = Number(req.body?.rating);
|
||||||
|
const content = (req.body?.content ?? "").trim();
|
||||||
|
|
||||||
|
if (!product_id || !order_id) {
|
||||||
|
throw new MedusaError(MedusaError.Types.INVALID_DATA, "product_id y order_id son obligatorios");
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
|
||||||
|
throw new MedusaError(MedusaError.Types.INVALID_DATA, "La calificación debe ser un número entero de 1 a 5");
|
||||||
|
}
|
||||||
|
if (content.length < 10 || content.length > 2000) {
|
||||||
|
throw new MedusaError(MedusaError.Types.INVALID_DATA, "El comentario debe tener entre 10 y 2000 caracteres");
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
|
||||||
|
const { data: orders } = await query.graph({
|
||||||
|
entity: "order",
|
||||||
|
fields: ["id", "customer_id", "status", "items.product_id"],
|
||||||
|
filters: { id: order_id },
|
||||||
|
});
|
||||||
|
const order = orders[0];
|
||||||
|
const purchased =
|
||||||
|
order &&
|
||||||
|
order.customer_id === customerId &&
|
||||||
|
order.status !== "canceled" &&
|
||||||
|
(order.items ?? []).some((item) => item?.product_id === product_id);
|
||||||
|
if (!purchased) {
|
||||||
|
throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Solo puedes reseñar productos que compraste");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
|
||||||
|
const existing = await reviews.listProductReviews({ product_id, customer_id: customerId });
|
||||||
|
if (existing.length > 0) {
|
||||||
|
throw new MedusaError(MedusaError.Types.DUPLICATE_ERROR, "Ya dejaste una reseña para este producto");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: customers } = await query.graph({
|
||||||
|
entity: "customer",
|
||||||
|
fields: ["first_name", "last_name"],
|
||||||
|
filters: { id: customerId },
|
||||||
|
});
|
||||||
|
const customer = customers[0];
|
||||||
|
const lastInitial = customer?.last_name ? ` ${customer.last_name.trim().charAt(0)}.` : "";
|
||||||
|
const authorName = `${customer?.first_name?.trim() || "Cliente"}${lastInitial}`;
|
||||||
|
|
||||||
|
const review = await reviews.createProductReviews({
|
||||||
|
product_id,
|
||||||
|
order_id,
|
||||||
|
customer_id: customerId,
|
||||||
|
author_name: authorName,
|
||||||
|
rating,
|
||||||
|
title: title?.trim() || null,
|
||||||
|
content,
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ review: { id: review.id, status: review.status } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
|
||||||
|
import { PRODUCT_REVIEW_MODULE } from "../../../../modules/product-review";
|
||||||
|
import type ProductReviewModuleService from "../../../../modules/product-review/service";
|
||||||
|
|
||||||
|
// GET /store/reviews/summary -- promedio y cantidad de reseñas aprobadas
|
||||||
|
// por producto, para las estrellas de las tarjetas del listado (una sola
|
||||||
|
// llamada para todo el catalogo en vez de una por tarjeta).
|
||||||
|
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||||
|
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
|
||||||
|
const approved = await reviews.listProductReviews({ status: "approved" }, { select: ["product_id", "rating"], take: 10000 });
|
||||||
|
const summary: Record<string, { average: number; count: number }> = {};
|
||||||
|
for (const review of approved) {
|
||||||
|
const entry = (summary[review.product_id] ??= { average: 0, count: 0 });
|
||||||
|
entry.average += review.rating;
|
||||||
|
entry.count += 1;
|
||||||
|
}
|
||||||
|
for (const entry of Object.values(summary)) {
|
||||||
|
entry.average = Math.round((entry.average / entry.count) * 10) / 10;
|
||||||
|
}
|
||||||
|
res.json({ summary });
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from "@medusajs/framework/utils"
|
||||||
|
import ProductReviewModuleService from "./service"
|
||||||
|
|
||||||
|
export const PRODUCT_REVIEW_MODULE = "productReview"
|
||||||
|
|
||||||
|
export default Module(PRODUCT_REVIEW_MODULE, {
|
||||||
|
service: ProductReviewModuleService,
|
||||||
|
})
|
||||||
+262
@@ -0,0 +1,262 @@
|
|||||||
|
{
|
||||||
|
"namespaces": [
|
||||||
|
"public"
|
||||||
|
],
|
||||||
|
"name": "public",
|
||||||
|
"tables": [
|
||||||
|
{
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": true,
|
||||||
|
"nullable": false,
|
||||||
|
"unique": false,
|
||||||
|
"length": null,
|
||||||
|
"precision": null,
|
||||||
|
"scale": null,
|
||||||
|
"default": null,
|
||||||
|
"comment": null,
|
||||||
|
"enumItems": [],
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"product_id": {
|
||||||
|
"name": "product_id",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"unique": false,
|
||||||
|
"length": null,
|
||||||
|
"precision": null,
|
||||||
|
"scale": null,
|
||||||
|
"default": null,
|
||||||
|
"comment": null,
|
||||||
|
"enumItems": [],
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"customer_id": {
|
||||||
|
"name": "customer_id",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"unique": false,
|
||||||
|
"length": null,
|
||||||
|
"precision": null,
|
||||||
|
"scale": null,
|
||||||
|
"default": null,
|
||||||
|
"comment": null,
|
||||||
|
"enumItems": [],
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"order_id": {
|
||||||
|
"name": "order_id",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"unique": false,
|
||||||
|
"length": null,
|
||||||
|
"precision": null,
|
||||||
|
"scale": null,
|
||||||
|
"default": null,
|
||||||
|
"comment": null,
|
||||||
|
"enumItems": [],
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"author_name": {
|
||||||
|
"name": "author_name",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"unique": false,
|
||||||
|
"length": null,
|
||||||
|
"precision": null,
|
||||||
|
"scale": null,
|
||||||
|
"default": null,
|
||||||
|
"comment": null,
|
||||||
|
"enumItems": [],
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"rating": {
|
||||||
|
"name": "rating",
|
||||||
|
"type": "integer",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"unique": false,
|
||||||
|
"length": null,
|
||||||
|
"precision": null,
|
||||||
|
"scale": null,
|
||||||
|
"default": null,
|
||||||
|
"comment": null,
|
||||||
|
"enumItems": [],
|
||||||
|
"mappedType": "integer"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": true,
|
||||||
|
"unique": false,
|
||||||
|
"length": null,
|
||||||
|
"precision": null,
|
||||||
|
"scale": null,
|
||||||
|
"default": null,
|
||||||
|
"comment": null,
|
||||||
|
"enumItems": [],
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"unique": false,
|
||||||
|
"length": null,
|
||||||
|
"precision": null,
|
||||||
|
"scale": null,
|
||||||
|
"default": null,
|
||||||
|
"comment": null,
|
||||||
|
"enumItems": [],
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"unique": false,
|
||||||
|
"length": null,
|
||||||
|
"precision": null,
|
||||||
|
"scale": null,
|
||||||
|
"default": "'pending'",
|
||||||
|
"comment": null,
|
||||||
|
"enumItems": [
|
||||||
|
"pending",
|
||||||
|
"approved",
|
||||||
|
"rejected"
|
||||||
|
],
|
||||||
|
"mappedType": "enum"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamptz",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"unique": false,
|
||||||
|
"length": 6,
|
||||||
|
"precision": null,
|
||||||
|
"scale": null,
|
||||||
|
"default": "now()",
|
||||||
|
"comment": null,
|
||||||
|
"enumItems": [],
|
||||||
|
"mappedType": "datetime"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamptz",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"unique": false,
|
||||||
|
"length": 6,
|
||||||
|
"precision": null,
|
||||||
|
"scale": null,
|
||||||
|
"default": "now()",
|
||||||
|
"comment": null,
|
||||||
|
"enumItems": [],
|
||||||
|
"mappedType": "datetime"
|
||||||
|
},
|
||||||
|
"deleted_at": {
|
||||||
|
"name": "deleted_at",
|
||||||
|
"type": "timestamptz",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": true,
|
||||||
|
"unique": false,
|
||||||
|
"length": 6,
|
||||||
|
"precision": null,
|
||||||
|
"scale": null,
|
||||||
|
"default": null,
|
||||||
|
"comment": null,
|
||||||
|
"enumItems": [],
|
||||||
|
"mappedType": "datetime"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "product_review",
|
||||||
|
"schema": "public",
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"keyName": "IDX_product_review_deleted_at",
|
||||||
|
"columnNames": [],
|
||||||
|
"composite": false,
|
||||||
|
"constraint": false,
|
||||||
|
"primary": false,
|
||||||
|
"unique": false,
|
||||||
|
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_review_deleted_at\" ON \"product_review\" (\"deleted_at\") WHERE deleted_at IS NULL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keyName": "IDX_product_review_product_id_status",
|
||||||
|
"columnNames": [],
|
||||||
|
"composite": false,
|
||||||
|
"constraint": false,
|
||||||
|
"primary": false,
|
||||||
|
"unique": false,
|
||||||
|
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_review_product_id_status\" ON \"product_review\" (\"product_id\", \"status\") WHERE deleted_at IS NULL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keyName": "IDX_product_review_customer_id",
|
||||||
|
"columnNames": [],
|
||||||
|
"composite": false,
|
||||||
|
"constraint": false,
|
||||||
|
"primary": false,
|
||||||
|
"unique": false,
|
||||||
|
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_review_customer_id\" ON \"product_review\" (\"customer_id\") WHERE deleted_at IS NULL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keyName": "IDX_product_review_product_id_customer_id_unique",
|
||||||
|
"columnNames": [],
|
||||||
|
"composite": false,
|
||||||
|
"constraint": false,
|
||||||
|
"primary": false,
|
||||||
|
"unique": false,
|
||||||
|
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_product_review_product_id_customer_id_unique\" ON \"product_review\" (\"product_id\", \"customer_id\") WHERE deleted_at IS NULL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keyName": "product_review_pkey",
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"composite": false,
|
||||||
|
"constraint": true,
|
||||||
|
"primary": true,
|
||||||
|
"unique": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"checks": [],
|
||||||
|
"foreignKeys": {},
|
||||||
|
"nativeEnums": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nativeEnums": {}
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
import { Migration } from "@medusajs/framework/mikro-orm/migrations";
|
||||||
|
|
||||||
|
export class Migration20260927062845 extends Migration {
|
||||||
|
|
||||||
|
override async up(): Promise<void> {
|
||||||
|
this.addSql(`alter table if exists "product_review" drop constraint if exists "product_review_product_id_customer_id_unique";`);
|
||||||
|
this.addSql(`create table if not exists "product_review" ("id" text not null, "product_id" text not null, "customer_id" text not null, "order_id" text not null, "author_name" text not null, "rating" integer not null, "title" text null, "content" text not null, "status" text check ("status" in ('pending', 'approved', 'rejected')) not null default 'pending', "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "product_review_pkey" primary key ("id"));`);
|
||||||
|
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_product_review_deleted_at" ON "product_review" ("deleted_at") WHERE deleted_at IS NULL;`);
|
||||||
|
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_product_review_product_id_status" ON "product_review" ("product_id", "status") WHERE deleted_at IS NULL;`);
|
||||||
|
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_product_review_customer_id" ON "product_review" ("customer_id") WHERE deleted_at IS NULL;`);
|
||||||
|
this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_product_review_product_id_customer_id_unique" ON "product_review" ("product_id", "customer_id") WHERE deleted_at IS NULL;`);
|
||||||
|
}
|
||||||
|
|
||||||
|
override async down(): Promise<void> {
|
||||||
|
this.addSql(`drop table if exists "product_review" cascade;`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { model } from "@medusajs/framework/utils"
|
||||||
|
|
||||||
|
// Reseña de producto (ADR pendiente, pedido del dueño 2026-09-27):
|
||||||
|
// solo compradores verificados -- customer_id + order_id se validan en
|
||||||
|
// POST /store/reviews contra un pedido real del cliente que contenga el
|
||||||
|
// producto -- y moderadas: nacen "pending" y solo se publican cuando el
|
||||||
|
// dueño las aprueba desde el admin (pagina "Reseñas").
|
||||||
|
export const ProductReview = model
|
||||||
|
.define("product_review", {
|
||||||
|
id: model.id({ prefix: "prev" }).primaryKey(),
|
||||||
|
product_id: model.text(),
|
||||||
|
customer_id: model.text(),
|
||||||
|
order_id: model.text(),
|
||||||
|
author_name: model.text(),
|
||||||
|
rating: model.number(),
|
||||||
|
title: model.text().nullable(),
|
||||||
|
content: model.text(),
|
||||||
|
status: model.enum(["pending", "approved", "rejected"]).default("pending"),
|
||||||
|
})
|
||||||
|
.indexes([
|
||||||
|
{ on: ["product_id", "status"] },
|
||||||
|
{ on: ["customer_id"] },
|
||||||
|
// Una reseña por cliente por producto.
|
||||||
|
{ on: ["product_id", "customer_id"], unique: true, where: "deleted_at IS NULL" },
|
||||||
|
])
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { MedusaService } from "@medusajs/framework/utils"
|
||||||
|
import { ProductReview } from "./models/product-review"
|
||||||
|
|
||||||
|
class ProductReviewModuleService extends MedusaService({ ProductReview }) {}
|
||||||
|
|
||||||
|
export default ProductReviewModuleService
|
||||||
+78
-17
@@ -176,7 +176,8 @@ en caliente y persistente:
|
|||||||
sysctl -w net.ipv6.conf.all.disable_ipv6=1
|
sysctl -w net.ipv6.conf.all.disable_ipv6=1
|
||||||
sysctl -w net.ipv6.conf.default.disable_ipv6=1
|
sysctl -w net.ipv6.conf.default.disable_ipv6=1
|
||||||
|
|
||||||
# Persistente: /etc/sysctl.conf del host
|
# Persistente: /etc/sysctl.d/99-disable-ipv6.conf del host
|
||||||
|
# (NO /etc/sysctl.conf -- ver "Validación tras reinicio real" abajo)
|
||||||
net.ipv6.conf.all.disable_ipv6 = 1
|
net.ipv6.conf.all.disable_ipv6 = 1
|
||||||
net.ipv6.conf.default.disable_ipv6 = 1
|
net.ipv6.conf.default.disable_ipv6 = 1
|
||||||
```
|
```
|
||||||
@@ -207,12 +208,66 @@ pero `/etc` es un overlay con capa de escritura en
|
|||||||
`/mnt/overlay/upper_etc` (ext4 en `/dev/sdc7`), así que el archivo
|
`/mnt/overlay/upper_etc` (ext4 en `/dev/sdc7`), así que el archivo
|
||||||
persiste.
|
persiste.
|
||||||
|
|
||||||
!!! note "Pendiente de validar"
|
### Validación tras reinicio real: `/etc/sysctl.conf` no alcanzaba (2026-09-26)
|
||||||
Todavía no se probó que el sysctl persistente se aplique después de un
|
|
||||||
**reinicio real del NAS**. Tampoco se sabe si una actualización de
|
Se reinició el NAS a propósito (~17:13Z) para probar la persistencia.
|
||||||
ZimaOS pisa `upper_etc`. En el próximo reinicio, revisar
|
Resultado **(verificado)**:
|
||||||
`sysctl net.ipv6.conf.all.disable_ipv6` antes de dar por cerrado este
|
|
||||||
punto.
|
- `/etc/sysctl.conf` **sí** sobrevivió al reinicio con las dos líneas
|
||||||
|
(el overlay de `/etc` persiste).
|
||||||
|
- Pero `sysctl net.ipv6.conf.all.disable_ipv6` volvió a dar **`0`**: el
|
||||||
|
archivo existía y **nunca se aplicó** al arrancar.
|
||||||
|
|
||||||
|
**Causa.** En ZimaOS, `systemd-sysctl` solo carga `/etc/sysctl.d/*.conf`
|
||||||
|
(y `/usr/lib/sysctl.d/`, `/lib/sysctl.d/`). Otras distros traen un
|
||||||
|
symlink `/etc/sysctl.d/99-sysctl.conf -> ../sysctl.conf` que hace que
|
||||||
|
`/etc/sysctl.conf` también se lea al arrancar. **ZimaOS no lo trae**, así
|
||||||
|
que escribir en `/etc/sysctl.conf` funciona solo en caliente (con
|
||||||
|
`sysctl -p`) y no sobrevive un reinicio. Nada avisa del problema: el
|
||||||
|
archivo está, el contenido es correcto y el valor igual vuelve a `0`.
|
||||||
|
|
||||||
|
**Por qué no se rompió enseguida.** Tras el reinicio el host no tenía
|
||||||
|
ninguna IPv6 global utilizable, así que `getaddrinfo` no devolvía la AAAA
|
||||||
|
de `api.anthropic.com` y todo salía por IPv4. Pero `curl -6` seguía
|
||||||
|
fallando (`Could not connect to server`). Si el router vuelve a entregar
|
||||||
|
una IPv6 global por SLAAC, vuelve el timeout. Funcionaba por casualidad,
|
||||||
|
no porque estuviera corregido.
|
||||||
|
|
||||||
|
**Fix.** Mover la configuración a un drop-in que ZimaOS sí lee:
|
||||||
|
`/etc/sysctl.d/99-disable-ipv6.conf` (mismas dos líneas).
|
||||||
|
|
||||||
|
Para aplicarlo en caliente sin reiniciar: dentro de `tmux-host`,
|
||||||
|
`/proc/sys` es de solo lectura (`sysctl -w` → `Read-only file system`).
|
||||||
|
Hace falta un contenedor privilegiado de un solo uso en la red del host,
|
||||||
|
o `sudo sysctl -p` directo en el host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm --privileged --network host \
|
||||||
|
-v /etc/sysctl.d:/etc/sysctl.d:ro \
|
||||||
|
alpine sysctl -p /etc/sysctl.d/99-disable-ipv6.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validado en vivo (verificado, 2026-09-26 ~19:15Z):**
|
||||||
|
`disable_ipv6 = 1` en `all` y `default`, ninguna dirección IPv6 global en
|
||||||
|
el host y `curl` a `api.anthropic.com` por IPv4 (`connect=0.035s`).
|
||||||
|
|
||||||
|
!!! warning "Lección"
|
||||||
|
"El archivo de configuración persistió" no es lo mismo que "la
|
||||||
|
configuración se aplica al arrancar". La única validación real de
|
||||||
|
algo persistente es **reiniciar y medir el valor efectivo**, no
|
||||||
|
revisar que el archivo siga ahí.
|
||||||
|
|
||||||
|
!!! success "Confirmado con un segundo reinicio real (2026-09-26 ~19:37Z)"
|
||||||
|
Tras reiniciar el NAS de nuevo, sin correr ningún `sysctl -p` a mano:
|
||||||
|
`disable_ipv6 = 1` en `all` y `default`, y `curl` a
|
||||||
|
`api.anthropic.com` por IPv4 (`connect=0.035s`). El drop-in de
|
||||||
|
`/etc/sysctl.d/` **sí** se aplica al arrancar. El único IPv6 que queda
|
||||||
|
es la ULA de `virbr0` (red interna de libvirt, `NO-CARRIER`,
|
||||||
|
`tentative`), que no sale a internet y no afecta.
|
||||||
|
|
||||||
|
Sigue sin verificarse si una **actualización de ZimaOS** pisa
|
||||||
|
`upper_etc`. Después de cada update del SO, revisar
|
||||||
|
`sysctl net.ipv6.conf.all.disable_ipv6`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -312,13 +367,19 @@ con un dato observable, no por deducción.
|
|||||||
|
|
||||||
## Pendientes
|
## Pendientes
|
||||||
|
|
||||||
- [ ] **Commitear el fix de `entrypoint.sh`.** La imagen `fix-real` que
|
Todos cerrados el 2026-09-26:
|
||||||
corre en producción lo tiene, pero en el repo `scripts` el cambio
|
|
||||||
sigue como modificación local sin commitear (`git status` →
|
- **Hecho: commitear el fix de `entrypoint.sh`.** Mergeado a `main` del
|
||||||
`M tmux-host/entrypoint.sh`). Si alguien reconstruye la imagen
|
repo `scripts` (`510cc41`). Antes se verificó que `entrypoint.sh` y
|
||||||
desde `main`, vuelve el `set -eu` sin supervisor.
|
`healthcheck.sh` del repo fueran idénticos (md5) a los de la imagen
|
||||||
- [ ] Validar que IPv6 siga deshabilitado después del próximo reinicio
|
`fix-real` en producción.
|
||||||
real del NAS (ver la nota de la causa c).
|
- **Hecho: validar IPv6 tras un reinicio real.** El primer reinicio
|
||||||
- [ ] Limpiar las etiquetas de imagen viejas de `tmux-host`
|
**falló**: `/etc/sysctl.conf` no se aplica al arrancar en ZimaOS. Se movió
|
||||||
(`supervised`, `supervised-v2`, `local`, `test-fix`, etc.) para no
|
a `/etc/sysctl.d/99-disable-ipv6.conf`, y un segundo reinicio confirmó
|
||||||
confundir cuál es la vigente.
|
que ahora sí se aplica solo (ver la causa c).
|
||||||
|
- **Hecho: limpiar las etiquetas de imagen viejas de `tmux-host`.** Quedan
|
||||||
|
solo `2026-09-26-fix-real` (vigente) y `2026-09-20-uv` (rollback).
|
||||||
|
|
||||||
|
Queda una sola vigilancia abierta, sin fecha: después de cada actualización
|
||||||
|
de ZimaOS, revisar que `/etc/sysctl.d/99-disable-ipv6.conf` siga existiendo
|
||||||
|
y que `disable_ipv6` siga en `1`.
|
||||||
@@ -25,7 +25,7 @@ spec:
|
|||||||
runAsNonRoot: false
|
runAsNonRoot: false
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- name: app
|
||||||
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.280
|
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.294
|
||||||
# El base saca TODAS las capabilities. Probado en vivo y
|
# El base saca TODAS las capabilities. Probado en vivo y
|
||||||
# revertido: con drop: ALL (aunque se agregue NET_BIND_SERVICE
|
# revertido: con drop: ALL (aunque se agregue NET_BIND_SERVICE
|
||||||
# de vuelta) el propio entrypoint de nginx:1.27-alpine falla al
|
# de vuelta) el propio entrypoint de nginx:1.27-alpine falla al
|
||||||
|
|||||||
@@ -26,11 +26,11 @@ spec:
|
|||||||
runAsUser: 1001
|
runAsUser: 1001
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- name: app
|
||||||
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.275
|
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.293
|
||||||
env:
|
env:
|
||||||
# Ver comentario en workloads/ecommerce/frontend/patches/deployment.yaml
|
# Ver comentario en workloads/ecommerce/frontend/patches/deployment.yaml
|
||||||
- name: APP_VERSION
|
- name: APP_VERSION
|
||||||
value: v1.0.275
|
value: v1.0.293
|
||||||
- name: HEADLESS_PROVIDER
|
- name: HEADLESS_PROVIDER
|
||||||
value: medusa
|
value: medusa
|
||||||
# Backend COMPARTIDO con Producción a propósito (decisión del
|
# Backend COMPARTIDO con Producción a propósito (decisión del
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { getMyOrders } from "@/lib/headless/providers/medusa-reviews";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const header = request.headers.get("authorization") ?? "";
|
||||||
|
if (!header.startsWith("Bearer ")) return Response.json({ error: "No autenticado" }, { status: 401 });
|
||||||
|
try {
|
||||||
|
return Response.json({ orders: await getMyOrders(header.slice(7)) });
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json({ error: (error as Error).message }, { status: (error as { status?: number }).status ?? 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { getMyReviews } from "@/lib/headless/providers/medusa-reviews";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const header = request.headers.get("authorization") ?? "";
|
||||||
|
if (!header.startsWith("Bearer ")) return Response.json({ error: "No autenticado" }, { status: 401 });
|
||||||
|
try {
|
||||||
|
return Response.json({ reviews: await getMyReviews(header.slice(7)) });
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json({ error: (error as Error).message }, { status: (error as { status?: number }).status ?? 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { createReview, getProductReviews } from "@/lib/headless/providers/medusa-reviews";
|
||||||
|
|
||||||
|
function bearer(request: Request): string | null {
|
||||||
|
const header = request.headers.get("authorization") ?? "";
|
||||||
|
return header.startsWith("Bearer ") ? header.slice(7) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const productId = new URL(request.url).searchParams.get("product_id");
|
||||||
|
if (!productId) return Response.json({ error: "product_id es obligatorio" }, { status: 400 });
|
||||||
|
try {
|
||||||
|
return Response.json(await getProductReviews(productId));
|
||||||
|
} catch {
|
||||||
|
// Backend sin el modulo todavia (o caido): la ficha muestra "sin
|
||||||
|
// reseñas" en vez de romperse.
|
||||||
|
return Response.json({ reviews: [], average: 0, count: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const token = bearer(request);
|
||||||
|
if (!token) return Response.json({ error: "Inicia sesión para dejar una reseña" }, { status: 401 });
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
try {
|
||||||
|
return Response.json(await createReview(token, body), { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
const status = (error as { status?: number }).status ?? 400;
|
||||||
|
return Response.json({ error: (error as Error).message }, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { getReviewSummary } from "@/lib/headless/providers/medusa-reviews";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
return Response.json({ summary: await getReviewSummary() });
|
||||||
|
} catch {
|
||||||
|
return Response.json({ summary: {} });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,13 @@ type CatalogPageProps = {
|
|||||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sin tildes/eñes ni mayusculas: los links viejos (o escritos a mano)
|
||||||
|
// como ?category=Munecas y peluches siguen llevando a la categoria real
|
||||||
|
// "Muñecas y peluches" (renombrada con tildes el 2026-09-27).
|
||||||
|
function normalizeName(value: string): string {
|
||||||
|
return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
function single(value: string | string[] | undefined): string {
|
function single(value: string | string[] | undefined): string {
|
||||||
return Array.isArray(value) ? value[0] ?? "" : value ?? "";
|
return Array.isArray(value) ? value[0] ?? "" : value ?? "";
|
||||||
}
|
}
|
||||||
@@ -19,9 +26,16 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) {
|
|||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const [products, categories] = await Promise.all([getProducts(), getCategories()]);
|
const [products, categories] = await Promise.all([getProducts(), getCategories()]);
|
||||||
|
|
||||||
const category = single(params.category) || "Todos";
|
const requestedCategory = single(params.category) || "Todos";
|
||||||
|
const category =
|
||||||
|
categories.find((node) => normalizeName(node.name) === normalizeName(requestedCategory))?.name ?? requestedCategory;
|
||||||
const collection = single(params.collection);
|
const collection = single(params.collection);
|
||||||
const query = single(params.q);
|
const query = single(params.q);
|
||||||
|
const taxonomy = {
|
||||||
|
franquicia: single(params.franquicia),
|
||||||
|
saga: single(params.saga),
|
||||||
|
personaje: single(params.personaje),
|
||||||
|
};
|
||||||
|
|
||||||
// Los links de navbar/tiles apuntan a categorias raiz (ej. "Mochilas y
|
// Los links de navbar/tiles apuntan a categorias raiz (ej. "Mochilas y
|
||||||
// morrales"), pero ningun producto tiene esa categoria asignada
|
// morrales"), pero ningun producto tiene esa categoria asignada
|
||||||
@@ -39,13 +53,14 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CatalogClient
|
<CatalogClient
|
||||||
key={`${category}:${collection}:${query}`}
|
key={`${category}:${collection}:${query}:${taxonomy.franquicia}:${taxonomy.saga}:${taxonomy.personaje}`}
|
||||||
products={products}
|
products={products}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
categoryGroups={categoryGroups}
|
categoryGroups={categoryGroups}
|
||||||
initialCategory={category}
|
initialCategory={category}
|
||||||
initialCollection={collection}
|
initialCollection={collection}
|
||||||
initialQuery={query}
|
initialQuery={query}
|
||||||
|
initialTaxonomy={taxonomy}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { OrdersClient } from "@/components/account/orders-client";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Mis pedidos",
|
||||||
|
description: "Tus pedidos en ARI Shopping y las reseñas de lo que compraste.",
|
||||||
|
robots: { index: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OrdersPage() {
|
||||||
|
return <OrdersClient />;
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||||
|
import { CardRating } from "@/components/product/card-rating";
|
||||||
import { ProductCard } from "@/components/product/product-card";
|
import { ProductCard } from "@/components/product/product-card";
|
||||||
|
import { ProductReviews } from "@/components/product/product-reviews";
|
||||||
import { ProductCarousel } from "@/components/product/product-carousel";
|
import { ProductCarousel } from "@/components/product/product-carousel";
|
||||||
import { ProductGallery } from "@/components/product/product-gallery";
|
import { ProductGallery } from "@/components/product/product-gallery";
|
||||||
import { ProductPurchase } from "@/components/product/product-purchase";
|
import { ProductPurchase } from "@/components/product/product-purchase";
|
||||||
@@ -69,6 +71,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">{product.category} · {product.collection}</span>
|
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">{product.category} · {product.collection}</span>
|
||||||
<h1 className="mt-3 text-4xl font-black tracking-tight sm:text-6xl">{product.name}</h1>
|
<h1 className="mt-3 text-4xl font-black tracking-tight sm:text-6xl">{product.name}</h1>
|
||||||
|
<CardRating productId={product.id} href="#resenas" />
|
||||||
<p className="mt-5 text-sm leading-6 text-slate-600">{product.description}</p>
|
<p className="mt-5 text-sm leading-6 text-slate-600">{product.description}</p>
|
||||||
{/* Antes escondido detrás de un click en el acordeón --
|
{/* Antes escondido detrás de un click en el acordeón --
|
||||||
siempre visible, estilo Amazon, con los mismos datos
|
siempre visible, estilo Amazon, con los mismos datos
|
||||||
@@ -87,6 +90,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
<AccordionContent>Coordinamos envíos nacionales y entrega según ubicación. Los tiempos se confirman durante la cotización.</AccordionContent>
|
<AccordionContent>Coordinamos envíos nacionales y entrega según ubicación. Los tiempos se confirman durante la cotización.</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
<ProductReviews productId={product.id} />
|
||||||
</div>
|
</div>
|
||||||
{/* Buy box estilo Amazon: precio/variantes/carrito en su propia
|
{/* Buy box estilo Amazon: precio/variantes/carrito en su propia
|
||||||
tarjeta, sticky desde xl mientras el resto de la pagina
|
tarjeta, sticky desde xl mientras el resto de la pagina
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ spec:
|
|||||||
memory: 64Mi
|
memory: 64Mi
|
||||||
|
|
||||||
- name: migrations
|
- name: migrations
|
||||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.265
|
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.295
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command:
|
command:
|
||||||
- node
|
- node
|
||||||
@@ -86,7 +86,7 @@ spec:
|
|||||||
|
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- name: app
|
||||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.265
|
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.295
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
envFrom:
|
envFrom:
|
||||||
- configMapRef:
|
- configMapRef:
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function AccountDialog({ open, onOpenChange }: { open: boolean; onOpenCha
|
|||||||
<h2 className="mt-3 text-3xl font-black">Hola, {customer.firstName ?? customer.email}</h2>
|
<h2 className="mt-3 text-3xl font-black">Hola, {customer.firstName ?? customer.email}</h2>
|
||||||
<p className="mt-3 text-sm leading-6 text-slate-600">{customer.email}</p>
|
<p className="mt-3 text-sm leading-6 text-slate-600">{customer.email}</p>
|
||||||
<div className="mt-6 grid gap-3">
|
<div className="mt-6 grid gap-3">
|
||||||
<Button asChild variant="outline"><Link href="/catalog" onClick={() => onOpenChange(false)}><PackageCheck />Ver mis opciones de compra</Link></Button>
|
<Button asChild variant="outline"><Link href="/cuenta/pedidos" onClick={() => onOpenChange(false)}><PackageCheck />Mis pedidos y reseñas</Link></Button>
|
||||||
<Button variant="ghost" onClick={logout}><LogOut className="size-4" />Cerrar sesión</Button>
|
<Button variant="ghost" onClick={logout}><LogOut className="size-4" />Cerrar sesión</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { Clock, PackageCheck, Star } from "lucide-react";
|
||||||
|
import { type FormEvent, type ReactNode, useCallback, useEffect, useState } from "react";
|
||||||
|
import { AccountDialog } from "@/components/account/account-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { formatMoney } from "@/lib/utils";
|
||||||
|
import { useCustomerStore } from "@/store/customer-store";
|
||||||
|
|
||||||
|
type OrderItem = { productId: string; title: string; thumbnail: string | null; handle: string | null; quantity: number };
|
||||||
|
type Order = { id: string; displayId: number | null; createdAt: string; status: string; total: number | null; currency: string; items: OrderItem[] };
|
||||||
|
type MyReview = { id: string; product_id: string; status: "pending" | "approved" | "rejected" };
|
||||||
|
|
||||||
|
const REVIEW_LABEL: Record<MyReview["status"], string> = {
|
||||||
|
pending: "Reseña enviada · en revisión",
|
||||||
|
approved: "Reseña publicada",
|
||||||
|
rejected: "Reseña no publicada",
|
||||||
|
};
|
||||||
|
|
||||||
|
// "Mis pedidos": historial real del cliente (Store API) y, por cada
|
||||||
|
// producto comprado, el boton para calificarlo. Solo desde aca se puede
|
||||||
|
// reseñar: el backend exige que el pedido sea del cliente y contenga el
|
||||||
|
// producto ("Compra verificada").
|
||||||
|
export function OrdersClient() {
|
||||||
|
const { token, customer, hasHydrated } = useCustomerStore();
|
||||||
|
const [orders, setOrders] = useState<Order[] | null>(null);
|
||||||
|
const [reviews, setReviews] = useState<MyReview[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [accountOpen, setAccountOpen] = useState(false);
|
||||||
|
const [reviewing, setReviewing] = useState<{ order: Order; item: OrderItem } | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const headers = { Authorization: `Bearer ${token}` };
|
||||||
|
const [ordersRes, reviewsRes] = await Promise.all([fetch("/api/customer/orders", { headers }), fetch("/api/reviews/me", { headers })]);
|
||||||
|
const ordersData = await ordersRes.json();
|
||||||
|
if (!ordersRes.ok) throw new Error(ordersData.error ?? "No pudimos cargar tus pedidos");
|
||||||
|
const reviewsData = await reviewsRes.json().catch(() => ({ reviews: [] }));
|
||||||
|
setOrders(ordersData.orders);
|
||||||
|
setReviews(reviewsRes.ok ? reviewsData.reviews : []);
|
||||||
|
} catch (loadError) {
|
||||||
|
setError((loadError as Error).message);
|
||||||
|
setOrders([]);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (!hasHydrated) return <Shell><p className="text-slate-500">Cargando…</p></Shell>;
|
||||||
|
|
||||||
|
if (!customer || !token) {
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<div className="rounded-2xl border border-slate-200 p-8 text-center">
|
||||||
|
<p className="text-lg font-black text-ari-marino">Inicia sesión para ver tus pedidos</p>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">Desde aquí puedes calificar los productos que compraste.</p>
|
||||||
|
<Button variant="primary" className="mt-5" onClick={() => setAccountOpen(true)}>Iniciar sesión</Button>
|
||||||
|
</div>
|
||||||
|
<AccountDialog open={accountOpen} onOpenChange={setAccountOpen} />
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviewFor = (productId: string) => reviews.find((review) => review.product_id === productId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
{error && <p className="mb-4 rounded-2xl bg-rose-50 p-4 text-sm font-bold text-rose-700">{error}</p>}
|
||||||
|
{orders === null ? (
|
||||||
|
<p className="text-slate-500">Cargando tus pedidos…</p>
|
||||||
|
) : orders.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-dashed border-slate-300 p-8 text-center">
|
||||||
|
<p className="font-black text-ari-marino">Todavía no tienes pedidos</p>
|
||||||
|
<Button asChild variant="primary" className="mt-4"><Link href="/catalog">Ver catálogo</Link></Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-5">
|
||||||
|
{orders.map((order) => (
|
||||||
|
<li key={order.id} className="rounded-2xl border border-slate-200 p-5 shadow-sm">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-100 pb-3 text-sm">
|
||||||
|
<span className="font-black text-ari-marino">Pedido #{order.displayId ?? order.id.slice(-6)}</span>
|
||||||
|
<span className="text-slate-500">
|
||||||
|
{new Date(order.createdAt).toLocaleDateString("es-CO", { year: "numeric", month: "long", day: "numeric" })}
|
||||||
|
{order.total !== null && <> · {formatMoney(order.total, order.currency)}</>}
|
||||||
|
{order.status === "canceled" && <span className="ml-2 font-bold text-rose-600">Cancelado</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul className="mt-3 grid gap-3">
|
||||||
|
{order.items.map((item) => {
|
||||||
|
const review = reviewFor(item.productId);
|
||||||
|
return (
|
||||||
|
<li key={`${order.id}-${item.productId}`} className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="relative size-16 shrink-0 overflow-hidden rounded-xl bg-slate-50">
|
||||||
|
{item.thumbnail && <Image src={item.thumbnail} alt="" fill sizes="64px" className="object-contain" unoptimized />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{item.handle ? (
|
||||||
|
<Link href={`/product/${item.handle}`} className="line-clamp-2 font-bold text-slate-900 hover:text-ari-coral">{item.title}</Link>
|
||||||
|
) : (
|
||||||
|
<span className="line-clamp-2 font-bold text-slate-900">{item.title}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-slate-500">Cantidad: {item.quantity}</span>
|
||||||
|
</div>
|
||||||
|
{review ? (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 text-xs font-bold ${review.status === "approved" ? "text-emerald-700" : review.status === "pending" ? "text-amber-700" : "text-slate-500"}`}>
|
||||||
|
{review.status === "pending" ? <Clock className="size-3.5" /> : <PackageCheck className="size-3.5" />}
|
||||||
|
{REVIEW_LABEL[review.status]}
|
||||||
|
</span>
|
||||||
|
) : order.status === "canceled" ? null : (
|
||||||
|
<Button size="sm" variant="primary" onClick={() => setReviewing({ order, item })}>
|
||||||
|
<Star className="size-4" />Calificar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<ReviewDialog
|
||||||
|
target={reviewing}
|
||||||
|
token={token}
|
||||||
|
onClose={() => setReviewing(null)}
|
||||||
|
onSubmitted={() => {
|
||||||
|
setReviewing(null);
|
||||||
|
load();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Shell({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-4 py-10">
|
||||||
|
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">Mi cuenta</span>
|
||||||
|
<h1 className="font-display mt-2 text-4xl font-bold tracking-tight text-ari-marino">Mis pedidos</h1>
|
||||||
|
<div className="mt-8">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewDialog({
|
||||||
|
target,
|
||||||
|
token,
|
||||||
|
onClose,
|
||||||
|
onSubmitted,
|
||||||
|
}: {
|
||||||
|
target: { order: Order; item: OrderItem } | null;
|
||||||
|
token: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmitted: () => void;
|
||||||
|
}) {
|
||||||
|
const [rating, setRating] = useState(0);
|
||||||
|
const [hover, setHover] = useState(0);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!target) return;
|
||||||
|
setRating(0);
|
||||||
|
setHover(0);
|
||||||
|
setTitle("");
|
||||||
|
setContent("");
|
||||||
|
setError(null);
|
||||||
|
}, [target]);
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!target) return;
|
||||||
|
if (rating < 1) return setError("Elige de 1 a 5 estrellas");
|
||||||
|
if (content.trim().length < 10) return setError("Cuéntanos un poco más (mínimo 10 caracteres)");
|
||||||
|
setSending(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/reviews", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ product_id: target.item.productId, order_id: target.order.id, rating, title: title.trim() || undefined, content: content.trim() }),
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(data.error ?? "No pudimos enviar tu reseña");
|
||||||
|
onSubmitted();
|
||||||
|
} catch (submitError) {
|
||||||
|
setError((submitError as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shown = hover || rating;
|
||||||
|
return (
|
||||||
|
<Dialog open={target !== null} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="max-w-lg p-6 sm:p-8">
|
||||||
|
<DialogPrimitive.Title className="pr-10 text-2xl font-black text-ari-marino">Califica tu compra</DialogPrimitive.Title>
|
||||||
|
<DialogPrimitive.Description className="mt-1 line-clamp-2 text-sm text-slate-500">{target?.item.title}</DialogPrimitive.Description>
|
||||||
|
<form onSubmit={submit} className="mt-6 grid gap-4">
|
||||||
|
<fieldset>
|
||||||
|
<legend className="mb-2 text-sm font-black">Tu calificación</legend>
|
||||||
|
{/* Patron ARIA de radiogroup (no toggles sueltos): el lector de
|
||||||
|
pantalla anuncia "3 estrellas, seleccionado, 3 de 5", un solo
|
||||||
|
tab-stop y flechas para cambiar -- con aria-pressed por
|
||||||
|
estrella solo se anunciaba la estrella exacta, no la
|
||||||
|
calificacion (reportado por frontend-critic). */}
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Tu calificación"
|
||||||
|
className="flex gap-1"
|
||||||
|
onMouseLeave={() => setHover(0)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
const delta = event.key === "ArrowRight" || event.key === "ArrowUp" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowDown" ? -1 : 0;
|
||||||
|
if (!delta) return;
|
||||||
|
event.preventDefault();
|
||||||
|
// Como un radiogroup nativo: avanza desde la opcion con foco
|
||||||
|
// (si aun no hay calificacion elegida) o desde la elegida.
|
||||||
|
const buttons = [...event.currentTarget.querySelectorAll("button")];
|
||||||
|
const focused = buttons.indexOf(document.activeElement as HTMLButtonElement) + 1;
|
||||||
|
const next = Math.min(5, Math.max(1, (rating || focused || 0) + delta));
|
||||||
|
setRating(next);
|
||||||
|
setError(null);
|
||||||
|
(event.currentTarget.querySelectorAll("button")[next - 1] as HTMLButtonElement | undefined)?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5].map((value) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={rating === value}
|
||||||
|
aria-label={`${value} ${value === 1 ? "estrella" : "estrellas"}`}
|
||||||
|
tabIndex={rating ? (rating === value ? 0 : -1) : value === 1 ? 0 : -1}
|
||||||
|
onMouseEnter={() => setHover(value)}
|
||||||
|
onClick={() => { setRating(value); setError(null); }}
|
||||||
|
className="rounded-md p-0.5 transition hover:scale-110 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ari-marino"
|
||||||
|
>
|
||||||
|
<Star className={`size-9 ${value <= shown ? "fill-amber-500 text-amber-600" : "text-slate-400"}`} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<label className="grid gap-1.5 text-sm font-black">
|
||||||
|
Título (opcional)
|
||||||
|
<Input value={title} onChange={(event) => setTitle(event.target.value)} maxLength={80} placeholder="Ej: ¡A mi hijo le encantó!" />
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-1.5 text-sm font-black">
|
||||||
|
Tu reseña
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(event) => setContent(event.target.value)}
|
||||||
|
rows={5}
|
||||||
|
maxLength={2000}
|
||||||
|
placeholder="¿Qué te pareció el producto? ¿Llegó bien?"
|
||||||
|
className="rounded-2xl border border-slate-200 bg-white p-4 text-sm font-normal text-slate-950 outline-none focus:border-ari-coral focus:ring-4 focus:ring-ari-coral/15"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-slate-500">Tu reseña se publica después de que la revisemos. Mostramos tu nombre y la inicial de tu apellido.</p>
|
||||||
|
{error && <p className="rounded-xl bg-rose-50 p-3 text-sm font-bold text-rose-700">{error}</p>}
|
||||||
|
<Button type="submit" variant="primary" size="lg" disabled={sending}>{sending ? "Enviando…" : "Enviar reseña"}</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { ProductCard } from "@/components/product/product-card";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import type { Category, FacetDistribution, Product } from "@/lib/headless/types";
|
import type { Category, FacetDistribution, Product } from "@/lib/headless/types";
|
||||||
|
import { matchesTaxonomy, taxonomyValues, type TaxonomyFilter } from "@/lib/taxonomy";
|
||||||
|
|
||||||
// Cada cuantas tarjetas de producto se intercala un tile promocional en
|
// Cada cuantas tarjetas de producto se intercala un tile promocional en
|
||||||
// la grilla (patron Walmart, ADR 0005 seccion 3): grid con tiles
|
// la grilla (patron Walmart, ADR 0005 seccion 3): grid con tiles
|
||||||
@@ -23,12 +24,19 @@ type CatalogClientProps = {
|
|||||||
initialCategory?: string;
|
initialCategory?: string;
|
||||||
initialCollection?: string;
|
initialCollection?: string;
|
||||||
initialQuery?: string;
|
initialQuery?: string;
|
||||||
|
initialTaxonomy?: TaxonomyFilter;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sin guiones ni espacios: "spiderman" encuentra "Spider-Man".
|
||||||
|
function normalizeSearch(value: string): string {
|
||||||
|
return value.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[\s-]+/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
function matchesQuery(product: Product, query: string): boolean {
|
function matchesQuery(product: Product, query: string): boolean {
|
||||||
if (!query) return true;
|
if (!query) return true;
|
||||||
const search = [product.name, product.category, product.collection, product.brand].join(" ").toLowerCase();
|
const characters = [...taxonomyValues(product, "franquicia"), ...taxonomyValues(product, "saga"), ...taxonomyValues(product, "personaje")];
|
||||||
return search.includes(query.toLowerCase());
|
const search = normalizeSearch([product.name, product.category, product.collection, product.brand, ...characters].join(" "));
|
||||||
|
return search.includes(normalizeSearch(query));
|
||||||
}
|
}
|
||||||
function matchesCategory(product: Product, allowedCategories: string[]): boolean {
|
function matchesCategory(product: Product, allowedCategories: string[]): boolean {
|
||||||
return allowedCategories.includes(product.category);
|
return allowedCategories.includes(product.category);
|
||||||
@@ -69,8 +77,18 @@ export function CatalogClient({
|
|||||||
initialCategory = "Todos",
|
initialCategory = "Todos",
|
||||||
initialCollection = "",
|
initialCollection = "",
|
||||||
initialQuery = "",
|
initialQuery = "",
|
||||||
|
initialTaxonomy = {},
|
||||||
}: CatalogClientProps) {
|
}: CatalogClientProps) {
|
||||||
const [query, setQuery] = useState(initialQuery);
|
const [query, setQuery] = useState(initialQuery);
|
||||||
|
// Franquicia/saga/personaje (lib/taxonomy.ts): se filtra 100% client-side
|
||||||
|
// sobre los tags -- Meilisearch no tiene estos campos como facetas.
|
||||||
|
const [taxonomy, setTaxonomy] = useState<TaxonomyFilter>(initialTaxonomy);
|
||||||
|
const taxonomyActive = Boolean(taxonomy.franquicia || taxonomy.saga || taxonomy.personaje);
|
||||||
|
// El titulo solo refleja la franquicia/saga/personaje que viene en la URL
|
||||||
|
// (menu "Personajes"), igual que la barra de navegacion: si reaccionara
|
||||||
|
// al filtro lateral (que no cambia la URL), el H1 diria "Marvel" con
|
||||||
|
// "Todo" marcado activo arriba (reportado por frontend-critic).
|
||||||
|
const urlTaxonomyTitle = initialTaxonomy.personaje || initialTaxonomy.saga || initialTaxonomy.franquicia || "";
|
||||||
const [category, setCategory] = useState(initialCategory);
|
const [category, setCategory] = useState(initialCategory);
|
||||||
const [collection, setCollection] = useState(initialCollection);
|
const [collection, setCollection] = useState(initialCollection);
|
||||||
const [brand, setBrand] = useState("Todas");
|
const [brand, setBrand] = useState("Todas");
|
||||||
@@ -96,13 +114,30 @@ export function CatalogClient({
|
|||||||
setAge("Todas");
|
setAge("Todas");
|
||||||
setSort("featured");
|
setSort("featured");
|
||||||
setMaxPrice(maxCatalogPrice);
|
setMaxPrice(maxCatalogPrice);
|
||||||
}, [initialCategory, initialCollection, initialQuery, maxCatalogPrice]);
|
setTaxonomy({ franquicia: initialTaxonomy.franquicia, saga: initialTaxonomy.saga, personaje: initialTaxonomy.personaje });
|
||||||
|
}, [initialCategory, initialCollection, initialQuery, maxCatalogPrice, initialTaxonomy.franquicia, initialTaxonomy.saga, initialTaxonomy.personaje]);
|
||||||
|
|
||||||
|
// Valores de franquicia/personaje presentes en el catalogo. Los
|
||||||
|
// personajes se acotan a la franquicia elegida (si hay una), para no
|
||||||
|
// ofrecer "Spider-Man" mientras se mira Disney.
|
||||||
|
const franchises = useMemo(() => ["Todas", ...new Set(products.flatMap((product) => taxonomyValues(product, "franquicia")))], [products]);
|
||||||
|
const characters = useMemo(
|
||||||
|
() => [
|
||||||
|
"Todos",
|
||||||
|
...new Set(
|
||||||
|
products
|
||||||
|
.filter((product) => matchesTaxonomy(product, { franquicia: taxonomy.franquicia, saga: taxonomy.saga }))
|
||||||
|
.flatMap((product) => taxonomyValues(product, "personaje")),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[products, taxonomy.franquicia, taxonomy.saga],
|
||||||
|
);
|
||||||
|
|
||||||
// Si `category` es una raiz con hijos (ej. "Mochilas y morrales"), se
|
// Si `category` es una raiz con hijos (ej. "Mochilas y morrales"), se
|
||||||
// expande a la lista de nombres reales de sus hijos -- ningun producto
|
// expande a la lista de nombres reales de sus hijos -- ningun producto
|
||||||
// tiene la raiz asignada directamente. Si no es una raiz conocida, se
|
// tiene la raiz asignada directamente. Si no es una raiz conocida, se
|
||||||
// trata como el nombre de categoria exacto de siempre.
|
// trata como el nombre de categoria exacto de siempre.
|
||||||
const allowedCategories = categoryGroups[category] ?? [category];
|
const allowedCategories = useMemo(() => categoryGroups[category] ?? [category], [categoryGroups, category]);
|
||||||
const breadcrumb = useMemo(() => (category === "Todos" ? [] : buildBreadcrumb(categoryNodes, category)), [categoryNodes, category]);
|
const breadcrumb = useMemo(() => (category === "Todos" ? [] : buildBreadcrumb(categoryNodes, category)), [categoryNodes, category]);
|
||||||
// No promociona la misma categoria raiz que el usuario ya esta viendo.
|
// No promociona la misma categoria raiz que el usuario ya esta viendo.
|
||||||
const promoRoots = useMemo(() => {
|
const promoRoots = useMemo(() => {
|
||||||
@@ -120,7 +155,8 @@ export function CatalogClient({
|
|||||||
matchesCollection(product, collection) &&
|
matchesCollection(product, collection) &&
|
||||||
matchesBrand(product, brand) &&
|
matchesBrand(product, brand) &&
|
||||||
matchesAge(product, age) &&
|
matchesAge(product, age) &&
|
||||||
matchesPrice(product, maxPrice),
|
matchesPrice(product, maxPrice) &&
|
||||||
|
matchesTaxonomy(product, taxonomy),
|
||||||
)
|
)
|
||||||
.sort((a, b) =>
|
.sort((a, b) =>
|
||||||
sort === "name"
|
sort === "name"
|
||||||
@@ -129,7 +165,7 @@ export function CatalogClient({
|
|||||||
? Number(b.isNew) - Number(a.isNew)
|
? Number(b.isNew) - Number(a.isNew)
|
||||||
: Number(b.featured) - Number(a.featured),
|
: Number(b.featured) - Number(a.featured),
|
||||||
),
|
),
|
||||||
[age, allowedCategories, brand, category, collection, maxPrice, products, query, sort],
|
[age, allowedCategories, brand, category, collection, maxPrice, products, query, sort, taxonomy],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Intercala un tile promocional cada PROMO_TILE_INTERVAL productos,
|
// Intercala un tile promocional cada PROMO_TILE_INTERVAL productos,
|
||||||
@@ -161,7 +197,7 @@ export function CatalogClient({
|
|||||||
// la busqueda en vivo todavia no respondio.
|
// la busqueda en vivo todavia no respondio.
|
||||||
const fallbackCategoryCounts = useMemo(() => {
|
const fallbackCategoryCounts = useMemo(() => {
|
||||||
const base = products.filter(
|
const base = products.filter(
|
||||||
(product) => matchesQuery(product, query) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice),
|
(product) => matchesQuery(product, query) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice) && matchesTaxonomy(product, taxonomy),
|
||||||
);
|
);
|
||||||
const counts: Record<string, number> = { Todos: base.length };
|
const counts: Record<string, number> = { Todos: base.length };
|
||||||
for (const name of categories) {
|
for (const name of categories) {
|
||||||
@@ -170,7 +206,7 @@ export function CatalogClient({
|
|||||||
counts[name] = base.filter((product) => matchesCategory(product, allowed)).length;
|
counts[name] = base.filter((product) => matchesCategory(product, allowed)).length;
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
}, [age, brand, categories, categoryGroups, collection, maxPrice, products, query]);
|
}, [age, brand, categories, categoryGroups, collection, maxPrice, products, query, taxonomy]);
|
||||||
|
|
||||||
const fallbackBrandCounts = useMemo(() => {
|
const fallbackBrandCounts = useMemo(() => {
|
||||||
const base = products.filter(
|
const base = products.filter(
|
||||||
@@ -179,7 +215,8 @@ export function CatalogClient({
|
|||||||
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
|
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
|
||||||
matchesCollection(product, collection) &&
|
matchesCollection(product, collection) &&
|
||||||
matchesAge(product, age) &&
|
matchesAge(product, age) &&
|
||||||
matchesPrice(product, maxPrice),
|
matchesPrice(product, maxPrice) &&
|
||||||
|
matchesTaxonomy(product, taxonomy),
|
||||||
);
|
);
|
||||||
const counts: Record<string, number> = { Todas: base.length };
|
const counts: Record<string, number> = { Todas: base.length };
|
||||||
for (const value of brands) {
|
for (const value of brands) {
|
||||||
@@ -187,7 +224,7 @@ export function CatalogClient({
|
|||||||
counts[value] = base.filter((product) => product.brand === value).length;
|
counts[value] = base.filter((product) => product.brand === value).length;
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
}, [age, allowedCategories, brands, category, collection, maxPrice, products, query]);
|
}, [age, allowedCategories, brands, category, collection, maxPrice, products, query, taxonomy]);
|
||||||
|
|
||||||
const fallbackAgeCounts = useMemo(() => {
|
const fallbackAgeCounts = useMemo(() => {
|
||||||
const base = products.filter(
|
const base = products.filter(
|
||||||
@@ -196,7 +233,8 @@ export function CatalogClient({
|
|||||||
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
|
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
|
||||||
matchesCollection(product, collection) &&
|
matchesCollection(product, collection) &&
|
||||||
matchesBrand(product, brand) &&
|
matchesBrand(product, brand) &&
|
||||||
matchesPrice(product, maxPrice),
|
matchesPrice(product, maxPrice) &&
|
||||||
|
matchesTaxonomy(product, taxonomy),
|
||||||
);
|
);
|
||||||
const counts: Record<string, number> = { Todas: base.length };
|
const counts: Record<string, number> = { Todas: base.length };
|
||||||
for (const value of ages) {
|
for (const value of ages) {
|
||||||
@@ -204,7 +242,7 @@ export function CatalogClient({
|
|||||||
counts[value] = base.filter((product) => product.ageRange === value).length;
|
counts[value] = base.filter((product) => product.ageRange === value).length;
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
}, [ages, allowedCategories, brand, category, collection, maxPrice, products, query]);
|
}, [ages, allowedCategories, brand, category, collection, maxPrice, products, query, taxonomy]);
|
||||||
|
|
||||||
// "Mochilas y morrales" etc.: nombre sintetico de grupo que no existe
|
// "Mochilas y morrales" etc.: nombre sintetico de grupo que no existe
|
||||||
// como valor indexado en Meilisearch (ningun producto lo tiene como
|
// como valor indexado en Meilisearch (ningun producto lo tiene como
|
||||||
@@ -296,18 +334,33 @@ export function CatalogClient({
|
|||||||
}, [age, brand, category, categoryIsLeafOrNone, collection, maxPrice, query]);
|
}, [age, brand, category, categoryIsLeafOrNone, collection, maxPrice, query]);
|
||||||
|
|
||||||
const categoryCounts = useMemo(
|
const categoryCounts = useMemo(
|
||||||
() => (liveFacets.category ? { Todos: liveFacets.categoryTotal ?? 0, ...liveFacets.category } : fallbackCategoryCounts),
|
() => (liveFacets.category && !taxonomyActive ? { Todos: liveFacets.categoryTotal ?? 0, ...liveFacets.category } : fallbackCategoryCounts),
|
||||||
[fallbackCategoryCounts, liveFacets.category, liveFacets.categoryTotal],
|
[fallbackCategoryCounts, liveFacets.category, liveFacets.categoryTotal, taxonomyActive],
|
||||||
);
|
);
|
||||||
const brandCounts = useMemo(
|
const brandCounts = useMemo(
|
||||||
() => (liveFacets.brand ? { Todas: liveFacets.brandTotal ?? 0, ...liveFacets.brand } : fallbackBrandCounts),
|
() => (liveFacets.brand && !taxonomyActive ? { Todas: liveFacets.brandTotal ?? 0, ...liveFacets.brand } : fallbackBrandCounts),
|
||||||
[fallbackBrandCounts, liveFacets.brand, liveFacets.brandTotal],
|
[fallbackBrandCounts, liveFacets.brand, liveFacets.brandTotal, taxonomyActive],
|
||||||
);
|
);
|
||||||
const ageCounts = useMemo(
|
const ageCounts = useMemo(
|
||||||
() => (liveFacets.ageRange ? { Todas: liveFacets.ageTotal ?? 0, ...liveFacets.ageRange } : fallbackAgeCounts),
|
() => (liveFacets.ageRange && !taxonomyActive ? { Todas: liveFacets.ageTotal ?? 0, ...liveFacets.ageRange } : fallbackAgeCounts),
|
||||||
[fallbackAgeCounts, liveFacets.ageRange, liveFacets.ageTotal],
|
[fallbackAgeCounts, liveFacets.ageRange, liveFacets.ageTotal, taxonomyActive],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Conteos de franquicia/personaje: siempre client-side (no son facetas
|
||||||
|
// de Meilisearch), excluyendo su propia dimension como el resto.
|
||||||
|
const franchiseCounts = useMemo(() => {
|
||||||
|
const base = products.filter((product) => matchesQuery(product, query) && (category === "Todos" || matchesCategory(product, allowedCategories)) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice) && matchesTaxonomy(product, { saga: taxonomy.saga, personaje: taxonomy.personaje }));
|
||||||
|
const counts: Record<string, number> = { Todas: base.length };
|
||||||
|
for (const value of franchises) if (value !== "Todas") counts[value] = base.filter((product) => taxonomyValues(product, "franquicia").includes(value)).length;
|
||||||
|
return counts;
|
||||||
|
}, [age, allowedCategories, brand, category, collection, franchises, maxPrice, products, query, taxonomy.saga, taxonomy.personaje]);
|
||||||
|
const characterCounts = useMemo(() => {
|
||||||
|
const base = products.filter((product) => matchesQuery(product, query) && (category === "Todos" || matchesCategory(product, allowedCategories)) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice) && matchesTaxonomy(product, { franquicia: taxonomy.franquicia, saga: taxonomy.saga }));
|
||||||
|
const counts: Record<string, number> = { Todos: base.length };
|
||||||
|
for (const value of characters) if (value !== "Todos") counts[value] = base.filter((product) => taxonomyValues(product, "personaje").includes(value)).length;
|
||||||
|
return counts;
|
||||||
|
}, [age, allowedCategories, brand, category, characters, collection, maxPrice, products, query, taxonomy.franquicia, taxonomy.saga]);
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setCategory("Todos");
|
setCategory("Todos");
|
||||||
@@ -316,6 +369,7 @@ export function CatalogClient({
|
|||||||
setAge("Todas");
|
setAge("Todas");
|
||||||
setMaxPrice(maxCatalogPrice);
|
setMaxPrice(maxCatalogPrice);
|
||||||
setSort("featured");
|
setSort("featured");
|
||||||
|
setTaxonomy({});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -343,9 +397,9 @@ export function CatalogClient({
|
|||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">
|
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">
|
||||||
Catálogo completo
|
{urlTaxonomyTitle ? "Personajes" : "Catálogo completo"}
|
||||||
</span>
|
</span>
|
||||||
<h1 className="font-display mt-2 text-5xl font-bold tracking-tight text-ari-marino">Todos los tesoros</h1>
|
<h1 className="font-display mt-2 text-5xl font-bold tracking-tight text-ari-marino">{urlTaxonomyTitle || "Todos los tesoros"}</h1>
|
||||||
<p className="mt-3 text-slate-500">
|
<p className="mt-3 text-slate-500">
|
||||||
Filtra por categoría, marca o edad sin recargar la página.
|
Filtra por categoría, marca o edad sin recargar la página.
|
||||||
</p>
|
</p>
|
||||||
@@ -381,6 +435,33 @@ export function CatalogClient({
|
|||||||
onChange={setCategory}
|
onChange={setCategory}
|
||||||
counts={categoryCounts}
|
counts={categoryCounts}
|
||||||
/>
|
/>
|
||||||
|
{franchises.length > 1 && (
|
||||||
|
<Filter
|
||||||
|
title="Franquicia"
|
||||||
|
values={franchises}
|
||||||
|
selected={taxonomy.franquicia || "Todas"}
|
||||||
|
onChange={(value) => setTaxonomy({ franquicia: value === "Todas" ? undefined : value })}
|
||||||
|
counts={franchiseCounts}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{taxonomy.saga && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-3 text-sm font-black">Saga</p>
|
||||||
|
<button type="button" onClick={() => setTaxonomy({ ...taxonomy, saga: undefined, personaje: undefined })} className="inline-flex items-center gap-1.5 rounded-md bg-ari-marino px-3 py-2 text-xs font-bold text-white">
|
||||||
|
{taxonomy.saga}
|
||||||
|
<X className="size-3" aria-label="Quitar saga" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{characters.length > 1 && (
|
||||||
|
<Filter
|
||||||
|
title="Personaje"
|
||||||
|
values={characters}
|
||||||
|
selected={taxonomy.personaje || "Todos"}
|
||||||
|
onChange={(value) => setTaxonomy({ ...taxonomy, personaje: value === "Todos" ? undefined : value })}
|
||||||
|
counts={characterCounts}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Filter title="Marca" values={brands} selected={brand} onChange={setBrand} counts={brandCounts} />
|
<Filter title="Marca" values={brands} selected={brand} onChange={setBrand} counts={brandCounts} />
|
||||||
<Filter title="Edad" values={ages} selected={age} onChange={setAge} counts={ageCounts} />
|
<Filter title="Edad" values={ages} selected={age} onChange={setAge} counts={ageCounts} />
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowLeft, Landmark, MapPin, MessageCircle, Truck } from "lucide-react";
|
import { ArrowLeft, Landmark, MapPin, MessageCircle, Truck } from "lucide-react";
|
||||||
import { type FormEvent, useState } from "react";
|
import { type FormEvent, useEffect, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ciudadesPorDepartamento, departamentos } from "@/lib/data/colombia";
|
import { ciudadesPorDepartamento, departamentos } from "@/lib/data/colombia";
|
||||||
import { formatMoney } from "@/lib/utils";
|
import { formatMoney } from "@/lib/utils";
|
||||||
import { useCartStore } from "@/store/cart-store";
|
import { useCartStore } from "@/store/cart-store";
|
||||||
|
import { useLocationStore } from "@/store/location-store";
|
||||||
|
|
||||||
const SELECT_CLASS =
|
const SELECT_CLASS =
|
||||||
"h-12 w-full rounded-full border border-slate-200 bg-white px-5 text-sm text-slate-950 outline-none transition focus:border-cyan-500 focus:ring-4 focus:ring-cyan-300/30 disabled:opacity-50";
|
"h-12 w-full rounded-full border border-slate-200 bg-white px-5 text-sm text-slate-950 outline-none transition focus:border-cyan-500 focus:ring-4 focus:ring-cyan-300/30 disabled:opacity-50";
|
||||||
@@ -72,12 +73,24 @@ export function CheckoutClient() {
|
|||||||
const [docType, setDocType] = useState<"CC" | "CE" | "NIT" | "TI" | "PP">("CC");
|
const [docType, setDocType] = useState<"CC" | "CE" | "NIT" | "TI" | "PP">("CC");
|
||||||
const [docNumber, setDocNumber] = useState("");
|
const [docNumber, setDocNumber] = useState("");
|
||||||
|
|
||||||
|
// Ciudad elegida en el header ("Enviar a ... · Cali"): precarga el
|
||||||
|
// departamento/ciudad solo si el cliente todavia no eligio nada aca.
|
||||||
|
const savedDepartmentId = useLocationStore((state) => state.departmentId);
|
||||||
|
const savedCityId = useLocationStore((state) => state.cityId);
|
||||||
|
const locationHydrated = useLocationStore((state) => state.hasHydrated);
|
||||||
|
const saveLocation = useLocationStore((state) => state.setLocation);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!locationHydrated || !savedDepartmentId || !savedCityId) return;
|
||||||
|
setForm((current) => (current.departmentId ? current : { ...current, departmentId: savedDepartmentId, cityId: savedCityId }));
|
||||||
|
}, [locationHydrated, savedDepartmentId, savedCityId]);
|
||||||
|
|
||||||
const itemsSubtotal = items.reduce((sum, item) => sum + (item.product.price ?? 0) * item.quantity, 0);
|
const itemsSubtotal = items.reduce((sum, item) => sum + (item.product.price ?? 0) * item.quantity, 0);
|
||||||
const currency = items[0]?.product.currency;
|
const currency = items[0]?.product.currency;
|
||||||
|
|
||||||
async function submitAddress(event: FormEvent) {
|
async function submitAddress(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!cartId || !selectedCiudad || !selectedDepartamento) return;
|
if (!cartId || !selectedCiudad || !selectedDepartamento) return;
|
||||||
|
saveLocation(selectedDepartamento.id, selectedCiudad.id);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MapPin } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ciudades, ciudadesPorDepartamento, departamentos } from "@/lib/data/colombia";
|
||||||
|
import { useLocationStore } from "@/store/location-store";
|
||||||
|
|
||||||
|
const selectClass =
|
||||||
|
"h-11 w-full rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-950 outline-none focus:border-ari-coral focus:ring-4 focus:ring-ari-coral/15";
|
||||||
|
|
||||||
|
// "Enviar a <nombre> · <ciudad>" del header (inspirado en el bloque de
|
||||||
|
// ubicacion de Amazon). La ciudad elegida se reusa en el checkout.
|
||||||
|
export function LocationPicker({ firstName }: { firstName: string | null }) {
|
||||||
|
const departmentId = useLocationStore((state) => state.departmentId);
|
||||||
|
const cityId = useLocationStore((state) => state.cityId);
|
||||||
|
const hydrated = useLocationStore((state) => state.hasHydrated);
|
||||||
|
const setLocation = useLocationStore((state) => state.setLocation);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [draftDepartment, setDraftDepartment] = useState<number | null>(null);
|
||||||
|
const [draftCity, setDraftCity] = useState<number | null>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const city = hydrated && cityId ? ciudades.find((item) => item.id === cityId)?.name : undefined;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setDraftDepartment(departmentId);
|
||||||
|
setDraftCity(cityId);
|
||||||
|
function handlePointerDown(event: PointerEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false);
|
||||||
|
}
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("pointerdown", handlePointerDown);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", handlePointerDown);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [open, departmentId, cityId]);
|
||||||
|
|
||||||
|
const draftCities = draftDepartment ? ciudadesPorDepartamento(draftDepartment) : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={city ? `Enviar a ${city}. Cambiar ciudad` : "Elegir ciudad de envío"}
|
||||||
|
className="flex items-center gap-1.5 rounded-xl px-2 py-1.5 text-left transition hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
<MapPin className="size-5 shrink-0 text-ari-coral" />
|
||||||
|
<span className="leading-tight">
|
||||||
|
<span className="block text-xs text-slate-500">Enviar a {firstName || "Colombia"}</span>
|
||||||
|
<span className="block max-w-36 truncate text-sm font-black text-ari-marino">{city ?? "Elige tu ciudad"}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute left-0 top-full z-50 mt-2 w-80 rounded-2xl border border-slate-200 bg-white p-5 shadow-2xl">
|
||||||
|
<p className="font-black text-ari-marino">¿A dónde enviamos?</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">Hacemos envíos a toda Colombia. Tu ciudad queda lista en el checkout.</p>
|
||||||
|
<div className="mt-4 grid gap-3">
|
||||||
|
<select
|
||||||
|
aria-label="Departamento"
|
||||||
|
className={selectClass}
|
||||||
|
value={draftDepartment ?? ""}
|
||||||
|
onChange={(event) => { setDraftDepartment(Number(event.target.value) || null); setDraftCity(null); }}
|
||||||
|
>
|
||||||
|
<option value="">Departamento</option>
|
||||||
|
{departamentos.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
aria-label="Ciudad"
|
||||||
|
className={selectClass}
|
||||||
|
value={draftCity ?? ""}
|
||||||
|
disabled={!draftDepartment}
|
||||||
|
onChange={(event) => setDraftCity(Number(event.target.value) || null)}
|
||||||
|
>
|
||||||
|
<option value="">Ciudad o municipio</option>
|
||||||
|
{draftCities.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={!draftDepartment || !draftCity}
|
||||||
|
onClick={() => {
|
||||||
|
if (draftDepartment && draftCity) setLocation(draftDepartment, draftCity);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Guardar ciudad
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,13 +2,15 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useSearchParams } from "next/navigation";
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
import { ChevronDown, Heart, LayoutGrid, Menu, ShoppingCart, UserRound } from "lucide-react";
|
import { ChevronDown, Heart, LayoutGrid, LifeBuoy, Menu, ShoppingCart, Sparkles, UserRound } from "lucide-react";
|
||||||
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
import { Suspense, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import { AccountDialog } from "@/components/account/account-dialog";
|
import { AccountDialog } from "@/components/account/account-dialog";
|
||||||
|
import { LocationPicker } from "@/components/layout/location-picker";
|
||||||
import { PredictiveSearch } from "@/components/search/predictive-search";
|
import { PredictiveSearch } from "@/components/search/predictive-search";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||||
import type { Category, Product } from "@/lib/headless/types";
|
import type { Category, Product } from "@/lib/headless/types";
|
||||||
|
import { buildFranchiseTree, taxonomyHref, type FranchiseNode, type TaxonomyKind } from "@/lib/taxonomy";
|
||||||
import { useCartStore } from "@/store/cart-store";
|
import { useCartStore } from "@/store/cart-store";
|
||||||
import { useCustomerStore } from "@/store/customer-store";
|
import { useCustomerStore } from "@/store/customer-store";
|
||||||
import { useWishlistStore } from "@/store/wishlist-store";
|
import { useWishlistStore } from "@/store/wishlist-store";
|
||||||
@@ -42,14 +44,34 @@ export function Navbar({ products, categories }: { products: Product[]; categori
|
|||||||
const tree = useMemo(() => buildCategoryTree(categories), [categories]);
|
const tree = useMemo(() => buildCategoryTree(categories), [categories]);
|
||||||
// Derivado de los productos reales (no de un endpoint de colecciones):
|
// Derivado de los productos reales (no de un endpoint de colecciones):
|
||||||
// solo aparecen colecciones que de verdad tienen productos hoy.
|
// solo aparecen colecciones que de verdad tienen productos hoy.
|
||||||
|
// Franquicia > saga > personaje desde los tags (lib/taxonomy.ts). Si el
|
||||||
|
// provider no trae tags (mock/shopify), "Personajes" cae a la lista plana
|
||||||
|
// de colecciones de siempre.
|
||||||
|
const franchises = useMemo(() => buildFranchiseTree(products), [products]);
|
||||||
const collections = useMemo(
|
const collections = useMemo(
|
||||||
() => [...new Set(products.map((product) => product.collection).filter(Boolean))],
|
() => [...new Set(products.map((product) => product.collection).filter(Boolean))],
|
||||||
[products],
|
[products],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Header compacto al hacer scroll (solo desktop, lg+): oculta la franja
|
||||||
|
// promocional y achica logo/padding. Histeresis (compacta pasado 160px,
|
||||||
|
// se expande recien por debajo de 40px) porque achicar un header sticky
|
||||||
|
// cambia la altura del documento -- con un solo umbral el header
|
||||||
|
// parpadea entre los dos estados justo en el borde.
|
||||||
|
const [compact, setCompact] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => {
|
||||||
|
const y = window.scrollY;
|
||||||
|
setCompact((current) => (current ? y > 40 : y > 160));
|
||||||
|
};
|
||||||
|
onScroll();
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener("scroll", onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-40 border-b border-slate-200/80 bg-white/95 shadow-sm backdrop-blur-xl">
|
<header data-compact={compact} className="sticky top-0 z-40 border-b border-slate-200/80 bg-white/95 shadow-sm backdrop-blur-xl">
|
||||||
<div className="bg-ari-marino px-4 py-2 text-center text-xs font-black text-white sm:text-sm">
|
<div className={`bg-ari-marino px-4 py-2 text-center text-xs font-black text-white sm:text-sm ${compact ? "lg:hidden" : ""}`}>
|
||||||
Paga tus compras a cuotas · Envíos nacionales · <span className="underline decoration-ari-coral-claro decoration-2 underline-offset-2">Compra segura con ARI</span>
|
Paga tus compras a cuotas · Envíos nacionales · <span className="underline decoration-ari-coral-claro decoration-2 underline-offset-2">Compra segura con ARI</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,16 +83,19 @@ export function Navbar({ products, categories }: { products: Product[]; categori
|
|||||||
angosto/recortado). En desktop (lg+) se mantiene la fila unica
|
angosto/recortado). En desktop (lg+) se mantiene la fila unica
|
||||||
original -- ahi sobra espacio y partirla en dos solo alargaria
|
original -- ahi sobra espacio y partirla en dos solo alargaria
|
||||||
el header sin necesidad. */}
|
el header sin necesidad. */}
|
||||||
<div className="mx-auto max-w-7xl px-4 py-3 lg:py-4">
|
<div className={`mx-auto max-w-7xl px-4 py-3 transition-[padding] ${compact ? "lg:py-2" : "lg:py-4"}`}>
|
||||||
<div className="flex items-center justify-between gap-4 lg:grid lg:grid-cols-[auto_1fr_auto] lg:gap-8">
|
<div className="flex items-center justify-between gap-4 lg:grid lg:grid-cols-[auto_auto_1fr_auto] lg:gap-5">
|
||||||
{/* Isotipo circular con icono + wordmark integrados (como en la
|
{/* Isotipo circular con icono + wordmark integrados (como en la
|
||||||
version previa a ADR 0008), pedido explicito del usuario en
|
version previa a ADR 0008), pedido explicito del usuario en
|
||||||
vez del lockup rectangular. logo-ari.svg (rectangular) se
|
vez del lockup rectangular. logo-ari.svg (rectangular) se
|
||||||
mantiene para el footer. */}
|
mantiene para el footer. */}
|
||||||
<Link href="/" aria-label="ARI shopping" className="shrink-0">
|
<Link href="/" aria-label="ARI shopping" className="shrink-0">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element -- vector propio, next/image no optimiza SVG sin habilitar dangerouslyAllowSVG */}
|
{/* eslint-disable-next-line @next/next/no-img-element -- vector propio, next/image no optimiza SVG sin habilitar dangerouslyAllowSVG */}
|
||||||
<img src="/images/brand/logo-ari-circular.svg" alt="ARI shopping" className="size-16 lg:size-20" width={200} height={200} />
|
<img src="/images/brand/logo-ari-circular.svg" alt="ARI shopping" className={`size-16 transition-[width,height] ${compact ? "lg:size-12" : "lg:size-20"}`} width={200} height={200} />
|
||||||
</Link>
|
</Link>
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<LocationPicker firstName={isLoggedIn ? customer?.firstName ?? null : null} />
|
||||||
|
</div>
|
||||||
<div className="hidden lg:block">
|
<div className="hidden lg:block">
|
||||||
<PredictiveSearch />
|
<PredictiveSearch />
|
||||||
</div>
|
</div>
|
||||||
@@ -84,6 +109,17 @@ export function Navbar({ products, categories }: { products: Product[]; categori
|
|||||||
<UserRound />
|
<UserRound />
|
||||||
{isLoggedIn && <span className="absolute -right-1 -top-1 size-3 rounded-full border-2 border-white bg-emerald-500" />}
|
{isLoggedIn && <span className="absolute -right-1 -top-1 size-3 rounded-full border-2 border-white bg-emerald-500" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Texto al lado de los iconos solo en desktop (estilo "Hola
|
||||||
|
Cristian / Cuenta y listas" de Amazon): en mobile no hay
|
||||||
|
lugar y los iconos solos ya se entienden. */}
|
||||||
|
<button type="button" onClick={() => setAccountOpen(true)} className="hidden rounded-xl px-2 py-1 text-left leading-tight transition hover:bg-slate-100 xl:block">
|
||||||
|
<span className="block text-xs text-slate-500">{isLoggedIn ? `Hola, ${customer?.firstName || "cliente"}` : "Hola, inicia sesión"}</span>
|
||||||
|
<span className="block text-sm font-black text-ari-marino">Mi cuenta</span>
|
||||||
|
</button>
|
||||||
|
<Link href="/cambios" prefetch={false} className="mr-1 hidden rounded-xl px-2 py-1 leading-tight transition hover:bg-slate-100 xl:block">
|
||||||
|
<span className="block text-xs text-slate-500">Cambios y</span>
|
||||||
|
<span className="block text-sm font-black text-ari-marino">devoluciones</span>
|
||||||
|
</Link>
|
||||||
<Button variant="outline" size="icon" aria-label={`Favoritos: ${wishlistCount}`} className="relative hidden border-slate-300 bg-white text-slate-900 hover:border-ari-coral hover:bg-ari-coral/10 sm:inline-flex" onClick={openWishlist}>
|
<Button variant="outline" size="icon" aria-label={`Favoritos: ${wishlistCount}`} className="relative hidden border-slate-300 bg-white text-slate-900 hover:border-ari-coral hover:bg-ari-coral/10 sm:inline-flex" onClick={openWishlist}>
|
||||||
<Heart />
|
<Heart />
|
||||||
{wishlistCount > 0 && <span className="absolute -right-1.5 -top-1.5 grid size-5 place-items-center rounded-full bg-ari-marino text-[10px] font-black text-white">{wishlistCount}</span>}
|
{wishlistCount > 0 && <span className="absolute -right-1.5 -top-1.5 grid size-5 place-items-center rounded-full bg-ari-marino text-[10px] font-black text-white">{wishlistCount}</span>}
|
||||||
@@ -100,8 +136,8 @@ export function Navbar({ products, categories }: { products: Product[]; categori
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Suspense fallback={<DesktopCategoryNavigationFallback tree={tree} onOpenMenu={() => setMenuOpen(true)} />}>
|
<Suspense fallback={<DesktopCategoryNavigationFallback tree={tree} collections={collections} franchises={franchises} onOpenMenu={() => setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} />}>
|
||||||
<DesktopCategoryNavigation tree={tree} onOpenMenu={() => setMenuOpen(true)} />
|
<DesktopCategoryNavigation tree={tree} collections={collections} franchises={franchises} onOpenMenu={() => setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
|
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
|
||||||
@@ -117,7 +153,28 @@ export function Navbar({ products, categories }: { products: Product[]; categori
|
|||||||
<MobileCategoryLinks tree={tree} onNavigate={() => setMenuOpen(false)} />
|
<MobileCategoryLinks tree={tree} onNavigate={() => setMenuOpen(false)} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
{collections.length > 0 && (
|
{franchises.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="mb-3 mt-8 text-xs font-black uppercase tracking-[.16em] text-ari-marino">Personajes</p>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{franchises.map((franchise) => (
|
||||||
|
<div key={franchise.name}>
|
||||||
|
<Link href={taxonomyHref("franquicia", franchise.name)} prefetch={false} onClick={() => setMenuOpen(false)} className="font-black text-ari-marino hover:text-ari-coral">
|
||||||
|
{franchise.name} <span className="font-bold text-slate-400">({franchise.count})</span>
|
||||||
|
</Link>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{franchise.sagas.map((saga) => (
|
||||||
|
<Link key={saga.name} href={taxonomyHref("saga", saga.name)} prefetch={false} onClick={() => setMenuOpen(false)} className="rounded-full bg-slate-100 px-3 py-1.5 text-sm font-bold hover:bg-ari-coral/10 hover:text-ari-coral">
|
||||||
|
{saga.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{franchises.length === 0 && collections.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<p className="mb-3 mt-8 text-xs font-black uppercase tracking-[.16em] text-ari-marino">Colecciones</p>
|
<p className="mb-3 mt-8 text-xs font-black uppercase tracking-[.16em] text-ari-marino">Colecciones</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -142,16 +199,32 @@ function useCategoryNavigationState() {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const currentCategory = searchParams.get("category") ?? "";
|
const currentCategory = searchParams.get("category") ?? "";
|
||||||
|
const currentCollection = searchParams.get("collection") ?? "";
|
||||||
|
const currentTaxonomy = { franquicia: searchParams.get("franquicia") ?? "", saga: searchParams.get("saga") ?? "", personaje: searchParams.get("personaje") ?? "" };
|
||||||
|
const anyTaxonomy = Boolean(currentTaxonomy.franquicia || currentTaxonomy.saga || currentTaxonomy.personaje);
|
||||||
const isCatalog = pathname === "/catalog";
|
const isCatalog = pathname === "/catalog";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isAllActive: isCatalog && !currentCategory,
|
isAllActive: isCatalog && !currentCategory && !currentCollection && !anyTaxonomy,
|
||||||
isActive: (name: string) => isCatalog && currentCategory === name,
|
isActive: (name: string) => isCatalog && currentCategory === name,
|
||||||
|
isCollectionActive: (name: string) => isCatalog && currentCollection === name,
|
||||||
|
isTaxonomyActive: (kind: TaxonomyKind, name: string) => isCatalog && currentTaxonomy[kind] === name,
|
||||||
|
anyTaxonomyActive: isCatalog && anyTaxonomy,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function pillClass(active: boolean): string {
|
type NavigationState = ReturnType<typeof useCategoryNavigationState>;
|
||||||
return `shrink-0 rounded-full px-4 py-2 text-sm font-extrabold transition ${
|
|
||||||
|
const INACTIVE_NAVIGATION: NavigationState = {
|
||||||
|
isAllActive: false,
|
||||||
|
isActive: () => false,
|
||||||
|
isCollectionActive: () => false,
|
||||||
|
isTaxonomyActive: () => false,
|
||||||
|
anyTaxonomyActive: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function pillClass(active: boolean, dense = false): string {
|
||||||
|
return `shrink-0 rounded-full ${dense ? "px-2.5" : "px-4"} py-2 text-sm font-extrabold transition ${
|
||||||
active
|
active
|
||||||
? "bg-white text-ari-marino shadow-lg shadow-black/10"
|
? "bg-white text-ari-marino shadow-lg shadow-black/10"
|
||||||
: "text-white/90 hover:bg-white/10 hover:text-white"
|
: "text-white/90 hover:bg-white/10 hover:text-white"
|
||||||
@@ -166,26 +239,26 @@ function mobileLinkClass(active: boolean): string {
|
|||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DesktopCategoryNavigation({ tree, onOpenMenu }: { tree: CategoryNode[]; onOpenMenu: () => void }) {
|
type NavBarProps = { tree: CategoryNode[]; collections: string[]; franchises: FranchiseNode[]; onOpenMenu: () => void; onOpenAccount: () => void };
|
||||||
const { isActive, isAllActive } = useCategoryNavigationState();
|
|
||||||
return <CategoryNavBar tree={tree} onOpenMenu={onOpenMenu} isActive={isActive} isAllActive={isAllActive} />;
|
function DesktopCategoryNavigation(props: NavBarProps) {
|
||||||
|
return <CategoryNavBar {...props} state={useCategoryNavigationState()} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DesktopCategoryNavigationFallback({ tree, onOpenMenu }: { tree: CategoryNode[]; onOpenMenu: () => void }) {
|
function DesktopCategoryNavigationFallback(props: NavBarProps) {
|
||||||
return <CategoryNavBar tree={tree} onOpenMenu={onOpenMenu} isActive={() => false} isAllActive={false} />;
|
return <CategoryNavBar {...props} state={INACTIVE_NAVIGATION} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CategoryNavBar({
|
// Desktop: con 1280px+ las 6 categorias raiz entran directo en la barra
|
||||||
tree,
|
// (cada una con su desplegable de subcategorias) -- antes solo se veian
|
||||||
onOpenMenu,
|
// "Departamentos" y "Todo" y el resto de la barra quedaba vacia. Entre
|
||||||
isActive,
|
// 1024 y 1280px no entran todas, asi que ahi se mantiene el mega menu de
|
||||||
isAllActive,
|
// "Departamentos". "Personajes" (colecciones) va a la derecha desde lg.
|
||||||
}: {
|
// La hamburguesa solo en mobile: en desktop repetia lo mismo que la barra.
|
||||||
tree: CategoryNode[];
|
function CategoryNavBar({ tree, collections, franchises, onOpenMenu, onOpenAccount, state }: NavBarProps & { state: NavigationState }) {
|
||||||
onOpenMenu: () => void;
|
const { isActive, isAllActive, isCollectionActive, isTaxonomyActive, anyTaxonomyActive } = state;
|
||||||
isActive: (name: string) => boolean;
|
const anyCollectionActive = collections.some(isCollectionActive) || anyTaxonomyActive;
|
||||||
isAllActive: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<nav className="bg-ari-marino text-white">
|
<nav className="bg-ari-marino text-white">
|
||||||
<div className="relative mx-auto flex max-w-7xl items-center gap-1 px-4 py-2">
|
<div className="relative mx-auto flex max-w-7xl items-center gap-1 px-4 py-2">
|
||||||
@@ -194,24 +267,265 @@ function CategoryNavBar({
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={onOpenMenu}
|
onClick={onOpenMenu}
|
||||||
aria-label="Abrir menú principal"
|
aria-label="Abrir menú principal"
|
||||||
className="mr-2 shrink-0 text-white hover:bg-white/10"
|
className="mr-2 shrink-0 text-white hover:bg-white/10 lg:hidden"
|
||||||
>
|
>
|
||||||
<Menu />
|
<Menu />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<DepartmentsMegaMenu tree={tree} isActive={isActive} />
|
<div className="shrink-0 xl:hidden">
|
||||||
|
<DepartmentsMegaMenu tree={tree} isActive={isActive} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Link href="/catalog" prefetch={false} aria-current={isAllActive ? "page" : undefined} className={pillClass(isAllActive)}>
|
<Link href="/catalog" prefetch={false} aria-current={isAllActive ? "page" : undefined} className={pillClass(isAllActive)}>
|
||||||
Todo
|
Todo
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<div className="hidden items-center gap-0.5 xl:flex">
|
||||||
|
{tree.map((root) => {
|
||||||
|
const active = isActive(root.name) || root.children.some((child) => isActive(child.name));
|
||||||
|
const href = `/catalog?category=${encodeURIComponent(root.name)}`;
|
||||||
|
if (root.children.length === 0) {
|
||||||
|
return (
|
||||||
|
<Link key={root.id} href={href} prefetch={false} aria-current={active ? "page" : undefined} className={pillClass(active, true)}>
|
||||||
|
{root.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<NavDropdown key={root.id} label={root.name} active={active}>
|
||||||
|
{(close) => (
|
||||||
|
<ul className="grid min-w-56 gap-1">
|
||||||
|
{root.children.map((child) => (
|
||||||
|
<li key={child.id}>
|
||||||
|
<Link
|
||||||
|
href={`/catalog?category=${encodeURIComponent(child.name)}`}
|
||||||
|
prefetch={false}
|
||||||
|
aria-current={isActive(child.name) ? "page" : undefined}
|
||||||
|
onClick={close}
|
||||||
|
className={`block rounded-lg px-3 py-2 text-sm transition ${isActive(child.name) ? "bg-white/10 font-bold text-ari-coral-claro" : "text-white/80 hover:bg-white/10 hover:text-white"}`}
|
||||||
|
>
|
||||||
|
{child.name}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li className="mt-1 border-t border-white/10 pt-2">
|
||||||
|
<Link href={href} prefetch={false} onClick={close} className="block rounded-lg px-3 py-2 text-sm font-black text-ari-coral-claro hover:bg-white/10">
|
||||||
|
Ver todo en {root.name}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</NavDropdown>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-auto hidden shrink-0 items-center gap-0.5 border-l border-white/20 pl-2 lg:flex xl:ml-2">
|
||||||
|
{(franchises.length > 0 || collections.length > 0) && (
|
||||||
|
<NavDropdown label="Personajes" active={anyCollectionActive} align="right" icon={<Sparkles className="size-4" />}>
|
||||||
|
{(close) => franchises.length > 0 ? (
|
||||||
|
<FranchisePanel franchises={franchises} isTaxonomyActive={isTaxonomyActive} close={close} />
|
||||||
|
) : (
|
||||||
|
<div className="grid w-[28rem] grid-cols-2 gap-1.5">
|
||||||
|
{collections.map((collection) => (
|
||||||
|
<Link
|
||||||
|
key={collection}
|
||||||
|
href={`/catalog?collection=${encodeURIComponent(collection)}`}
|
||||||
|
prefetch={false}
|
||||||
|
aria-current={isCollectionActive(collection) ? "page" : undefined}
|
||||||
|
onClick={close}
|
||||||
|
className={`rounded-xl px-3 py-2.5 text-sm font-bold transition ${isCollectionActive(collection) ? "bg-white text-ari-marino" : "bg-white/5 text-white hover:bg-white/15"}`}
|
||||||
|
>
|
||||||
|
{collection}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</NavDropdown>
|
||||||
|
)}
|
||||||
|
{/* Equivalente al "Podemos ayudarte" de Amazon, con las paginas
|
||||||
|
de ayuda que la tienda ya tiene (antes solo en el footer). */}
|
||||||
|
<NavDropdown label="Ayuda" labelClassName="sr-only" ariaLabel="Ayuda" active={false} align="right" icon={<LifeBuoy className="size-4" />}>
|
||||||
|
{(close) => (
|
||||||
|
<ul className="grid min-w-60 gap-1">
|
||||||
|
<li><button type="button" onClick={() => { close(); onOpenAccount(); }} className={helpLinkClass}>Tu cuenta</button></li>
|
||||||
|
<li><Link href="/cuenta/pedidos" prefetch={false} onClick={close} className={helpLinkClass}>Tus pedidos y reseñas</Link></li>
|
||||||
|
<li><Link href="/envios" prefetch={false} onClick={close} className={helpLinkClass}>Envíos y tarifas</Link></li>
|
||||||
|
<li><Link href="/cambios" prefetch={false} onClick={close} className={helpLinkClass}>Cambios y devoluciones</Link></li>
|
||||||
|
<li><Link href="/preguntas-frecuentes" prefetch={false} onClick={close} className={helpLinkClass}>Preguntas frecuentes</Link></li>
|
||||||
|
<li><Link href="/contacto" prefetch={false} onClick={close} className={helpLinkClass}>Contacto</Link></li>
|
||||||
|
<li className="mt-1 border-t border-white/10 pt-2">
|
||||||
|
<a href="https://wa.me/573016138155" target="_blank" rel="noopener noreferrer" onClick={close} className={`${helpLinkClass} font-black text-ari-coral-claro`}>Escríbenos por WhatsApp</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</NavDropdown>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Con mouse, el mismo gesto dispara mouseenter (abre) y click: si el click
|
||||||
|
// alternara, cerraria el menu que se acaba de abrir por hover y para el
|
||||||
|
// usuario "no abre" (reportado por frontend-critic, reproducido 4/4 con
|
||||||
|
// Playwright; el bug ya existia en DepartmentsMegaMenu en prod). Un click
|
||||||
|
// dentro de los 600ms posteriores a abrir por hover lo deja abierto; en
|
||||||
|
// touch (sin hover) el click sigue alternando normal.
|
||||||
|
// El hover usa onPointerEnter/Leave filtrando pointerType === "mouse": en
|
||||||
|
// touch, Chromium emite un mouseenter+mouseleave sinteticos por cada tap, y
|
||||||
|
// ese mouseleave fantasma agendaba el cierre de 200ms -- el menu se cerraba
|
||||||
|
// solo en tablets (iPad horizontal cae en el rango del mega menu).
|
||||||
|
// Reportado por frontend-critic con log de eventos del navegador.
|
||||||
|
function keepOpenAfterHover(open: boolean, hoverOpenedAt: number): boolean {
|
||||||
|
if (open && Date.now() - hoverOpenedAt < 600) return true;
|
||||||
|
return !open;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mega panel de "Personajes" (inspirado en el menu "Theme" de Entertainment
|
||||||
|
// Earth): una columna por franquicia con su total, sus sagas y los
|
||||||
|
// personajes de cada saga -- "Disney" trae todo Disney (Pixar incluido),
|
||||||
|
// "Spider-Man" solo lo de Spider-Man.
|
||||||
|
function FranchisePanel({
|
||||||
|
franchises,
|
||||||
|
isTaxonomyActive,
|
||||||
|
close,
|
||||||
|
}: {
|
||||||
|
franchises: FranchiseNode[];
|
||||||
|
isTaxonomyActive: (kind: TaxonomyKind, name: string) => boolean;
|
||||||
|
close: () => void;
|
||||||
|
}) {
|
||||||
|
const link = (kind: TaxonomyKind, name: string, base: string) => (
|
||||||
|
<Link
|
||||||
|
href={taxonomyHref(kind, name)}
|
||||||
|
prefetch={false}
|
||||||
|
aria-current={isTaxonomyActive(kind, name) ? "page" : undefined}
|
||||||
|
onClick={close}
|
||||||
|
className={`${base} ${isTaxonomyActive(kind, name) ? "text-ari-coral-claro" : ""}`}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="grid w-max max-w-[min(92vw,56rem)] gap-x-8 gap-y-5 p-2" style={{ gridTemplateColumns: `repeat(${Math.min(franchises.length, 4)}, minmax(10rem, 1fr))` }}>
|
||||||
|
{franchises.map((franchise) => (
|
||||||
|
<div key={franchise.name}>
|
||||||
|
<p className="flex items-baseline gap-1.5 border-b border-white/15 pb-2">
|
||||||
|
{link("franquicia", franchise.name, "text-sm font-black uppercase tracking-wide text-white hover:text-ari-coral-claro")}
|
||||||
|
<span className="text-xs font-bold text-white/50">{franchise.count}</span>
|
||||||
|
</p>
|
||||||
|
<ul className="mt-2.5 grid gap-2.5">
|
||||||
|
{franchise.sagas.map((saga) => (
|
||||||
|
<li key={saga.name}>
|
||||||
|
{link("saga", saga.name, "text-sm font-bold text-white hover:text-ari-coral-claro")}
|
||||||
|
{saga.characters.length > 1 || (saga.characters[0] && saga.characters[0].name !== saga.name) ? (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-x-2 gap-y-0.5">
|
||||||
|
{saga.characters.map((character) => (
|
||||||
|
<span key={character.name}>{link("personaje", character.name, "text-xs text-white/80 hover:text-white")}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const helpLinkClass = "block w-full rounded-lg px-3 py-2 text-left text-sm text-white/80 transition hover:bg-white/10 hover:text-white";
|
||||||
|
|
||||||
|
// Desplegable con la misma "intencion de hover" que DepartmentsMegaMenu
|
||||||
|
// (cierre con 200ms de gracia para movimientos en diagonal, click afuera y
|
||||||
|
// Escape cierran), pero anclado al propio boton en vez de a todo el ancho.
|
||||||
|
function NavDropdown({
|
||||||
|
label,
|
||||||
|
labelClassName,
|
||||||
|
ariaLabel,
|
||||||
|
active,
|
||||||
|
align = "left",
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
labelClassName?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
active: boolean;
|
||||||
|
align?: "left" | "right";
|
||||||
|
icon?: ReactNode;
|
||||||
|
children: (close: () => void) => ReactNode;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const hoverOpenedAt = useRef(0);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const cancelClose = () => {
|
||||||
|
if (closeTimer.current) {
|
||||||
|
clearTimeout(closeTimer.current);
|
||||||
|
closeTimer.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const scheduleClose = () => {
|
||||||
|
cancelClose();
|
||||||
|
closeTimer.current = setTimeout(() => setOpen(false), 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function handlePointerDown(event: PointerEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false);
|
||||||
|
}
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
// Devolver el foco al boton que abrio el menu: si no, queda en
|
||||||
|
// <body> y quien navega con teclado pierde su posicion (reportado
|
||||||
|
// por frontend-critic).
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("pointerdown", handlePointerDown);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", handlePointerDown);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => () => cancelClose(), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative shrink-0" onPointerEnter={(event) => { if (event.pointerType !== "mouse") return; cancelClose(); if (!open) hoverOpenedAt.current = Date.now(); setOpen(true); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") scheduleClose(); }}>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
onClick={() => setOpen((value) => keepOpenAfterHover(value, hoverOpenedAt.current))}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="true"
|
||||||
|
className={`flex items-center gap-1.5 ${pillClass(active || open, true)}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span className={labelClassName}>{label}</span>
|
||||||
|
<ChevronDown className={`size-3.5 opacity-70 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className={`absolute top-full z-50 pt-1 ${align === "right" ? "right-0" : "left-0"}`}>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-ari-marino p-3 shadow-2xl">{children(() => setOpen(false))}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActive: (name: string) => boolean }) {
|
function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActive: (name: string) => boolean }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const hoverOpenedAt = useRef(0);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const cancelClose = () => {
|
const cancelClose = () => {
|
||||||
@@ -237,7 +551,13 @@ function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActiv
|
|||||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false);
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false);
|
||||||
}
|
}
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
if (event.key === "Escape") setOpen(false);
|
if (event.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
// Devolver el foco al boton que abrio el menu: si no, queda en
|
||||||
|
// <body> y quien navega con teclado pierde su posicion (reportado
|
||||||
|
// por frontend-critic).
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener("pointerdown", handlePointerDown);
|
document.addEventListener("pointerdown", handlePointerDown);
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
@@ -251,14 +571,16 @@ function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActiv
|
|||||||
|
|
||||||
const openNow = () => {
|
const openNow = () => {
|
||||||
cancelClose();
|
cancelClose();
|
||||||
|
if (!open) hoverOpenedAt.current = Date.now();
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="shrink-0" onMouseEnter={openNow} onMouseLeave={scheduleClose}>
|
<div ref={containerRef} className="shrink-0" onPointerEnter={(event) => { if (event.pointerType === "mouse") openNow(); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") scheduleClose(); }}>
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((value) => !value)}
|
onClick={() => setOpen((value) => keepOpenAfterHover(value, hoverOpenedAt.current))}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-label="Departamentos"
|
aria-label="Departamentos"
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { StarRating } from "@/components/product/star-rating";
|
||||||
|
import { useReviewSummaryStore } from "@/store/review-summary-store";
|
||||||
|
|
||||||
|
// Promedio + cantidad de reseñas aprobadas. No renderiza nada si el
|
||||||
|
// producto aun no tiene reseñas (ni un link vacio, en la ficha).
|
||||||
|
export function CardRating({ productId, href }: { productId: string; href?: string }) {
|
||||||
|
const load = useReviewSummaryStore((state) => state.load);
|
||||||
|
const entry = useReviewSummaryStore((state) => state.summary[productId]);
|
||||||
|
useEffect(() => load(), [load]);
|
||||||
|
if (!entry || entry.count === 0) return null;
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<StarRating value={entry.average} />
|
||||||
|
{entry.average.toLocaleString("es-CO", { minimumFractionDigits: 1 })} ({entry.count} {entry.count === 1 ? "reseña" : "reseñas"})
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
const className = "flex items-center gap-1.5 text-xs font-bold text-slate-500";
|
||||||
|
return href ? <a href={href} className={`${className} mt-3 hover:text-ari-coral`}>{content}</a> : <span className={className}>{content}</span>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
|
// Galería deslizable de la tarjeta de producto: permite ver todas las
|
||||||
|
// imágenes desde el listado sin entrar al producto. En móvil se desliza
|
||||||
|
// con el dedo (scroll-snap nativo); en escritorio aparecen flechas al
|
||||||
|
// pasar el mouse. Cada imagen sigue siendo un link a la ficha.
|
||||||
|
export function ProductCardGallery({
|
||||||
|
images,
|
||||||
|
name,
|
||||||
|
href,
|
||||||
|
priority = false,
|
||||||
|
}: {
|
||||||
|
images: string[];
|
||||||
|
name: string;
|
||||||
|
href: string;
|
||||||
|
priority?: boolean;
|
||||||
|
}) {
|
||||||
|
const scroller = useRef<HTMLDivElement>(null);
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const total = images.length;
|
||||||
|
|
||||||
|
const goTo = (target: number) => {
|
||||||
|
const node = scroller.current;
|
||||||
|
if (!node) return;
|
||||||
|
const next = Math.max(0, Math.min(total - 1, target));
|
||||||
|
node.scrollTo({ left: next * node.clientWidth, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
const node = scroller.current;
|
||||||
|
if (!node || node.clientWidth === 0) return;
|
||||||
|
setIndex(Math.round(node.scrollLeft / node.clientWidth));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={scroller}
|
||||||
|
onScroll={total > 1 ? onScroll : undefined}
|
||||||
|
className="flex h-full snap-x snap-mandatory overflow-x-auto [overscroll-behavior-x:contain] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||||
|
>
|
||||||
|
{images.map((src, slide) => (
|
||||||
|
<Link
|
||||||
|
key={src}
|
||||||
|
href={href}
|
||||||
|
prefetch={false}
|
||||||
|
className="relative block h-full w-full shrink-0 snap-center snap-always"
|
||||||
|
aria-label={total > 1 ? `${name}, imagen ${slide + 1} de ${total}` : name}
|
||||||
|
tabIndex={slide === 0 ? 0 : -1}
|
||||||
|
>
|
||||||
|
{/* object-contain (no object-cover): las fotos secundarias del
|
||||||
|
catálogo no son cuadradas (verticales tipo 933x1500 u
|
||||||
|
horizontales) y cover les cortaba cabeza, pies o bordes
|
||||||
|
-- reportado en vivo con Tristeza y Buzz. Mismo criterio que
|
||||||
|
product-gallery.tsx; el letterbox queda del bg-white de la
|
||||||
|
tarjeta. */}
|
||||||
|
{/* unoptimized desde la 2da imagen: pasar todas por el
|
||||||
|
optimizador de Next multiplica el trabajo de sharp dentro
|
||||||
|
del pod (~58 imagenes en el catalogo vs. 18 antes) -- una
|
||||||
|
carga en rafaga de todas las galerias dejo un pod de prod
|
||||||
|
OOMKilled (limite 768Mi) el 2026-09-27. Los originales de
|
||||||
|
media.cruzcloud.net pesan ~165 KB promedio y son lazy: solo
|
||||||
|
se bajan al deslizar. La 1ra sigue optimizada como antes. */}
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={slide === 0 ? name : `${name} (imagen ${slide + 1})`}
|
||||||
|
fill
|
||||||
|
unoptimized={slide > 0}
|
||||||
|
priority={priority && slide === 0}
|
||||||
|
sizes="(max-width: 640px) 82vw, (max-width: 1024px) 44vw, 280px"
|
||||||
|
className="object-contain transition duration-500 group-hover:scale-[1.035]"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{total > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Imagen anterior"
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => goTo(index - 1)}
|
||||||
|
className="absolute left-2 top-1/2 hidden size-9 -translate-y-1/2 place-items-center rounded-full bg-white/95 text-ari-marino opacity-0 shadow-lg transition group-hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none disabled:!opacity-0 pointer-fine:grid"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Imagen siguiente"
|
||||||
|
disabled={index === total - 1}
|
||||||
|
onClick={() => goTo(index + 1)}
|
||||||
|
className="absolute right-2 top-1/2 hidden size-9 -translate-y-1/2 place-items-center rounded-full bg-white/95 text-ari-marino opacity-0 shadow-lg transition group-hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none disabled:!opacity-0 pointer-fine:grid"
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-5" />
|
||||||
|
</button>
|
||||||
|
<div className="absolute inset-x-0 bottom-2 flex justify-center">
|
||||||
|
<div className="flex items-center rounded-full bg-white/85 px-1 shadow-sm backdrop-blur">
|
||||||
|
{images.map((src, dot) => (
|
||||||
|
<button
|
||||||
|
key={src}
|
||||||
|
type="button"
|
||||||
|
aria-label={`Ver imagen ${dot + 1} de ${total}`}
|
||||||
|
aria-current={dot === index}
|
||||||
|
onClick={() => goTo(dot)}
|
||||||
|
className="grid h-5 place-items-center px-[3px]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`block h-1.5 rounded-full transition-all ${
|
||||||
|
dot === index ? "w-4 bg-ari-coral" : "w-1.5 bg-ari-marino/35"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Heart, ShoppingBag, Sparkles, TriangleAlert } from "lucide-react";
|
import { Heart, ShoppingBag, Sparkles, TriangleAlert } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { CardRating } from "@/components/product/card-rating";
|
||||||
|
import { ProductCardGallery } from "@/components/product/product-card-gallery";
|
||||||
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";
|
||||||
@@ -22,6 +23,9 @@ export function ProductCard({ product, priority = false }: { product: Product; p
|
|||||||
? Math.round((1 - product.price / product.compareAtPrice) * 100)
|
? Math.round((1 - product.price / product.compareAtPrice) * 100)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const galleryImages = Array.from(new Set(product.images.filter(Boolean)));
|
||||||
|
if (galleryImages.length === 0) galleryImages.push(product.thumbnail);
|
||||||
|
|
||||||
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 selectedVariant = product.variants.find((variant) => variant.id === selectedVariantId) ?? product.variants[0];
|
||||||
const outOfStock = selectedVariant?.available === false;
|
const outOfStock = selectedVariant?.available === false;
|
||||||
@@ -37,7 +41,7 @@ export function ProductCard({ product, priority = false }: { product: Product; p
|
|||||||
return (
|
return (
|
||||||
<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">
|
<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">
|
<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>
|
<ProductCardGallery images={galleryImages} name={product.name} href={`/product/${product.slug}`} priority={priority} />
|
||||||
<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>
|
<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" />}
|
{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>
|
<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>
|
||||||
@@ -45,6 +49,7 @@ export function ProductCard({ product, priority = false }: { product: Product; p
|
|||||||
<div className="flex flex-1 flex-col space-y-3 p-5">
|
<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-md bg-ari-coral-claro/10 px-2 py-1 text-ari-marino">{product.collection}</span></div>
|
<div className="flex items-center justify-between gap-2 text-xs font-bold text-slate-500"><span>{product.category}</span><span className="rounded-md bg-ari-coral-claro/10 px-2 py-1 text-ari-marino">{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-ari-marino group-hover:text-ari-coral">{product.name}</h3></Link>
|
<Link href={`/product/${product.slug}`} prefetch={false}><h3 className="line-clamp-2 min-h-12 text-lg font-black leading-6 text-ari-marino group-hover:text-ari-coral">{product.name}</h3></Link>
|
||||||
|
<CardRating productId={product.id} />
|
||||||
<p className="line-clamp-2 min-h-10 text-sm leading-5 text-slate-500">{product.shortDescription}</p>
|
<p className="line-clamp-2 min-h-10 text-sm leading-5 text-slate-500">{product.shortDescription}</p>
|
||||||
{product.variants.length > 1 && (
|
{product.variants.length > 1 && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { BadgeCheck, MessageSquareText } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { StarRating } from "@/components/product/star-rating";
|
||||||
|
|
||||||
|
type Review = { id: string; author_name: string; rating: number; title: string | null; content: string; created_at: string; verified_purchase: boolean };
|
||||||
|
|
||||||
|
// Reseñas de la ficha: solo las aprobadas por el dueño (el backend no
|
||||||
|
// devuelve las pendientes), siempre de compradores verificados.
|
||||||
|
export function ProductReviews({ productId }: { productId: string }) {
|
||||||
|
const [data, setData] = useState<{ reviews: Review[]; average: number; count: number } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/reviews?product_id=${encodeURIComponent(productId)}`)
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then(setData)
|
||||||
|
.catch(() => setData({ reviews: [], average: 0, count: 0 }));
|
||||||
|
}, [productId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="resenas" aria-labelledby="resenas-titulo" className="mt-10 scroll-mt-40">
|
||||||
|
<h2 id="resenas-titulo" className="flex items-center gap-2 text-sm font-black uppercase tracking-wide text-slate-500">
|
||||||
|
<MessageSquareText className="size-4" />
|
||||||
|
Reseñas de clientes
|
||||||
|
</h2>
|
||||||
|
{!data ? (
|
||||||
|
<p className="mt-4 text-sm text-slate-400">Cargando reseñas…</p>
|
||||||
|
) : data.count === 0 ? (
|
||||||
|
<div className="mt-4 rounded-2xl border border-dashed border-slate-300 p-5 text-sm text-slate-600">
|
||||||
|
Aún no hay reseñas de este producto. ¿Ya lo compraste?{" "}
|
||||||
|
<Link href="/cuenta/pedidos" className="font-bold text-ari-marino underline decoration-ari-coral-claro underline-offset-2">Califícalo desde Mis pedidos</Link>.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mt-4 flex items-center gap-3">
|
||||||
|
<StarRating value={data.average} size="lg" />
|
||||||
|
<span className="text-2xl font-black text-ari-marino">{data.average.toLocaleString("es-CO", { minimumFractionDigits: 1 })}</span>
|
||||||
|
<span className="text-sm text-slate-500">de 5 · {data.count} {data.count === 1 ? "reseña" : "reseñas"}</span>
|
||||||
|
</div>
|
||||||
|
<ul className="mt-6 grid gap-4">
|
||||||
|
{data.reviews.map((review) => (
|
||||||
|
<li key={review.id} className="rounded-2xl border border-slate-200 p-5">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
|
<StarRating value={review.rating} />
|
||||||
|
{review.title && <strong className="text-slate-900">{review.title}</strong>}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 whitespace-pre-line text-sm leading-6 text-slate-700">{review.content}</p>
|
||||||
|
<p className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500">
|
||||||
|
<span className="font-bold text-slate-700">{review.author_name}</span>
|
||||||
|
<span>{new Date(review.created_at).toLocaleDateString("es-CO", { year: "numeric", month: "long", day: "numeric" })}</span>
|
||||||
|
{review.verified_purchase && (
|
||||||
|
<span className="inline-flex items-center gap-1 font-bold text-emerald-700"><BadgeCheck className="size-3.5" />Compra verificada</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Star } from "lucide-react";
|
||||||
|
|
||||||
|
// Estrellas de solo lectura. El relleno parcial (ej. 4.5) se dibuja
|
||||||
|
// recortando una capa de estrellas llenas encima de las vacias. amber-600
|
||||||
|
// (no 400): contraste ~3.2:1 sobre blanco, minimo WCAG 1.4.11 para un
|
||||||
|
// grafico informativo (amber-400 daba 1.7:1, reportado por frontend-critic).
|
||||||
|
export function StarRating({ value, size = "sm", className = "" }: { value: number; size?: "sm" | "md" | "lg"; className?: string }) {
|
||||||
|
const px = size === "lg" ? "size-6" : size === "md" ? "size-5" : "size-4";
|
||||||
|
const pct = Math.max(0, Math.min(100, (value / 5) * 100));
|
||||||
|
return (
|
||||||
|
<span className={`relative inline-flex ${className}`} role="img" aria-label={`${value.toLocaleString("es-CO")} de 5 estrellas`}>
|
||||||
|
<span className="flex text-slate-300">{[0, 1, 2, 3, 4].map((i) => <Star key={i} className={`${px} fill-current`} />)}</span>
|
||||||
|
<span className="absolute inset-0 flex overflow-hidden text-amber-600" style={{ width: `${pct}%` }}>
|
||||||
|
{[0, 1, 2, 3, 4].map((i) => <Star key={i} className={`${px} shrink-0 fill-current`} />)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,14 +25,14 @@ spec:
|
|||||||
runAsUser: 1001
|
runAsUser: 1001
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- name: app
|
||||||
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.275
|
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.293
|
||||||
env:
|
env:
|
||||||
# La actualiza el mismo paso del CD (.gitea/workflows/build.yaml)
|
# La actualiza el mismo paso del CD (.gitea/workflows/build.yaml)
|
||||||
# que bumpea el tag de la imagen -- el footer la lee
|
# que bumpea el tag de la imagen -- el footer la lee
|
||||||
# (process.env.APP_VERSION) para mostrar la versión real
|
# (process.env.APP_VERSION) para mostrar la versión real
|
||||||
# desplegada en vez de un "v4.1" fijo.
|
# desplegada en vez de un "v4.1" fijo.
|
||||||
- name: APP_VERSION
|
- name: APP_VERSION
|
||||||
value: v1.0.275
|
value: v1.0.293
|
||||||
- name: HEADLESS_PROVIDER
|
- name: HEADLESS_PROVIDER
|
||||||
value: medusa
|
value: medusa
|
||||||
- name: MEDUSA_BACKEND_URL
|
- name: MEDUSA_BACKEND_URL
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// Reseñas de productos + pedidos del cliente (Store API de Medusa, modulo
|
||||||
|
// propio commerce-backend/src/modules/product-review). Server-side y sin
|
||||||
|
// cache: las reseñas cambian al aprobarse en el admin y los pedidos son
|
||||||
|
// estado de sesion.
|
||||||
|
|
||||||
|
function backendUrl(): string {
|
||||||
|
const value = process.env.MEDUSA_BACKEND_URL;
|
||||||
|
if (!value) throw new Error("MEDUSA_BACKEND_URL no está configurado.");
|
||||||
|
return value.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function medusaFetch<T>(path: string, init?: { method?: string; body?: unknown; token?: string }): Promise<T> {
|
||||||
|
const publishableKey = process.env.MEDUSA_PUBLISHABLE_KEY;
|
||||||
|
const response = await fetch(`${backendUrl()}${path}`, {
|
||||||
|
method: init?.method ?? "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(publishableKey ? { "x-publishable-api-key": publishableKey } : {}),
|
||||||
|
...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}),
|
||||||
|
},
|
||||||
|
body: init?.body ? JSON.stringify(init.body) : undefined,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = typeof data.message === "string" ? data.message : `Error ${response.status}`;
|
||||||
|
throw Object.assign(new Error(message), { status: response.status });
|
||||||
|
}
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicReview {
|
||||||
|
id: string;
|
||||||
|
author_name: string;
|
||||||
|
rating: number;
|
||||||
|
title: string | null;
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
verified_purchase: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewSummary {
|
||||||
|
average: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MyReview {
|
||||||
|
id: string;
|
||||||
|
product_id: string;
|
||||||
|
order_id: string;
|
||||||
|
rating: number;
|
||||||
|
status: "pending" | "approved" | "rejected";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerOrder {
|
||||||
|
id: string;
|
||||||
|
displayId: number | null;
|
||||||
|
createdAt: string;
|
||||||
|
status: string;
|
||||||
|
total: number | null;
|
||||||
|
currency: string;
|
||||||
|
items: { productId: string; title: string; thumbnail: string | null; handle: string | null; quantity: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProductReviews(productId: string) {
|
||||||
|
return medusaFetch<{ reviews: PublicReview[]; average: number; count: number }>(`/store/reviews?product_id=${encodeURIComponent(productId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReviewSummary(): Promise<Record<string, ReviewSummary>> {
|
||||||
|
return (await medusaFetch<{ summary: Record<string, ReviewSummary> }>("/store/reviews/summary")).summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMyReviews(token: string): Promise<MyReview[]> {
|
||||||
|
return (await medusaFetch<{ reviews: MyReview[] }>("/store/reviews/me", { token })).reviews;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createReview(token: string, body: { product_id: string; order_id: string; rating: number; title?: string; content: string }) {
|
||||||
|
return medusaFetch<{ review: { id: string; status: string } }>("/store/reviews", { method: "POST", body, token });
|
||||||
|
}
|
||||||
|
|
||||||
|
type MedusaOrder = {
|
||||||
|
id: string;
|
||||||
|
display_id?: number | null;
|
||||||
|
created_at: string;
|
||||||
|
status: string;
|
||||||
|
total?: number | null;
|
||||||
|
currency_code?: string | null;
|
||||||
|
items?: { product_id?: string | null; product_title?: string | null; title?: string | null; thumbnail?: string | null; product_handle?: string | null; quantity?: number }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getMyOrders(token: string): Promise<CustomerOrder[]> {
|
||||||
|
const fields = "id,display_id,created_at,status,total,currency_code,*items";
|
||||||
|
const data = await medusaFetch<{ orders: MedusaOrder[] }>(`/store/orders?limit=50&order=-created_at&fields=${encodeURIComponent(fields)}`, { token });
|
||||||
|
return data.orders.map((order) => ({
|
||||||
|
id: order.id,
|
||||||
|
displayId: order.display_id ?? null,
|
||||||
|
createdAt: order.created_at,
|
||||||
|
status: order.status,
|
||||||
|
total: typeof order.total === "number" ? order.total : null,
|
||||||
|
currency: (order.currency_code ?? "cop").toUpperCase(),
|
||||||
|
items: (order.items ?? [])
|
||||||
|
.filter((item) => item.product_id)
|
||||||
|
.map((item) => ({
|
||||||
|
productId: item.product_id as string,
|
||||||
|
title: item.product_title || item.title || "Producto",
|
||||||
|
thumbnail: item.thumbnail ?? null,
|
||||||
|
handle: item.product_handle ?? null,
|
||||||
|
quantity: item.quantity ?? 1,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { Product } from "@/lib/headless/types";
|
||||||
|
|
||||||
|
// Taxonomia Franquicia > Saga > Personaje (ej. Disney > Toy Story > Rex,
|
||||||
|
// Marvel > Spider-Man > Spider-Man). Vive en los tags de Medusa con
|
||||||
|
// prefijo ("franquicia:Disney", "saga:Toy Story", "personaje:Rex") porque
|
||||||
|
// la coleccion de Medusa es una sola por producto y mezclaba niveles
|
||||||
|
// ("Disney" y "Toy Story" como pares). Los tags son muchos-a-muchos: un
|
||||||
|
// producto puede tener varios personajes ("Minnie y Mickey").
|
||||||
|
export type TaxonomyKind = "franquicia" | "saga" | "personaje";
|
||||||
|
|
||||||
|
export const TAXONOMY_KINDS: TaxonomyKind[] = ["franquicia", "saga", "personaje"];
|
||||||
|
|
||||||
|
export function taxonomyValues(product: Product, kind: TaxonomyKind): string[] {
|
||||||
|
const prefix = `${kind}:`;
|
||||||
|
return product.tags.filter((tag) => tag.startsWith(prefix)).map((tag) => tag.slice(prefix.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TaxonomyFilter = Partial<Record<TaxonomyKind, string>>;
|
||||||
|
|
||||||
|
export function matchesTaxonomy(product: Product, filter: TaxonomyFilter): boolean {
|
||||||
|
return TAXONOMY_KINDS.every((kind) => {
|
||||||
|
const value = filter[kind];
|
||||||
|
return !value || taxonomyValues(product, kind).includes(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SagaNode = { name: string; count: number; characters: { name: string; count: number }[] };
|
||||||
|
export type FranchiseNode = { name: string; count: number; sagas: SagaNode[] };
|
||||||
|
|
||||||
|
function byCountThenName<T extends { name: string; count: number }>(a: T, b: T): number {
|
||||||
|
return b.count - a.count || a.name.localeCompare(b.name, "es");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFranchiseTree(products: Product[]): FranchiseNode[] {
|
||||||
|
const franchises = new Map<string, { count: number; sagas: Map<string, { count: number; characters: Map<string, number> }> }>();
|
||||||
|
for (const product of products) {
|
||||||
|
const sagas = taxonomyValues(product, "saga");
|
||||||
|
const characters = taxonomyValues(product, "personaje");
|
||||||
|
for (const franchise of taxonomyValues(product, "franquicia")) {
|
||||||
|
const node = franchises.get(franchise) ?? { count: 0, sagas: new Map() };
|
||||||
|
node.count += 1;
|
||||||
|
for (const saga of sagas) {
|
||||||
|
const sagaNode = node.sagas.get(saga) ?? { count: 0, characters: new Map() };
|
||||||
|
sagaNode.count += 1;
|
||||||
|
for (const character of characters) sagaNode.characters.set(character, (sagaNode.characters.get(character) ?? 0) + 1);
|
||||||
|
node.sagas.set(saga, sagaNode);
|
||||||
|
}
|
||||||
|
franchises.set(franchise, node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...franchises.entries()]
|
||||||
|
.map(([name, node]) => ({
|
||||||
|
name,
|
||||||
|
count: node.count,
|
||||||
|
sagas: [...node.sagas.entries()]
|
||||||
|
.map(([sagaName, sagaNode]) => ({
|
||||||
|
name: sagaName,
|
||||||
|
count: sagaNode.count,
|
||||||
|
characters: [...sagaNode.characters.entries()].map(([characterName, count]) => ({ name: characterName, count })).sort(byCountThenName),
|
||||||
|
}))
|
||||||
|
.sort(byCountThenName),
|
||||||
|
}))
|
||||||
|
.sort(byCountThenName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function taxonomyHref(kind: TaxonomyKind, value: string): string {
|
||||||
|
return `/catalog?${kind}=${encodeURIComponent(value)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
// Ciudad de envio elegida desde el header ("Enviar a ... · Cali"), mismo
|
||||||
|
// patron persistido que wishlist/cart. Se guardan los ids DIVIPOLA (no
|
||||||
|
// texto libre, ver lib/data/colombia.ts) para que el checkout arranque con
|
||||||
|
// el mismo departamento/ciudad ya seleccionados.
|
||||||
|
interface LocationState {
|
||||||
|
departmentId: number | null;
|
||||||
|
cityId: number | null;
|
||||||
|
hasHydrated: boolean;
|
||||||
|
setHasHydrated: (value: boolean) => void;
|
||||||
|
setLocation: (departmentId: number, cityId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLocationStore = create<LocationState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
departmentId: null,
|
||||||
|
cityId: null,
|
||||||
|
hasHydrated: false,
|
||||||
|
setHasHydrated: (value) => set({ hasHydrated: value }),
|
||||||
|
setLocation: (departmentId, cityId) => set({ departmentId, cityId }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "ari-shopping-location-v1",
|
||||||
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
partialize: (state) => ({ departmentId: state.departmentId, cityId: state.cityId }),
|
||||||
|
onRehydrateStorage: () => (state) => state?.setHasHydrated(true),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
// Promedio/cantidad de reseñas aprobadas por producto, para las estrellas
|
||||||
|
// de las tarjetas. Se pide UNA vez por sesion (/api/reviews/summary) en
|
||||||
|
// vez de una llamada por tarjeta, y sin depender del cache ISR de las
|
||||||
|
// paginas (una reseña recien aprobada aparece al recargar).
|
||||||
|
interface ReviewSummaryState {
|
||||||
|
summary: Record<string, { average: number; count: number }>;
|
||||||
|
status: "idle" | "loading" | "ready";
|
||||||
|
load: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useReviewSummaryStore = create<ReviewSummaryState>()((set, get) => ({
|
||||||
|
summary: {},
|
||||||
|
status: "idle",
|
||||||
|
load: () => {
|
||||||
|
if (get().status !== "idle") return;
|
||||||
|
set({ status: "loading" });
|
||||||
|
fetch("/api/reviews/summary")
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => set({ summary: data.summary ?? {}, status: "ready" }))
|
||||||
|
.catch(() => set({ status: "ready" }));
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in new issue
Block a user