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

41
project/frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->

View File

@@ -0,0 +1 @@
@AGENTS.md

View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View File

@@ -0,0 +1,11 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: '**' },
],
},
};
export default nextConfig;

View File

@@ -0,0 +1,26 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.3.1",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.3.1",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

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

View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,39 @@
import ContentPage from '@/components/content/ContentPage';
import type { Metadata } from 'next';
import { fetchPage } from '@/lib/api';
export const metadata: Metadata = {
title: 'Quiénes somos',
description: 'Conoce la historia, misión y valores de MercadoDeVida. Productos naturales y orgánicos de confianza.',
};
const FALLBACK_HTML = `
<h2>Nuestra historia</h2>
<p>MercadoDeVida nació de la convicción de que comer bien no debería ser complicado. Seleccionamos cuidadosamente cada producto para asegurar la máxima calidad y transparencia.</p>
<h2>Nuestra misión</h2>
<p>Facilitar el acceso a productos naturales y orgánicos de alta calidad, directamente desde productores certificados, sin intermediarios.</p>
<h2>Valores</h2>
<ul>
<li>Transparencia total en el origen de los productos</li>
<li>Compromiso con la agricultura ecológica y sostenible</li>
<li>Selección rigurosa de proveedores certificados</li>
<li>Envío responsable con packaging reciclable</li>
<li>Atención al cliente cercana y personalizada</li>
</ul>
<h2>Dónde estamos</h2>
<p>Operamos exclusivamente online, enviando a toda España peninsular. Nuestros productos proceden de explotaciones ecológicas certificadas tanto nacionales como europeas.</p>
`;
export default async function AboutPage() {
const cms = await fetchPage('about').catch(() => null);
const body = cms?.body ?? FALLBACK_HTML;
return (
<ContentPage
title={cms?.title ?? 'Quiénes somos'}
description="Conoce la historia, misión y valores de MercadoDeVida. Productos naturales y orgánicos de confianza."
>
<div dangerouslySetInnerHTML={{ __html: body }} />
</ContentPage>
);
}

View File

@@ -0,0 +1,5 @@
import AdminLayout from '@/components/admin/AdminLayout';
export default function AdminRootLayout({ children }: { children: React.ReactNode }) {
return <AdminLayout>{children}</AdminLayout>;
}

View File

