feat(ADM-018): completed feature
This commit is contained in:
4
project/storefront/.gitignore
vendored
Normal file
4
project/storefront/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.next/
|
||||
out/
|
||||
node_modules/
|
||||
.env*.local
|
||||
4
project/storefront/.prettierignore
Normal file
4
project/storefront/.prettierignore
Normal 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
7
project/storefront/next-env.d.ts
vendored
Normal 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.
|
||||
14
project/storefront/next.config.ts
Normal file
14
project/storefront/next.config.ts
Normal 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
1690
project/storefront/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
project/storefront/package.json
Normal file
32
project/storefront/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
project/storefront/postcss.config.mjs
Normal file
7
project/storefront/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
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)]);
|
||||
}
|
||||
30
project/storefront/src/components/product-card.tsx
Normal file
30
project/storefront/src/components/product-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
project/storefront/src/components/site-footer.tsx
Normal file
10
project/storefront/src/components/site-footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
project/storefront/src/components/site-header.tsx
Normal file
30
project/storefront/src/components/site-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
project/storefront/src/lib/api.ts
Normal file
141
project/storefront/src/lib/api.ts
Normal 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;
|
||||
}
|
||||
13
project/storefront/src/lib/seo.ts
Normal file
13
project/storefront/src/lib/seo.ts
Normal 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}`;
|
||||
}
|
||||
56
project/storefront/src/lib/seo/json-ld.tsx
Normal file
56
project/storefront/src/lib/seo/json-ld.tsx
Normal 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));
|
||||
}
|
||||
58
project/storefront/src/lib/seo/redirects.ts
Normal file
58
project/storefront/src/lib/seo/redirects.ts
Normal 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;
|
||||
}
|
||||
15
project/storefront/src/proxy.ts
Normal file
15
project/storefront/src/proxy.ts
Normal 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).*)'],
|
||||
};
|
||||
33
project/storefront/tsconfig.json
Normal file
33
project/storefront/tsconfig.json
Normal 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"]
|
||||
}
|
||||
1
project/storefront/tsconfig.tsbuildinfo
Normal file
1
project/storefront/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user