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

4
project/storefront/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.next/
out/
node_modules/
.env*.local

View File

@@ -0,0 +1,4 @@
.next/
node_modules/
next-env.d.ts
package-lock.json

7
project/storefront/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -0,0 +1,14 @@
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { NextConfig } from 'next';
const storefrontRoot = dirname(fileURLToPath(import.meta.url));
const nextConfig: NextConfig = {
reactStrictMode: true,
turbopack: {
root: storefrontRoot,
},
};
export default nextConfig;

1690
project/storefront/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
{
"name": "mercadodevida-storefront",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "MercadoDeVida customer storefront shell",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "prettier --check .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@tailwindcss/postcss": "^4.1.17",
"next": "^16.0.5",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"server-only": "^0.0.1",
"tailwindcss": "^4.1.17"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@types/react": "^19.2.6",
"@types/react-dom": "^19.2.3",
"prettier": "^3.7.0",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;

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)]);
}

View File

@@ -0,0 +1,30 @@
import type { ProductSummaryDto } from '@/lib/api';
export function ProductCard({ product }: Readonly<{ product: ProductSummaryDto }>) {
const mainImage = product.images?.find((image) => image.role === 'main') ?? product.images?.[0];
return (
<a
href={product.url}
className="group block overflow-hidden rounded-3xl border border-emerald-900/10 bg-white shadow-sm transition hover:-translate-y-0.5 hover:border-emerald-700"
>
<div className="flex aspect-[4/3] items-center justify-center bg-emerald-50 text-sm text-emerald-900">
{mainImage ? (
// Keep plain img for remote/local URL compatibility until image pipeline configuration exists.
// eslint-disable-next-line @next/next/no-img-element
<img src={mainImage.url} alt={mainImage.altText} className="h-full w-full object-cover" />
) : (
<span>Producto ecológico</span>
)}
</div>
<div className="space-y-2 p-5">
<h2 className="text-lg font-semibold text-emerald-950 group-hover:text-emerald-800">
{product.name}
</h2>
<p className="line-clamp-3 text-sm leading-6 text-stone-600">
{product.description ?? 'Producto activo del catálogo público.'}
</p>
</div>
</a>
);
}

View File

@@ -0,0 +1,10 @@
export function SiteFooter() {
return (
<footer className="border-t border-emerald-900/10 bg-white">
<div className="mx-auto flex max-w-6xl flex-col gap-2 px-6 py-8 text-sm text-stone-600 md:flex-row md:items-center md:justify-between">
<p>© {new Date().getFullYear()} MercadoDeVida</p>
<p>Alimentación consciente, datos claros y compra sin sorpresas.</p>
</div>
</footer>
);
}

View File

@@ -0,0 +1,30 @@
const navigation = [
{ href: '/', label: 'Inicio' },
{ href: '/productos', label: 'Productos' },
{ href: '/categoria/ecologico', label: 'Categorías' },
{ href: '/marca', label: 'Marcas' },
];
export function SiteHeader() {
return (
<header className="border-b border-emerald-900/10 bg-white/85 backdrop-blur">
<nav
className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4"
aria-label="Principal"
>
<a href="/" className="text-xl font-bold tracking-tight text-emerald-950">
MercadoDeVida
</a>
<ul className="hidden items-center gap-6 text-sm font-medium text-stone-700 md:flex">
{navigation.map((item) => (
<li key={item.href}>
<a className="transition hover:text-emerald-800" href={item.href}>
{item.label}
</a>
</li>
))}
</ul>
</nav>
</header>
);
}

View File

@@ -0,0 +1,141 @@
import 'server-only';
export interface ProductImageDto {
id: string;
productId: string;
variantId: string | null;
url: string;
altText: string;
position: number;
role: 'main' | 'gallery';
createdAt: string;
updatedAt: string;
}
export interface CategoryDto {
id: string;
parentId: string | null;
name: string;
slug: string;
url: string;
seoTitle: string | null;
seoDescription: string | null;
createdAt: string;
updatedAt: string;
}
export interface CategoryTreeNodeDto extends CategoryDto {
children: CategoryTreeNodeDto[];
}
export interface BrandDto {
id: string;
name: string;
slug: string;
url: string;
seoTitle: string | null;
seoDescription: string | null;
createdAt: string;
updatedAt: string;
}
export interface ProductSummaryDto {
id: string;
name: string;
slug: string;
url: string;
images?: ProductImageDto[];
description: string | null;
state: 'draft' | 'active' | 'archived';
seoTitle: string | null;
seoDescription: string | null;
categoryIds: string[];
brandId: string | null;
createdAt: string;
updatedAt: string;
}
interface ProductSearchResponse {
items: ProductSummaryDto[];
}
interface CategoryTreeResponse {
items: CategoryTreeNodeDto[];
}
interface BrandListResponse {
items: BrandDto[];
}
export interface SearchProductsInput {
q?: string;
brandSlug?: string;
categorySlug?: string;
limit?: number;
offset?: number;
}
const DEFAULT_API_BASE_URL = 'http://localhost:3000';
function apiBaseUrl(): string {
return (
process.env.API_BASE_URL ??
process.env.NEXT_PUBLIC_API_BASE_URL ??
DEFAULT_API_BASE_URL
).replace(/\/$/, '');
}
function toQueryString(input: SearchProductsInput): string {
const params = new URLSearchParams();
if (input.q !== undefined && input.q.trim() !== '') params.set('q', input.q.trim());
if (input.brandSlug !== undefined && input.brandSlug.trim() !== '') {
params.set('brandSlug', input.brandSlug.trim());
}
if (input.categorySlug !== undefined && input.categorySlug.trim() !== '') {
params.set('categorySlug', input.categorySlug.trim());
}
if (input.limit !== undefined) params.set('limit', String(input.limit));
if (input.offset !== undefined) params.set('offset', String(input.offset));
const query = params.toString();
return query === '' ? '' : `?${query}`;
}
async function fetchJson<T>(path: string): Promise<T> {
const response = await fetch(`${apiBaseUrl()}${path}`, {
headers: { accept: 'application/json' },
next: { revalidate: 60 },
});
if (!response.ok) {
throw new Error(`Public API request failed with status ${response.status}`);
}
return (await response.json()) as T;
}
export async function searchProducts(
input: SearchProductsInput = {},
): Promise<ProductSummaryDto[]> {
const payload = await fetchJson<ProductSearchResponse>(`/products/search${toQueryString(input)}`);
return payload.items;
}
export async function getProductBySlug(slug: string): Promise<ProductSummaryDto> {
return fetchJson<ProductSummaryDto>(`/productos/${encodeURIComponent(slug)}`);
}
export async function getCategoryBySlug(slug: string): Promise<CategoryDto> {
return fetchJson<CategoryDto>(`/categoria/${encodeURIComponent(slug)}`);
}
export async function listCategoryTree(): Promise<CategoryTreeNodeDto[]> {
const payload = await fetchJson<CategoryTreeResponse>('/categories/tree');
return payload.items;
}
export async function getBrandBySlug(slug: string): Promise<BrandDto> {
return fetchJson<BrandDto>(`/marca/${encodeURIComponent(slug)}`);
}
export async function listBrands(): Promise<BrandDto[]> {
const payload = await fetchJson<BrandListResponse>('/brands');
return payload.items;
}

