feat(ADM-018): completed feature
This commit is contained in:
54
project/storefront/src/app/api/revalidate/route.ts
Normal file
54
project/storefront/src/app/api/revalidate/route.ts
Normal 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 });
|
||||
}
|
||||
94
project/storefront/src/app/categoria/[slug]/page.tsx
Normal file
94
project/storefront/src/app/categoria/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
project/storefront/src/app/globals.css
Normal file
26
project/storefront/src/app/globals.css
Normal 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;
|
||||
}
|
||||
31
project/storefront/src/app/layout.tsx
Normal file
31
project/storefront/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
project/storefront/src/app/marca/[slug]/page.tsx
Normal file
89
project/storefront/src/app/marca/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
project/storefront/src/app/page.tsx
Normal file
92
project/storefront/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
project/storefront/src/app/productos/[slug]/page.tsx
Normal file
113
project/storefront/src/app/productos/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
project/storefront/src/app/products/search/page.tsx
Normal file
88
project/storefront/src/app/products/search/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
project/storefront/src/app/robots.ts
Normal file
14
project/storefront/src/app/robots.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
56
project/storefront/src/app/sitemap.ts
Normal file
56
project/storefront/src/app/sitemap.ts
Normal 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)]);
|
||||
}
|
||||
Reference in New Issue
Block a user