feat(ADM-018): completed feature
41
project/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
9
project/frontend/AGENTS.md
Normal file
@@ -0,0 +1,9 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
1
project/frontend/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
36
project/frontend/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
18
project/frontend/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
11
project/frontend/next.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{ protocol: 'https', hostname: '**' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
26
project/frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.3.1",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
project/frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
project/frontend/public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
project/frontend/public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
BIN
project/frontend/public/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
project/frontend/public/images/logo-main.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
project/frontend/public/images/logo-small.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
1
project/frontend/public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
project/frontend/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
project/frontend/public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
After Width: | Height: | Size: 25 KiB |
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
78
project/frontend/src/components/admin/AdminLayout.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const navItems = [
|
||||
{ href: '/admin', label: 'Dashboard', icon: '📊' },
|
||||
{ href: '/admin/products', label: 'Productos', icon: '📦' },
|
||||
{ href: '/admin/orders', label: 'Pedidos', icon: '🧾' },
|
||||
];
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && (!user || user.role !== 'admin')) {
|
||||
router.push('/auth/login');
|
||||
}
|
||||
}, [user, loading, router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-gray-500">Cargando...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user || user.role !== 'admin') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-56 bg-white border-r border-gray-200 flex-shrink-0">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-gray-400">Admin</p>
|
||||
<p className="text-sm font-semibold text-gray-900 truncate">{user.email}</p>
|
||||
</div>
|
||||
<nav className="p-3 space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const active = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-[#70ad47] text-white'
|
||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<div className="pt-4 border-t border-gray-200 mt-4">
|
||||
<Link href="/" className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100 transition-colors">
|
||||
<span>←</span>
|
||||
Ver tienda
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex-1 p-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
project/frontend/src/components/auth/UserMenu.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function UserMenu() {
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-600 hidden sm:block">
|
||||
{user.email}
|
||||
</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-sm font-medium text-[#70ad47] hover:text-[#5a9040] transition-colors"
|
||||
>
|
||||
Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/auth/login"
|
||||
className="text-sm font-medium text-[#70ad47] hover:text-[#5a9040] transition-colors"
|
||||
>
|
||||
Iniciar sesión
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
52
project/frontend/src/components/cart/AddToCartButton.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useCart } from '@/contexts/CartContext';
|
||||
|
||||
interface Props {
|
||||
variantId: string;
|
||||
productId: string;
|
||||
productName: string;
|
||||
priceCents: number;
|
||||
imageUrl?: string;
|
||||
available?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function AddToCartButton({
|
||||
variantId, productId, productName, priceCents, imageUrl, available = true, className = '',
|
||||
}: Props) {
|
||||
const { addItem, itemCount } = useCart();
|
||||
const [added, setAdded] = useState(false);
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!available) return;
|
||||
addItem({ variantId, productId, productName, quantity: 1, priceCents, imageUrl });
|
||||
setAdded(true);
|
||||
setTimeout(() => setAdded(false), 2000);
|
||||
};
|
||||
|
||||
if (!available) {
|
||||
return (
|
||||
<button disabled className={`px-8 py-3.5 bg-gray-200 text-gray-500 font-semibold rounded-xl cursor-not-allowed ${className}`}>
|
||||
Agotado
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (added) {
|
||||
return (
|
||||
<button disabled className={`px-8 py-3.5 bg-[#52B788] text-white font-semibold rounded-xl cursor-default ${className}`}>
|
||||
✓ Añadido
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className={`px-8 py-3.5 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors shadow-lg ${className}`}
|
||||
>
|
||||
Añadir al carrito
|
||||
</button>
|
||||
);
|
||||
}
|
||||
24
project/frontend/src/components/cart/CartLink.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
import Link from 'next/link';
|
||||
import { useCart } from '@/contexts/CartContext';
|
||||
|
||||
export default function CartLink() {
|
||||
const { itemCount } = useCart();
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/cart"
|
||||
className="relative p-2 text-gray-600 hover:text-[#70ad47] transition-colors"
|
||||
aria-label={`Carrito (${itemCount} artículos)`}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM8.625 10.5a.375.375 0 11-.75 0 .375.375 0 01.75 0zm7.5 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
|
||||
</svg>
|
||||
{itemCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-[#E76F51] text-white text-xs font-bold rounded-full flex items-center justify-center">
|
||||
{itemCount > 9 ? '9+' : itemCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
149
project/frontend/src/components/cart/CartPageContent.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useCart, type CartItem } from '@/contexts/CartContext';
|
||||
|
||||
function formatPrice(cents: number) {
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function CartItemRow({ item }: { item: CartItem }) {
|
||||
const { removeItem, changeQuantity } = useCart();
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 py-4 border-b border-gray-100 last:border-0">
|
||||
{/* Image */}
|
||||
<div className="w-20 h-20 bg-gray-50 rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
|
||||
{item.imageUrl ? (
|
||||
<Image src={item.imageUrl} alt={item.productName} width={80} height={80} className="object-cover" />
|
||||
) : (
|
||||
<span className="text-3xl">🌿</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link href={`/products/${item.productId}`} className="font-semibold text-gray-900 hover:text-[#70ad47] transition-colors line-clamp-2 text-sm">
|
||||
{item.productName}
|
||||
</Link>
|
||||
<p className="text-gray-500 text-sm mt-1">{formatPrice(item.priceCents)}/ud</p>
|
||||
|
||||
{/* Quantity controls */}
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<div className="flex items-center border border-gray-300 rounded-lg">
|
||||
<button
|
||||
onClick={() => changeQuantity(item.variantId, item.quantity - 1)}
|
||||
className="w-8 h-8 flex items-center justify-center text-gray-600 hover:text-[#70ad47] transition-colors"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-8 text-center text-sm font-medium">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => changeQuantity(item.variantId, item.quantity + 1)}
|
||||
className="w-8 h-8 flex items-center justify-center text-gray-600 hover:text-[#70ad47] transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeItem(item.variantId)}
|
||||
className="text-gray-400 hover:text-red-500 transition-colors text-sm"
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtotal */}
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="font-bold text-[#70ad47]">{formatPrice(item.priceCents * item.quantity)}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CartPageContent() {
|
||||
const { items, subtotalCents, itemCount, clearCart } = useCart();
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
|
||||
<div className="text-6xl mb-4">🛒</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Tu carrito está vacío</h1>
|
||||
<p className="text-gray-500 mb-8">Añade productos para empezar tu pedido.</p>
|
||||
<Link href="/products" className="inline-flex items-center gap-2 px-6 py-3 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors">
|
||||
Ver productos
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900" style={{ fontFamily: 'var(--font-heading)' }}>
|
||||
Carrito de compra
|
||||
</h1>
|
||||
<p className="mt-1 text-gray-500">{itemCount} artículo{itemCount !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Items */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
{items.map((item) => (
|
||||
<CartItemRow key={item.variantId} item={item} />
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={clearCart}
|
||||
className="mt-4 text-gray-400 hover:text-red-500 text-sm transition-colors"
|
||||
>
|
||||
Vaciar carrito
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div>
|
||||
<div className="bg-gray-50 rounded-xl p-6 border border-gray-200 sticky top-24">
|
||||
<h2 className="font-bold text-gray-900 mb-4">Resumen del pedido</h2>
|
||||
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Subtotal ({itemCount} artículos)</span>
|
||||
<span className="font-medium">{formatPrice(subtotalCents)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Envío</span>
|
||||
<span className="text-gray-500">Calculado en checkout</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Impuestos</span>
|
||||
<span>Incluidos</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 mt-4 pt-4 flex justify-between items-center">
|
||||
<span className="font-bold text-gray-900">Total</span>
|
||||
<span className="text-2xl font-bold text-[#70ad47]">{formatPrice(subtotalCents)}</span>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/checkout"
|
||||
className="mt-4 w-full block text-center px-6 py-3.5 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
Proceder al checkout
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/products"
|
||||
className="mt-2 w-full block text-center px-6 py-2 text-sm text-gray-500 hover:text-[#70ad47] transition-colors"
|
||||
>
|
||||
← Seguir comprando
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
project/frontend/src/components/cart/ProductAddToCart.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import AddToCartButton from './AddToCartButton';
|
||||
|
||||
interface Props {
|
||||
variantId: string;
|
||||
productId: string;
|
||||
productName: string;
|
||||
priceCents: number;
|
||||
imageUrl?: string;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export default function ProductAddToCart({ variantId, productId, productName, priceCents, imageUrl, available }: Props) {
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<AddToCartButton
|
||||
variantId={variantId}
|
||||
productId={productId}
|
||||
productName={productName}
|
||||
priceCents={priceCents}
|
||||
imageUrl={imageUrl}
|
||||
available={available}
|
||||
className="w-full sm:w-auto"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
295
project/frontend/src/components/checkout/CheckoutClient.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
'use client';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useCart } from '@/contexts/CartContext';
|
||||
|
||||
function formatPrice(cents: number) {
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
export default function CheckoutClient() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const { items, subtotalCents, itemCount, clearCart } = useCart();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Form state
|
||||
const [form, setForm] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: user?.email ?? '',
|
||||
phone: '',
|
||||
address: '',
|
||||
city: '',
|
||||
postalCode: '',
|
||||
notes: '',
|
||||
shippingMethod: 'standard',
|
||||
});
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-16 flex items-center justify-center">
|
||||
<div className="text-gray-500">Cargando...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 py-16 text-center">
|
||||
<div className="text-6xl mb-4">🛒</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Tu carrito está vacío</h1>
|
||||
<p className="text-gray-500 mb-8">Añade productos antes de hacer el pedido.</p>
|
||||
<Link href="/products" className="inline-flex px-6 py-3 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors">
|
||||
Ver productos
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const shippingCost = form.shippingMethod === 'express' ? 899 : 499;
|
||||
const totalCents = subtotalCents + shippingCost;
|
||||
|
||||
const handlePlaceOrder = async () => {
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
shippingAddress: {
|
||||
firstName: form.firstName,
|
||||
lastName: form.lastName,
|
||||
email: form.email,
|
||||
phone: form.phone,
|
||||
line1: form.address,
|
||||
city: form.city,
|
||||
postalCode: form.postalCode,
|
||||
country: 'ES',
|
||||
},
|
||||
shippingMethod: form.shippingMethod,
|
||||
notes: form.notes,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error?.message || 'Error al procesar el pedido');
|
||||
}
|
||||
const { orderId } = await res.json();
|
||||
clearCart();
|
||||
window.location.href = `/order-confirmation?orderId=${orderId}`;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al procesar el pedido');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-8" style={{ fontFamily: 'var(--font-heading)' }}>
|
||||
Finalizar pedido
|
||||
</h1>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
{/* Form */}
|
||||
<div>
|
||||
{/* Login prompt — solo si NO está autenticado */}
|
||||
{!user && (
|
||||
<div className="bg-[#70ad47]/5 border border-[#70ad47]/20 rounded-xl p-6 mb-6">
|
||||
<h2 className="font-bold text-gray-900 mb-1">¿Ya tienes cuenta?</h2>
|
||||
<p className="text-sm text-gray-600 mb-3">Inicia sesión para una experiencia más rápida.</p>
|
||||
<div className="flex gap-3">
|
||||
<Link href="/auth/login" className="px-4 py-2 bg-[#70ad47] hover:bg-[#5a9040] text-white text-sm font-semibold rounded-lg transition-colors">
|
||||
Iniciar sesión
|
||||
</Link>
|
||||
<Link href="/auth/register" className="px-4 py-2 border border-gray-300 hover:border-[#70ad47] text-gray-700 text-sm font-semibold rounded-lg transition-colors">
|
||||
Crear cuenta
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shipping form */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h2 className="font-bold text-gray-900 mb-4">Datos de envío</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre *</label>
|
||||
<input
|
||||
type="text" required placeholder="María"
|
||||
value={form.firstName}
|
||||
onChange={e => setForm(f => ({ ...f, firstName: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Apellidos *</label>
|
||||
<input
|
||||
type="text" required placeholder="García López"
|
||||
value={form.lastName}
|
||||
onChange={e => setForm(f => ({ ...f, lastName: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email *</label>
|
||||
<input
|
||||
type="email" required placeholder="maria@ejemplo.com"
|
||||
value={form.email}
|
||||
onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Teléfono *</label>
|
||||
<input
|
||||
type="tel" required placeholder="+34 600 000 000"
|
||||
value={form.phone}
|
||||
onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Dirección *</label>
|
||||
<input
|
||||
type="text" required placeholder="Calle Gran Vía 42"
|
||||
value={form.address}
|
||||
onChange={e => setForm(f => ({ ...f, address: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ciudad *</label>
|
||||
<input
|
||||
type="text" required placeholder="Madrid"
|
||||
value={form.city}
|
||||
onChange={e => setForm(f => ({ ...f, city: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">CP *</label>
|
||||
<input
|
||||
type="text" required placeholder="28013"
|
||||
value={form.postalCode}
|
||||
onChange={e => setForm(f => ({ ...f, postalCode: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">País</label>
|
||||
<input type="text" disabled value="España"
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg bg-gray-50 text-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4 mt-4">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Método de envío</h3>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ id: 'standard', name: 'Estándar', desc: 'Entrega 3-5 días laborables', price: '€4.99' },
|
||||
{ id: 'express', name: 'Express 24h', desc: 'Entrega al día siguiente', price: '€8.99' },
|
||||
].map(opt => (
|
||||
<label key={opt.id}
|
||||
className={`flex items-center gap-3 p-3 border rounded-lg cursor-pointer transition-colors ${form.shippingMethod === opt.id ? 'border-[#70ad47] bg-[#70ad47]/5' : 'border-gray-200 hover:border-[#70ad47]'}`}>
|
||||
<input type="radio" name="shipping" value={opt.id} checked={form.shippingMethod === opt.id}
|
||||
onChange={e => setForm(f => ({ ...f, shippingMethod: e.target.value }))}
|
||||
className="text-[#70ad47]" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900">{opt.name}</p>
|
||||
<p className="text-sm text-gray-500">{opt.desc}</p>
|
||||
</div>
|
||||
<span className="font-semibold text-[#70ad47]">{opt.price}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Notas del pedido</h3>
|
||||
<textarea rows={3} placeholder="Observaciones, instrucciones de entrega..."
|
||||
value={form.notes}
|
||||
onChange={e => setForm(f => ({ ...f, notes: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order summary */}
|
||||
<div>
|
||||
<div className="bg-gray-50 rounded-xl border border-gray-200 p-6 sticky top-24">
|
||||
<h2 className="font-bold text-gray-900 mb-4">Tu pedido ({itemCount})</h2>
|
||||
|
||||
<div className="space-y-3 max-h-80 overflow-y-auto mb-4">
|
||||
{items.map(item => (
|
||||
<div key={item.variantId} className="flex gap-3">
|
||||
<div className="w-12 h-12 bg-white rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
|
||||
{item.imageUrl
|
||||
? <Image src={item.imageUrl} alt={item.productName} width={48} height={48} className="object-cover" />
|
||||
: <span className="text-2xl">🌿</span>
|
||||
}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 line-clamp-1">{item.productName}</p>
|
||||
<p className="text-xs text-gray-500">Cantidad: {item.quantity}</p>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-gray-900 flex-shrink-0">
|
||||
{formatPrice(item.priceCents * item.quantity)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4 space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Subtotal</span>
|
||||
<span className="font-medium">{formatPrice(subtotalCents)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Envío</span>
|
||||
<span className="text-gray-500">{form.shippingMethod === 'express' ? '€8.99' : '€4.99'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>IVA</span>
|
||||
<span>Incluido</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 mt-4 pt-4 flex justify-between items-center">
|
||||
<span className="font-bold text-gray-900">Total</span>
|
||||
<span className="text-2xl font-bold text-[#70ad47]">{formatPrice(totalCents)}</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-2.5 rounded-xl">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handlePlaceOrder}
|
||||
disabled={submitting || !form.firstName || !form.email || !form.address || !form.city || !form.postalCode}
|
||||
className="mt-4 w-full py-3.5 bg-[#70ad47] hover:bg-[#5a9040] disabled:bg-gray-300 disabled:text-gray-500 text-white font-semibold rounded-xl transition-colors cursor-pointer"
|
||||
>
|
||||
{submitting ? 'Procesando...' : 'Finalizar pedido'}
|
||||
</button>
|
||||
|
||||
<Link href="/cart" className="mt-3 w-full block text-center text-sm text-gray-500 hover:text-[#70ad47] transition-colors">
|
||||
← Volver al carrito
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
project/frontend/src/components/content/ContentPage.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ContentPage({ title, description, children }: Props) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<h1
|
||||
className="text-4xl font-bold text-gray-900 mb-4"
|
||||
style={{ fontFamily: 'var(--font-heading)' }}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 mb-10 leading-relaxed">{description}</p>
|
||||
<div className="prose prose-gray max-w-none">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
project/frontend/src/components/home/BrandsSection.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import Link from 'next/link';
|
||||
import type { Brand } from '@/types/api';
|
||||
import { fetchBrands } from '@/lib/api';
|
||||
|
||||
export default async function BrandsSection() {
|
||||
const brands = await fetchBrands();
|
||||
|
||||
if (brands.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="py-16 bg-white border-t border-gray-100">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-8 text-center">Nuestras marcas</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{brands.map((brand) => (
|
||||
<Link key={brand.id} href={`/brands/${brand.slug}`}>
|
||||
<div className="p-4 rounded-xl border border-gray-200 hover:border-[#70ad47] transition-all hover:shadow-md bg-gray-50 text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-2 bg-[#70ad47]/10 rounded-full flex items-center justify-center">
|
||||
<span className="text-[#70ad47] font-bold text-sm">{brand.name.slice(0, 2).toUpperCase()}</span>
|
||||
</div>
|
||||
<p className="font-medium text-gray-900 text-sm">{brand.name}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
65
project/frontend/src/components/home/CategoriesGrid.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import Link from 'next/link';
|
||||
import type { Category } from '@/types/api';
|
||||
import { fetchCategories } from '@/lib/api';
|
||||
|
||||
export default async function CategoriesGrid() {
|
||||
let categories: Awaited<ReturnType<typeof fetchCategories>> = [];
|
||||
try {
|
||||
categories = await fetchCategories();
|
||||
} catch (e) {
|
||||
console.error('[CategoriesGrid] fetch error:', e);
|
||||
}
|
||||
|
||||
if (categories.length === 0) return null;
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
alimentacion: '🥜',
|
||||
suplementos: '💊',
|
||||
'cosmetica-natural': '🌸',
|
||||
'limpieza-ecologica': '🌿',
|
||||
'frutos-secos': '🥜',
|
||||
aceites: '🫒',
|
||||
'hierbas-infusiones': '🍵',
|
||||
vitaminas: '💊',
|
||||
proteinas: '🏋️',
|
||||
cremas: '🧴',
|
||||
jabones: '🧼',
|
||||
};
|
||||
|
||||
const colors = [
|
||||
'bg-[#70ad47]/10 text-[#70ad47]',
|
||||
'bg-[#E76F51]/10 text-[#E76F51]',
|
||||
'bg-[#F4A261]/10 text-[#F4A261]',
|
||||
'bg-[#52B788]/10 text-[#52B788]',
|
||||
'bg-[#5a9040]/10 text-[#5a9040]',
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="py-16 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-8 text-center">Explora categorías</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{categories.map((cat, i) => {
|
||||
const totalProducts = cat.children ? cat.children.length * 5 : 5;
|
||||
return (
|
||||
<Link key={cat.id} href={`/categories/${cat.slug}`}>
|
||||
<div className={`p-5 rounded-xl border border-gray-200 hover:border-[#70ad47] transition-all hover:shadow-md bg-white text-center ${colors[i % colors.length]}`}>
|
||||
<div className="text-4xl mb-2">{icons[cat.slug] ?? '📦'}</div>
|
||||
<h3 className="font-semibold text-gray-900 text-sm">{cat.name}</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">{totalProducts} productos</p>
|
||||
{cat.children && cat.children.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap justify-center gap-1">
|
||||
{cat.children.slice(0, 3).map((child) => (
|
||||
<span key={child.id} className="text-xs bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded">{child.name}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
64
project/frontend/src/components/home/FeaturedProducts.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import type { Product } from '@/types/api';
|
||||
import { fetchProducts } from '@/lib/api';
|
||||
|
||||
function formatPrice(cents: number): string {
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
export default async function FeaturedProducts() {
|
||||
let allProducts: Awaited<ReturnType<typeof fetchProducts>> = [];
|
||||
try {
|
||||
allProducts = await fetchProducts();
|
||||
} catch (e) {
|
||||
console.error('[FeaturedProducts] fetch error:', e);
|
||||
}
|
||||
const products = allProducts.slice(0, 4);
|
||||
|
||||
if (products.length === 0) {
|
||||
return (
|
||||
<section className="py-16 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<p className="text-center text-gray-500">Cargando productos...</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-16 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-8">Productos destacados</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{products.map((product) => (
|
||||
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
|
||||
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-colors">
|
||||
<div className="aspect-square relative bg-white flex items-center justify-center">
|
||||
{product.images?.[0] ? (
|
||||
<Image src={product.images[0].url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
|
||||
) : (
|
||||
<span className="text-5xl">🌿</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{product.brandId && <p className="text-xs text-[#70ad47] font-medium uppercase tracking-wide mb-1">Marca</p>}
|
||||
<h3 className="font-semibold text-gray-900 group-hover:text-[#70ad47] transition-colors line-clamp-2">{product.name}</h3>
|
||||
<p className="text-gray-500 text-sm mt-1 line-clamp-2">{product.description}</p>
|
||||
<div className="mt-3 pr-2">
|
||||
<span className="text-lg font-bold text-[#70ad47]">{formatPrice(0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-center mt-8">
|
||||
<Link href="/products" className="text-[#70ad47] font-medium hover:text-[#5a9040] transition-colors">
|
||||
Ver todos los productos →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
77
project/frontend/src/components/home/Hero.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-gradient-to-br from-[#70ad47] via-[#40916C] to-[#52B788]">
|
||||
{/* Decorative circles */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-24 -right-24 w-96 h-96 rounded-full bg-white opacity-5" />
|
||||
<div className="absolute top-1/2 -left-16 w-64 h-64 rounded-full bg-white opacity-5" />
|
||||
<div className="absolute bottom-0 right-1/4 w-48 h-48 rounded-full bg-white opacity-5" />
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 md:py-32">
|
||||
<div className="max-w-2xl">
|
||||
{/* Badge */}
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 backdrop-blur-sm text-sm text-green-100 mb-6">
|
||||
<svg className="w-4 h-4 text-green-300" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
Calidad certificada · 100% natural
|
||||
</div>
|
||||
|
||||
<h1
|
||||
className="text-4xl md:text-6xl font-bold text-white leading-tight mb-6"
|
||||
style={{ fontFamily: 'var(--font-heading)' }}
|
||||
>
|
||||
Productos naturales y orgánicos para tu bienestar
|
||||
</h1>
|
||||
|
||||
<p className="text-lg md:text-xl text-green-100 leading-relaxed mb-8 max-w-xl">
|
||||
Descubre nuestra selección de productos ecológicos, saludables y sostenibles. Envío a toda España.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Link
|
||||
href="/products"
|
||||
className="inline-flex items-center justify-center gap-2 px-8 py-3.5 bg-white text-[#70ad47] font-semibold rounded-lg hover:bg-green-50 transition-colors shadow-lg"
|
||||
>
|
||||
Ver productos
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href="/categories"
|
||||
className="inline-flex items-center justify-center gap-2 px-8 py-3.5 bg-white/10 backdrop-blur-sm text-white font-semibold rounded-lg hover:bg-white/20 transition-colors border border-white/20"
|
||||
>
|
||||
Explorar categorías
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Trust badges */}
|
||||
<div className="flex flex-wrap items-center gap-6 mt-12 text-sm text-green-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-green-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12" />
|
||||
</svg>
|
||||
Envío gratis +49€
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-green-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
Pago seguro
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-green-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Entrega 24-48h
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
120
project/frontend/src/components/layout/Footer.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="mt-auto" style={{ backgroundColor: 'var(--color-footer-bg)', color: 'var(--color-footer-text)' }}>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
|
||||
{/* Brand */}
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
{/* FIX-04: Natural Almagro / Mercado de Vida stacked */}
|
||||
<div className="mb-3">
|
||||
<p
|
||||
className="text-2xl font-bold leading-none"
|
||||
style={{ fontFamily: 'var(--font-heading)', letterSpacing: '0.04em', color: 'var(--color-footer-text)' }}
|
||||
>
|
||||
Natural
|
||||
</p>
|
||||
<p
|
||||
className="text-xs font-normal leading-tight mt-0.5"
|
||||
style={{ fontFamily: 'var(--font-sans)', color: 'var(--color-footer-muted)', letterSpacing: '0.06em' }}
|
||||
>
|
||||
Mercado de Vida
|
||||
</p>
|
||||
</div>
|
||||
<blockquote
|
||||
className="pl-3 border-l-2 leading-relaxed text-sm italic"
|
||||
style={{ borderLeftColor: 'var(--color-primary)', color: 'var(--color-footer-muted)' }}
|
||||
>
|
||||
Productos naturales y orgánicos para tu bienestar. Calidad certificada, envío a toda España.
|
||||
</blockquote>
|
||||
</div>
|
||||
|
||||
{/* Shop */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-widest mb-4" style={{ color: 'var(--color-footer-text)' }}>
|
||||
Tienda
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{[
|
||||
['/products', 'Productos'],
|
||||
['/categories', 'Categorías'],
|
||||
['/brands', 'Marcas'],
|
||||
['/search', 'Buscar'],
|
||||
].map(([href, label]) => (
|
||||
<li key={href}>
|
||||
<Link href={href}
|
||||
className="text-sm transition-colors hover:opacity-80"
|
||||
style={{ color: 'var(--color-footer-muted)' }}>
|
||||
{label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Company */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-widest mb-4" style={{ color: 'var(--color-footer-text)' }}>
|
||||
Empresa
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{[
|
||||
['/about', 'Quiénes somos'],
|
||||
['/contact', 'Contacto'],
|
||||
].map(([href, label]) => (
|
||||
<li key={href}>
|
||||
<Link href={href}
|
||||
className="text-sm transition-colors hover:opacity-80"
|
||||
style={{ color: 'var(--color-footer-muted)' }}>
|
||||
{label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Legal */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-widest mb-4" style={{ color: 'var(--color-footer-text)' }}>
|
||||
Legal
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{[
|
||||
['/shipping', 'Envíos'],
|
||||
['/privacy', 'Privacidad'],
|
||||
['/terms', 'Términos'],
|
||||
['/cookies', 'Cookies'],
|
||||
].map(([href, label]) => (
|
||||
<li key={href}>
|
||||
<Link href={href}
|
||||
className="text-sm transition-colors hover:opacity-80"
|
||||
style={{ color: 'var(--color-footer-muted)' }}>
|
||||
{label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div className="mt-10 pt-6 flex flex-col sm:flex-row justify-between items-center gap-4" style={{ borderTop: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<p className="text-xs" style={{ color: 'rgba(232,245,224,0.4)' }}>
|
||||
© 2026 MercadoDeVida. Todos los derechos reservados.
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<a href="https://instagram.com/mercadodevida/" target="_blank" rel="noopener" aria-label="Instagram"
|
||||
className="transition-opacity hover:opacity-70" style={{ color: 'var(--color-footer-muted)' }}>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/></svg>
|
||||
</a>
|
||||
<a href="https://www.facebook.com/mercadodevida" target="_blank" rel="noopener" aria-label="Facebook"
|
||||
className="transition-opacity hover:opacity-70" style={{ color: 'var(--color-footer-muted)' }}>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
246
project/frontend/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import CartLink from '@/components/cart/CartLink';
|
||||
import UserMenu from '@/components/auth/UserMenu';
|
||||
import { fetchProducts, fetchSearchSuggestions } from '@/lib/api';
|
||||
|
||||
interface SearchProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
images?: { url: string }[];
|
||||
}
|
||||
|
||||
function LiveSearchResults({
|
||||
q,
|
||||
results,
|
||||
suggestions,
|
||||
onClose,
|
||||
onSuggestionClick,
|
||||
}: {
|
||||
q: string;
|
||||
results: SearchProduct[];
|
||||
suggestions: string[];
|
||||
onClose: () => void;
|
||||
onSuggestionClick: (term: string) => void;
|
||||
}) {
|
||||
if (!q.trim()) return null;
|
||||
|
||||
const hasResults = results.length > 0;
|
||||
|
||||
return (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-xl shadow-lg z-50 overflow-hidden">
|
||||
{results.slice(0, 6).map((product) => (
|
||||
<Link
|
||||
key={product.id}
|
||||
href={`/products/${product.slug}`}
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 bg-gray-100 rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
|
||||
{product.images?.[0] ? (
|
||||
<Image
|
||||
src={product.images[0].url}
|
||||
alt={product.name}
|
||||
width={40}
|
||||
height={40}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-lg">🌿</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-800 line-clamp-1">{product.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{!hasResults && (
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-sm text-gray-400 mb-2">Sin resultados para “{q}”</p>
|
||||
{suggestions.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-1">Quizás buscabas:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{suggestions.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => onSuggestionClick(s)}
|
||||
className="text-xs px-2 py-1 bg-[#70ad47]/10 text-[#70ad47] rounded-full hover:bg-[#70ad47]/20 transition-colors font-medium"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href={`/search?q=${encodeURIComponent(q)}`}
|
||||
onClick={onClose}
|
||||
className="block px-4 py-2.5 text-xs text-center text-[#70ad47] hover:bg-[#70ad47]/5 font-medium border-t border-gray-100 transition-colors"
|
||||
>
|
||||
Ver todos los resultados para “{q}” →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const [q, setQ] = useState('');
|
||||
const [results, setResults] = useState<SearchProduct[]>([]);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const loadResults = async (term: string) => {
|
||||
setSearching(true);
|
||||
try {
|
||||
const [items, suggs] = await Promise.all([
|
||||
fetchProducts({ q: term, limit: 6 }),
|
||||
fetchSearchSuggestions(term),
|
||||
]);
|
||||
setResults(Array.isArray(items) ? items as SearchProduct[] : []);
|
||||
setSuggestions(suggs);
|
||||
setShowDropdown(true);
|
||||
} catch {
|
||||
setResults([]);
|
||||
setSuggestions([]);
|
||||
setShowDropdown(false);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!q.trim()) {
|
||||
setResults([]);
|
||||
setSuggestions([]);
|
||||
setShowDropdown(false);
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
loadResults(q.trim());
|
||||
}, 300);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [q]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
const handleSuggestionClick = (term: string) => {
|
||||
setQ(term);
|
||||
setShowDropdown(false);
|
||||
router.push(`/search?q=${encodeURIComponent(term)}`);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setShowDropdown(false);
|
||||
const term = q.trim();
|
||||
if (term) router.push(`/search?q=${encodeURIComponent(term)}`);
|
||||
else router.push('/search');
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-white border-b border-gray-100">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16 gap-4">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center shrink-0">
|
||||
<Image
|
||||
src="/images/logo-main.png"
|
||||
alt="MercadoDeVida"
|
||||
width={56}
|
||||
height={56}
|
||||
className="h-14 w-auto object-contain"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="flex-1 max-w-xl hidden sm:block relative" ref={dropdownRef}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
type="search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onFocus={() => { if (q.trim() && (results.length > 0 || suggestions.length > 0)) setShowDropdown(true); }}
|
||||
placeholder="Buscar productos, marcas..."
|
||||
className="w-full pl-10 pr-10 py-2.5 border border-gray-200 rounded-full text-sm
|
||||
focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none
|
||||
placeholder-gray-400 text-gray-900 bg-gray-50 hover:bg-white transition-colors"
|
||||
/>
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
{searching && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<div className="w-4 h-4 border-2 border-gray-300 border-t-[#70ad47] rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
{showDropdown && (
|
||||
<LiveSearchResults
|
||||
q={q}
|
||||
results={results}
|
||||
suggestions={suggestions}
|
||||
onClose={() => setShowDropdown(false)}
|
||||
onSuggestionClick={handleSuggestionClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="hidden lg:flex items-center gap-6">
|
||||
<Link href="/products" className="text-sm font-medium text-gray-700 hover:text-[#70ad47] transition-colors">
|
||||
Productos
|
||||
</Link>
|
||||
<Link href="/categories" className="text-sm font-medium text-gray-700 hover:text-[#70ad47] transition-colors">
|
||||
Categorías
|
||||
</Link>
|
||||
<Link href="/brands" className="text-sm font-medium text-gray-700 hover:text-[#70ad47] transition-colors">
|
||||
Marcas
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Cart + user */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<Link href="/search" className="sm:hidden text-gray-500 hover:text-[#70ad47]">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</Link>
|
||||
<CartLink />
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
80
project/frontend/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
login: (email: string, password: string) => Promise<{ ok: boolean; error?: string }>;
|
||||
register: (email: string, password: string) => Promise<{ ok: boolean; error?: string }>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setUser(data.user ?? null);
|
||||
})
|
||||
.catch(() => setUser(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
credentials: 'include',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: data.error?.message || 'Error de login' };
|
||||
}
|
||||
setUser(data);
|
||||
return { ok: true };
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (email: string, password: string) => {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
credentials: 'include',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: data.error?.message || 'Error de registro' };
|
||||
}
|
||||
setUser(data);
|
||||
return { ok: true };
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
88
project/frontend/src/contexts/CartContext.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
|
||||
export interface CartItem {
|
||||
variantId: string;
|
||||
productId: string;
|
||||
productName: string;
|
||||
quantity: number;
|
||||
priceCents: number;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
interface CartContextValue {
|
||||
items: CartItem[];
|
||||
addItem: (item: CartItem) => void;
|
||||
removeItem: (variantId: string) => void;
|
||||
changeQuantity: (variantId: string, quantity: number) => void;
|
||||
clearCart: () => void;
|
||||
itemCount: number;
|
||||
subtotalCents: number;
|
||||
}
|
||||
|
||||
const CartContext = createContext<CartContextValue | null>(null);
|
||||
|
||||
export function CartProvider({ children }: { children: React.ReactNode }) {
|
||||
const [items, setItems] = useState<CartItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('mdv_cart');
|
||||
if (stored) setItems(JSON.parse(stored));
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
const persist = useCallback((newItems: CartItem[]) => {
|
||||
setItems(newItems);
|
||||
localStorage.setItem('mdv_cart', JSON.stringify(newItems));
|
||||
}, []);
|
||||
|
||||
const addItem = useCallback((item: CartItem) => {
|
||||
setItems((prev) => {
|
||||
const existing = prev.find((i) => i.variantId === item.variantId);
|
||||
const next = existing
|
||||
? prev.map((i) => i.variantId === item.variantId ? { ...i, quantity: i.quantity + item.quantity } : i)
|
||||
: [...prev, item];
|
||||
localStorage.setItem('mdv_cart', JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeItem = useCallback((variantId: string) => {
|
||||
setItems((prev) => {
|
||||
const next = prev.filter((i) => i.variantId !== variantId);
|
||||
localStorage.setItem('mdv_cart', JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const changeQuantity = useCallback((variantId: string, quantity: number) => {
|
||||
setItems((prev) => {
|
||||
const next = quantity <= 0
|
||||
? prev.filter((i) => i.variantId !== variantId)
|
||||
: prev.map((i) => i.variantId === variantId ? { ...i, quantity } : i);
|
||||
localStorage.setItem('mdv_cart', JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearCart = useCallback(() => {
|
||||
setItems([]);
|
||||
localStorage.removeItem('mdv_cart');
|
||||
}, []);
|
||||
|
||||
const itemCount = items.reduce((sum, i) => sum + i.quantity, 0);
|
||||
const subtotalCents = items.reduce((sum, i) => sum + i.priceCents * i.quantity, 0);
|
||||
|
||||
return (
|
||||
<CartContext.Provider value={{ items, addItem, removeItem, changeQuantity, clearCart, itemCount, subtotalCents }}>
|
||||
{children}
|
||||
</CartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCart() {
|
||||
const ctx = useContext(CartContext);
|
||||
if (!ctx) throw new Error('useCart must be used within CartProvider');
|
||||
return ctx;
|
||||
}
|
||||
132
project/frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { Category, Product, Brand } from '@/types/api';
|
||||
|
||||
interface CmsPage {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
body: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
|
||||
|
||||
export async function fetchCategories(): Promise<Category[]> {
|
||||
const res = await fetch(`${BASE}/categories/tree`, { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error(`Failed to fetch categories: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.items ?? [];
|
||||
}
|
||||
|
||||
export async function fetchProducts(params?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
q?: string;
|
||||
categorySlug?: string;
|
||||
brandSlug?: string;
|
||||
}): Promise<Product[]> {
|
||||
const sp = new URLSearchParams();
|
||||
sp.set('limit', String(params?.limit ?? 8));
|
||||
if (params?.offset !== undefined) sp.set('offset', String(params.offset));
|
||||
if (params?.q) sp.set('q', params.q);
|
||||
if (params?.categorySlug) sp.set('categorySlug', params.categorySlug);
|
||||
if (params?.brandSlug) sp.set('brandSlug', params.brandSlug);
|
||||
const res = await fetch(`${BASE}/products/search?${sp}`, { cache: 'no-store', credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`Failed to fetch products: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.items ?? [];
|
||||
}
|
||||
|
||||
export async function fetchSearchSuggestions(term: string): Promise<string[]> {
|
||||
const sp = new URLSearchParams({ q: term });
|
||||
const res = await fetch(`${BASE}/products/suggest?${sp}`, {
|
||||
cache: 'no-store',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.suggestions ?? [];
|
||||
}
|
||||
|
||||
export async function fetchBrandBySlug(slug: string): Promise<Brand | null> {
|
||||
const res = await fetch(`${BASE}/marca/${slug}`, { cache: 'no-store' });
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error(`Failed to fetch brand: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchPage(slug: string): Promise<CmsPage | null> {
|
||||
const res = await fetch(`${BASE}/cms/pages/${slug}`, { cache: 'no-store' });
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error(`Failed to fetch page: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchBrands(): Promise<Brand[]> {
|
||||
const res = await fetch(`${BASE}/brands`, { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error(`Failed to fetch brands: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.items ?? [];
|
||||
}
|
||||
|
||||
export async function fetchCategoryBySlug(slug: string): Promise<Category | null> {
|
||||
const res = await fetch(`${BASE}/categoria/${slug}`, { cache: 'no-store' });
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error(`Failed to fetch category: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchProductBySlug(slug: string): Promise<Product | null> {
|
||||
const res = await fetch(`${BASE}/productos/${slug}`, { cache: 'no-store' });
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error(`Failed to fetch product: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface ProductVariant {
|
||||
id: string;
|
||||
productId: string;
|
||||
sku: string;
|
||||
ean: string | null;
|
||||
attributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface VariantPrice {
|
||||
variantId: string;
|
||||
netUnitAmountCents: number;
|
||||
vatRate: 'general' | 'reduced';
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface StockAvailability {
|
||||
available: boolean;
|
||||
availableQuantity: number;
|
||||
}
|
||||
|
||||
export async function fetchProductVariants(productId: string): Promise<ProductVariant[]> {
|
||||
const res = await fetch(`${BASE}/products/${productId}/variants`, { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error(`Failed to fetch variants: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.items ?? [];
|
||||
}
|
||||
|
||||
export async function fetchVariantPrice(variantId: string): Promise<VariantPrice | null> {
|
||||
const res = await fetch(`${BASE}/pricing/variants/${variantId}`, { cache: 'no-store' });
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error(`Failed to fetch price: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchStockAvailability(variantId: string): Promise<StockAvailability> {
|
||||
const res = await fetch(`${BASE}/inventory/${variantId}/availability`, { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error(`Failed to fetch stock: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function formatPrice(cents: number): string {
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function calcGrossPrice(netCents: number, vatRate: 'general' | 'reduced'): number {
|
||||
const rate = vatRate === 'general' ? 0.21 : 0.10;
|
||||
return Math.round(netCents * (1 + rate));
|
||||
}
|
||||
38
project/frontend/src/types/api.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
state: string;
|
||||
seoTitle?: string;
|
||||
seoDescription?: string;
|
||||
images: Array<{ id: string; url: string; altText?: string }>;
|
||||
brandId?: string;
|
||||
categoryIds?: string[];
|
||||
priceCents?: number; // populated via separate pricing lookup
|
||||
brand?: { id: string; name: string; slug: string };
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
seoTitle?: string;
|
||||
seoDescription?: string;
|
||||
imageUrl?: string;
|
||||
description?: string;
|
||||
children?: Category[];
|
||||
}
|
||||
|
||||
export interface Brand {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
logoUrl?: string;
|
||||
seoTitle?: string;
|
||||
seoDescription?: string;
|
||||
}
|
||||
34
project/frontend/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||