View File

@@ -0,0 +1,13 @@
export const SITE_NAME = 'MercadoDeVida';
export const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3001').replace(
/\/$/,
'',
);
export function absoluteUrl(path: string): string {
return `${SITE_URL}${path.startsWith('/') ? path : `/${path}`}`;
}
export function metadataTitle(title: string): string {
return `${title} | ${SITE_NAME}`;
}

View File

@@ -0,0 +1,56 @@
import type { ProductSummaryDto } from '@/lib/api';
import { absoluteUrl, SITE_NAME, SITE_URL } from '@/lib/seo';
export type JsonLd = Readonly<Record<string, unknown>>;
export function organizationJsonLd(): JsonLd {
return {
'@context': 'https://schema.org',
'@type': 'Organization',
name: SITE_NAME,
url: SITE_URL,
};
}
export function breadcrumbJsonLd(items: ReadonlyArray<{ name: string; path: string }>): JsonLd {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: absoluteUrl(item.path),
})),
};
}
export function productJsonLd(product: ProductSummaryDto): JsonLd {
const image = product.images?.find((item) => item.role === 'main') ?? product.images?.[0];
return withoutUndefined({
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description ?? product.seoDescription ?? undefined,
url: absoluteUrl(product.url),
image: image ? absoluteCatalogUrl(image.url) : undefined,
sku: product.slug,
});
}
export function JsonLdScript({ data }: Readonly<{ data: JsonLd | readonly JsonLd[] }>) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, '\\u003c') }}
/>
);
}
function absoluteCatalogUrl(value: string): string {
return URL.canParse(value) ? value : absoluteUrl(value);
}
function withoutUndefined(input: JsonLd): JsonLd {
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
}

View File

@@ -0,0 +1,58 @@
export interface StoredRedirect {
from: string;
to: string;
}
export interface RedirectRepository {
findBySourcePath(pathname: string): StoredRedirect | undefined;
}
export class EnvRedirectRepository implements RedirectRepository {
private readonly redirects: readonly StoredRedirect[];
constructor(rawJson = process.env.REDIRECTS_JSON) {
this.redirects = parseRedirects(rawJson);
}
findBySourcePath(pathname: string): StoredRedirect | undefined {
return this.redirects.find((redirect) => redirect.from === normalizePath(pathname));
}
}
export function parseRedirects(rawJson: string | undefined): readonly StoredRedirect[] {
if (rawJson === undefined || rawJson.trim() === '') return [];
try {
const parsed = JSON.parse(rawJson) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.flatMap((entry) => {
if (!isRedirectEntry(entry)) return [];
const from = normalizePath(entry.from);
const to = normalizePath(entry.to);
if (!isSafeLocalPath(from) || !isSafeLocalPath(to) || from === to) return [];
return [{ from, to }];
});
} catch {
return [];
}
}
function isRedirectEntry(value: unknown): value is StoredRedirect {
return (
typeof value === 'object' &&
value !== null &&
'from' in value &&
'to' in value &&
typeof value.from === 'string' &&
typeof value.to === 'string'
);
}
function isSafeLocalPath(pathname: string): boolean {
return pathname.startsWith('/') && !pathname.startsWith('//') && !pathname.includes('://');
}
function normalizePath(pathname: string): string {
if (pathname.length > 1) return pathname.replace(/\/+$/, '');
return pathname;
}

View File

@@ -0,0 +1,15 @@
import { NextResponse, type NextRequest } from 'next/server';
import { EnvRedirectRepository } from '@/lib/seo/redirects';
const redirects = new EnvRedirectRepository();
export function proxy(request: NextRequest) {
const redirect = redirects.findBySourcePath(request.nextUrl.pathname);
if (!redirect) return NextResponse.next();
return NextResponse.redirect(new URL(redirect.to, request.url), 301);
}
export const config = {
matcher: ['/((?!api/revalidate|_next/static|_next/image|favicon.ico).*)'],
};

View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}

File diff suppressed because one or more lines are too long