@@ -0,0 +1,21 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Admin Pedidos — MercadoDeVida',
};
export default function AdminOrdersPage() {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-6">Pedidos</h1>
<div className="bg-white border border-gray-200 rounded-xl p-12 text-center">
<div className="text-5xl mb-4">🧾</div>
<h2 className="text-lg font-semibold text-gray-900 mb-2">Sin pedidos aún</h2>
<p className="text-gray-500 text-sm">Los pedidos que realicen los clientes aparecerán aquí.</p>
<a href="/admin" className="mt-6 inline-block text-sm text-[#70ad47] hover:underline">
Volver al dashboard
</a>
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import type { Metadata } from 'next';
import Link from 'next/link';
export const metadata: Metadata = {
title: 'Admin — MercadoDeVida',
};
export default function AdminDashboard() {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-6">Dashboard</h1>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
{[
{ label: 'Productos', value: '—', icon: '📦', color: 'bg-blue-50 border-blue-100' },
{ label: 'Pedidos', value: '—', icon: '🧾', color: 'bg-green-50 border-green-100' },
{ label: 'Usuarios', value: '—', icon: '👥', color: 'bg-orange-50 border-orange-100' },
].map((stat) => (
<div key={stat.label} className={`${stat.color} border rounded-xl p-6`}>
<div className="text-3xl mb-2">{stat.icon}</div>
<p className="text-3xl font-bold text-gray-900">{stat.value}</p>
<p className="text-sm text-gray-500 mt-1">{stat.label}</p>
</div>
))}
</div>
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="font-bold text-gray-900 mb-4">Acciones rápidas</h2>
<div className="space-y-2">
<a href="/admin/products" className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition-colors text-gray-700">
<span>📦</span>
<span className="text-sm font-medium">Gestionar productos</span>
</a>
<a href="/admin/orders" className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition-colors text-gray-700">
<span>🧾</span>
<span className="text-sm font-medium">Ver pedidos</span>
</a>
<Link href="/" className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition-colors text-gray-700">
<span>🌿</span>
<span className="text-sm font-medium">Ver tienda</span>
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import type { Metadata } from 'next';
import { fetchProducts } from '@/lib/api';
export const metadata: Metadata = {
title: 'Admin Productos — MercadoDeVida',
};
export default async function AdminProductsPage() {
const products = await fetchProducts();
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Productos</h1>
<span className="text-sm text-gray-500">{products.length} productos</span>
</div>
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Producto</th>
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Marca</th>
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Precio</th>
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Stock</th>
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{products.map((p) => (
<tr key={p.id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3">
<a href={`/products/${p.slug}`} className="text-sm font-medium text-gray-900 hover:text-[#70ad47] transition-colors">
{p.name}
</a>
<p className="text-xs text-gray-400 truncate max-w-xs">{p.description}</p>
</td>
<td className="px-4 py-3 text-sm text-gray-600">{p.brand?.name ?? '—'}</td>
<td className="px-4 py-3">
<span className="text-sm font-bold text-[#70ad47]"></span>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Activo
</span>
</td>
<td className="px-4 py-3">
<a href={`/products/${p.slug}`} className="text-sm text-[#70ad47] hover:underline">
Ver
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email, password } = body;
const backendRes = await fetch('http://127.0.0.1:3000/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await backendRes.json();
if (!backendRes.ok) {
return NextResponse.json(data, { status: backendRes.status });
}
// Forward session cookie from backend
const backendSetCookie = backendRes.headers.get('set-cookie');
const response = NextResponse.json(data, { status: 200 });
if (backendSetCookie) {
response.headers.set('Set-Cookie', backendSetCookie);
}
return response;
} catch {
return NextResponse.json(
{ error: { code: 'SERVER_ERROR', message: 'Error del servidor' } },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from 'next/server';
export async function POST() {
const response = NextResponse.json({ ok: true });
response.cookies.set('session_token', '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 0,
});
return response;
}

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const sessionToken = request.cookies.get('session_token')?.value;
if (!sessionToken) {
return NextResponse.json({ user: null });
}
try {
const backendRes = await fetch('http://127.0.0.1:3000/auth/me', {
headers: {
Cookie: `session_token=${sessionToken}`,
},
});
const data = await backendRes.json();
// Backend returns { id, email, role } when authenticated,
// or { user: null } when not (AppError 401 → reply.send({ user: null }))
// Normalize into { user: ... }
if (!backendRes.ok || !data.id) {
return NextResponse.json({ user: null });
}
return NextResponse.json({ user: data });
} catch {
return NextResponse.json({ user: null });
}
}

View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email, password } = body;
const backendRes = await fetch('http://127.0.0.1:3000/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await backendRes.json();
if (!backendRes.ok) {
return NextResponse.json(data, { status: backendRes.status });
}
const backendSetCookie = backendRes.headers.get('set-cookie');
const response = NextResponse.json(data, { status: 201 });
if (backendSetCookie) {
response.headers.set('Set-Cookie', backendSetCookie);
}
return response;
} catch {
return NextResponse.json(
{ error: { code: 'SERVER_ERROR', message: 'Error del servidor' } },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,76 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
export default function LoginPage() {
const { login } = useAuth();
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
const result = await login(email, password);
setLoading(false);
if (result.ok) {
router.push('/');
} else {
setError(result.error || 'Credenciales inválidas');
}
};
return (
<div className="max-w-md mx-auto px-4 py-16">
<div className="bg-white border border-gray-200 rounded-2xl p-8 shadow-sm">
<h1 className="text-2xl font-bold text-gray-900 mb-6 text-center" style={{ fontFamily: 'var(--font-heading)' }}>
Iniciar sesión
</h1>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg px-4 py-3">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="tu@email.com"
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Contraseña</label>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-[#70ad47] hover:bg-[#5a9040] disabled:opacity-60 text-white font-semibold rounded-xl transition-colors"
>
{loading ? 'Entrando...' : 'Iniciar sesión'}
</button>
<p className="text-center text-sm text-gray-500">
¿No tienes cuenta? <Link href="/auth/register" className="text-[#70ad47] hover:underline font-medium">Créala aquí</Link>
</p>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
export default function RegisterPage() {
const { register } = useAuth();
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (password !== confirm) {
setError('Las contraseñas no coinciden');
return;
}
setLoading(true);
const result = await register(email, password);
setLoading(false);
if (result.ok) {
router.push('/');
} else {
setError(result.error || 'Error al crear cuenta');
}
};
return (
<div className="max-w-md mx-auto px-4 py-16">
<div className="bg-white border border-gray-200 rounded-2xl p-8 shadow-sm">
<h1 className="text-2xl font-bold text-gray-900 mb-6 text-center" style={{ fontFamily: 'var(--font-heading)' }}>
Crear cuenta
</h1>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg px-4 py-3">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Contraseña</label>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
minLength={8}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Confirmar contraseña</label>
<input
type="password"
required
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-[#70ad47] hover:bg-[#5a9040] disabled:opacity-60 text-white font-semibold rounded-xl transition-colors"
>
{loading ? 'Creando...' : 'Crear cuenta'}
</button>
<p className="text-center text-sm text-gray-500">
¿Ya tienes cuenta? <Link href="/auth/login" className="text-[#70ad47] hover:underline font-medium">Inicia sesión</Link>
</p>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
import Link from 'next/link';
import Image from 'next/image';
import type { Metadata } from 'next';
import { fetchBrandBySlug, fetchProducts, formatPrice } from '@/lib/api';
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const brand = await fetchBrandBySlug(slug);
if (!brand) return { title: 'Marca no encontrada' };
return {
title: brand.seoTitle ?? brand.name,
description: brand.seoDescription ?? `Productos ${brand.name} en MercadoDeVida.`,
};
}
export default async function BrandPage({ params }: Props) {
const { slug } = await params;
const [brand, products] = await Promise.all([
fetchBrandBySlug(slug),
fetchProducts({ brandSlug: slug, limit: 24 }),
]);
if (!brand) {
return (
<div className="max-w-7xl mx-auto px-4 py-16 text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-4">Marca no encontrada</h1>
<Link href="/brands" className="text-[#70ad47] font-medium hover:underline">
Ver todas las marcas
</Link>
</div>
);
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Breadcrumb */}
<nav className="mb-6" aria-label="Breadcrumb">
<ol className="flex items-center gap-2 text-sm text-gray-500">
<li><Link href="/" className="hover:text-[#70ad47]">Inicio</Link></li>
<li><span className="text-gray-300">/</span></li>
<li><Link href="/brands" className="hover:text-[#70ad47]">Marcas</Link></li>
<li><span className="text-gray-300">/</span></li>
<li className="text-gray-900 font-medium">{brand.name}</li>
</ol>
</nav>
{/* Header */}
<div className="mb-8 flex items-center gap-4">
<div className="w-16 h-16 bg-[#70ad47]/10 rounded-2xl flex items-center justify-center">
<span className="text-2xl font-bold text-[#70ad47]">
{brand.name.slice(0, 2).toUpperCase()}
</span>
</div>
<div>
<h1 className="text-3xl font-bold text-gray-900" style={{ fontFamily: 'var(--font-heading)' }}>
{brand.name}
</h1>
{brand.seoDescription && (
<p className="mt-1 text-gray-600">{brand.seoDescription}</p>
)}
<p className="mt-1 text-sm text-gray-500">{products.length} producto{products.length !== 1 ? 's' : ''}</p>
</div>
</div>
{/* Products */}
{products.length === 0 ? (
<div className="py-16 text-center">
<p className="text-gray-500">No hay productos de esta marca todavía.</p>
<Link href="/brands" className="text-[#70ad47] font-medium hover:underline mt-4 inline-block">
Ver otras marcas
</Link>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 justify-items-center">
{products.map((product) => (
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-all hover:shadow-md">
<div className="aspect-square relative bg-white flex items-center justify-center">
{product.images?.[0] ? (
<Image src={product.images[0].url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
) : (
<span className="text-5xl">🌿</span>
)}
</div>
<div className="p-4">
<h3 className="font-semibold text-gray-900 group-hover:text-[#70ad47] transition-colors line-clamp-2 text-sm">
{product.name}
</h3>
<p className="text-gray-500 text-xs mt-1 line-clamp-2">{product.description}</p>
<div className="mt-3 pr-2">
<span className="text-lg font-bold text-[#70ad47]">{formatPrice(0)}</span>
</div>
</div>
</div>
</Link>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,40 @@
import Link from 'next/link';
import type { Metadata } from 'next';
import { fetchBrands } from '@/lib/api';
export const metadata: Metadata = {
title: 'Marcas — MercadoDeVida',
description: 'Todas las marcas de productos naturales y ecológicos.',
};
export default async function BrandsPage() {
const brands = await fetchBrands();
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900" style={{ fontFamily: 'var(--font-heading)' }}>
Nuestras marcas
</h1>
<p className="mt-2 text-gray-600">
Descubre las marcas de confianza que trabajan con nosotros.
</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4 justify-center">
{brands.map((brand) => (
<Link key={brand.id} href={`/brands/${brand.slug}`}>
<div className="p-6 bg-gray-50 hover:bg-[#70ad47] hover:text-white rounded-xl border border-gray-200 hover:border-[#70ad47] transition-all text-center group">
<div className="w-14 h-14 mx-auto mb-3 bg-[#70ad47]/10 group-hover:bg-white/20 rounded-full flex items-center justify-center">
<span className="text-xl font-bold text-[#70ad47] group-hover:text-white">
{brand.name.slice(0, 2).toUpperCase()}
</span>
</div>
<p className="font-semibold text-sm">{brand.name}</p>
</div>
</Link>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import type { Metadata } from 'next';
import CartPageContent from '@/components/cart/CartPageContent';
export const metadata: Metadata = {
title: 'Carrito — MercadoDeVida',
};
export default function CartPage() {
return <CartPageContent />;
}

View File

@@ -0,0 +1,118 @@
import Link from 'next/link';
import Image from 'next/image';
import type { Metadata } from 'next';
import { fetchCategoryBySlug, fetchProducts, fetchBrands } from '@/lib/api';
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const category = await fetchCategoryBySlug(slug);
if (!category) return { title: 'Categoría no encontrada' };
return {
title: category.seoTitle ?? category.name,
description: category.seoDescription ?? `${category.name} — Productos naturales y orgánicos en MercadoDeVida.`,
};
}
function formatPrice(cents: number): string {
return `${(cents / 100).toFixed(2)}`;
}
export default async function CategoryPage({ params }: Props) {
const { slug } = await params;
const [category, products, brands] = await Promise.all([
fetchCategoryBySlug(slug),
fetchProducts({ categorySlug: slug, limit: 20 }),
fetchBrands(),
]);
if (!category) {
return (
<div className="max-w-7xl mx-auto px-4 py-16 text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-4">Categoría no encontrada</h1>
<p className="text-gray-500 mb-8">La categoría que buscas no existe.</p>
<Link href="/categories" className="text-[#70ad47] font-medium hover:underline">
Ver todas las categorías
</Link>
</div>
);
}
const breadcrumb = [
{ label: 'Inicio', href: '/' },
{ label: 'Categorías', href: '/categories' },
{ label: category.name, href: `/categories/${category.slug}` },
];
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Breadcrumb */}
<nav className="mb-6" aria-label="Breadcrumb">
<ol className="flex items-center gap-2 text-sm text-gray-500">
{breadcrumb.map((item, i) => (
<li key={item.href} className="flex items-center gap-2">
{i > 0 && <span className="text-gray-300">/</span>}
<Link href={item.href} className="hover:text-[#70ad47] transition-colors">
{item.label}
</Link>
</li>
))}
</ol>
</nav>
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900" style={{ fontFamily: 'var(--font-heading)' }}>
{category.name}
</h1>
{category.seoDescription && (
<p className="mt-2 text-gray-600">{category.seoDescription}</p>
)}
<p className="mt-1 text-sm text-gray-500">{products.length} producto{products.length !== 1 ? 's' : ''}</p>
</div>
{/* Products grid */}
{products.length === 0 ? (
<div className="py-16 text-center">
<p className="text-gray-500 mb-4">No hay productos en esta categoría todavía.</p>
<Link href="/categories" className="text-[#70ad47] font-medium hover:underline">
Explorar otras categorías
</Link>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 justify-items-center">
{products.map((product) => (
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-all hover:shadow-md">
<div className="aspect-square relative bg-white flex items-center justify-center">
{product.images?.[0] ? (
<Image src={product.images[0].url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
) : (
<span className="text-5xl">🌿</span>
)}
</div>
<div className="p-4">
{product.brandId && (
<p className="text-xs text-[#E76F51] font-medium uppercase tracking-wide mb-1">
{brands.find((b) => b.id === product.brandId)?.name ?? 'Marca'}
</p>
)}
<h3 className="font-semibold text-gray-900 group-hover:text-[#70ad47] transition-colors line-clamp-2 text-sm">
{product.name}
</h3>
<p className="text-gray-500 text-xs mt-1 line-clamp-2">{product.description}</p>
<div className="mt-3 pr-2">
<span className="text-lg font-bold text-[#70ad47]">{formatPrice(0)}</span>
</div>
</div>
</div>
</Link>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,67 @@
import Link from 'next/link';
import type { Metadata } from 'next';
import { fetchCategories } from '@/lib/api';
export const metadata: Metadata = {
title: 'Categorías — MercadoDeVida',
description: 'Explora todas las categorías de productos naturales y orgánicos.',
};
const icons: Record<string, string> = {
alimentacion: '🥜',
suplementos: '💊',
'cosmetica-natural': '🌸',
'limpieza-ecologica': '🌿',
};
const colors = [
'from-[#70ad47] to-[#40916C]',
'from-[#E76F51] to-[#F4A261]',
'from-[#52B788] to-[#74C69D]',
'from-[#5a9040] to-[#70ad47]',
];
export default async function CategoriesPage() {
const tree = await fetchCategories();
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900" style={{ fontFamily: 'var(--font-heading)' }}>
Categorías
</h1>
<p className="mt-2 text-gray-600">
Explora nuestra selección de productos naturales y ecológicos organizados por categoría.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 justify-center">
{tree.map((cat, i) => (
<Link key={cat.id} href={`/categories/${cat.slug}`} className="group block">
<div className={`relative overflow-hidden rounded-2xl bg-gradient-to-br ${colors[i % colors.length]} p-6 text-white min-h-[140px] flex flex-col justify-between`}>
<div className="absolute top-4 right-4 text-5xl opacity-20">{icons[cat.slug] ?? '📦'}</div>
<div>
<h2 className="text-xl font-bold group-hover:underline">{cat.name}</h2>
{cat.seoDescription && (
<p className="mt-1 text-sm text-white/80 line-clamp-2">{cat.seoDescription}</p>
)}
</div>
<div className="mt-4 flex flex-wrap gap-2">
{cat.children?.map((child) => (
<span key={child.id} className="text-xs bg-white/20 px-2 py-1 rounded-full backdrop-blur-sm">
{child.name}
</span>
))}
{(!cat.children || cat.children.length === 0) && (
<span className="text-xs bg-white/20 px-2 py-1 rounded-full backdrop-blur-sm">
Ver productos
</span>
)}
</div>
</div>
</Link>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import type { Metadata } from 'next';
import CheckoutClient from '@/components/checkout/CheckoutClient';
export const metadata: Metadata = {
title: 'Checkout — MercadoDeVida',
};
export default function CheckoutPage() {
return <CheckoutClient />;
}

View File

@@ -0,0 +1,36 @@
import ContentPage from '@/components/content/ContentPage';
import type { Metadata } from 'next';
import { fetchPage } from '@/lib/api';
export const metadata: Metadata = {
title: 'Contacto',
description: 'Ponte en contacto con el equipo de MercadoDeVida. Resolvemos tus dudas sobre productos, pedidos y envíos.',
};
const FALLBACK_HTML = `
<h2>Email</h2>
<p><a href="mailto:hola@mercadodevida.es" class="text-[#70ad47] hover:underline">hola@mercadodevida.es</a></p>
<p>Intentamos responder en un plazo de 24-48 horas laborables.</p>
<h2>Horario de atención</h2>
<p>Lunes a viernes: 9:00 18:00h</p>
<p>Sábados: 10:00 14:00h</p>
<p>Domingos y festivos: cerrado</p>
<h2>Preguntas frecuentes</h2>
<p>Antes de escribirnos, puede que tu duda ya esté resuelta en nuestra sección de <a href="/shipping" class="text-[#70ad47] hover:underline">envíos</a>.</p>
<h2>Redes sociales</h2>
<p>Síguenos en nuestras redes para estar al día de nuevas incorporaciones, ofertas y recetas saludables.</p>
`;
export default async function ContactPage() {
const cms = await fetchPage('contact').catch(() => null);
const body = cms?.body ?? FALLBACK_HTML;
return (
<ContentPage
title={cms?.title ?? 'Contacto'}
description="Estamos aquí para ayudarte. Contáctanos por cualquiera de estos canales."
>
<div dangerouslySetInnerHTML={{ __html: body }} />
</ContentPage>
);
}

View File

@@ -0,0 +1,71 @@
import ContentPage from '@/components/content/ContentPage';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Política de cookies',
description: 'Información sobre el uso de cookies en MercadoDeVida.',
robots: { index: false, follow: false },
};
export default function CookiesPage() {
return (
<ContentPage
title="Política de cookies"
description="Última actualización: agosto de 2026. Esta página requiere revisión por un asesor legal antes de uso en producción."
>
<p className="bg-amber-50 border border-amber-200 text-amber-800 rounded-lg p-4 mb-6 text-sm">
<strong>Placeholder:</strong> Este texto es un marcador. El contenido legal real debe ser
redactado o aprobado por un profesional jurídico antes de публикации en producción.
</p>
<h2>¿Qué son las cookies?</h2>
<p>
Las cookies son pequeños archivos de texto que se almacenan en tu dispositivo cuando visitas
una página web.
</p>
<h2>Tipos de cookies que usamos</h2>
<h3>Cookies necesarias</h3>
<p>
Requeridas para el funcionamiento básico de la tienda: carrito de compra, sesión de usuario,
seguridad. No requieren consentimiento.
</p>
<h3>Cookies de análisis</h3>
<p>
Usamos herramientas de análisis para entender cómo los visitantes usan nuestra web. Estas
cookies son anónimas y nos ayudan a mejorar la experiencia.
</p>
<h3>Cookies de preferencias</h3>
<p>
Recuerdan tus preferencias de idioma, región y otros ajustes para personalizar tu experiencia.
</p>
<h2>Gestión de cookies</h2>
<p>
Puedes aceptar o rechazar cookies no esenciales desde el banner de cookies que aparece al
visitar nuestra web por primera vez.
</p>
<p>
También puedes configurar tu navegador para bloquear cookies. Ten en cuenta que bloquear
algunas cookies puede afectar al funcionamiento de la tienda.
</p>
<h2>Más información</h2>
<p>
Para más información sobre cookies, visita{' '}
<a
href="https://www.allaboutcookies.org"
target="_blank"
rel="noopener noreferrer"
className="text-[#70ad47] hover:underline"
>
www.allaboutcookies.org
</a>
.
</p>
</ContentPage>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,25 @@
@import "tailwindcss";
@theme {
--color-primary: #70ad47;
--color-primary-dark: #5a9040;
--color-secondary: #F5F0E8;
--color-accent: #E76F51;
--color-text: #1a1a1a;
--color-muted: #6b7280;
--color-footer-bg: #ffffff;
--color-footer-text: #1a1a1a;
--color-footer-muted: #6b7280;
--font-sans: "Open Sans", system-ui, sans-serif;
}
:root {
--background: #ffffff;
--foreground: #1a1a1a;
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans), system-ui, sans-serif;
}

View File

@@ -0,0 +1,43 @@
import type { Metadata } from 'next';
import { Open_Sans } from 'next/font/google';
import { Header } from '@/components/layout/Header';
import { Footer } from '@/components/layout/Footer';
import { CartProvider } from '@/contexts/CartContext';
import { AuthProvider } from '@/contexts/AuthContext';
import './globals.css';
const opensans = Open_Sans({
subsets: ['latin'],
variable: '--font-sans',
display: 'swap',
});
export const metadata: Metadata = {
title: 'MercadoDeVida — Productos naturales y orgánicos',
description:
'Tienda online de productos naturales, orgánicos y saludables. Envío a toda España. Calidad certificada.',
icons: {
icon: '/images/favicon.png',
},
openGraph: {
title: 'MercadoDeVida — Productos naturales y orgánicos',
description: 'Tienda online de productos naturales, orgánicos y saludables.',
type: 'website',
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="es" suppressHydrationWarning className={opensans.variable}>
<body className="min-h-screen flex flex-col">
<CartProvider>
<AuthProvider>
<Header />
<main className="flex-1">{children}</main>
<Footer />
</AuthProvider>
</CartProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,32 @@
import Link from 'next/link';
export default function NotFound() {
return (
<div className="max-w-2xl mx-auto px-4 py-24 text-center">
<div className="text-7xl mb-6">🔍</div>
<h1
className="text-4xl font-bold text-gray-900 mb-4"
style={{ fontFamily: 'var(--font-heading)' }}
>
Página no encontrada
</h1>
<p className="text-lg text-gray-600 mb-8">
Lo sentimos, la página que buscas no existe o ha sido movida.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
href="/"
className="px-6 py-3 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors"
>
Volver al inicio
</Link>
<Link
href="/products"
className="px-6 py-3 border border-gray-300 hover:border-[#70ad47] text-gray-700 font-semibold rounded-xl transition-colors"
>
Ver productos
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
import Link from 'next/link';
import type { Metadata } from 'next';
interface Props {
searchParams: Promise<{ orderId?: string }>;
}
export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
const { orderId } = await searchParams;
return {
title: orderId
? `Pedido ${orderId.slice(0, 8).toUpperCase()} confirmado — MercadoDeVida`
: 'Pedido confirmado — MercadoDeVida',
};
}
export default async function OrderConfirmationPage({ searchParams }: Props) {
const { orderId } = await searchParams;
return (
<div className="max-w-2xl mx-auto px-4 py-16 text-center">
<div className="text-7xl mb-6"></div>
<h1 className="text-3xl font-bold text-gray-900 mb-3" style={{ fontFamily: 'var(--font-heading)' }}>
¡Pedido confirmado!
</h1>
{orderId && (
<p className="text-sm text-gray-500 mb-2 font-mono">
Referencia: {orderId.slice(0, 8).toUpperCase()}
</p>
)}
<p className="text-lg text-gray-600 mb-2">
Tu pedido ha sido recibido correctamente.
</p>
<p className="text-gray-500 mb-8">
Te hemos enviado un email de confirmación con los detalles.
</p>
<div className="bg-gray-50 rounded-xl border border-gray-200 p-6 mb-8 text-left">
<h2 className="font-bold text-gray-900 mb-4">Próximos pasos</h2>
<ul className="space-y-3 text-sm text-gray-600">
<li className="flex gap-3">
<span className="w-6 h-6 bg-[#70ad47] text-white rounded-full flex items-center justify-center flex-shrink-0 text-xs font-bold">1</span>
<span>Recibirás un email de confirmación en tu bandeja de entrada.</span>
</li>
<li className="flex gap-3">
<span className="w-6 h-6 bg-[#70ad47] text-white rounded-full flex items-center justify-center flex-shrink-0 text-xs font-bold">2</span>
<span>Prepararemos tu pedido en 24-48 horas laborables.</span>
</li>
<li className="flex gap-3">
<span className="w-6 h-6 bg-[#70ad47] text-white rounded-full flex items-center justify-center flex-shrink-0 text-xs font-bold">3</span>
<span>Recibirás un email con el número de seguimiento.</span>
</li>
</ul>
</div>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
href="/"
className="px-6 py-3 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors"
>
Volver al inicio
</Link>
<Link
href="/products"
className="px-6 py-3 border border-gray-300 hover:border-[#70ad47] text-gray-700 font-semibold rounded-xl transition-colors"
>
Seguir comprando
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { Hero } from '@/components/home/Hero';
import FeaturedProducts from '@/components/home/FeaturedProducts';
import CategoriesGrid from '@/components/home/CategoriesGrid';
import BrandsSection from '@/components/home/BrandsSection';
export const revalidate = 3600; // ISR: revalidate every hour
export default async function HomePage() {
return (
<>
<Hero />
<FeaturedProducts />
<CategoriesGrid />
<BrandsSection />
</>
);
}

View File

@@ -0,0 +1,63 @@
import ContentPage from '@/components/content/ContentPage';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Política de privacidad',
description: 'Información sobre cómo MercadoDeVida recopila, usa y protege tus datos personales.',
robots: { index: false, follow: false },
};
export default function PrivacyPage() {
return (
<ContentPage
title="Política de privacidad"
description="Última actualización: agosto de 2026. Esta página requiere revisión por un asesor legal antes de uso en producción."
>
<p className="bg-amber-50 border border-amber-200 text-amber-800 rounded-lg p-4 mb-6 text-sm">
<strong>Placeholder:</strong> Este texto es un marcador. El contenido legal real debe ser
redactado o aprobado por un profesional jurídico antes de публикации en producción.
</p>
<h2>Responsable del tratamiento</h2>
<p>
MercadoDeVida<br />
Email: hola@mercadodevida.es
</p>
<h2>Datos que recopilamos</h2>
<p>
Recopilamos datos de registro (nombre, email, dirección), datos de pedido (productos,
importe, dirección de entrega) y datos de navegación con tu consentimiento.
</p>
<h2>Finalidad del tratamiento</h2>
<ul>
<li>Gestión de pedidos y entregas</li>
<li>Atención al cliente</li>
<li>Envío de comunicaciones comerciales (solo con consentimiento)</li>
<li>Cumplimiento de obligaciones fiscales</li>
</ul>
<h2>Tus derechos</h2>
<p>
Puedes ejercer tus derechos de acceso, rectificación, supresión, portabilidad y oposición
escribiéndonos a hola@mercadodevida.es.
</p>
<h2>Conservación de datos</h2>
<p>
Conservamos tus datos mientras mantengas una cuenta activa. Los datos de pedidos se conservan
durante el período legalmente exigido para cumplir obligaciones fiscales.
</p>
<h2>Cookies</h2>
<p>
Consulta nuestra{' '}
<a href="/cookies" className="text-[#70ad47] hover:underline">
política de cookies
</a>
.
</p>
</ContentPage>
);
}

View File

@@ -0,0 +1,224 @@
import Link from 'next/link';
import Image from 'next/image';
import type { Metadata } from 'next';
import {
fetchProductBySlug,
fetchProductVariants,
fetchVariantPrice,
fetchStockAvailability,
fetchCategories,
fetchBrands,
formatPrice,
calcGrossPrice,
} from '@/lib/api';
import ProductAddToCart from '@/components/cart/ProductAddToCart';
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const product = await fetchProductBySlug(slug);
if (!product) return { title: 'Producto no encontrado' };
return {
title: product.seoTitle ?? product.name,
description: product.seoDescription ?? product.description,
};
}
export default async function ProductPage({ params }: Props) {
const { slug } = await params;
const [product, brands] = await Promise.all([
fetchProductBySlug(slug),
fetchBrands(),
]);
if (!product) {
return (
<div className="max-w-7xl mx-auto px-4 py-16 text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-4">Producto no encontrado</h1>
<p className="text-gray-500 mb-8">El producto que buscas no existe.</p>
<Link href="/products" className="text-[#70ad47] font-medium hover:underline">
Ver todos los productos
</Link>
</div>
);
}
const brand = brands.find((b) => b.id === product.brandId);
// Get primary variant + price + stock
const variants = await fetchProductVariants(product.id);
const primaryVariant = variants[0];
let price = null;
let stock = null;
if (primaryVariant) {
[price, stock] = await Promise.all([
fetchVariantPrice(primaryVariant.id),
fetchStockAvailability(primaryVariant.id),
]);
}
// Build category links from all category IDs
const tree = await fetchCategories();
const flatCats: Array<{ id: string; name: string; slug: string; parentSlug?: string }> = [];
function flatten(cats: typeof tree, parentSlug?: string) {
for (const cat of cats) {
flatCats.push({ id: cat.id, name: cat.name, slug: cat.slug, parentSlug });
if (cat.children?.length) flatten(cat.children, cat.slug);
}
}
flatten(tree);
const productCats = flatCats.filter((c) => product.categoryIds?.includes(c.id));
const netCents = price?.netUnitAmountCents ?? 0;
const vatRate = price?.vatRate ?? 'general';
const grossCents = calcGrossPrice(netCents, vatRate);
const vatPercent = vatRate === 'general' ? 21 : 10;
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Breadcrumb */}
<nav className="mb-6" aria-label="Breadcrumb">
<ol className="flex items-center gap-2 text-sm text-gray-500 flex-wrap">
<li><Link href="/" className="hover:text-[#70ad47]">Inicio</Link></li>
<li><span className="text-gray-300">/</span></li>
<li><Link href="/products" className="hover:text-[#70ad47]">Productos</Link></li>
{productCats[0] && (
<>
<li><span className="text-gray-300">/</span></li>
<li><Link href={`/categories/${productCats[0].slug}`} className="hover:text-[#70ad47]">{productCats[0].name}</Link></li>
</>
)}
<li><span className="text-gray-300">/</span></li>
<li className="text-gray-900 font-medium truncate max-w-xs">{product.name}</li>
</ol>
</nav>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Image */}
<div>
<div className="aspect-square bg-gray-50 rounded-2xl border border-gray-100 flex items-center justify-center overflow-hidden">
{product.images?.[0] ? (
<Image
src={product.images[0].url}
alt={product.name}
fill
className="object-cover"
priority
sizes="(max-width: 1024px) 100vw, 50vw"
/>
) : (
<span className="text-8xl">🌿</span>
)}
</div>
</div>
{/* Details */}
<div>
{brand && (
<Link href={`/brands/${brand.slug}`} className="text-sm text-[#E76F51] font-medium uppercase tracking-wide hover:underline">
{brand.name}
</Link>
)}
<h1 className="text-3xl font-bold text-gray-900 mt-2" style={{ fontFamily: 'var(--font-heading)' }}>
{product.name}
</h1>
{/* Price */}
{price ? (
<div className="mt-6 bg-gray-50 rounded-xl p-6">
<div className="flex items-baseline gap-3">
<span className="text-4xl font-bold text-[#70ad47]">{formatPrice(grossCents)}</span>
<span className="text-lg text-gray-500">inc. IVA {vatPercent}%</span>
</div>
<div className="mt-2 text-sm text-gray-500">
{formatPrice(netCents)} sin IVA · IVA {vatPercent}%
</div>
</div>
) : (
<div className="mt-6 bg-gray-50 rounded-xl p-6">
<span className="text-2xl text-gray-400">Precio no disponible</span>
</div>
)}
{/* Stock */}
{stock && (
<div className="mt-4">
{stock.available ? (
<div className="flex items-center gap-2 text-[#70ad47]">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span className="text-sm font-medium">En stock {stock.availableQuantity} unidades</span>
</div>
) : (
<div className="flex items-center gap-2 text-red-500">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<span className="text-sm font-medium">Sin stock</span>
</div>
)}
</div>
)}
{/* Add to cart */}
{stock?.available && primaryVariant ? (
<ProductAddToCart
variantId={primaryVariant.id}
productId={product.id}
productName={product.name}
priceCents={grossCents}
imageUrl={product.images?.[0]?.url}
available={true}
/>
) : (
<button
disabled
className="mt-6 px-8 py-3.5 bg-gray-200 text-gray-500 font-semibold rounded-xl cursor-not-allowed"
>
Agotado
</button>
)}
{/* Description */}
{product.description && (
<div className="mt-8">
<h2 className="text-lg font-semibold text-gray-900 mb-2">Descripción</h2>
<p className="text-gray-600 leading-relaxed">{product.description}</p>
</div>
)}
{/* Categories */}
{productCats.length > 0 && (
<div className="mt-6">
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-2">Categorías</h3>
<div className="flex flex-wrap gap-2">
{productCats.map((cat) => (
<Link
key={cat.id}
href={`/categories/${cat.slug}`}
className="px-3 py-1 bg-gray-100 hover:bg-[#70ad47] hover:text-white text-sm rounded-full transition-colors"
>
{cat.name}
</Link>
))}
</div>
</div>
)}
{/* SKU */}
{primaryVariant && (
<div className="mt-4 text-xs text-gray-400">
SKU: {primaryVariant.sku}
{primaryVariant.ean && ` · EAN: ${primaryVariant.ean}`}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,78 @@
import Link from 'next/link';
import Image from 'next/image';
import type { Metadata } from 'next';
import { fetchProducts, fetchBrands, fetchCategories, formatPrice } from '@/lib/api';
export const metadata: Metadata = {
title: 'Productos — MercadoDeVida',
description: 'Todos los productos naturales y orgánicos.',
};
function findCatName(cats: ReturnType<typeof flattenCats>, id: string): string | undefined {
return cats.find((c) => c.id === id)?.name;
}
function flattenCats(cats: Awaited<ReturnType<typeof fetchCategories>>): Array<{ id: string; name: string; slug: string }> {
const flat: Array<{ id: string; name: string; slug: string }> = [];
function walk(c: typeof cats[number]) {
flat.push({ id: c.id, name: c.name, slug: c.slug });
if (c.children?.length) c.children.forEach(walk);
}
cats.forEach(walk);
return flat;
}
export default async function ProductsPage() {
const [products, brands, categories] = await Promise.all([
fetchProducts({ limit: 24 }),
fetchBrands(),
fetchCategories(),
]);
const flatCats = flattenCats(categories);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900" style={{ fontFamily: 'var(--font-heading)' }}>
Todos los productos
</h1>
<p className="mt-2 text-gray-600">{products.length} productos disponibles</p>
</div>
{products.length === 0 ? (
<p className="text-gray-500 text-center py-16">No hay productos disponibles.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{products.map((product) => {
const brand = brands.find((b) => b.id === product.brandId);
return (
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-all hover:shadow-md">
<div className="aspect-square relative bg-white flex items-center justify-center">
{product.images?.[0] ? (
<Image src={product.images[0].url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
) : (
<span className="text-5xl">🌿</span>
)}
</div>
<div className="p-4">
{brand && (
<p className="text-xs text-[#E76F51] font-medium uppercase tracking-wide mb-1">{brand.name}</p>
)}
<h3 className="font-semibold text-gray-900 group-hover:text-[#70ad47] transition-colors line-clamp-2 text-sm">
{product.name}
</h3>
<p className="text-gray-500 text-xs mt-1 line-clamp-2">{product.description}</p>
<div className="mt-3 pr-2">
<span className="text-lg font-bold text-[#70ad47]">{formatPrice(0)}</span>
</div>
</div>
</div>
</Link>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,14 @@
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/admin/', '/api/', '/auth/', '/cart', '/checkout', '/order-confirmation'],
},
],
sitemap: 'https://mercadodevida.es/sitemap.xml',
};
}

View File

@@ -0,0 +1,125 @@
import Link from 'next/link';
import Image from 'next/image';
import type { Metadata } from 'next';
import { fetchProducts, fetchBrands, fetchCategories, formatPrice } from '@/lib/api';
interface Props {
searchParams: Promise<{ q?: string; brand?: string; category?: string }>;
}
export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
const { q } = await searchParams;
const title = q ? `Buscar: "${q}"` : 'Buscar productos';
return { title, description: `${title} en MercadoDeVida.` };
}
export default async function SearchPage({ searchParams }: Props) {
const { q, brand, category } = await searchParams;
const query = q?.trim() ?? '';
const [products, brands, categories] = await Promise.all([
fetchProducts({ q: query || undefined, brandSlug: brand, categorySlug: category, limit: 24 }),
fetchBrands(),
fetchCategories(),
]);
// Flatten categories for display
const flatCats: Array<{ id: string; name: string; slug: string }> = [];
function flatten(cats: typeof categories) {
for (const c of cats) {
flatCats.push({ id: c.id, name: c.name, slug: c.slug });
if (c.children?.length) flatten(c.children);
}
}
flatten(categories);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Search form */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-4" style={{ fontFamily: 'var(--font-heading)' }}>
Buscar productos
</h1>
<form method="GET" action="/search" className="flex gap-3">
<input
name="q"
type="search"
defaultValue={query}
placeholder="Buscar productos, marcas, categorías..."
className="flex-1 px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none text-gray-900"
autoFocus
/>
<button
type="submit"
className="px-6 py-3 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors"
>
Buscar
</button>
</form>
</div>
{/* Results */}
{query && (
<div className="mb-6">
<p className="text-gray-600">
{products.length > 0
? `${products.length} resultado${products.length !== 1 ? 's' : ''} para "${query}"`
: `Sin resultados para "${query}"`
}
</p>
</div>
)}
{products.length === 0 && query ? (
<div className="py-16 text-center">
<p className="text-gray-500 text-lg mb-4">No encontramos productos para tu búsqueda.</p>
<p className="text-gray-400 mb-8">Prueba con otros términos o explora nuestras categorías.</p>
<div className="flex flex-wrap justify-center gap-2">
{['Almendras', 'Aceite', 'Vitamina', 'Crema', 'Jabón', 'Matcha'].map((term) => (
<Link key={term} href={`/search?q=${encodeURIComponent(term)}`}
className="px-4 py-2 bg-gray-100 hover:bg-[#70ad47] hover:text-white rounded-full text-sm transition-colors">
{term}
</Link>
))}
</div>
</div>
) : products.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 justify-items-center">
{products.map((product) => {
const brand_ = brands.find((b) => b.id === product.brandId);
const cats = product.categoryIds?.map((id) => flatCats.find((c) => c.id === id)).filter(Boolean) ?? [];
return (
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-all hover:shadow-md">
<div className="aspect-square relative bg-white flex items-center justify-center">
{product.images?.[0] ? (
<Image src={product.images[0].url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
) : (
<span className="text-5xl">🌿</span>
)}
</div>
<div className="p-4">
{brand_ && (
<p className="text-xs text-[#E76F51] font-medium uppercase tracking-wide mb-1">{brand_.name}</p>
)}
<h3 className="font-semibold text-gray-900 group-hover:text-[#70ad47] transition-colors line-clamp-2 text-sm">
{product.name}
</h3>
<p className="text-gray-500 text-xs mt-1 line-clamp-2">{product.description}</p>
<div className="mt-3 pr-2">
<span className="text-lg font-bold text-[#70ad47]">{formatPrice(0)}</span>
</div>
</div>
</div>
</Link>
);
})}
</div>
) : (
<div className="py-16 text-center text-gray-500">
<p>Escribe un término de búsqueda y presiona Enter.</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,38 @@
import ContentPage from '@/components/content/ContentPage';
import type { Metadata } from 'next';
import { fetchPage } from '@/lib/api';
export const metadata: Metadata = {
title: 'Envíos y entregas',
description: 'Información sobre métodos de envío, plazos de entrega y costes. Envío a toda España peninsular.',
};
const FALLBACK_HTML = `
<h2>Zonas de envío</h2>
<p>Realizamos envíos a toda España peninsular. Para Canarias, Ceuta y Melilla, contacta con nosotros antes de realizar tu pedido.</p>
<h2>Métodos de envío</h2>
<h3>Envío estándar (3-5 días laborables)</h3>
<p>Entrega en 3-5 días laborables. Coste según peso del pedido.</p>
<h3>Envío express 24h</h3>
<p>Entrega al día siguiente laborable para pedidos realizados antes de las 13:00h. Disponible para productos en stock.</p>
<h2>Seguimiento del pedido</h2>
<p>Una vez despachado tu pedido, recibirás un email con el número de seguimiento. Puedes rastrear tu paquete en la web del transportista.</p>
<h2>Costes de envío</h2>
<p>El coste exacto se calcula al finalizar tu pedido en función del peso y la dirección de entrega. Para pedidos superiores a un umbral mínimo, el envío estándar es gratuito.</p>
<h2>Problemas con la entrega</h2>
<p>Si tu pedido no llega en el plazo indicado, ponte en contacto con nosotros en <a href="mailto:hola@mercadodevida.es" class="text-[#70ad47] hover:underline">hola@mercadodevida.es</a>.</p>
`;
export default async function ShippingPage() {
const cms = await fetchPage('shipping').catch(() => null);
const body = cms?.body ?? FALLBACK_HTML;
return (
<ContentPage
title={cms?.title ?? 'Envíos y entregas'}
description="Información sobre cómo enviamos tu pedido y los plazos de entrega estimados."
>
<div dangerouslySetInnerHTML={{ __html: body }} />
</ContentPage>
);
}

View File

@@ -0,0 +1,16 @@
import type { MetadataRoute } from 'next';
const BASE_URL = 'https://mercadodevida.es';
export default function sitemap(): MetadataRoute.Sitemap {
return [
{ url: BASE_URL, lastModified: new Date(), changeFrequency: 'weekly', priority: 1 },
{ url: `${BASE_URL}/products`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.9 },
{ url: `${BASE_URL}/categories`, lastModified: new Date(), changeFrequency: 'weekly', priority: 0.8 },
{ url: `${BASE_URL}/brands`, lastModified: new Date(), changeFrequency: 'weekly', priority: 0.8 },
{ url: `${BASE_URL}/search`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.7 },
{ url: `${BASE_URL}/about`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.5 },
{ url: `${BASE_URL}/contact`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.5 },
{ url: `${BASE_URL}/shipping`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.5 },
];
}

View File

@@ -0,0 +1,64 @@
import ContentPage from '@/components/content/ContentPage';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Términos y condiciones',
description: 'Condiciones generales de venta de MercadoDeVida. Lea atentamente antes de realizar su pedido.',
robots: { index: false, follow: false },
};
export default function TermsPage() {
return (
<ContentPage
title="Términos y condiciones"
description="Última actualización: agosto de 2026. Esta página requiere revisión por un asesor legal antes de uso en producción."
>
<p className="bg-amber-50 border border-amber-200 text-amber-800 rounded-lg p-4 mb-6 text-sm">
<strong>Placeholder:</strong> Este texto es un marcador. El contenido legal real debe ser
redactado o aprobado por un profesional jurídico antes de публикации en producción.
</p>
<h2>Identificación del vendedor</h2>
<p>
MercadoDeVida<br />
Email: hola@mercadodevida.es
</p>
<h2>Objeto</h2>
<p>
Las presentes condiciones regulan la compra de productos naturales y orgánicos ofrecidos en
esta tienda online.
</p>
<h2>Proceso de compra</h2>
<p>
Selecciona los productos, añádelos al carrito, revisa tu pedido y procede al pago. Recibirás
un email de confirmación una vez completado el pedido.
</p>
<h2>Precios</h2>
<p>
Todos los precios incluyen IVA. Nos reservamos el derecho a modificar precios sin previo aviso.
Los precios aplicados serán los vigentes en el momento de confirmación del pedido.
</p>
<h2>Formas de pago</h2>
<p>Aceptamos pago con tarjeta de crédito/débito a través de pasarela segura (Stripe).</p>
<h2>Envío</h2>
<p>
Consulta nuestra{' '}
<a href="/shipping" className="text-[#70ad47] hover:underline">
política de envíos
</a>
.
</p>
<h2>Devoluciones</h2>
<p>
Aceptamos devoluciones de productos no abiertos en su embalaje original en un plazo de 14 días
desde la recepción. Consulta las condiciones detalladas escribiéndonos.
</p>
</ContentPage>
);
}

View File

@@ -0,0 +1,78 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
const navItems = [
{ href: '/admin', label: 'Dashboard', icon: '📊' },
{ href: '/admin/products', label: 'Productos', icon: '📦' },
{ href: '/admin/orders', label: 'Pedidos', icon: '🧾' },
];
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (!loading && (!user || user.role !== 'admin')) {
router.push('/auth/login');
}
}, [user, loading, router]);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-500">Cargando...</div>
</div>
);
}
if (!user || user.role !== 'admin') {
return null;
}
return (
<div className="min-h-screen bg-gray-50 flex">
{/* Sidebar */}
<aside className="w-56 bg-white border-r border-gray-200 flex-shrink-0">
<div className="p-4 border-b border-gray-200">
<p className="text-xs font-bold uppercase tracking-wider text-gray-400">Admin</p>
<p className="text-sm font-semibold text-gray-900 truncate">{user.email}</p>
</div>
<nav className="p-3 space-y-1">
{navItems.map((item) => {
const active = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
active
? 'bg-[#70ad47] text-white'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
}`}
>
<span>{item.icon}</span>
{item.label}
</Link>
);
})}
<div className="pt-4 border-t border-gray-200 mt-4">
<Link href="/" className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100 transition-colors">
<span></span>
Ver tienda
</Link>
</div>
</nav>
</aside>
{/* Content */}
<main className="flex-1 p-8">
{children}
</main>
</div>
);
}

View File

@@ -0,0 +1,32 @@
'use client';
import { useAuth } from '@/contexts/AuthContext';
import Link from 'next/link';
export default function UserMenu() {
const { user, logout } = useAuth();
if (user) {
return (
<div className="flex items-center gap-3">
<span className="text-sm text-gray-600 hidden sm:block">
{user.email}
</span>
<button
onClick={logout}
className="text-sm font-medium text-[#70ad47] hover:text-[#5a9040] transition-colors"
>
Cerrar sesión
</button>
</div>
);
}
return (
<Link
href="/auth/login"
className="text-sm font-medium text-[#70ad47] hover:text-[#5a9040] transition-colors"
>
Iniciar sesión
</Link>
);
}

View File

@@ -0,0 +1,52 @@
'use client';
import { useState } from 'react';
import { useCart } from '@/contexts/CartContext';
interface Props {
variantId: string;
productId: string;
productName: string;
priceCents: number;
imageUrl?: string;
available?: boolean;
className?: string;
}
export default function AddToCartButton({
variantId, productId, productName, priceCents, imageUrl, available = true, className = '',
}: Props) {
const { addItem, itemCount } = useCart();
const [added, setAdded] = useState(false);
const handleAdd = () => {
if (!available) return;
addItem({ variantId, productId, productName, quantity: 1, priceCents, imageUrl });
setAdded(true);
setTimeout(() => setAdded(false), 2000);
};
if (!available) {
return (
<button disabled className={`px-8 py-3.5 bg-gray-200 text-gray-500 font-semibold rounded-xl cursor-not-allowed ${className}`}>
Agotado
</button>
);
}
if (added) {
return (
<button disabled className={`px-8 py-3.5 bg-[#52B788] text-white font-semibold rounded-xl cursor-default ${className}`}>
Añadido
</button>
);
}
return (
<button
onClick={handleAdd}
className={`px-8 py-3.5 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors shadow-lg ${className}`}
>
Añadir al carrito
</button>
);
}

View File

@@ -0,0 +1,24 @@
'use client';
import Link from 'next/link';
import { useCart } from '@/contexts/CartContext';
export default function CartLink() {
const { itemCount } = useCart();
return (
<Link
href="/cart"
className="relative p-2 text-gray-600 hover:text-[#70ad47] transition-colors"
aria-label={`Carrito (${itemCount} artículos)`}
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM8.625 10.5a.375.375 0 11-.75 0 .375.375 0 01.75 0zm7.5 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
</svg>
{itemCount > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-[#E76F51] text-white text-xs font-bold rounded-full flex items-center justify-center">
{itemCount > 9 ? '9+' : itemCount}
</span>
)}
</Link>
);
}

View File

@@ -0,0 +1,149 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import { useCart, type CartItem } from '@/contexts/CartContext';
function formatPrice(cents: number) {
return `${(cents / 100).toFixed(2)}`;
}
function CartItemRow({ item }: { item: CartItem }) {
const { removeItem, changeQuantity } = useCart();
return (
<div className="flex gap-4 py-4 border-b border-gray-100 last:border-0">
{/* Image */}
<div className="w-20 h-20 bg-gray-50 rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
{item.imageUrl ? (
<Image src={item.imageUrl} alt={item.productName} width={80} height={80} className="object-cover" />
) : (
<span className="text-3xl">🌿</span>
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<Link href={`/products/${item.productId}`} className="font-semibold text-gray-900 hover:text-[#70ad47] transition-colors line-clamp-2 text-sm">
{item.productName}
</Link>
<p className="text-gray-500 text-sm mt-1">{formatPrice(item.priceCents)}/ud</p>
{/* Quantity controls */}
<div className="flex items-center gap-3 mt-2">
<div className="flex items-center border border-gray-300 rounded-lg">
<button
onClick={() => changeQuantity(item.variantId, item.quantity - 1)}
className="w-8 h-8 flex items-center justify-center text-gray-600 hover:text-[#70ad47] transition-colors"
>
</button>
<span className="w-8 text-center text-sm font-medium">{item.quantity}</span>
<button
onClick={() => changeQuantity(item.variantId, item.quantity + 1)}
className="w-8 h-8 flex items-center justify-center text-gray-600 hover:text-[#70ad47] transition-colors"
>
+
</button>
</div>
<button
onClick={() => removeItem(item.variantId)}
className="text-gray-400 hover:text-red-500 transition-colors text-sm"
>
Eliminar
</button>
</div>
</div>
{/* Subtotal */}
<div className="text-right flex-shrink-0">
<p className="font-bold text-[#70ad47]">{formatPrice(item.priceCents * item.quantity)}</p>
</div>
</div>
);
}
export default function CartPageContent() {
const { items, subtotalCents, itemCount, clearCart } = useCart();
if (items.length === 0) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
<div className="text-6xl mb-4">🛒</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Tu carrito está vacío</h1>
<p className="text-gray-500 mb-8">Añade productos para empezar tu pedido.</p>
<Link href="/products" className="inline-flex items-center gap-2 px-6 py-3 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors">
Ver productos
</Link>
</div>
);
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900" style={{ fontFamily: 'var(--font-heading)' }}>
Carrito de compra
</h1>
<p className="mt-1 text-gray-500">{itemCount} artículo{itemCount !== 1 ? 's' : ''}</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Items */}
<div className="lg:col-span-2">
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
{items.map((item) => (
<CartItemRow key={item.variantId} item={item} />
))}
</div>
<button
onClick={clearCart}
className="mt-4 text-gray-400 hover:text-red-500 text-sm transition-colors"
>
Vaciar carrito
</button>
</div>
{/* Summary */}
<div>
<div className="bg-gray-50 rounded-xl p-6 border border-gray-200 sticky top-24">
<h2 className="font-bold text-gray-900 mb-4">Resumen del pedido</h2>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Subtotal ({itemCount} artículos)</span>
<span className="font-medium">{formatPrice(subtotalCents)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Envío</span>
<span className="text-gray-500">Calculado en checkout</span>
</div>
<div className="flex justify-between text-gray-600">
<span>Impuestos</span>
<span>Incluidos</span>
</div>
</div>
<div className="border-t border-gray-200 mt-4 pt-4 flex justify-between items-center">
<span className="font-bold text-gray-900">Total</span>
<span className="text-2xl font-bold text-[#70ad47]">{formatPrice(subtotalCents)}</span>
</div>
<Link
href="/checkout"
className="mt-4 w-full block text-center px-6 py-3.5 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors"
>
Proceder al checkout
</Link>
<Link
href="/products"
className="mt-2 w-full block text-center px-6 py-2 text-sm text-gray-500 hover:text-[#70ad47] transition-colors"
>
Seguir comprando
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
'use client';
import { useState } from 'react';
import AddToCartButton from './AddToCartButton';
interface Props {
variantId: string;
productId: string;
productName: string;
priceCents: number;
imageUrl?: string;
available: boolean;
}
export default function ProductAddToCart({ variantId, productId, productName, priceCents, imageUrl, available }: Props) {
return (
<div className="mt-6">
<AddToCartButton
variantId={variantId}
productId={productId}
productName={productName}
priceCents={priceCents}
imageUrl={imageUrl}
available={available}
className="w-full sm:w-auto"
/>
</div>
);
}

View File

@@ -0,0 +1,295 @@
'use client';
import Link from 'next/link';
import Image from 'next/image';
import { useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { useCart } from '@/contexts/CartContext';
function formatPrice(cents: number) {
return `${(cents / 100).toFixed(2)}`;
}
export default function CheckoutClient() {
const { user, loading: authLoading } = useAuth();
const { items, subtotalCents, itemCount, clearCart } = useCart();
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
// Form state
const [form, setForm] = useState({
firstName: '',
lastName: '',
email: user?.email ?? '',
phone: '',
address: '',
city: '',
postalCode: '',
notes: '',
shippingMethod: 'standard',
});
if (authLoading) {
return (
<div className="max-w-7xl mx-auto px-4 py-16 flex items-center justify-center">
<div className="text-gray-500">Cargando...</div>
</div>
);
}
if (items.length === 0) {
return (
<div className="max-w-2xl mx-auto px-4 py-16 text-center">
<div className="text-6xl mb-4">🛒</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Tu carrito está vacío</h1>
<p className="text-gray-500 mb-8">Añade productos antes de hacer el pedido.</p>
<Link href="/products" className="inline-flex px-6 py-3 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors">
Ver productos
</Link>
</div>
);
}
const shippingCost = form.shippingMethod === 'express' ? 899 : 499;
const totalCents = subtotalCents + shippingCost;
const handlePlaceOrder = async () => {
setSubmitting(true);
setError('');
try {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
shippingAddress: {
firstName: form.firstName,
lastName: form.lastName,
email: form.email,
phone: form.phone,
line1: form.address,
city: form.city,
postalCode: form.postalCode,
country: 'ES',
},
shippingMethod: form.shippingMethod,
notes: form.notes,
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error?.message || 'Error al procesar el pedido');
}
const { orderId } = await res.json();
clearCart();
window.location.href = `/order-confirmation?orderId=${orderId}`;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al procesar el pedido');
} finally {
setSubmitting(false);
}
};
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 className="text-3xl font-bold text-gray-900 mb-8" style={{ fontFamily: 'var(--font-heading)' }}>
Finalizar pedido
</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Form */}
<div>
{/* Login prompt — solo si NO está autenticado */}
{!user && (
<div className="bg-[#70ad47]/5 border border-[#70ad47]/20 rounded-xl p-6 mb-6">
<h2 className="font-bold text-gray-900 mb-1">¿Ya tienes cuenta?</h2>
<p className="text-sm text-gray-600 mb-3">Inicia sesión para una experiencia más rápida.</p>
<div className="flex gap-3">
<Link href="/auth/login" className="px-4 py-2 bg-[#70ad47] hover:bg-[#5a9040] text-white text-sm font-semibold rounded-lg transition-colors">
Iniciar sesión
</Link>
<Link href="/auth/register" className="px-4 py-2 border border-gray-300 hover:border-[#70ad47] text-gray-700 text-sm font-semibold rounded-lg transition-colors">
Crear cuenta
</Link>
</div>
</div>
)}
{/* Shipping form */}
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="font-bold text-gray-900 mb-4">Datos de envío</h2>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre *</label>
<input
type="text" required placeholder="María"
value={form.firstName}
onChange={e => setForm(f => ({ ...f, firstName: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Apellidos *</label>
<input
type="text" required placeholder="García López"
value={form.lastName}
onChange={e => setForm(f => ({ ...f, lastName: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email *</label>
<input
type="email" required placeholder="maria@ejemplo.com"
value={form.email}
onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Teléfono *</label>
<input
type="tel" required placeholder="+34 600 000 000"
value={form.phone}
onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Dirección *</label>
<input
type="text" required placeholder="Calle Gran Vía 42"
value={form.address}
onChange={e => setForm(f => ({ ...f, address: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ciudad *</label>
<input
type="text" required placeholder="Madrid"
value={form.city}
onChange={e => setForm(f => ({ ...f, city: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">CP *</label>
<input
type="text" required placeholder="28013"
value={form.postalCode}
onChange={e => setForm(f => ({ ...f, postalCode: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">País</label>
<input type="text" disabled value="España"
className="w-full px-3 py-2 border border-gray-200 rounded-lg bg-gray-50 text-gray-500" />
</div>
</div>
<div className="border-t border-gray-200 pt-4 mt-4">
<h3 className="font-semibold text-gray-900 mb-3">Método de envío</h3>
<div className="space-y-2">
{[
{ id: 'standard', name: 'Estándar', desc: 'Entrega 3-5 días laborables', price: '€4.99' },
{ id: 'express', name: 'Express 24h', desc: 'Entrega al día siguiente', price: '€8.99' },
].map(opt => (
<label key={opt.id}
className={`flex items-center gap-3 p-3 border rounded-lg cursor-pointer transition-colors ${form.shippingMethod === opt.id ? 'border-[#70ad47] bg-[#70ad47]/5' : 'border-gray-200 hover:border-[#70ad47]'}`}>
<input type="radio" name="shipping" value={opt.id} checked={form.shippingMethod === opt.id}
onChange={e => setForm(f => ({ ...f, shippingMethod: e.target.value }))}
className="text-[#70ad47]" />
<div className="flex-1">
<p className="font-medium text-gray-900">{opt.name}</p>
<p className="text-sm text-gray-500">{opt.desc}</p>
</div>
<span className="font-semibold text-[#70ad47]">{opt.price}</span>
</label>
))}
</div>
</div>
<div className="border-t border-gray-200 pt-4">
<h3 className="font-semibold text-gray-900 mb-3">Notas del pedido</h3>
<textarea rows={3} placeholder="Observaciones, instrucciones de entrega..."
value={form.notes}
onChange={e => setForm(f => ({ ...f, notes: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none resize-none"
/>
</div>
</div>
</div>
</div>
{/* Order summary */}
<div>
<div className="bg-gray-50 rounded-xl border border-gray-200 p-6 sticky top-24">
<h2 className="font-bold text-gray-900 mb-4">Tu pedido ({itemCount})</h2>
<div className="space-y-3 max-h-80 overflow-y-auto mb-4">
{items.map(item => (
<div key={item.variantId} className="flex gap-3">
<div className="w-12 h-12 bg-white rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
{item.imageUrl
? <Image src={item.imageUrl} alt={item.productName} width={48} height={48} className="object-cover" />
: <span className="text-2xl">🌿</span>
}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 line-clamp-1">{item.productName}</p>
<p className="text-xs text-gray-500">Cantidad: {item.quantity}</p>
</div>
<p className="text-sm font-bold text-gray-900 flex-shrink-0">
{formatPrice(item.priceCents * item.quantity)}
</p>
</div>
))}
</div>
<div className="border-t border-gray-200 pt-4 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Subtotal</span>
<span className="font-medium">{formatPrice(subtotalCents)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Envío</span>
<span className="text-gray-500">{form.shippingMethod === 'express' ? '€8.99' : '€4.99'}</span>
</div>
<div className="flex justify-between text-gray-600">
<span>IVA</span>
<span>Incluido</span>
</div>
</div>
<div className="border-t border-gray-200 mt-4 pt-4 flex justify-between items-center">
<span className="font-bold text-gray-900">Total</span>
<span className="text-2xl font-bold text-[#70ad47]">{formatPrice(totalCents)}</span>
</div>
{error && (
<div className="mt-3 bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-2.5 rounded-xl">
{error}
</div>
)}
<button
onClick={handlePlaceOrder}
disabled={submitting || !form.firstName || !form.email || !form.address || !form.city || !form.postalCode}
className="mt-4 w-full py-3.5 bg-[#70ad47] hover:bg-[#5a9040] disabled:bg-gray-300 disabled:text-gray-500 text-white font-semibold rounded-xl transition-colors cursor-pointer"
>
{submitting ? 'Procesando...' : 'Finalizar pedido'}
</button>
<Link href="/cart" className="mt-3 w-full block text-center text-sm text-gray-500 hover:text-[#70ad47] transition-colors">
Volver al carrito
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
interface Props {
title: string;
description: string;
children: React.ReactNode;
}
export default function ContentPage({ title, description, children }: Props) {
return (
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<h1
className="text-4xl font-bold text-gray-900 mb-4"
style={{ fontFamily: 'var(--font-heading)' }}
>
{title}
</h1>
<p className="text-lg text-gray-600 mb-10 leading-relaxed">{description}</p>
<div className="prose prose-gray max-w-none">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import Link from 'next/link';
import type { Brand } from '@/types/api';
import { fetchBrands } from '@/lib/api';
export default async function BrandsSection() {
const brands = await fetchBrands();
if (brands.length === 0) return null;
return (
<section className="py-16 bg-white border-t border-gray-100">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-2xl font-bold text-gray-900 mb-8 text-center">Nuestras marcas</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
{brands.map((brand) => (
<Link key={brand.id} href={`/brands/${brand.slug}`}>
<div className="p-4 rounded-xl border border-gray-200 hover:border-[#70ad47] transition-all hover:shadow-md bg-gray-50 text-center">
<div className="w-12 h-12 mx-auto mb-2 bg-[#70ad47]/10 rounded-full flex items-center justify-center">
<span className="text-[#70ad47] font-bold text-sm">{brand.name.slice(0, 2).toUpperCase()}</span>
</div>
<p className="font-medium text-gray-900 text-sm">{brand.name}</p>
</div>
</Link>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,65 @@
import Link from 'next/link';
import type { Category } from '@/types/api';
import { fetchCategories } from '@/lib/api';
export default async function CategoriesGrid() {
let categories: Awaited<ReturnType<typeof fetchCategories>> = [];
try {
categories = await fetchCategories();
} catch (e) {
console.error('[CategoriesGrid] fetch error:', e);
}
if (categories.length === 0) return null;
const icons: Record<string, string> = {
alimentacion: '🥜',
suplementos: '💊',
'cosmetica-natural': '🌸',
'limpieza-ecologica': '🌿',
'frutos-secos': '🥜',
aceites: '🫒',
'hierbas-infusiones': '🍵',
vitaminas: '💊',
proteinas: '🏋️',
cremas: '🧴',
jabones: '🧼',
};
const colors = [
'bg-[#70ad47]/10 text-[#70ad47]',
'bg-[#E76F51]/10 text-[#E76F51]',
'bg-[#F4A261]/10 text-[#F4A261]',
'bg-[#52B788]/10 text-[#52B788]',
'bg-[#5a9040]/10 text-[#5a9040]',
];
return (
<section className="py-16 bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-2xl font-bold text-gray-900 mb-8 text-center">Explora categorías</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
{categories.map((cat, i) => {
const totalProducts = cat.children ? cat.children.length * 5 : 5;
return (
<Link key={cat.id} href={`/categories/${cat.slug}`}>
<div className={`p-5 rounded-xl border border-gray-200 hover:border-[#70ad47] transition-all hover:shadow-md bg-white text-center ${colors[i % colors.length]}`}>
<div className="text-4xl mb-2">{icons[cat.slug] ?? '📦'}</div>
<h3 className="font-semibold text-gray-900 text-sm">{cat.name}</h3>
<p className="text-xs text-gray-500 mt-1">{totalProducts} productos</p>
{cat.children && cat.children.length > 0 && (
<div className="mt-2 flex flex-wrap justify-center gap-1">
{cat.children.slice(0, 3).map((child) => (
<span key={child.id} className="text-xs bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded">{child.name}</span>
))}
</div>
)}
</div>
</Link>
);
})}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,64 @@
import Link from 'next/link';
import Image from 'next/image';
import type { Product } from '@/types/api';
import { fetchProducts } from '@/lib/api';
function formatPrice(cents: number): string {
return `${(cents / 100).toFixed(2)}`;
}
export default async function FeaturedProducts() {
let allProducts: Awaited<ReturnType<typeof fetchProducts>> = [];
try {
allProducts = await fetchProducts();
} catch (e) {
console.error('[FeaturedProducts] fetch error:', e);
}
const products = allProducts.slice(0, 4);
if (products.length === 0) {
return (
<section className="py-16 bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<p className="text-center text-gray-500">Cargando productos...</p>
</div>
</section>
);
}
return (
<section className="py-16 bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-2xl font-bold text-gray-900 mb-8">Productos destacados</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{products.map((product) => (
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-colors">
<div className="aspect-square relative bg-white flex items-center justify-center">
{product.images?.[0] ? (
<Image src={product.images[0].url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
) : (
<span className="text-5xl">🌿</span>
)}
</div>
<div className="p-4">
{product.brandId && <p className="text-xs text-[#70ad47] font-medium uppercase tracking-wide mb-1">Marca</p>}
<h3 className="font-semibold text-gray-900 group-hover:text-[#70ad47] transition-colors line-clamp-2">{product.name}</h3>
<p className="text-gray-500 text-sm mt-1 line-clamp-2">{product.description}</p>
<div className="mt-3 pr-2">
<span className="text-lg font-bold text-[#70ad47]">{formatPrice(0)}</span>
</div>
</div>
</div>
</Link>
))}
</div>
<div className="text-center mt-8">
<Link href="/products" className="text-[#70ad47] font-medium hover:text-[#5a9040] transition-colors">
Ver todos los productos
</Link>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,77 @@
import Link from 'next/link';
export function Hero() {
return (
<section className="relative overflow-hidden bg-gradient-to-br from-[#70ad47] via-[#40916C] to-[#52B788]">
{/* Decorative circles */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-24 -right-24 w-96 h-96 rounded-full bg-white opacity-5" />
<div className="absolute top-1/2 -left-16 w-64 h-64 rounded-full bg-white opacity-5" />
<div className="absolute bottom-0 right-1/4 w-48 h-48 rounded-full bg-white opacity-5" />
</div>
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 md:py-32">
<div className="max-w-2xl">
{/* Badge */}
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 backdrop-blur-sm text-sm text-green-100 mb-6">
<svg className="w-4 h-4 text-green-300" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
Calidad certificada · 100% natural
</div>
<h1
className="text-4xl md:text-6xl font-bold text-white leading-tight mb-6"
style={{ fontFamily: 'var(--font-heading)' }}
>
Productos naturales y orgánicos para tu bienestar
</h1>
<p className="text-lg md:text-xl text-green-100 leading-relaxed mb-8 max-w-xl">
Descubre nuestra selección de productos ecológicos, saludables y sostenibles. Envío a toda España.
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Link
href="/products"
className="inline-flex items-center justify-center gap-2 px-8 py-3.5 bg-white text-[#70ad47] font-semibold rounded-lg hover:bg-green-50 transition-colors shadow-lg"
>
Ver productos
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</Link>
<Link
href="/categories"
className="inline-flex items-center justify-center gap-2 px-8 py-3.5 bg-white/10 backdrop-blur-sm text-white font-semibold rounded-lg hover:bg-white/20 transition-colors border border-white/20"
>
Explorar categorías
</Link>
</div>
{/* Trust badges */}
<div className="flex flex-wrap items-center gap-6 mt-12 text-sm text-green-100">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-green-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12" />
</svg>
Envío gratis +49
</div>
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-green-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
Pago seguro
</div>
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-green-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Entrega 24-48h
</div>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,120 @@
import Link from 'next/link';
export function Footer() {
return (
<footer className="mt-auto" style={{ backgroundColor: 'var(--color-footer-bg)', color: 'var(--color-footer-text)' }}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
{/* Brand */}
<div className="col-span-2 md:col-span-1">
{/* FIX-04: Natural Almagro / Mercado de Vida stacked */}
<div className="mb-3">
<p
className="text-2xl font-bold leading-none"
style={{ fontFamily: 'var(--font-heading)', letterSpacing: '0.04em', color: 'var(--color-footer-text)' }}
>
Natural
</p>
<p
className="text-xs font-normal leading-tight mt-0.5"
style={{ fontFamily: 'var(--font-sans)', color: 'var(--color-footer-muted)', letterSpacing: '0.06em' }}
>
Mercado de Vida
</p>
</div>
<blockquote
className="pl-3 border-l-2 leading-relaxed text-sm italic"
style={{ borderLeftColor: 'var(--color-primary)', color: 'var(--color-footer-muted)' }}
>
Productos naturales y orgánicos para tu bienestar. Calidad certificada, envío a toda España.
</blockquote>
</div>
{/* Shop */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-widest mb-4" style={{ color: 'var(--color-footer-text)' }}>
Tienda
</h3>
<ul className="space-y-2">
{[
['/products', 'Productos'],
['/categories', 'Categorías'],
['/brands', 'Marcas'],
['/search', 'Buscar'],
].map(([href, label]) => (
<li key={href}>
<Link href={href}
className="text-sm transition-colors hover:opacity-80"
style={{ color: 'var(--color-footer-muted)' }}>
{label}
</Link>
</li>
))}
</ul>
</div>
{/* Company */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-widest mb-4" style={{ color: 'var(--color-footer-text)' }}>
Empresa
</h3>
<ul className="space-y-2">
{[
['/about', 'Quiénes somos'],
['/contact', 'Contacto'],
].map(([href, label]) => (
<li key={href}>
<Link href={href}
className="text-sm transition-colors hover:opacity-80"
style={{ color: 'var(--color-footer-muted)' }}>
{label}
</Link>
</li>
))}
</ul>
</div>
{/* Legal */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-widest mb-4" style={{ color: 'var(--color-footer-text)' }}>
Legal
</h3>
<ul className="space-y-2">
{[
['/shipping', 'Envíos'],
['/privacy', 'Privacidad'],
['/terms', 'Términos'],
['/cookies', 'Cookies'],
].map(([href, label]) => (
<li key={href}>
<Link href={href}
className="text-sm transition-colors hover:opacity-80"
style={{ color: 'var(--color-footer-muted)' }}>
{label}
</Link>
</li>
))}
</ul>
</div>
</div>
{/* Bottom bar */}
<div className="mt-10 pt-6 flex flex-col sm:flex-row justify-between items-center gap-4" style={{ borderTop: '1px solid rgba(255,255,255,0.1)' }}>
<p className="text-xs" style={{ color: 'rgba(232,245,224,0.4)' }}>
© 2026 MercadoDeVida. Todos los derechos reservados.
</p>
<div className="flex items-center gap-4">
<a href="https://instagram.com/mercadodevida/" target="_blank" rel="noopener" aria-label="Instagram"
className="transition-opacity hover:opacity-70" style={{ color: 'var(--color-footer-muted)' }}>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/></svg>
</a>
<a href="https://www.facebook.com/mercadodevida" target="_blank" rel="noopener" aria-label="Facebook"
className="transition-opacity hover:opacity-70" style={{ color: 'var(--color-footer-muted)' }}>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
</a>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,246 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import CartLink from '@/components/cart/CartLink';
import UserMenu from '@/components/auth/UserMenu';
import { fetchProducts, fetchSearchSuggestions } from '@/lib/api';
interface SearchProduct {
id: string;
name: string;
slug: string;
images?: { url: string }[];
}
function LiveSearchResults({
q,
results,
suggestions,
onClose,
onSuggestionClick,
}: {
q: string;
results: SearchProduct[];
suggestions: string[];
onClose: () => void;
onSuggestionClick: (term: string) => void;
}) {
if (!q.trim()) return null;
const hasResults = results.length > 0;
return (
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-xl shadow-lg z-50 overflow-hidden">
{results.slice(0, 6).map((product) => (
<Link
key={product.id}
href={`/products/${product.slug}`}
onClick={onClose}
className="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 transition-colors"
>
<div className="w-10 h-10 bg-gray-100 rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
{product.images?.[0] ? (
<Image
src={product.images[0].url}
alt={product.name}
width={40}
height={40}
className="object-cover w-full h-full"
/>
) : (
<span className="text-lg">🌿</span>
)}
</div>
<span className="text-sm font-medium text-gray-800 line-clamp-1">{product.name}</span>
</Link>
))}
{!hasResults && (
<div className="px-4 py-3">
<p className="text-sm text-gray-400 mb-2">Sin resultados para &ldquo;{q}&rdquo;</p>
{suggestions.length > 0 && (
<div>
<p className="text-xs text-gray-400 mb-1">Quizás buscabas:</p>
<div className="flex flex-wrap gap-1">
{suggestions.map((s) => (
<button
key={s}
onClick={() => onSuggestionClick(s)}
className="text-xs px-2 py-1 bg-[#70ad47]/10 text-[#70ad47] rounded-full hover:bg-[#70ad47]/20 transition-colors font-medium"
>
{s}
</button>
))}
</div>
</div>
)}
</div>
)}
<Link
href={`/search?q=${encodeURIComponent(q)}`}
onClick={onClose}
className="block px-4 py-2.5 text-xs text-center text-[#70ad47] hover:bg-[#70ad47]/5 font-medium border-t border-gray-100 transition-colors"
>
Ver todos los resultados para &ldquo;{q}&rdquo;
</Link>
</div>
);
}
export function Header() {
const [q, setQ] = useState('');
const [results, setResults] = useState<SearchProduct[]>([]);
const [suggestions, setSuggestions] = useState<string[]>([]);
const [showDropdown, setShowDropdown] = useState(false);
const [searching, setSearching] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const loadResults = async (term: string) => {
setSearching(true);
try {
const [items, suggs] = await Promise.all([
fetchProducts({ q: term, limit: 6 }),
fetchSearchSuggestions(term),
]);
setResults(Array.isArray(items) ? items as SearchProduct[] : []);
setSuggestions(suggs);
setShowDropdown(true);
} catch {
setResults([]);
setSuggestions([]);
setShowDropdown(false);
} finally {
setSearching(false);
}
};
useEffect(() => {
if (!q.trim()) {
setResults([]);
setSuggestions([]);
setShowDropdown(false);
setSearching(false);
return;
}
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
loadResults(q.trim());
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [q]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setShowDropdown(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const handleSuggestionClick = (term: string) => {
setQ(term);
setShowDropdown(false);
router.push(`/search?q=${encodeURIComponent(term)}`);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setShowDropdown(false);
const term = q.trim();
if (term) router.push(`/search?q=${encodeURIComponent(term)}`);
else router.push('/search');
};
return (
<header className="sticky top-0 z-50 bg-white border-b border-gray-100">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16 gap-4">
{/* Logo */}
<Link href="/" className="flex items-center shrink-0">
<Image
src="/images/logo-main.png"
alt="MercadoDeVida"
width={56}
height={56}
className="h-14 w-auto object-contain"
priority
/>
</Link>
{/* Search bar */}
<div className="flex-1 max-w-xl hidden sm:block relative" ref={dropdownRef}>
<form onSubmit={handleSubmit}>
<div className="relative w-full">
<input
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
onFocus={() => { if (q.trim() && (results.length > 0 || suggestions.length > 0)) setShowDropdown(true); }}
placeholder="Buscar productos, marcas..."
className="w-full pl-10 pr-10 py-2.5 border border-gray-200 rounded-full text-sm
focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none
placeholder-gray-400 text-gray-900 bg-gray-50 hover:bg-white transition-colors"
/>
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none"
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{searching && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="w-4 h-4 border-2 border-gray-300 border-t-[#70ad47] rounded-full animate-spin" />
</div>
)}
</div>
</form>
{showDropdown && (
<LiveSearchResults
q={q}
results={results}
suggestions={suggestions}
onClose={() => setShowDropdown(false)}
onSuggestionClick={handleSuggestionClick}
/>
)}
</div>
{/* Nav */}
<nav className="hidden lg:flex items-center gap-6">
<Link href="/products" className="text-sm font-medium text-gray-700 hover:text-[#70ad47] transition-colors">
Productos
</Link>
<Link href="/categories" className="text-sm font-medium text-gray-700 hover:text-[#70ad47] transition-colors">
Categorías
</Link>
<Link href="/brands" className="text-sm font-medium text-gray-700 hover:text-[#70ad47] transition-colors">
Marcas
</Link>
</nav>
{/* Cart + user */}
<div className="flex items-center gap-3 shrink-0">
<Link href="/search" className="sm:hidden text-gray-500 hover:text-[#70ad47]">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</Link>
<CartLink />
<UserMenu />
</div>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,80 @@
'use client';
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
interface User {
id: string;
email: string;
role: string;
}
interface AuthContextValue {
user: User | null;
loading: boolean;
login: (email: string, password: string) => Promise<{ ok: boolean; error?: string }>;
register: (email: string, password: string) => Promise<{ ok: boolean; error?: string }>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/auth/me')
.then((r) => r.json())
.then((data) => {
setUser(data.user ?? null);
})
.catch(() => setUser(null))
.finally(() => setLoading(false));
}, []);
const login = useCallback(async (email: string, password: string) => {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
credentials: 'include',
});
const data = await res.json();
if (!res.ok) {
return { ok: false, error: data.error?.message || 'Error de login' };
}
setUser(data);
return { ok: true };
}, []);
const register = useCallback(async (email: string, password: string) => {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
credentials: 'include',
});
const data = await res.json();
if (!res.ok) {
return { ok: false, error: data.error?.message || 'Error de registro' };
}
setUser(data);
return { ok: true };
}, []);
const logout = useCallback(async () => {
await fetch('/api/auth/logout', { method: 'POST' });
setUser(null);
}, []);
return (
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}

View File

@@ -0,0 +1,88 @@
'use client';
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
export interface CartItem {
variantId: string;
productId: string;
productName: string;
quantity: number;
priceCents: number;
imageUrl?: string;
}
interface CartContextValue {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (variantId: string) => void;
changeQuantity: (variantId: string, quantity: number) => void;
clearCart: () => void;
itemCount: number;
subtotalCents: number;
}
const CartContext = createContext<CartContextValue | null>(null);
export function CartProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
useEffect(() => {
try {
const stored = localStorage.getItem('mdv_cart');
if (stored) setItems(JSON.parse(stored));
} catch {}
}, []);
const persist = useCallback((newItems: CartItem[]) => {
setItems(newItems);
localStorage.setItem('mdv_cart', JSON.stringify(newItems));
}, []);
const addItem = useCallback((item: CartItem) => {
setItems((prev) => {
const existing = prev.find((i) => i.variantId === item.variantId);
const next = existing
? prev.map((i) => i.variantId === item.variantId ? { ...i, quantity: i.quantity + item.quantity } : i)
: [...prev, item];
localStorage.setItem('mdv_cart', JSON.stringify(next));
return next;
});
}, []);
const removeItem = useCallback((variantId: string) => {
setItems((prev) => {
const next = prev.filter((i) => i.variantId !== variantId);
localStorage.setItem('mdv_cart', JSON.stringify(next));
return next;
});
}, []);
const changeQuantity = useCallback((variantId: string, quantity: number) => {
setItems((prev) => {
const next = quantity <= 0
? prev.filter((i) => i.variantId !== variantId)
: prev.map((i) => i.variantId === variantId ? { ...i, quantity } : i);
localStorage.setItem('mdv_cart', JSON.stringify(next));
return next;
});
}, []);
const clearCart = useCallback(() => {
setItems([]);
localStorage.removeItem('mdv_cart');
}, []);
const itemCount = items.reduce((sum, i) => sum + i.quantity, 0);
const subtotalCents = items.reduce((sum, i) => sum + i.priceCents * i.quantity, 0);
return (
<CartContext.Provider value={{ items, addItem, removeItem, changeQuantity, clearCart, itemCount, subtotalCents }}>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const ctx = useContext(CartContext);
if (!ctx) throw new Error('useCart must be used within CartProvider');
return ctx;
}

View File

@@ -0,0 +1,132 @@
import type { Category, Product, Brand } from '@/types/api';
interface CmsPage {
id: string;
slug: string;
title: string;
body: string;
status: string;
}
const BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
export async function fetchCategories(): Promise<Category[]> {
const res = await fetch(`${BASE}/categories/tree`, { cache: 'no-store' });
if (!res.ok) throw new Error(`Failed to fetch categories: ${res.status}`);
const data = await res.json();
return data.items ?? [];
}
export async function fetchProducts(params?: {
limit?: number;
offset?: number;
q?: string;
categorySlug?: string;
brandSlug?: string;
}): Promise<Product[]> {
const sp = new URLSearchParams();
sp.set('limit', String(params?.limit ?? 8));
if (params?.offset !== undefined) sp.set('offset', String(params.offset));
if (params?.q) sp.set('q', params.q);
if (params?.categorySlug) sp.set('categorySlug', params.categorySlug);
if (params?.brandSlug) sp.set('brandSlug', params.brandSlug);
const res = await fetch(`${BASE}/products/search?${sp}`, { cache: 'no-store', credentials: 'include' });
if (!res.ok) throw new Error(`Failed to fetch products: ${res.status}`);
const data = await res.json();
return data.items ?? [];
}
export async function fetchSearchSuggestions(term: string): Promise<string[]> {
const sp = new URLSearchParams({ q: term });
const res = await fetch(`${BASE}/products/suggest?${sp}`, {
cache: 'no-store',
credentials: 'include',
});
if (!res.ok) return [];
const data = await res.json();
return data.suggestions ?? [];
}
export async function fetchBrandBySlug(slug: string): Promise<Brand | null> {
const res = await fetch(`${BASE}/marca/${slug}`, { cache: 'no-store' });
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Failed to fetch brand: ${res.status}`);
return res.json();
}
export async function fetchPage(slug: string): Promise<CmsPage | null> {
const res = await fetch(`${BASE}/cms/pages/${slug}`, { cache: 'no-store' });
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Failed to fetch page: ${res.status}`);
return res.json();
}
export async function fetchBrands(): Promise<Brand[]> {
const res = await fetch(`${BASE}/brands`, { cache: 'no-store' });
if (!res.ok) throw new Error(`Failed to fetch brands: ${res.status}`);
const data = await res.json();
return data.items ?? [];
}
export async function fetchCategoryBySlug(slug: string): Promise<Category | null> {
const res = await fetch(`${BASE}/categoria/${slug}`, { cache: 'no-store' });
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Failed to fetch category: ${res.status}`);
return res.json();
}
export async function fetchProductBySlug(slug: string): Promise<Product | null> {
const res = await fetch(`${BASE}/productos/${slug}`, { cache: 'no-store' });
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Failed to fetch product: ${res.status}`);
return res.json();
}
export interface ProductVariant {
id: string;
productId: string;
sku: string;
ean: string | null;
attributes: Record<string, unknown>;
}
export interface VariantPrice {
variantId: string;
netUnitAmountCents: number;
vatRate: 'general' | 'reduced';
currency: string;
}
export interface StockAvailability {
available: boolean;
availableQuantity: number;
}
export async function fetchProductVariants(productId: string): Promise<ProductVariant[]> {
const res = await fetch(`${BASE}/products/${productId}/variants`, { cache: 'no-store' });
if (!res.ok) throw new Error(`Failed to fetch variants: ${res.status}`);
const data = await res.json();
return data.items ?? [];
}
export async function fetchVariantPrice(variantId: string): Promise<VariantPrice | null> {
const res = await fetch(`${BASE}/pricing/variants/${variantId}`, { cache: 'no-store' });
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Failed to fetch price: ${res.status}`);
return res.json();
}
export async function fetchStockAvailability(variantId: string): Promise<StockAvailability> {
const res = await fetch(`${BASE}/inventory/${variantId}/availability`, { cache: 'no-store' });
if (!res.ok) throw new Error(`Failed to fetch stock: ${res.status}`);
return res.json();
}
export function formatPrice(cents: number): string {
return `${(cents / 100).toFixed(2)}`;
}
export function calcGrossPrice(netCents: number, vatRate: 'general' | 'reduced'): number {
const rate = vatRate === 'general' ? 0.21 : 0.10;
return Math.round(netCents * (1 + rate));
}

View File

@@ -0,0 +1,38 @@
export interface Product {
id: string;
name: string;
slug: string;
url: string;
description?: string;
state: string;
seoTitle?: string;
seoDescription?: string;
images: Array<{ id: string; url: string; altText?: string }>;
brandId?: string;
categoryIds?: string[];
priceCents?: number; // populated via separate pricing lookup
brand?: { id: string; name: string; slug: string };
imageUrl?: string;
}
export interface Category {
id: string;
parentId: string | null;
name: string;
slug: string;
url: string;
seoTitle?: string;
seoDescription?: string;
imageUrl?: string;
description?: string;
children?: Category[];
}
export interface Brand {
id: string;
name: string;
slug: string;
logoUrl?: string;
seoTitle?: string;
seoDescription?: string;
}

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"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",
"**/*.mts"
],
"exclude": ["node_modules"]
}