feat(ADM-018): completed feature
This commit is contained in:
39
project/frontend/src/app/about/page.tsx
Normal file
39
project/frontend/src/app/about/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
project/frontend/src/app/admin/layout.tsx
Normal file
5
project/frontend/src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminLayout from '@/components/admin/AdminLayout';
|
||||
|
||||
export default function AdminRootLayout({ children }: { children: React.ReactNode }) {
|
||||
return <AdminLayout>{children}</AdminLayout>;
|
||||
}
|
||||
21
project/frontend/src/app/admin/orders/page.tsx
Normal file
21
project/frontend/src/app/admin/orders/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
project/frontend/src/app/admin/page.tsx
Normal file
47
project/frontend/src/app/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
project/frontend/src/app/admin/products/page.tsx
Normal file
59
project/frontend/src/app/admin/products/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
project/frontend/src/app/api/auth/login/route.ts
Normal file
33
project/frontend/src/app/api/auth/login/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
12
project/frontend/src/app/api/auth/logout/route.ts
Normal file
12
project/frontend/src/app/api/auth/logout/route.ts
Normal 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;
|
||||
}
|
||||
30
project/frontend/src/app/api/auth/me/route.ts
Normal file
30
project/frontend/src/app/api/auth/me/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
32
project/frontend/src/app/api/auth/register/route.ts
Normal file
32
project/frontend/src/app/api/auth/register/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
76
project/frontend/src/app/auth/login/page.tsx
Normal file
76
project/frontend/src/app/auth/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
project/frontend/src/app/auth/register/page.tsx
Normal file
90
project/frontend/src/app/auth/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
project/frontend/src/app/brands/[slug]/page.tsx
Normal file
105
project/frontend/src/app/brands/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
project/frontend/src/app/brands/page.tsx
Normal file
40
project/frontend/src/app/brands/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
project/frontend/src/app/cart/page.tsx
Normal file
10
project/frontend/src/app/cart/page.tsx
Normal 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 />;
|
||||
}
|
||||
118
project/frontend/src/app/categories/[slug]/page.tsx
Normal file
118
project/frontend/src/app/categories/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
project/frontend/src/app/categories/page.tsx
Normal file
67
project/frontend/src/app/categories/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
project/frontend/src/app/checkout/page.tsx
Normal file
10
project/frontend/src/app/checkout/page.tsx
Normal 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 />;
|
||||
}
|
||||
36
project/frontend/src/app/contact/page.tsx
Normal file
36
project/frontend/src/app/contact/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
project/frontend/src/app/cookies/page.tsx
Normal file
71
project/frontend/src/app/cookies/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
BIN
project/frontend/src/app/favicon.ico
Normal file
BIN
project/frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
25
project/frontend/src/app/globals.css
Normal file
25
project/frontend/src/app/globals.css
Normal 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;
|
||||
}
|
||||
43
project/frontend/src/app/layout.tsx
Normal file
43
project/frontend/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
project/frontend/src/app/not-found.tsx
Normal file
32
project/frontend/src/app/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
project/frontend/src/app/order-confirmation/page.tsx
Normal file
72
project/frontend/src/app/order-confirmation/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
project/frontend/src/app/page.tsx
Normal file
17
project/frontend/src/app/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
63
project/frontend/src/app/privacy/page.tsx
Normal file
63
project/frontend/src/app/privacy/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
224
project/frontend/src/app/products/[slug]/page.tsx
Normal file
224
project/frontend/src/app/products/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
project/frontend/src/app/products/page.tsx
Normal file
78
project/frontend/src/app/products/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
project/frontend/src/app/robots.ts
Normal file
14
project/frontend/src/app/robots.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
125
project/frontend/src/app/search/page.tsx
Normal file
125
project/frontend/src/app/search/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
project/frontend/src/app/shipping/page.tsx
Normal file
38
project/frontend/src/app/shipping/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
project/frontend/src/app/sitemap.ts
Normal file
16
project/frontend/src/app/sitemap.ts
Normal 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 },
|
||||
];
|
||||
}
|
||||
64
project/frontend/src/app/terms/page.tsx
Normal file
64
project/frontend/src/app/terms/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user