feat(ADM-018): completed feature

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

View File

@@ -0,0 +1,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>
);
}