feat(ADM-018): completed feature

This commit is contained in:
chattie
2026-08-17 22:23:10 +02:00
parent cf1c69fc8b
commit d595b4871f
871 changed files with 47411 additions and 281 deletions

View File

@@ -0,0 +1,54 @@
import { timingSafeEqual } from 'node:crypto';
import { revalidatePath } from 'next/cache';
import { NextResponse, type NextRequest } from 'next/server';
const SUPPORTED_PATHS = [
/^\/productos\/[a-z0-9]+(?:-[a-z0-9]+)*$/,
/^\/categoria\/[a-z0-9]+(?:-[a-z0-9]+)*$/,
/^\/marca\/[a-z0-9]+(?:-[a-z0-9]+)*$/,
/^\/products\/search$/,
];
function isSupportedCatalogPath(path: unknown): path is string {
return typeof path === 'string' && SUPPORTED_PATHS.some((pattern) => pattern.test(path));
}
function secretsMatch(actual: string | null, expected: string): boolean {
if (actual === null) return false;
const actualBuffer = Buffer.from(actual);
const expectedBuffer = Buffer.from(expected);
return (
actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer)
);
}
export async function POST(request: NextRequest) {
const secret = process.env.REVALIDATE_SECRET;
if (!secret) {
return NextResponse.json(
{ revalidated: false, error: 'Revalidation is not configured' },
{ status: 503 },
);
}
if (!secretsMatch(request.headers.get('x-revalidate-secret'), secret)) {
return NextResponse.json({ revalidated: false, error: 'Unauthorized' }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ revalidated: false, error: 'Invalid JSON body' }, { status: 400 });
}
const path = typeof body === 'object' && body !== null && 'path' in body ? body.path : undefined;
if (!isSupportedCatalogPath(path)) {
return NextResponse.json(
{ revalidated: false, error: 'Unsupported revalidation path' },
{ status: 400 },
);
}
revalidatePath(path);
return NextResponse.json({ revalidated: true, path });
}

View File

@@ -0,0 +1,94 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { ProductCard } from '@/components/product-card';
import { getCategoryBySlug, searchProducts } from '@/lib/api';
import { absoluteUrl, metadataTitle } from '@/lib/seo';
import { breadcrumbJsonLd, JsonLdScript } from '@/lib/seo/json-ld';
export const revalidate = 300;
type PageProps = Readonly<{ params: Promise<{ slug: string }> }>;
export function generateStaticParams() {
return [];
}
async function loadCategory(slug: string) {
try {
return await getCategoryBySlug(slug);
} catch {
notFound();
}
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const category = await loadCategory(slug);
const title = category.seoTitle ?? category.name;
const description =
category.seoDescription ?? `Productos de la categoría ${category.name} en MercadoDeVida.`;
return {
title: metadataTitle(title),
description,
alternates: { canonical: absoluteUrl(category.url) },
openGraph: {
title,
description,
url: absoluteUrl(category.url),
type: 'website',
},
};
}
export default async function CategoryPage({ params }: PageProps) {
const { slug } = await params;
const category = await loadCategory(slug);
const products = await searchProducts({
categorySlug: category.slug,
limit: 24,
offset: 0,
}).catch(() => []);
const breadcrumbs = breadcrumbJsonLd([
{ name: 'Inicio', path: '/' },
{ name: category.name, path: category.url },
]);
return (
<div className="mx-auto max-w-6xl px-6 py-12">
<JsonLdScript data={breadcrumbs} />
<nav className="text-sm text-stone-600" aria-label="Breadcrumb">
<a href="/" className="hover:text-emerald-800">
Inicio
</a>{' '}
/ <span>{category.name}</span>
</nav>
<header className="mt-8 max-w-3xl space-y-4">
<p className="text-sm font-semibold uppercase tracking-[0.25em] text-emerald-700">
Categoría
</p>
<h1 className="text-4xl font-bold tracking-tight text-emerald-950 md:text-5xl">
{category.name}
</h1>
<p className="text-lg leading-8 text-stone-700">
{category.seoDescription ?? 'Descubrí productos seleccionados dentro de esta categoría.'}
</p>
</header>
<section className="mt-10" aria-label={`Productos de ${category.name}`}>
{products.length > 0 ? (
<div className="grid gap-5 md:grid-cols-3">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
) : (
<div className="rounded-3xl bg-emerald-50 p-6 text-emerald-950">
Todavía no hay productos visibles para esta categoría.
</div>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,26 @@
@import 'tailwindcss';
:root {
color-scheme: light;
--background: #fbfaf5;
--foreground: #213127;
}
body {
margin: 0;
background: var(--background);
color: var(--foreground);
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
}
a {
color: inherit;
text-decoration: none;
}

View File

@@ -0,0 +1,31 @@
import type { Metadata } from 'next';
import './globals.css';
import { SiteFooter } from '@/components/site-footer';
import { SiteHeader } from '@/components/site-header';
import { absoluteUrl } from '@/lib/seo';
import { JsonLdScript, organizationJsonLd } from '@/lib/seo/json-ld';
export const metadata: Metadata = {
title: 'MercadoDeVida',
description: 'Productos ecológicos y saludables seleccionados con información transparente.',
alternates: { canonical: absoluteUrl('/') },
openGraph: {
title: 'MercadoDeVida',
description: 'Productos ecológicos y saludables seleccionados con información transparente.',
url: absoluteUrl('/'),
type: 'website',
},
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="es">
<body className="min-h-screen bg-[#fbfaf5] text-stone-900 antialiased">
<JsonLdScript data={organizationJsonLd()} />
<SiteHeader />
<main>{children}</main>
<SiteFooter />
</body>
</html>
);
}

View File

@@ -0,0 +1,89 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { ProductCard } from '@/components/product-card';
import { getBrandBySlug, searchProducts } from '@/lib/api';
import { absoluteUrl, metadataTitle } from '@/lib/seo';
import { breadcrumbJsonLd, JsonLdScript } from '@/lib/seo/json-ld';
export const revalidate = 300;
type PageProps = Readonly<{ params: Promise<{ slug: string }> }>;
export function generateStaticParams() {
return [];
}
async function loadBrand(slug: string) {
try {
return await getBrandBySlug(slug);
} catch {
notFound();
}
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const brand = await loadBrand(slug);
const title = brand.seoTitle ?? brand.name;
const description = brand.seoDescription ?? `Productos de ${brand.name} en MercadoDeVida.`;
return {
title: metadataTitle(title),
description,
alternates: { canonical: absoluteUrl(brand.url) },
openGraph: {
title,
description,
url: absoluteUrl(brand.url),
type: 'website',
},
};
}
export default async function BrandPage({ params }: PageProps) {
const { slug } = await params;
const brand = await loadBrand(slug);
const products = await searchProducts({ brandSlug: brand.slug, limit: 24, offset: 0 }).catch(
() => [],
);
const breadcrumbs = breadcrumbJsonLd([
{ name: 'Inicio', path: '/' },
{ name: brand.name, path: brand.url },
]);
return (
<div className="mx-auto max-w-6xl px-6 py-12">
<JsonLdScript data={breadcrumbs} />
<nav className="text-sm text-stone-600" aria-label="Breadcrumb">
<a href="/" className="hover:text-emerald-800">
Inicio
</a>{' '}
/ <span>{brand.name}</span>
</nav>
<header className="mt-8 max-w-3xl space-y-4">
<p className="text-sm font-semibold uppercase tracking-[0.25em] text-emerald-700">Marca</p>
<h1 className="text-4xl font-bold tracking-tight text-emerald-950 md:text-5xl">
{brand.name}
</h1>
<p className="text-lg leading-8 text-stone-700">
{brand.seoDescription ?? 'Productos seleccionados de esta marca.'}
</p>
</header>
<section className="mt-10" aria-label={`Productos de ${brand.name}`}>
{products.length > 0 ? (
<div className="grid gap-5 md:grid-cols-3">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
) : (
<div className="rounded-3xl bg-emerald-50 p-6 text-emerald-950">
Todavía no hay productos visibles para esta marca.
</div>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { searchProducts, type ProductSummaryDto } from '@/lib/api';
export const dynamic = 'force-dynamic';
async function getPreviewProducts(): Promise<ProductSummaryDto[]> {
try {
return await searchProducts({ limit: 3, offset: 0 });
} catch {
return [];
}
}
export default async function HomePage() {
const products = await getPreviewProducts();
return (
<div className="mx-auto flex max-w-6xl flex-col gap-16 px-6 py-12 md:py-20">
<section className="grid gap-10 md:grid-cols-[1.15fr_0.85fr] md:items-center">
<div className="space-y-6">
<p className="text-sm font-semibold uppercase tracking-[0.3em] text-emerald-700">
Tienda saludable · Datos claros
</p>
<h1 className="max-w-3xl text-4xl font-bold tracking-tight text-emerald-950 md:text-6xl">
Alimentación ecológica con información transparente desde el primer clic.
</h1>
<p className="max-w-2xl text-lg leading-8 text-stone-700">
MercadoDeVida une catálogo, marcas y datos nutricionales confiables para que cada compra
sea simple, consciente y sin sorpresas.
</p>
<div className="flex flex-col gap-3 sm:flex-row">
<a
href="/products/search"
className="rounded-full bg-emerald-800 px-6 py-3 text-center text-sm font-semibold text-white shadow-sm transition hover:bg-emerald-900"
>
Explorar productos
</a>
<a
href="/categoria/ecologico"
className="rounded-full border border-emerald-800/30 px-6 py-3 text-center text-sm font-semibold text-emerald-950 transition hover:border-emerald-800"
>
Ver categorías
</a>
</div>
</div>
<aside className="rounded-[2rem] border border-emerald-900/10 bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-emerald-950">Vista previa del catálogo</h2>
<div className="mt-5 space-y-4">
{products.length > 0 ? (
products.map((product) => (
<a
key={product.id}
href={product.url}
className="block rounded-2xl border border-stone-200 p-4 transition hover:border-emerald-700"
>
<p className="font-semibold text-stone-900">{product.name}</p>
<p className="mt-1 line-clamp-2 text-sm text-stone-600">
{product.description ?? 'Producto activo del catálogo público.'}
</p>
</a>
))
) : (
<div className="rounded-2xl bg-emerald-50 p-4 text-sm leading-6 text-emerald-950">
El storefront está listo. Cuando el backend esté disponible, este bloque mostrará
productos desde la API pública tipada.
</div>
)}
</div>
</aside>
</section>
<section className="grid gap-4 md:grid-cols-3">
{[
['Catálogo público', 'Productos activos, marcas y categorías desde la API pública.'],
['Server-first', 'La home renderiza en servidor y evita JavaScript cliente innecesario.'],
[
'Cliente tipado',
'El frontend consume contratos propios sin importar internals del backend.',
],
].map(([title, description]) => (
<article
key={title}
className="rounded-3xl border border-emerald-900/10 bg-white p-6 shadow-sm"
>
<h2 className="font-semibold text-emerald-950">{title}</h2>
<p className="mt-3 text-sm leading-6 text-stone-600">{description}</p>
</article>
))}
</section>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { ProductCard } from '@/components/product-card';
import { getProductBySlug, searchProducts } from '@/lib/api';
import { absoluteUrl, metadataTitle } from '@/lib/seo';
import { breadcrumbJsonLd, JsonLdScript, productJsonLd } from '@/lib/seo/json-ld';
export const revalidate = 300;
type PageProps = Readonly<{ params: Promise<{ slug: string }> }>;
export function generateStaticParams() {
return [];
}
async function loadProduct(slug: string) {
try {
return await getProductBySlug(slug);
} catch {
notFound();
}
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const product = await loadProduct(slug);
const title = product.seoTitle ?? product.name;
const description = product.seoDescription ?? product.description ?? `Comprar ${product.name}`;
const image = product.images?.find((item) => item.role === 'main') ?? product.images?.[0];
return {
title: metadataTitle(title),
description,
alternates: { canonical: absoluteUrl(product.url) },
openGraph: {
title,
description,
url: absoluteUrl(product.url),
type: 'website',
images: image ? [{ url: image.url, alt: image.altText }] : undefined,
},
};
}
export default async function ProductPage({ params }: PageProps) {
const { slug } = await params;
const product = await loadProduct(slug);
const related = await searchProducts({ limit: 3, offset: 0 }).catch(() => []);
const image = product.images?.find((item) => item.role === 'main') ?? product.images?.[0];
const breadcrumbs = breadcrumbJsonLd([
{ name: 'Inicio', path: '/' },
{ name: product.name, path: product.url },
]);
return (
<div className="mx-auto max-w-6xl px-6 py-12">
<JsonLdScript data={[productJsonLd(product), breadcrumbs]} />
<nav className="text-sm text-stone-600" aria-label="Breadcrumb">
<a href="/" className="hover:text-emerald-800">
Inicio
</a>{' '}
/ <span>{product.name}</span>
</nav>
<article className="mt-8 grid gap-10 md:grid-cols-[0.9fr_1.1fr] md:items-start">
<div className="overflow-hidden rounded-[2rem] border border-emerald-900/10 bg-emerald-50">
{image ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={image.url} alt={image.altText} className="aspect-[4/3] w-full object-cover" />
) : (
<div className="flex aspect-[4/3] items-center justify-center text-emerald-900">
Imagen próximamente
</div>
)}
</div>
<div className="space-y-6">
<p className="text-sm font-semibold uppercase tracking-[0.25em] text-emerald-700">
Producto activo
</p>
<h1 className="text-4xl font-bold tracking-tight text-emerald-950 md:text-5xl">
{product.name}
</h1>
<p className="text-lg leading-8 text-stone-700">
{product.description ?? 'Producto del catálogo público de MercadoDeVida.'}
</p>
<dl className="grid gap-4 rounded-3xl border border-emerald-900/10 bg-white p-6 text-sm text-stone-700 sm:grid-cols-2">
<div>
<dt className="font-semibold text-emerald-950">URL pública</dt>
<dd>{product.url}</dd>
</div>
<div>
<dt className="font-semibold text-emerald-950">Estado</dt>
<dd>{product.state}</dd>
</div>
</dl>
</div>
</article>
{related.length > 0 ? (
<section className="mt-16">
<h2 className="text-2xl font-semibold text-emerald-950">También te puede interesar</h2>
<div className="mt-6 grid gap-5 md:grid-cols-3">
{related
.filter((item) => item.slug !== product.slug)
.map((item) => (
<ProductCard key={item.id} product={item} />
))}
</div>
</section>
) : null}
</div>
);
}

View File

@@ -0,0 +1,88 @@
import type { Metadata } from 'next';
import { ProductCard } from '@/components/product-card';
import { searchProducts } from '@/lib/api';
import { absoluteUrl, metadataTitle } from '@/lib/seo';
export const revalidate = 120;
type SearchParams = Record<string, string | string[] | undefined>;
type PageProps = Readonly<{ searchParams: Promise<SearchParams> }>;
function firstParam(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
function boundedText(value: string | undefined, maxLength = 200): string | undefined {
const trimmed = value?.trim();
if (!trimmed) return undefined;
return trimmed.slice(0, maxLength);
}
function boundedInt(value: string | undefined, fallback: number, min: number, max: number): number {
const parsed = Number(value);
if (!Number.isInteger(parsed)) return fallback;
return Math.min(Math.max(parsed, min), max);
}
export async function generateMetadata({ searchParams }: PageProps): Promise<Metadata> {
const params = await searchParams;
const q = boundedText(firstParam(params.q));
const title = q ? `Resultados para ${q}` : 'Buscar productos';
const description = q
? `Resultados de búsqueda para ${q} en MercadoDeVida.`
: 'Buscá productos ecológicos y saludables en MercadoDeVida.';
return {
title: metadataTitle(title),
description,
alternates: { canonical: absoluteUrl('/products/search') },
openGraph: {
title,
description,
url: absoluteUrl('/products/search'),
type: 'website',
},
};
}
export default async function SearchPage({ searchParams }: PageProps) {
const params = await searchParams;
const q = boundedText(firstParam(params.q));
const brandSlug = boundedText(firstParam(params.brandSlug));
const categorySlug = boundedText(firstParam(params.categorySlug));
const limit = boundedInt(firstParam(params.limit), 24, 1, 100);
const offset = boundedInt(firstParam(params.offset), 0, 0, 10_000);
const products = await searchProducts({ q, brandSlug, categorySlug, limit, offset }).catch(
() => [],
);
return (
<div className="mx-auto max-w-6xl px-6 py-12">
<header className="max-w-3xl space-y-4">
<p className="text-sm font-semibold uppercase tracking-[0.25em] text-emerald-700">
Búsqueda
</p>
<h1 className="text-4xl font-bold tracking-tight text-emerald-950 md:text-5xl">
{q ? `Resultados para “${q}` : 'Buscar productos'}
</h1>
<p className="text-lg leading-8 text-stone-700">
Resultados desde la API pública del catálogo, con orden estable y paginación.
</p>
</header>
<section className="mt-10" aria-label="Resultados de búsqueda">
{products.length > 0 ? (
<div className="grid gap-5 md:grid-cols-3">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
) : (
<div className="rounded-3xl bg-emerald-50 p-6 text-emerald-950">
No encontramos productos con esos filtros por ahora.
</div>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import type { MetadataRoute } from 'next';
import { absoluteUrl, SITE_URL } from '@/lib/seo';
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/api/'],
},
sitemap: absoluteUrl('/sitemap.xml'),
host: SITE_URL,
};
}

View File

@@ -0,0 +1,56 @@
import type { MetadataRoute } from 'next';
import {
listBrands,
listCategoryTree,
searchProducts,
type CategoryTreeNodeDto,
type ProductSummaryDto,
} from '@/lib/api';
import { absoluteUrl } from '@/lib/seo';
export const revalidate = 300;
const STATIC_ROUTES = ['/', '/products/search'] as const;
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const [products, categories, brands] = await Promise.all([
listActiveProducts(),
listSafe(() => listCategoryTree()),
listSafe(() => listBrands()),
]);
const urls = new Set<string>();
for (const route of STATIC_ROUTES) urls.add(route);
for (const product of products) urls.add(product.url);
for (const category of flattenCategories(categories)) urls.add(category.url);
for (const brand of brands) urls.add(brand.url);
return [...urls].map((path) => ({
url: absoluteUrl(path),
changeFrequency: path === '/' ? 'daily' : 'weekly',
priority: path === '/' ? 1 : 0.7,
}));
}
async function listActiveProducts(): Promise<ProductSummaryDto[]> {
const all: ProductSummaryDto[] = [];
const limit = 100;
for (let offset = 0; offset <= 10_000; offset += limit) {
const page = await searchProducts({ limit, offset }).catch(() => []);
all.push(...page);
if (page.length < limit) break;
}
return all;
}
async function listSafe<T>(loader: () => Promise<T[]>): Promise<T[]> {
try {
return await loader();
} catch {
return [];
}
}
function flattenCategories(categories: readonly CategoryTreeNodeDto[]): CategoryTreeNodeDto[] {
return categories.flatMap((category) => [category, ...flattenCategories(category.children)]);
}