feat(ADM-018): completed feature

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

View File

@@ -4,3 +4,4 @@ coverage/
package-lock.json
scripts/tests/fixtures/
design_prompt.md
storefront/

248
project/CAVEMAN.md Normal file
View File

@@ -0,0 +1,248 @@
# MercadoDeVida vNext — Caveman Architecture
> SIMPLE CODE. CLEAR MODULES. SMALL CHANGES. NO MAGIC.
---
# 0. CORE PHILOSOPHY
Build boring software. Boring = good.
Prefer: simple code, explicit dependencies, small modules, clear APIs, strong typing, predictable behavior, easy testing, easy replacement, easy debugging.
Avoid: clever abstractions, unnecessary microservices, circular dependencies, global state, giant service classes, framework magic.
The system must be easy for humans AND AI agents to understand.
---
# 1. ARCHITECTURE
**Modular Monolith** — each business domain is an isolated module.
```
src/
├── modules/
│ ├── auth/ # Identity module (sessions, login, register)
│ ├── users/ # User profiles
│ ├── catalog/ # Products, variants, attributes
│ ├── categories/ # Category taxonomy
│ ├── brands/ # Brand management
│ ├── pricing/ # Price calculation + VAT
│ ├── promotions/ # Discounts, promo codes
│ ├── inventory/ # Stock management
│ ├── cart/ # Shopping cart
│ ├── checkout/ # Checkout orchestrator
│ ├── orders/ # Order management
│ ├── payments/ # Payment provider interface
│ ├── shipping/ # Shipping zones and methods
│ ├── seo/ # SEO metadata
│ ├── cms/ # Content management
│ ├── reviews/ # Product reviews
│ ├── notifications/ # Email/push notifications
│ ├── cache/ # Caching layer
│ ├── security/ # Rate limiting, audit log, MFA
│ ├── observability/ # Traces, metrics
│ └── flags/ # Feature flags
├── shared/
├── infrastructure/
└── app/
```
Modules communicate through: (1) explicit public interfaces, (2) domain/application events, (3) typed contracts. Never access another module's internal implementation.
---
# 2. MODULE RULE
Every module owns its logic. Structure:
```
modules/<name>/
├── domain/ # Pure business rules. No DB, no HTTP, no framework.
├── application/ # Use cases: CreateProduct, ReserveStock, CreateOrder
├── infrastructure/ # PostgreSQL, Redis, Stripe, Email adapters
├── api/ # Thin HTTP controllers
├── tests/ # Unit + boundary tests
└── index.ts # Public API only
```
Controllers must be thin: request → validate → use case → response. NO business logic in controllers.
---
# 3. SDD IS MANDATORY
**NO FEATURE STARTS WITH CODE.** Every change starts with a specification.
Directory: `specs/<feature>/`
Each feature gets:
- `SPEC.md` — Problem, Goal, User story, Functional requirements, Acceptance criteria
- `DESIGN.md` — Affected modules, new interfaces, API changes, DB changes, events
- `TASKS.md` — Small atomic tasks
- `TESKS.md` — Required tests
- Optional: `ADR.md`, `MIGRATION.md`, `ROLLBACK.md`
---
# 4. TECH STACK
## Frontend
- **Next.js + React + TypeScript + Tailwind CSS**
- Server Components where useful
- SSR/SSG for SEO-sensitive pages
- Client-side only where interaction requires it
## Backend
- TypeScript + Node.js + Fastify
- Strict module boundaries
- PostgreSQL (primary database)
- Redis (cache, sessions, rate limiting — NOT source of truth)
## Observability
- Structured logs, metrics, traces
- OpenTelemetry-compatible interfaces
- Per-module metrics
---
# 5. DATABASE RULES
Database belongs to modules. Logical ownership must remain clear.
Naming: `<module>_<entity>` — e.g., `catalog_products`, `orders_orders`, `inventory_stock`.
Never let random modules query arbitrary tables. Access data through module interfaces only.
---
# 6. CORE BUSINESS RULES
- **Catalog ≠ Inventory**: Catalog answers "what is this product?"; Inventory answers "can I sell it?"
- **Cart ≠ Checkout**: Cart stores items; Checkout validates, calculates, orchestrates
- **Checkout is orchestrator**: coordinates cart, pricing, inventory, shipping, orders, payments
- **Orders are historical**: snapshot product name, SKU, EAN, prices, taxes at creation time
- **Payment provider behind interface**: StripePaymentProvider, RedsysPaymentProvider — domain never imports Stripe SDK directly
- **Backend calculates everything**: never trust price, stock, discount, total from frontend
- **Idempotency everywhere**: checkout, webhooks, payment operations
---
# 7. SEO IS CORE BUSINESS LOGIC
Support: canonical URLs, structured data (Product, Breadcrumb, Organization schema), sitemap.xml, robots.txt, OpenGraph, metadata per page.
URLs: `/productos/<slug>`, `/categoria/<slug>`, `/marca/<slug>`.
---
# 8. CAVEMAN RULES
```
ONE MODULE = ONE JOB
ONE USE CASE = ONE PURPOSE
DATABASE = SOURCE OF TRUTH
REDIS = CACHE, NOT TRUTH
CONTROLLER = THIN
BUSINESS LOGIC = DOMAIN
EXTERNAL API = ADAPTER
NO CROSS-MODULE TABLE QUERIES
NO GLOBAL STATE
NO HIDDEN MAGIC
NO COPY-PASTE BUSINESS LOGIC
NO FEATURE WITHOUT SPEC
NO DATABASE CHANGE WITHOUT MIGRATION
NO CRITICAL LOGIC WITHOUT TEST
NO EXTERNAL EVENT WITHOUT IDEMPOTENCY
NO PAYMENT TRUST FROM FRONTEND
NO PRICE TRUST FROM FRONTEND
NO BIG REWRITE
SMALL CHANGE
TEST CHANGE
SHIP CHANGE
```
---
# 9. FRONTEND STRUCTURE (to be built)
```
frontend/
├── src/
│ ├── app/ # Next.js App Router pages
│ │ ├── (shop)/ # Shop routes (product, category, brand, search)
│ │ ├── (checkout)/ # Cart + checkout flow
│ │ ├── (account)/ # User account, orders
│ │ ├── (admin)/ # Admin panel (protected)
│ │ └── api/ # API routes
│ ├── components/ # Shared UI components
│ │ ├── ui/ # Base components (Button, Input, Card...)
│ │ ├── product/ # Product-specific components
│ │ ├── cart/ # Cart components
│ │ └── layout/ # Header, Footer, Nav
│ ├── modules/ # Module-specific frontend code (mirrors backend modules)
│ ├── lib/ # Utilities, API client, types
│ └── styles/
├── public/
└── tests/
├── unit/
├── integration/
└── e2e/
```
**Pages to build (Frontend v1):**
1. Homepage — Hero, featured products, categories, brand highlights
2. Category page — Product listing with filters, pagination, SEO metadata
3. Product detail page — Images, description, nutrition, reviews, add to cart
4. Brand page — Brand info + brand products
5. Search results page — Search with filters
6. Cart page — Cart items, totals, promo code
7. Checkout — Address, shipping, payment, order summary
8. Order confirmation — Order details, next steps
9. User account — Profile, orders history, addresses
10. Admin panel — Product CRUD, order management, CMS
---
# 10. FRONTEND-BACKEND COMMUNICATION
Frontend communicates with backend via:
1. **Server Components** (SSR): Direct DB queries through Prisma/Postgres (same DB, no HTTP overhead)
2. **Server Actions**: Form submissions, mutations (type-safe, no REST overhead)
3. **API Routes** (minimal): External integrations, webhooks, special cases
Never call backend REST API from client components. Use Server Components and Server Actions.
---
# 11. TESTING PYRAMID
- **Unit tests**: Business rules in domain/application layers — many
- **Integration tests**: Repositories against real DB, API endpoints — some
- **E2E tests**: Critical flows — few
Critical E2E flows: register → login → search → view product → add to cart → checkout → payment → order confirmation
---
# 12. DEFINITION OF DONE
A feature is DONE when:
- SPEC.md complete with testable acceptance criteria
- DESIGN.md reviewed and approved
- Implementation complete (smallest possible change)
- Unit + integration tests passing
- Security reviewed
- Observability added (logs/metrics)
- Documentation updated
- Migration tested
- Rollback possible
- Acceptance criteria verified against spec

View File

@@ -19,6 +19,17 @@ npm run lint # eslint + prettier check
npm run lint:boundaries # module boundary check
```
Frontend shell commands live in `storefront/`:
```bash
cd storefront
npm install
npm run dev # Next.js development server
npm run lint # storefront formatting check
npm run typecheck # frontend TypeScript check
npm run build # production Next.js build
```
## Configuration
Startup is fail-fast: `src/infrastructure/config` parses env once and refuses to boot
@@ -38,6 +49,11 @@ is separate from deployment (no redeploy). Copy `.env.example` to `.env` to star
- Errors always use one envelope:
`{ "error": { "statusCode", "code", "message", "details?" }, "requestId" }`
Codes: `NOT_FOUND`, `VALIDATION_ERROR`, `BAD_REQUEST`/Fastify 4xx codes, `INTERNAL_ERROR`.
Category-specific codes: `CATEGORY_SLUG_EXISTS` (409), `CATEGORY_PARENT_NOT_FOUND` (422),
`CATEGORY_TREE_CYCLE` (422), `CATEGORY_HAS_CHILDREN` (409). Product-specific codes:
`PRODUCT_SLUG_EXISTS` (409), `PRODUCT_CATEGORY_NOT_FOUND` (422),
`PRODUCT_BRAND_NOT_FOUND` (422), `PRODUCT_VARIANT_CODE_EXISTS` (409).
Brand-specific codes: `BRAND_SLUG_EXISTS` (409).
5xx messages are always generic; stack traces stay in server logs only.
- Input validation is explicit per route: `parseJson(schema, body)` (zod) in the handler.
- Auth codes: `UNAUTHORIZED` (401, missing/invalid/revoked session),
@@ -96,6 +112,111 @@ operation in this slice (no admin API yet).
- The users module never imports identity: session resolution arrives as an
injected `Authenticate` function from the composition root.
## Categories (categories module)
Categories provide the public taxonomy for SEO-friendly catalog URLs. The module
owns `categories_categories`; products are not assigned to categories until the
catalog slice.
| Route | Access | Result |
| ---------------------- | ---------- | ------------------------------------------- |
| GET /categories/tree | public | `200` + `{ items: [categoryTreeNode] }` |
| GET /categoria/:slug | public | `200` category by slug, `404` if missing |
| POST /categories | admin only | `201` category, `409` on duplicate slug |
| PATCH /categories/:id | admin only | `200` category, `422` on invalid tree move |
| DELETE /categories/:id | admin only | `204`, `409` when the category has children |
- Public URLs are slug-based: `/categoria/<slug>`, never internal ids.
- Slugs are globally unique for this slice.
- Category hierarchy uses `parentId`; self-parenting and descendant-as-parent moves
are rejected with `CATEGORY_TREE_CYCLE`.
- Category rows include `seoTitle` and `seoDescription`.
## Catalog core (catalog module)
Catalog owns product identity, variants, rich product data, product images and public product discovery.
Stock, prices and external sync jobs are intentionally outside this slice.
| Route | Access | Result |
| --------------------------------------- | ---------- | ------------------------------------------------------- |
| GET /productos/:slug | public | `200` active product by slug, `404` otherwise |
| GET /products/search | public | `200` + `{ items: [activeProduct] }` via PostgreSQL FTS |
| POST /products | admin only | `201` product, `409` on duplicate slug |
| PATCH /products/:id | admin only | `200` product, `422` on unknown category |
| GET /products/:id/variants | public | `200` + `{ items: [variant] }` |
| POST /products/:id/variants | admin only | `201` variant, `409` on duplicate SKU/EAN |
| PATCH /products/:id/variants/:variantId | admin only | `200` variant, `404` if missing |
| PATCH /products/:id/rich-data | admin only | `200` rich data with nutrition provenance |
| GET /products/:id/images | public | `200` + `{ items: [image] }` |
| POST /products/:id/images | admin only | `201` image, `404` on missing product |
| DELETE /products/:id/images/:imageId | admin only | `204`, `404` if missing |
| PATCH /products/:id/images/reorder | admin only | `200` + reordered `{ items: [image] }` |
- Public URLs are slug-based: `/productos/<slug>`, never internal ids.
- Product states are `draft`, `active` and `archived`; public reads/search return
only `active` products.
- Product rows include `seoTitle` and `seoDescription`.
- Category assignment is stored in `catalog_product_categories` and validates
category ids against `categories_categories` without importing categories internals.
- Product search is behind a `ProductSearchRepository` port so future engines can replace PostgreSQL
without changing the HTTP API. `GET /products/search?q=<term>` uses PostgreSQL full-text search over
product, brand and category text with stable pagination/relevance ordering.
- Product reads can be filtered by brand with `GET /products/search?brandSlug=<slug>` and by category
with `GET /products/search?categorySlug=<slug>`.
- Product brand assignment is stored as `catalog_products.brand_id` and validates
brand ids against `brands_brands` without importing brands internals.
- Variants live in `catalog_product_variants`; non-null SKU and optional EAN are globally unique.
- Images live in `catalog_product_images`; product pages expose ordered image metadata with `url`,
`altText`, `position` and `role` (`main` or `gallery`). Images can be product-level or variant-level.
Storage is behind a catalog infrastructure adapter; this slice stores URLs only, with no binary upload,
CDN or processing pipeline.
- Rich data lives in `catalog_product_rich_data`; nutrition payloads require `nutritionSource`
(`manual`, `manufacturer`, `openfoodfacts`).
- Manual nutrition is trusted: external-source updates cannot overwrite existing manual nutrition.
- Search logs structured `catalog_search` telemetry with duration, result count and sanitized bounded
query metadata for later popular-search/cache work.
## Brands (brands module)
Brands provide public SEO-friendly brand identity and product filtering support.
The module owns `brands_brands`; product assignment remains catalog-owned.
| Route | Access | Result |
| ----------------- | ---------- | ------------------------------------- |
| GET /brands | public | `200` brand list for sitemap/catalog |
| GET /marca/:slug | public | `200` brand by slug, `404` if missing |
| POST /brands | admin only | `201` brand, `409` on duplicate slug |
| PATCH /brands/:id | admin only | `200` brand, `404` if missing |
- Public URLs are slug-based: `/marca/<slug>`, never internal ids.
- Slugs are globally unique for this slice.
- Brand rows include `seoTitle` and `seoDescription`.
## Storefront shell (Next.js)
The customer-facing shell lives in `storefront/` as a separate frontend package. It uses
Next.js App Router, React, TypeScript and Tailwind CSS.
- Home renders as a server component with global layout, navigation and footer.
- Product detail pages live at `/productos/<slug>`; category pages at `/categoria/<slug>`; brand pages at
`/marca/<slug>`; search results at `/products/search`.
- Product, category and brand pages use ISR (`revalidate = 300`) and generate metadata with canonical URLs
and OpenGraph fields. Search results use `revalidate = 120`.
- Product pages embed Product JSON-LD; product/category/brand pages embed BreadcrumbList JSON-LD; the root
layout embeds Organization JSON-LD.
- `sitemap.xml` and `robots.txt` are generated by Next.js metadata routes. Sitemap includes static public URLs,
active products, categories and brands, and degrades to static URLs if the API is unavailable.
- Permanent SEO redirects are configured with `REDIRECTS_JSON`, an array of local same-origin
`{ "from": "/old", "to": "/new" }` path entries handled by `src/proxy.ts` with HTTP 301.
- On-demand catalog revalidation is available at `POST /api/revalidate` with `REVALIDATE_SECRET` and
`x-revalidate-secret`; accepted paths are catalog public paths only.
- The frontend consumes backend public endpoints only through `src/lib/api.ts` typed DTOs.
- `src/lib/api.ts` imports `server-only`, so API calls are not accidentally bundled into client components.
- Backend internals under `src/` are never imported by the storefront.
- Configure API origin with `API_BASE_URL` for server-side rendering or `NEXT_PUBLIC_API_BASE_URL` when needed;
the local default is `http://localhost:3000`.
- Cart and checkout UI are intentionally outside this slice.
## Database (local dev)
```bash
@@ -129,7 +250,10 @@ src/
│ ├── health/ # exemplar module: public API only via index.ts
│ ├── flags/ # feature flags (unknown default OFF, runtime flip)
│ ├── identity/ # register/login/logout, argon2, sessions, rate limit
── users/ # profile + address CRUD, owner-or-admin RBAC
── users/ # profile + address CRUD, owner-or-admin RBAC
│ ├── categories/ # category tree, slugs, SEO metadata
│ ├── catalog/ # products, states, slugs, category/brand assignment
│ └── brands/ # brands, slugs, SEO metadata
└── shared/ # cross-cutting helpers (error envelope, input parsing)
```

View File

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

View File

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

View File

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

7
project/apps/admin/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

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

6781
project/apps/admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,178 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
// Redirect if already logged in
useEffect(() => {
fetch('/api/auth/me')
.then((r) => r.json())
.then((data) => {
if (data.id) {
router.push('/');
}
})
.catch(() => {});
}, [router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
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) {
setError(
data?.message ||
(data?.code === 'TOO_MANY_ATTEMPTS'
? 'Demasiados intentos. Espera un momento.'
: 'Email o contraseña incorrectos'),
);
} else {
router.push('/');
}
} catch {
setError('Error de conexión. Intenta de nuevo.');
} finally {
setLoading(false);
}
};
return (
<div
style={{ minHeight: '100vh' }}
className="flex items-center justify-center bg-gray-50 px-4"
>
<div className="w-full max-w-sm">
{/* Logo */}
<div className="text-center mb-8">
<div className="inline-flex items-center gap-2 mb-2">
<svg
className="w-10 h-10 text-[#2D6A4F]"
viewBox="0 0 32 32"
fill="none"
>
<circle cx="16" cy="16" r="14" stroke="currentColor" strokeWidth="2" />
<path
d="M10 20c2-4 4-8 6-10s4 6 6 10"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<circle cx="16" cy="10" r="2" fill="currentColor" />
</svg>
</div>
<h1
className="text-2xl font-bold text-gray-900"
style={{ fontFamily: 'var(--font-heading)' }}
>
MercadoDeVida
</h1>
<p className="text-sm text-gray-500 mt-1">Panel de administración</p>
</div>
{/* Card */}
<div className="bg-white border border-gray-200 rounded-2xl p-8 shadow-sm">
<h2 className="text-lg font-bold text-gray-900 mb-6 text-center">
Iniciar sesión
</h2>
<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
htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-1"
>
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@mercadodevida.es"
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none transition-all"
disabled={loading}
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
Contraseña
</label>
<input
id="password"
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-[#2D6A4F] focus:border-transparent outline-none transition-all"
disabled={loading}
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-60 text-white font-semibold rounded-xl transition-colors flex items-center justify-center gap-2"
>
{loading ? (
<>
<svg
className="animate-spin h-4 w-4"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Entrando...
</>
) : (
'Iniciar sesión'
)}
</button>
</form>
</div>
<p className="text-center text-sm text-gray-400 mt-6">
&copy; {new Date().getFullYear()} MercadoDeVida
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,129 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { auditApi, type AuditEntry } from '@/lib/api-client';
const PAGE_SIZE = 50;
const ACTION_COLORS: Record<string, string> = {
'admin.mfa.enroll': 'bg-purple-100 text-purple-700',
'admin.mfa.status': 'bg-purple-100 text-purple-700',
'admin.mfa.challenge': 'bg-purple-100 text-purple-700',
'auth.login': 'bg-blue-100 text-blue-700',
'auth.logout': 'bg-gray-100 text-gray-600',
'product.created': 'bg-green-100 text-green-700',
'product.updated': 'bg-green-100 text-green-700',
'product.deleted': 'bg-red-100 text-red-700',
'order.placed': 'bg-indigo-100 text-indigo-700',
'order.state_changed': 'bg-indigo-100 text-indigo-700',
};
function colorForAction(action: string): string {
return ACTION_COLORS[action] ?? 'bg-gray-100 text-gray-600';
}
export default function AuditLogPage() {
const [items, setItems] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filter, setFilter] = useState('');
const [debounced, setDebounced] = useState('');
const [page, setPage] = useState(0);
const [total, setTotal] = useState(0);
useEffect(() => {
const t = setTimeout(() => setDebounced(filter), 400);
return () => clearTimeout(t);
}, [filter]);
useEffect(() => { setPage(0); }, [debounced]);
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const data = await auditApi.list({ action: debounced || undefined, limit: PAGE_SIZE, offset: page * PAGE_SIZE });
setItems(data.items ?? []);
setTotal(data.total ?? 0);
} catch (e) { setError(e instanceof Error ? e.message : 'Error'); }
finally { setLoading(false); }
}, [page, debounced]);
useEffect(() => { load(); }, [load]);
return (
<div className="p-8 space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Log de auditoría</h1>
<p className="text-sm text-gray-500 mt-0.5">
{total > 0 ? `${total} entrada${total !== 1 ? 's' : ''}` : 'Sin entradas'}
</p>
</div>
<div className="max-w-xs">
<div className="relative">
<input type="text" placeholder="Filtrar por acción..." value={filter}
onChange={e => setFilter(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">🔍</span>
</div>
</div>
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-16 text-gray-400 text-sm">Cargando...</div>
) : error ? (
<div className="flex items-center justify-center py-16 text-red-500 text-sm">{error}</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400 text-sm gap-2">
<span className="text-3xl">📋</span><span>Sin entradas de auditoría</span>
</div>
) : (
<>
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Fecha</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Acción</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Objetivo</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Actor</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Detalles</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{items.map(entry => (
<tr key={entry.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 text-sm text-gray-500 whitespace-nowrap">
{new Date(entry.createdAt).toLocaleString('es-ES', { dateStyle: 'short', timeStyle: 'short' })}
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colorForAction(entry.action)}`}>
{entry.action}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-600 max-w-xs truncate">{entry.target}</td>
<td className="px-6 py-4 text-sm text-gray-400 font-mono">{entry.actorId?.slice(0, 8) ?? '—'}</td>
<td className="px-6 py-4 text-xs text-gray-400 font-mono max-w-xs truncate">
{Object.keys(entry.metadata ?? {}).length > 0 ? JSON.stringify(entry.metadata) : '—'}
</td>
</tr>
))}
</tbody>
</table>
{total > PAGE_SIZE && (
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200">
<span className="text-sm text-gray-500">
{page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, total)} de {total}
</span>
<div className="flex gap-2">
<button disabled={page === 0} onClick={() => setPage(p => p - 1)}
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">Anterior</button>
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => setPage(p => p + 1)}
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">Siguiente</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,244 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import type { Brand } from '@/types';
import { brandsApi } from '@/lib/api-client';
function slugify(text: string): string {
return text
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function autoSeoTitle(name: string): string {
return name;
}
function autoSeoDescription(name: string): string {
return `${name} — Compra online en MercadoDeVida. Productos naturales y ecológicos con envío a toda España.`;
}
export default function BrandsPage() {
const [brands, setBrands] = useState<Brand[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<Brand | null>(null);
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [slugManual, setSlugManual] = useState(false);
const [seoTitle, setSeoTitle] = useState('');
const [seoTitleManual, setSeoTitleManual] = useState(false);
const [seoDescription, setSeoDescription] = useState('');
const [seoDescriptionManual, setSeoDescriptionManual] = useState(false);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState('');
const load = useCallback(async () => {
setLoading(true);
try {
const data = await brandsApi.list();
setBrands((data as { items?: Brand[] }).items ?? []);
} catch (e) {
setError(e instanceof Error ? e.message : 'Error');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const openCreate = () => {
setEditing(null);
setName(''); setSlug(''); setSlugManual(false);
setSeoTitle(''); setSeoTitleManual(false);
setSeoDescription(''); setSeoDescriptionManual(false);
setShowForm(true);
};
const openEdit = (b: Brand) => {
setEditing(b);
setName(b.name);
setSlug(b.slug); setSlugManual(true);
setSeoTitle(b.seoTitle ?? ''); setSeoTitleManual(true);
setSeoDescription(b.seoDescription ?? ''); setSeoDescriptionManual(true);
setShowForm(true);
};
const handleNameChange = (value: string) => {
setName(value);
if (!slugManual) setSlug(slugify(value));
if (!seoTitleManual) setSeoTitle(autoSeoTitle(value));
if (!seoDescriptionManual) setSeoDescription(autoSeoDescription(value));
};
const handleSave = async () => {
setSaving(true); setMsg('');
const payload = {
name,
slug,
seoTitle: seoTitle || undefined,
seoDescription: seoDescription || undefined,
};
try {
if (editing) {
await brandsApi.update(editing.id, payload);
setMsg('Marca actualizada');
} else {
await brandsApi.create(payload);
setMsg('Marca creada');
}
setShowForm(false);
load();
} catch (e) {
setMsg(e instanceof Error ? e.message : 'Error al guardar');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm('¿Eliminar esta marca?')) return;
try {
await brandsApi.delete!(id);
load();
} catch (e) {
alert(e instanceof Error ? e.message : 'Error');
}
};
return (
<div className="p-8 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Marcas</h1>
<button onClick={openCreate} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl">+ Nueva marca</button>
</div>
{msg && (
<div className={`p-4 rounded-xl text-sm ${msg.startsWith('Error') ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>{msg}</div>
)}
{showForm && (
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
<h2 className="font-semibold text-gray-900">{editing ? 'Editar marca' : 'Nueva marca'}</h2>
{/* Nombre */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre *</label>
<input
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Ej: NaturGreen"
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
/>
</div>
{/* Slug */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-sm font-medium text-gray-700">Slug *</label>
<span className="text-xs text-gray-400">{slugManual ? 'editado' : 'auto'}</span>
</div>
<input
value={slug}
onChange={(e) => { setSlugManual(true); setSlug(e.target.value); }}
placeholder="auto-generado"
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none"
/>
</div>
{/* SEO Title */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-sm font-medium text-gray-700">SEO Title</label>
<span className="text-xs text-gray-400">{seoTitleManual ? 'editado' : 'auto'}</span>
</div>
<div className="relative">
<input
value={seoTitle}
onChange={(e) => { setSeoTitleManual(true); setSeoTitle(e.target.value); }}
placeholder="auto-generado desde nombre"
className="w-full px-4 py-2.5 pr-14 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
/>
<span className={`absolute right-3 top-1/2 -translate-y-1/2 text-xs ${seoTitle.length > 60 ? 'text-red-500 font-medium' : 'text-gray-400'}`}>
{seoTitle.length}/60
</span>
</div>
</div>
{/* SEO Description */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-sm font-medium text-gray-700">SEO Description</label>
<span className="text-xs text-gray-400">{seoDescriptionManual ? 'editado' : 'auto'}</span>
</div>
<div className="relative">
<textarea
value={seoDescription}
onChange={(e) => { setSeoDescriptionManual(true); setSeoDescription(e.target.value); }}
rows={2}
placeholder="auto-generado desde nombre"
className="w-full px-4 py-2.5 pr-14 border border-gray-300 rounded-xl text-sm resize-none focus:ring-2 focus:ring-[#2D6A4F] outline-none"
/>
<span className={`absolute right-3 bottom-2 text-xs ${seoDescription.length > 160 ? 'text-red-500 font-medium' : 'text-gray-400'}`}>
{seoDescription.length}/160
</span>
</div>
</div>
<div className="flex gap-3">
<button
onClick={handleSave}
disabled={saving || !name || !slug}
className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl"
>
{saving ? 'Guardando...' : 'Guardar'}
</button>
<button
onClick={() => setShowForm(false)}
className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl hover:bg-gray-50"
>
Cancelar
</button>
</div>
</div>
)}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{loading ? <div className="p-12 text-center text-gray-400">Cargando...</div> :
error ? <div className="p-8 text-center text-red-600">{error}</div> :
brands.length === 0 ? <div className="p-12 text-center text-gray-400">No hay marcas</div> :
<table className="w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
{['Nombre', 'Slug', 'SEO Title'].map(h => (
<th key={h} className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">{h}</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-50">
{brands.map(b => (
<tr key={b.id} className="hover:bg-gray-50">
<td className="px-4 py-3.5 text-sm font-medium text-gray-900">{b.name}</td>
<td className="px-4 py-3.5 text-sm text-gray-500 font-mono">/{b.slug}</td>
<td className="px-4 py-3.5 text-sm text-gray-500">{b.seoTitle ?? '—'}</td>
<td className="px-4 py-3.5">
<div className="flex gap-2">
<button onClick={() => openEdit(b)} className="text-xs text-[#2D6A4F] hover:underline">Editar</button>
<button onClick={() => handleDelete(b.id)} className="text-xs text-red-600 hover:underline">Eliminar</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
}
</div>
</div>
);
}

View File

@@ -0,0 +1,272 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import type { Category } from '@/types';
import { categoriesApi } from '@/lib/api-client';
function slugify(text: string): string {
return text
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function CategoryRow({ cat, onEdit, onDelete }: { cat: Category; onEdit: (c: Category) => void; onDelete: (id: string) => void }) {
return (
<tr className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{cat.children && cat.children.length > 0 && <span className="text-gray-300">📁</span>}
<div>
<p className="text-sm font-medium text-gray-900">{cat.name}</p>
<p className="text-xs text-gray-400">/{cat.slug}</p>
</div>
</div>
</td>
<td className="px-4 py-3 text-sm text-gray-500">{cat.parentId ? 'Sí' : 'Raíz'}</td>
<td className="px-4 py-3">
<div className="flex gap-2">
<button onClick={() => onEdit(cat)} className="p-1.5 text-gray-400 hover:text-[#2D6A4F] hover:bg-green-50 rounded-lg transition-colors" title="Editar">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button onClick={() => onDelete(cat.id)} className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="Eliminar">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
</tr>
);
}
export default function CategoriesPage() {
const [tree, setTree] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<Category | null>(null);
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [slugManual, setSlugManual] = useState(false);
const [description, setDescription] = useState('');
const [seoTitle, setSeoTitle] = useState('');
const [seoTitleManual, setSeoTitleManual] = useState(false);
const [seoDescription, setSeoDescription] = useState('');
const [seoDescManual, setSeoDescManual] = useState(false);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState('');
const load = useCallback(async () => {
setLoading(true);
try {
const data = await categoriesApi.list() as { items?: Category[] };
setTree(data?.items ?? []);
} catch (e) {
setError(e instanceof Error ? e.message : 'Error');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const openCreate = () => {
setEditing(null);
setName(''); setSlug(''); setSlugManual(false);
setDescription('');
setSeoTitle(''); setSeoTitleManual(false);
setSeoDescription(''); setSeoDescManual(false);
setShowForm(true);
};
const openEdit = (cat: Category) => {
setEditing(cat);
setName(cat.name); setSlug(cat.slug); setSlugManual(true);
setDescription(cat.description ?? '');
setSeoTitle((cat as any).seoTitle ?? ''); setSeoTitleManual(true);
setSeoDescription((cat as any).seoDescription ?? ''); setSeoDescManual(true);
setShowForm(true);
};
const handleNameChange = (value: string) => {
setName(value);
if (!slugManual) setSlug(slugify(value));
if (!seoTitleManual) setSeoTitle(value);
if (!seoDescManual) setSeoDescription(`${value} — Compra online en MercadoDeVida. Productos naturales y ecológicos.`);
};
const handleSave = async () => {
setSaving(true); setMsg('');
try {
if (editing) {
await categoriesApi.update(editing.id, { name, slug, description: description || undefined, seoTitle: seoTitle || undefined, seoDescription: seoDescription || undefined });
setMsg('Categoría actualizada');
} else {
await categoriesApi.create({ name, slug, description: description || undefined, seoTitle: seoTitle || undefined, seoDescription: seoDescription || undefined });
setMsg('Categoría creada');
}
setShowForm(false);
load();
} catch (e) {
setMsg(e instanceof Error ? e.message : 'Error al guardar');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm('¿Eliminar esta categoría?')) return;
try {
await categoriesApi.delete(id);
load();
} catch (e) {
alert(e instanceof Error ? e.message : 'Error al eliminar');
}
};
const flat = (cats: Category[]): Category[] =>
cats.flatMap((c) => [c, ...flat(c.children ?? [])]);
return (
<div className="p-8 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Categorías</h1>
<button onClick={openCreate} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl">
+ Nueva categoría
</button>
</div>
{msg && (
<div className={`p-4 rounded-xl text-sm ${msg.startsWith('Error') ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>{msg}</div>
)}
{showForm && (
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
<h2 className="font-semibold text-gray-900">{editing ? 'Editar categoría' : 'Nueva categoría'}</h2>
{/* Nombre */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre *</label>
<input
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Ej: Alimentación"
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
/>
</div>
{/* Slug */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-sm font-medium text-gray-700">Slug *</label>
<span className="text-xs text-gray-400">{slugManual ? 'editado' : 'auto'}</span>
</div>
<input
value={slug}
onChange={(e) => { setSlugManual(true); setSlug(e.target.value); }}
placeholder="auto-generado"
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none"
/>
</div>
{/* Descripción */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Descripción</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
placeholder="Descripción opcional de la categoría"
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm resize-none focus:ring-2 focus:ring-[#2D6A4F] outline-none"
/>
</div>
{/* SEO Title */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-sm font-medium text-gray-700">Título SEO (Google)</label>
<span className={`text-xs ${seoTitleManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
{seoTitleManual ? 'editado' : 'copiado del nombre'}
</span>
</div>
<input
type="text"
value={seoTitle}
maxLength={60}
onChange={(e) => { setSeoTitleManual(true); setSeoTitle(e.target.value); }}
placeholder="Título para Google (copiado del nombre)"
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
/>
<div className="mt-1 text-xs text-gray-400">{seoTitle.length}/60</div>
</div>
{/* SEO Description */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-sm font-medium text-gray-700">Descripción SEO (Google)</label>
<span className={`text-xs ${seoDescManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
{seoDescManual ? 'editada' : 'auto-generada'}
</span>
</div>
<textarea
value={seoDescription}
maxLength={160}
onChange={(e) => { setSeoDescManual(true); setSeoDescription(e.target.value); }}
rows={2}
placeholder="Descripción para Google (max 160 caracteres)"
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm resize-none focus:ring-2 focus:ring-[#2D6A4F] outline-none"
/>
<div className="mt-1 text-xs text-gray-400">{seoDescription.length}/160</div>
</div>
<div className="flex gap-3">
<button
onClick={handleSave}
disabled={saving || !name || !slug}
className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl"
>
{saving ? 'Guardando...' : 'Guardar'}
</button>
<button
onClick={() => setShowForm(false)}
className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl hover:bg-gray-50"
>
Cancelar
</button>
</div>
</div>
)}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{loading ? (
<div className="p-12 text-center text-gray-400">Cargando...</div>
) : error ? (
<div className="p-8 text-center text-red-600">{error}</div>
) : flat(tree).length === 0 ? (
<div className="p-12 text-center text-gray-400">No hay categorías</div>
) : (
<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">Nombre</th>
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Subcategoría</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-50">
{flat(tree).map((c) => (
<CategoryRow key={c.id} cat={c} onEdit={openEdit} onDelete={handleDelete} />
))}
</tbody>
</table>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { cmsApi } from '@/lib/api-client';
interface Page { id: string; slug: string; title: string; body: string; status: string; createdAt: string; updatedAt: string; }
export default function CmsPage() {
const [items, setItems] = useState<Page[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showForm, setShowForm] = useState(false);
const [msg, setMsg] = useState('');
const [form, setForm] = useState({ slug: '', title: '', body: '' });
const load = useCallback(async () => {
setLoading(true);
try { const d = await cmsApi.list() as { items: Page[] }; setItems(d.items ?? []); }
catch (e) { setError(e instanceof Error ? e.message : 'Error'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const handleSave = async () => {
setMsg('');
try {
await cmsApi.create({ slug: form.slug, title: form.title, body: form.body });
setMsg('Página creada');
setShowForm(false);
setForm({ slug: '', title: '', body: '' });
load();
} catch (e) { setMsg(e instanceof Error ? e.message : 'Error al crear'); }
};
const togglePublish = async (id: string, currentStatus: string) => {
try {
if (currentStatus === 'published') await cmsApi.unpublish(id);
else await cmsApi.publish(id);
load();
} catch (e) { alert(e instanceof Error ? e.message : 'Error'); }
};
return (
<div className="p-8 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Páginas CMS</h1>
<button onClick={() => setShowForm(true)} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl">+ Nueva página</button>
</div>
{msg && <div className={`p-4 rounded-xl text-sm ${msg.startsWith('Error') ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>{msg}</div>}
{showForm && (
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
<h2 className="font-semibold text-gray-900">Nueva página</h2>
{[['slug','Slug *','text'],['title','Título *','text']].map(([k,label,t]) => (
<div key={k}>
<label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
<input value={(form as Record<string,string>)[k]} onChange={e => setForm({...form,[k]:e.target.value})} className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
))}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Contenido *</label>
<textarea value={form.body} onChange={e => setForm({...form,body:e.target.value})} rows={6} className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none resize-none font-mono" />
</div>
<div className="flex gap-3">
<button onClick={handleSave} disabled={!form.slug || !form.title || !form.body} className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl">Crear</button>
<button onClick={() => setShowForm(false)} className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl">Cancelar</button>
</div>
</div>
)}
<div className="space-y-3">
{loading ? <div className="p-12 text-center text-gray-400">Cargando...</div> :
error ? <div className="p-8 text-center text-red-600">{error}</div> :
items.length === 0 ? <div className="p-12 text-center text-gray-400">No hay páginas</div> :
items.map(p => (
<div key={p.id} className="bg-white border border-gray-200 rounded-xl p-4">
<div className="flex items-center justify-between">
<div>
<p className="font-semibold text-gray-900 text-sm">{p.title}</p>
<p className="text-xs text-gray-400 font-mono">/{p.slug}</p>
</div>
<div className="flex items-center gap-3">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${p.status === 'published' ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'}`}>
{p.status === 'published' ? 'Publicada' : 'Borrador'}
</span>
<button onClick={() => togglePublish(p.id, p.status)} className="text-xs text-[#2D6A4F] hover:underline">
{p.status === 'published' ? 'Despublicar' : 'Publicar'}
</button>
</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,88 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { customersApi } from '@/lib/api-client';
import type { Customer } from '@/types';
export default function CustomerDetailPage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
const [customer, setCustomer] = useState<Customer | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState('');
useEffect(() => {
customersApi.get(id).then(setCustomer).catch(() => setError('No se encontró el cliente')).finally(() => setLoading(false));
}, [id]);
const handleSave = async () => {
if (!customer) return;
setSaving(true);
setMsg('');
try {
// PATCH /users/:id for profile fields (displayName, phone — role changes require separate process)
await fetch(`/api/customers/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: customer.role }),
});
setMsg('Cliente actualizado');
} catch {
setMsg('Error al guardar');
} finally {
setSaving(false);
}
};
if (loading) return <div className="p-8 text-gray-400">Cargando...</div>;
if (error || !customer) return <div className="p-8 text-red-600">{error || 'No encontrado'}</div>;
return (
<div className="p-8 max-w-2xl space-y-6">
<div className="flex items-center gap-4 mb-6">
<button onClick={() => router.push('/customers')} className="text-sm text-gray-500 hover:text-gray-700"> Clientes</button>
<h1 className="text-2xl font-bold text-gray-900">{customer.email}</h1>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-5">
<div>
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Email</p>
<p className="text-sm text-gray-900">{customer.email}</p>
</div>
<div>
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Rol</p>
<select
value={customer.role}
onChange={(e) => setCustomer({ ...customer, role: e.target.value as 'customer' | 'admin' })}
className="px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white"
>
<option value="customer">Cliente</option>
<option value="admin">Administrador</option>
</select>
</div>
<div>
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Registrado</p>
<p className="text-sm text-gray-900">
{customer.createdAt ? new Date(customer.createdAt).toLocaleString('es-ES') : '—'}
</p>
</div>
</div>
{msg && (
<div className={`p-4 rounded-xl text-sm ${msg.startsWith('Error') ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>
{msg}
</div>
)}
<button
onClick={handleSave}
disabled={saving}
className="px-6 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors"
>
{saving ? 'Guardando...' : 'Guardar cambios'}
</button>
</div>
);
}

View File

@@ -0,0 +1,298 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import type { Customer } from '@/types';
import { customersApi } from '@/lib/api-client';
const PAGE_SIZE = 20;
// ── Modal genérico ────────────────────────────────────────────────────────────
function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-6">{children}</div>
</div>
</div>
);
}
// ── Formulario crear cliente ─────────────────────────────────────────────────
function CreateForm({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [phone, setPhone] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const handle = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true); setError('');
try {
await customersApi.create({ email, password, displayName: displayName || undefined, phone: phone || undefined });
onCreated();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al crear cliente');
} finally {
setSaving(false);
}
};
return (
<form onSubmit={handle} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email *</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Contraseña *</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={8}
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre / Razón social</label>
<input type="text" value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Opcional"
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Teléfono</label>
<input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="+34 600 000 000"
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
{error && <p className="text-sm text-red-600 bg-red-50 rounded-xl px-4 py-2">{error}</p>}
<div className="flex gap-3 pt-2">
<button type="submit" disabled={saving}
className="flex-1 px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
{saving ? 'Creando...' : 'Crear cliente'}
</button>
<button type="button" onClick={onClose}
className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl hover:bg-gray-50 transition-colors">
Cancelar
</button>
</div>
</form>
);
}
// ── Formulario editar cliente ────────────────────────────────────────────────
function EditForm({ customer, onClose, onSaved }: { customer: Customer; onClose: () => void; onSaved: () => void }) {
const [displayName, setDisplayName] = useState('');
const [phone, setPhone] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
setDisplayName(customer.displayName || '');
setPhone(customer.phone || '');
}, [customer]);
const handle = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true); setError('');
try {
await customersApi.update(customer.id, {
displayName: displayName || undefined,
phone: phone || undefined,
});
onSaved();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al guardar');
} finally {
setSaving(false);
}
};
return (
<form onSubmit={handle} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input type="email" value={customer.email} disabled
className="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50 text-gray-400" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre / Razón social</label>
<input type="text" value={displayName} onChange={(e) => setDisplayName(e.target.value)}
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Teléfono</label>
<input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="+34 600 000 000"
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
{error && <p className="text-sm text-red-600 bg-red-50 rounded-xl px-4 py-2">{error}</p>}
<div className="flex gap-3 pt-2">
<button type="submit" disabled={saving}
className="flex-1 px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
{saving ? 'Guardando...' : 'Guardar cambios'}
</button>
<button type="button" onClick={onClose}
className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl hover:bg-gray-50 transition-colors">
Cancelar
</button>
</div>
</form>
);
}
// ── Página principal ───────────────────────────────────────────────────────────
export default function CustomersPage() {
const [customers, setCustomers] = useState<Customer[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
const [debounced, setDebounced] = useState('');
const [page, setPage] = useState(0);
const [total, setTotal] = useState(0);
const [showCreate, setShowCreate] = useState(false);
const [editing, setEditing] = useState<Customer | null>(null);
const [msg, setMsg] = useState('');
useEffect(() => {
const t = setTimeout(() => setDebounced(search), 400);
return () => clearTimeout(t);
}, [search]);
useEffect(() => { setPage(0); }, [debounced]);
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const data = await customersApi.list({ limit: PAGE_SIZE, offset: page * PAGE_SIZE, q: debounced || undefined });
setCustomers(data.items ?? []);
setTotal(data.total ?? 0);
} catch (e) {
setError(e instanceof Error ? e.message : 'Error');
} finally {
setLoading(false);
}
}, [page, debounced]);
useEffect(() => { load(); }, [load]);
const handleCreated = () => { setMsg('Cliente creado correctamente'); setTimeout(() => setMsg(''), 3000); load(); };
const handleSaved = () => { setMsg('Cliente actualizado'); setTimeout(() => setMsg(''), 3000); load(); };
return (
<div className="p-8 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Clientes</h1>
<p className="text-sm text-gray-500 mt-0.5">{total > 0 ? `${total} cliente${total !== 1 ? 's' : ''}` : ''}</p>
</div>
<button
onClick={() => setShowCreate(true)}
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl transition-colors"
>
+ Nuevo cliente
</button>
</div>
{msg && (
<div className="p-4 rounded-xl text-sm bg-green-50 text-green-700">{msg}</div>
)}
{/* Buscador */}
<div className="relative max-w-sm">
<input type="search" placeholder="Buscar por email..." value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none" />
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
</svg>
</div>
{/* Tabla */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{loading ? (
<div className="p-12 flex items-center justify-center gap-3 text-gray-400">
<div className="h-5 w-5 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full animate-spin" />
<span className="text-sm">Cargando...</span>
</div>
) : error ? (
<div className="p-8 text-center">
<p className="text-red-600 text-sm mb-3">{error}</p>
<button onClick={load} className="text-sm text-[#2D6A4F] hover:underline">Reintentar</button>
</div>
) : customers.length === 0 ? (
<div className="p-12 text-center">
<p className="text-4xl mb-3">👥</p>
<p className="text-gray-500 text-sm">No hay clientes</p>
</div>
) : (
<>
<table className="w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
{['Email', 'Nombre', 'Teléfono', 'Rol', 'Alta', ''].map((h) => (
<th key={h} className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{customers.map((c) => (
<tr key={c.id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3.5 text-sm font-medium text-gray-900">{c.email}</td>
<td className="px-4 py-3.5 text-sm text-gray-600">{c.displayName || '—'}</td>
<td className="px-4 py-3.5 text-sm text-gray-600">{c.phone || '—'}</td>
<td className="px-4 py-3.5">
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium capitalize ${
c.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'
}`}>{c.role}</span>
</td>
<td className="px-4 py-3.5 text-sm text-gray-500">
{c.createdAt ? new Date(c.createdAt).toLocaleDateString('es-ES') : '—'}
</td>
<td className="px-4 py-3.5">
<button onClick={() => setEditing(c)}
className="text-xs text-[#2D6A4F] hover:underline">Editar</button>
</td>
</tr>
))}
</tbody>
</table>
{/* Paginación */}
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200 bg-gray-50">
<p className="text-sm text-gray-500">Página {page + 1}</p>
<div className="flex gap-2">
<button onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-white">
Anterior
</button>
<button onClick={() => setPage((p) => p + 1)} disabled={customers.length < PAGE_SIZE}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-white">
Siguiente
</button>
</div>
</div>
</>
)}
</div>
{/* Modal crear */}
{showCreate && (
<Modal title="Nuevo cliente" onClose={() => setShowCreate(false)}>
<CreateForm onClose={() => setShowCreate(false)} onCreated={handleCreated} />
</Modal>
)}
{/* Modal editar */}
{editing && (
<Modal title={`Editar: ${editing.email}`} onClose={() => setEditing(null)}>
<EditForm customer={editing} onClose={() => setEditing(null)} onSaved={handleSaved} />
</Modal>
)}
</div>
);
}

View File

@@ -0,0 +1,348 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { productsApi, inventoryApi } from '@/lib/api-client';
import type { Product, ProductVariant, StockAvailability } from '@/types';
interface VariantRow {
productId: string;
productName: string;
variant: ProductVariant;
stock: StockAvailability | null;
loading: boolean;
editing: boolean;
editValue: string;
saving: boolean;
msg: string;
}
type StockFilter = 'all' | 'in_stock' | 'low_stock' | 'out_of_stock';
const STOCK_LABELS: Record<StockFilter, string> = {
all: 'Todos',
in_stock: 'En stock',
low_stock: 'Stock bajo',
out_of_stock: 'Sin stock',
};
function StockBadge({ qty }: { qty: number }) {
if (qty === 0) return <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">Sin stock</span>;
if (qty < 5) return <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700">Stock bajo ({qty})</span>;
return <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">En stock ({qty})</span>;
}
export default function InventoryPage() {
const [rows, setRows] = useState<VariantRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filter, setFilter] = useState<StockFilter>('all');
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
// Debounce search
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 400);
return () => clearTimeout(t);
}, [search]);
// Load products + variants + stock
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const { items: products } = await productsApi.list({
limit: 100,
q: debouncedSearch || undefined,
});
const variantRows: VariantRow[] = [];
for (const product of products ?? []) {
const { items: variants } = await productsApi.getVariants(product.id);
for (const variant of variants ?? []) {
variantRows.push({
productId: product.id,
productName: product.name,
variant,
stock: null,
loading: true,
editing: false,
editValue: '',
saving: false,
msg: '',
});
}
}
setRows(variantRows);
// Load stock for each variant
for (const vr of variantRows) {
inventoryApi.getAvailability(vr.variant.id)
.then((stock) => {
setRows((prev) =>
prev.map((r) =>
r.variant.id === vr.variant.id
? { ...r, stock, loading: false, editValue: String(stock.availableQuantity) }
: r,
),
);
})
.catch(() => {
setRows((prev) =>
prev.map((r) =>
r.variant.id === vr.variant.id ? { ...r, loading: false, editValue: '0' } : r,
),
);
});
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Error al cargar inventario');
} finally {
setLoading(false);
}
}, [debouncedSearch]);
useEffect(() => { load(); }, [load]);
// Filter rows
const filtered = rows.filter((r) => {
if (filter === 'in_stock') return (r.stock?.availableQuantity ?? 0) >= 5;
if (filter === 'low_stock') return (r.stock?.availableQuantity ?? 0) > 0 && (r.stock?.availableQuantity ?? 0) < 5;
if (filter === 'out_of_stock') return (r.stock?.availableQuantity ?? 0) === 0;
return true;
});
const inStockCount = rows.filter((r) => (r.stock?.availableQuantity ?? 0) >= 5).length;
const lowStockCount = rows.filter((r) => {
const q = r.stock?.availableQuantity ?? 0;
return q > 0 && q < 5;
}).length;
const outOfStockCount = rows.filter((r) => (r.stock?.availableQuantity ?? 0) === 0).length;
return (
<div className="p-8 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Inventario</h1>
<p className="text-sm text-gray-500 mt-0.5">{rows.length} variantes</p>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4">
{[
{ label: 'En stock', count: inStockCount, cls: 'bg-green-50 border-green-100 text-green-700' },
{ label: 'Stock bajo', count: lowStockCount, cls: 'bg-amber-50 border-amber-100 text-amber-700' },
{ label: 'Sin stock', count: outOfStockCount, cls: 'bg-red-50 border-red-100 text-red-700' },
].map(({ label, count, cls }) => (
<div key={label} className={`p-4 rounded-xl border ${cls}`}>
<p className="text-2xl font-bold">{count}</p>
<p className="text-sm font-medium">{label}</p>
</div>
))}
</div>
{/* Search + filters */}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<input
type="search"
placeholder="Buscar por producto o SKU..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none"
/>
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
</svg>
</div>
<div className="flex gap-2">
{(Object.keys(STOCK_LABELS) as StockFilter[]).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
filter === f
? 'bg-[#2D6A4F] text-white'
: 'bg-white border border-gray-300 text-gray-600 hover:bg-gray-50'
}`}
>
{STOCK_LABELS[f]}
</button>
))}
</div>
<button
onClick={load}
className="text-sm text-[#2D6A4F] hover:underline"
>
Recargar
</button>
</div>
{/* Table */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{loading ? (
<div className="p-12 flex items-center justify-center gap-3 text-gray-400">
<div className="h-5 w-5 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full animate-spin" />
<span className="text-sm">Cargando inventario...</span>
</div>
) : error ? (
<div className="p-8 text-center">
<p className="text-red-600 text-sm mb-3">{error}</p>
<button onClick={load} className="text-sm text-[#2D6A4F] hover:underline">Reintentar</button>
</div>
) : filtered.length === 0 ? (
<div className="p-12 text-center">
<p className="text-4xl mb-3">📦</p>
<p className="text-gray-500 text-sm">No hay variantes para este filtro</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b border-gray-200 text-left">
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Producto</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">SKU</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">EAN</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Stock</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Estado</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Acción</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{filtered.map((row) => (
<tr key={row.variant.id} className="hover:bg-gray-50/50 transition-colors">
<td className="px-4 py-3">
<p className="text-sm font-medium text-gray-900">{row.productName}</p>
</td>
<td className="px-4 py-3 font-mono text-xs text-gray-600">{row.variant.sku}</td>
<td className="px-4 py-3 font-mono text-xs text-gray-400">{row.variant.ean ?? '—'}</td>
<td className="px-4 py-3">
{row.loading ? (
<span className="text-gray-300"></span>
) : row.editing ? (
<div className="flex items-center gap-1">
<input
type="number"
min={0}
value={row.editValue}
onChange={(e) =>
setRows((prev) =>
prev.map((r) =>
r.variant.id === row.variant.id
? { ...r, editValue: e.target.value }
: r,
),
)
}
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-sm focus:ring-1 focus:ring-[#2D6A4F] outline-none"
/>
<button
onClick={async () => {
const qty = parseInt(row.editValue, 10);
if (isNaN(qty) || qty < 0) return;
setRows((prev) =>
prev.map((r) =>
r.variant.id === row.variant.id ? { ...r, saving: true } : r,
),
);
try {
const result = await inventoryApi.setStock(row.variant.id, qty);
setRows((prev) =>
prev.map((r) =>
r.variant.id === row.variant.id
? {
...r,
stock: { available: result.available > 0, availableQuantity: result.available },
editing: false,
saving: false,
msg: '✓',
}
: r,
),
);
setTimeout(() => {
setRows((prev) =>
prev.map((r) =>
r.variant.id === row.variant.id ? { ...r, msg: '' } : r,
),
);
}, 3000);
} catch {
setRows((prev) =>
prev.map((r) =>
r.variant.id === row.variant.id
? { ...r, saving: false, msg: 'Error' }
: r,
),
);
}
}}
disabled={row.saving}
className="px-2 py-1 bg-[#2D6A4F] text-white text-xs rounded-lg hover:bg-[#1B4332] disabled:opacity-50"
>
{row.saving ? '...' : 'OK'}
</button>
<button
onClick={() =>
setRows((prev) =>
prev.map((r) =>
r.variant.id === row.variant.id
? {
...r,
editing: false,
editValue: String(r.stock?.availableQuantity ?? 0),
}
: r,
),
)
}
className="text-gray-400 hover:text-gray-600 text-xs"
>
</button>
</div>
) : (
<div className="flex items-center gap-1">
<span className="font-medium text-gray-900">
{row.stock?.availableQuantity ?? '—'}
</span>
<button
onClick={() =>
setRows((prev) =>
prev.map((r) =>
r.variant.id === row.variant.id ? { ...r, editing: true } : r,
),
)
}
className="ml-1 text-gray-400 hover:text-[#2D6A4F] text-xs"
title="Editar stock"
>
</button>
</div>
)}
</td>
<td className="px-4 py-3">
<StockBadge qty={row.stock?.availableQuantity ?? 0} />
</td>
<td className="px-4 py-3">
{row.msg && (
<span className={`text-xs ${row.msg === '✓' ? 'text-green-600' : 'text-red-600'}`}>
{row.msg}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,122 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import Link from 'next/link';
import { AuthProvider, useAuth } from '@/features/auth/components/AuthProvider';
import { visibleNavItems, type NavItem } from '@/lib/permissions';
import type { Role } from '@/types';
function Sidebar({
navItems,
user,
onLogout,
}: {
navItems: NavItem[];
user: { email: string; role: Role };
onLogout: () => void;
}) {
const pathname = usePathname();
return (
<div className="w-60 bg-white border-r border-gray-200 flex flex-col h-screen sticky top-0">
{/* Logo */}
<div className="px-4 py-5 border-b border-gray-100">
<img
src="/images/logo-main.png"
alt="MercadoDeVida"
className="h-9 w-auto object-contain mx-auto"
/>
</div>
{/* Nav */}
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
{navItems.map((item) => {
const active =
item.href === '/'
? pathname === '/'
: pathname.startsWith(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-all
${
active
? 'bg-[#2D6A4F]/10 text-[#2D6A4F] border-l-[3px] border-[#2D6A4F]'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}
`}
>
<span className="text-base">{item.icon}</span>
<span className="truncate">{item.label}</span>
{item.badge != null && item.badge > 0 && (
<span className="ml-auto bg-[#E76F51] text-white text-xs font-bold rounded-full px-1.5 py-0.5 min-w-[18px] text-center">
{item.badge}
</span>
)}
</Link>
);
})}
</nav>
{/* User footer */}
<div className="px-3 py-4 border-t border-gray-100">
<div className="px-3 py-2 mb-2">
<p className="text-xs text-gray-400 truncate">{user.email}</p>
<p className="text-xs text-gray-500 capitalize">{user.role}</p>
</div>
<button
onClick={onLogout}
className="w-full text-left px-3 py-2 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-50 rounded-lg transition-colors"
>
Cerrar sesión
</button>
</div>
</div>
);
}
function DashboardShell({ children }: { children: React.ReactNode }) {
const { user, loading, logout } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading && !user) {
router.push('/login');
}
}, [user, loading, router]);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-gray-500">Cargando...</div>
</div>
);
}
if (!user) return null;
const navItems = visibleNavItems(user.role);
return (
<div className="flex min-h-screen bg-gray-50">
<Sidebar navItems={navItems} user={user} onLogout={logout} />
<main className="flex-1 min-w-0">
{children}
</main>
</div>
);
}
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<AuthProvider>
<DashboardShell>{children}</DashboardShell>
</AuthProvider>
);
}

View File

@@ -0,0 +1,273 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import type { Order, OrderState } from '@/types';
import { ordersApi } from '@/lib/api-client';
const STATE_LABELS: Record<OrderState, string> = {
PENDING: 'Pendiente',
AWAITING_PAYMENT: 'Esperando pago',
PAID: 'Pagado',
PROCESSING: 'Procesando',
SHIPPED: 'Enviado',
DELIVERED: 'Entregado',
CANCELLED: 'Cancelado',
REFUNDED: 'Reembolsado',
PARTIALLY_REFUNDED: 'Reembolso parcial',
};
const STATE_COLORS: Record<OrderState, string> = {
PENDING: 'bg-amber-100 text-amber-800',
AWAITING_PAYMENT: 'bg-orange-100 text-orange-800',
PAID: 'bg-blue-100 text-blue-800',
PROCESSING: 'bg-indigo-100 text-indigo-800',
SHIPPED: 'bg-purple-100 text-purple-800',
DELIVERED: 'bg-green-100 text-green-800',
CANCELLED: 'bg-red-100 text-red-800',
REFUNDED: 'bg-purple-100 text-purple-800',
PARTIALLY_REFUNDED: 'bg-pink-100 text-pink-800',
};
const ALLOWED_TRANSITIONS: Record<OrderState, OrderState[]> = {
PENDING: ['AWAITING_PAYMENT', 'CANCELLED'],
AWAITING_PAYMENT: ['PAID', 'CANCELLED'],
PAID: ['PROCESSING', 'CANCELLED', 'REFUNDED'],
PROCESSING: ['SHIPPED', 'CANCELLED', 'REFUNDED'],
SHIPPED: ['DELIVERED', 'PARTIALLY_REFUNDED'],
DELIVERED: ['PARTIALLY_REFUNDED'],
CANCELLED: [],
REFUNDED: [],
PARTIALLY_REFUNDED: [],
};
const ACTION_LABELS: Record<OrderState, string> = {
AWAITING_PAYMENT: 'Marcar como Pagado',
PAID: 'Procesar pedido',
PROCESSING: 'Marcar como Enviado',
SHIPPED: 'Marcar como Entregado',
DELIVERED: 'Reembolso parcial',
CANCELLED: 'Cancelar pedido',
PENDING: 'Marcar como Pagado',
REFUNDED: 'Reembolsar',
PARTIALLY_REFUNDED: 'Reembolso parcial',
};
function formatPrice(cents: number) {
return `${(cents / 100).toFixed(2)}`;
}
export default function OrderDetailPage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
const [order, setOrder] = useState<Order | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [transitioning, setTransitioning] = useState(false);
const [showConfirm, setShowConfirm] = useState<OrderState | null>(null);
const [confirmReason, setConfirmReason] = useState('');
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await ordersApi.get(id);
setOrder(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar');
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => { load(); }, [load]);
const handleTransition = async (nextState: OrderState) => {
setTransitioning(true);
try {
const updated = await ordersApi.transition(id, nextState);
setOrder(updated);
setShowConfirm(null);
setConfirmReason('');
} catch (err) {
alert(err instanceof Error ? err.message : 'Error al cambiar estado');
} finally {
setTransitioning(false);
}
};
if (loading) {
return (
<div className="p-8 flex items-center justify-center min-h-64">
<div className="text-gray-400">Cargando...</div>
</div>
);
}
if (error || !order) {
return (
<div className="p-8">
<p className="text-red-600">{error || 'Pedido no encontrado'}</p>
<button onClick={load} className="text-sm text-[#2D6A4F] hover:underline mt-2">
Reintentar
</button>
</div>
);
}
const currentState = order.state as OrderState;
const allowed = ALLOWED_TRANSITIONS[currentState] ?? [];
return (
<div className="p-8">
{/* Back */}
<Link href="/orders" className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6">
Volver a pedidos
</Link>
{/* Header */}
<div className="flex items-start justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900 font-mono">#{order.id.slice(0, 8)}</h1>
<p className="text-sm text-gray-500 mt-1">
{new Date(order.createdAt).toLocaleString('es-ES', {
dateStyle: 'long',
timeStyle: 'short',
})}
</p>
</div>
<span className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium ${STATE_COLORS[currentState]}`}>
<span className="w-2 h-2 rounded-full bg-current" />
{STATE_LABELS[currentState]}
</span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
{/* Actions */}
{allowed.length > 0 && (
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="font-bold text-gray-900 mb-4">Acciones</h2>
<div className="flex flex-wrap gap-2">
{allowed.map((next) => (
<button
key={next}
onClick={() => setShowConfirm(next)}
disabled={transitioning}
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors"
>
{ACTION_LABELS[next] ?? next}
</button>
))}
</div>
</div>
)}
{/* Order items */}
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="font-bold text-gray-900 mb-4">Productos</h2>
<div className="space-y-3">
{order.items.map((item) => (
<div key={item.id} className="flex justify-between items-start py-2 border-b border-gray-50 last:border-0">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900">{item.name}</p>
<p className="text-xs text-gray-400">
{item.quantity} × {formatPrice(item.unitPriceCents)}
{item.discountCents > 0 && ` (-${formatPrice(item.discountCents)})`}
</p>
</div>
<p className="text-sm font-bold text-gray-900 ml-4">
{formatPrice((item.unitPriceCents - item.discountCents) * item.quantity)}
</p>
</div>
))}
</div>
</div>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Totals */}
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="font-bold text-gray-900 mb-4">Resumen</h2>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Subtotal</span>
<span className="font-medium">{formatPrice(order.subtotalCents)}</span>
</div>
{order.discountCents > 0 && (
<div className="flex justify-between">
<span className="text-gray-600">Descuento</span>
<span className="font-medium text-green-600">-{formatPrice(order.discountCents)}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-gray-600">IVA</span>
<span className="font-medium">{formatPrice(order.taxCents)}</span>
</div>
<div className="border-t border-gray-200 pt-2 mt-2 flex justify-between items-center">
<span className="font-bold text-gray-900">Total</span>
<span className="text-xl font-bold text-[#2D6A4F]">{formatPrice(order.totalCents)}</span>
</div>
</div>
</div>
{/* Timeline */}
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="font-bold text-gray-900 mb-4">Historial</h2>
<div className="space-y-3">
<div className="flex gap-3">
<div className="w-2 h-2 rounded-full bg-[#2D6A4F] mt-1.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-gray-900">{STATE_LABELS[currentState]}</p>
<p className="text-xs text-gray-400">
{new Date(order.createdAt).toLocaleString('es-ES')}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Confirmation Modal */}
{showConfirm && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 max-w-md w-full shadow-xl">
<h3 className="text-lg font-bold text-gray-900 mb-2">
Confirmar cambio de estado
</h3>
<p className="text-sm text-gray-600 mb-4">
¿{ACTION_LABELS[showConfirm] ?? showConfirm}?
</p>
{(showConfirm === 'CANCELLED' || showConfirm === 'REFUNDED') && (
<textarea
value={confirmReason}
onChange={(e) => setConfirmReason(e.target.value)}
placeholder="Motivo (opcional)"
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none mb-4 resize-none"
/>
)}
<div className="flex gap-3 justify-end">
<button
onClick={() => { setShowConfirm(null); setConfirmReason(''); }}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 transition-colors"
>
Cancelar
</button>
<button
onClick={() => handleTransition(showConfirm)}
disabled={transitioning}
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors"
>
{transitioning ? 'Guardando...' : 'Confirmar'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,203 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import type { Order, OrderState } from '@/types';
import { ordersApi } from '@/lib/api-client';
const ORDER_STATES: OrderState[] = [
'PENDING',
'AWAITING_PAYMENT',
'PAID',
'PROCESSING',
'SHIPPED',
'DELIVERED',
'CANCELLED',
'REFUNDED',
'PARTIALLY_REFUNDED',
];
const STATE_LABELS: Record<OrderState, string> = {
PENDING: 'Pendiente',
AWAITING_PAYMENT: 'Esperando pago',
PAID: 'Pagado',
PROCESSING: 'Procesando',
SHIPPED: 'Enviado',
DELIVERED: 'Entregado',
CANCELLED: 'Cancelado',
REFUNDED: 'Reembolsado',
PARTIALLY_REFUNDED: 'Reembolso parcial',
};
const STATE_COLORS: Record<OrderState, string> = {
PENDING: 'bg-amber-100 text-amber-800',
AWAITING_PAYMENT: 'bg-orange-100 text-orange-800',
PAID: 'bg-blue-100 text-blue-800',
PROCESSING: 'bg-indigo-100 text-indigo-800',
SHIPPED: 'bg-purple-100 text-purple-800',
DELIVERED: 'bg-green-100 text-green-800',
CANCELLED: 'bg-red-100 text-red-800',
REFUNDED: 'bg-purple-100 text-purple-800',
PARTIALLY_REFUNDED: 'bg-pink-100 text-pink-800',
};
function formatPrice(cents: number) {
return `${(cents / 100).toFixed(2)}`;
}
function timeAgo(dateStr: string) {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Hoy';
if (diffDays === 1) return 'Ayer';
if (diffDays < 30) return `Hace ${diffDays} días`;
return date.toLocaleDateString('es-ES', { day: 'numeric', month: 'short' });
}
export default function OrdersPage() {
const [orders, setOrders] = useState<Order[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filterState, setFilterState] = useState('');
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 400);
return () => clearTimeout(t);
}, [search]);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await ordersApi.list({
status: filterState || undefined,
q: debouncedSearch || undefined,
limit: 20,
});
setOrders(data.items);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar');
} finally {
setLoading(false);
}
}, [filterState, debouncedSearch]);
useEffect(() => { load(); }, [load]);
return (
<div className="p-8">
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Pedidos</h1>
<p className="text-sm text-gray-500 mt-0.5">{orders?.length ?? 0} pedidos</p>
</div>
{/* Filters */}
<div className="flex gap-3 mb-6 flex-wrap">
<div className="relative flex-1 max-w-xs">
<input
type="search"
placeholder="Buscar por ID o email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none"
/>
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
</div>
<select
value={filterState}
onChange={(e) => setFilterState(e.target.value)}
className="px-3 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none bg-white"
>
<option value="">Todos los estados</option>
{ORDER_STATES.map((s) => (
<option key={s} value={s}>{STATE_LABELS[s]}</option>
))}
</select>
</div>
{/* Table */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{loading ? (
<div className="p-8 text-center text-gray-400">
<div className="inline-block animate-spin h-5 w-5 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full" />
<p className="mt-2 text-sm">Cargando...</p>
</div>
) : error ? (
<div className="p-8 text-center">
<p className="text-red-600 text-sm mb-3">{error}</p>
<button onClick={load} className="text-sm text-[#2D6A4F] hover:underline">
Reintentar
</button>
</div>
) : !orders || orders.length === 0 ? (
<div className="p-12 text-center">
<p className="text-4xl mb-3">🧾</p>
<p className="text-gray-500 text-sm">No hay pedidos</p>
</div>
) : (
<table className="w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
{['ID', 'Fecha', 'Total', 'Estado'].map((h) => (
<th
key={h}
className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3"
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{orders.map((o) => (
<tr key={o.id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3.5">
<Link
href={`/orders/${o.id}`}
className="text-sm font-mono text-[#2D6A4F] hover:underline"
>
{o.id.slice(0, 8)}...
</Link>
</td>
<td className="px-4 py-3">
<p className="text-sm text-gray-600">{timeAgo(o.createdAt)}</p>
<p className="text-xs text-gray-400">
{new Date(o.createdAt).toLocaleTimeString('es-ES', {
hour: '2-digit',
minute: '2-digit',
})}
</p>
</td>
<td className="px-4 py-3">
<p className="text-sm font-bold text-gray-900">{formatPrice(o.totalCents)}</p>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${STATE_COLORS[o.state]}`}
>
<span className="w-1.5 h-1.5 rounded-full bg-current" />
{STATE_LABELS[o.state]}
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,248 @@
'use client';
import { useState, useEffect } from 'react';
import { api } from '@/lib/api-client';
interface Stats {
ordersToday: number;
revenueTodayCents: number;
revenueTodayFormatted: string;
ordersByState: Record<string, number>;
outOfStockVariants: number;
totalActiveProducts: number;
newCustomersThisMonth: number;
generatedAt: string;
}
function formatCents(cents: number): string {
return `${(cents / 100).toFixed(2)}`;
}
const STATE_LABELS: Record<string, string> = {
PENDING: 'Pendientes',
AWAITING_PAYMENT: 'Esperando pago',
PAID: 'Pagados',
PROCESSING: 'Procesando',
SHIPPED: 'Enviados',
DELIVERED: 'Entregados',
CANCELLED: 'Cancelados',
REFUNDED: 'Reembolsados',
PARTIALLY_REFUNDED: 'Reembolso parcial',
};
const STATE_COLORS: Record<string, string> = {
PENDING: 'bg-amber-100 text-amber-700',
AWAITING_PAYMENT: 'bg-orange-100 text-orange-700',
PAID: 'bg-green-100 text-green-700',
PROCESSING: 'bg-blue-100 text-blue-700',
SHIPPED: 'bg-indigo-100 text-indigo-700',
DELIVERED: 'bg-emerald-100 text-emerald-700',
CANCELLED: 'bg-gray-100 text-gray-600',
REFUNDED: 'bg-red-100 text-red-700',
PARTIALLY_REFUNDED: 'bg-pink-100 text-pink-700',
};
function KPICard({
label,
value,
sub,
icon,
trend,
}: {
label: string;
value: string;
sub?: string;
icon: string;
trend?: 'up' | 'down' | 'neutral';
}) {
return (
<div className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-medium text-gray-500">{label}</p>
<p className="text-3xl font-bold text-gray-900 mt-1">{value}</p>
{sub && <p className="text-xs text-gray-400 mt-1">{sub}</p>}
</div>
<div className="text-3xl">{icon}</div>
</div>
</div>
);
}
function OrderStateBar({ state, count, total }: { state: string; count: number; total: number }) {
const pct = total > 0 ? (count / total) * 100 : 0;
const label = STATE_LABELS[state] ?? state;
const color = STATE_COLORS[state] ?? 'bg-gray-100 text-gray-700';
return (
<div className="flex items-center gap-3 py-2">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium min-w-[100px] ${color}`}>
{label}
</span>
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-[#2D6A4F] rounded-full transition-all"
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-sm font-semibold text-gray-700 min-w-[32px] text-right">{count}</span>
</div>
);
}
export default function DashboardPage() {
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
api
.get<Stats>('/admin/stats')
.then(setStats)
.catch(() => setError('No se pudieron cargar las estadísticas'))
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<div className="p-8 space-y-6">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-white border border-gray-200 rounded-xl p-5 animate-pulse">
<div className="h-4 bg-gray-200 rounded w-1/2 mb-3" />
<div className="h-8 bg-gray-200 rounded w-3/4" />
</div>
))}
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6 animate-pulse">
<div className="h-5 bg-gray-200 rounded w-1/4 mb-4" />
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-8 bg-gray-100 rounded" />
))}
</div>
</div>
</div>
);
}
if (error || !stats) {
return (
<div className="p-8">
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">
{error ?? 'Error desconocido'}
</div>
</div>
);
}
const totalOrders = Object.values(stats.ordersByState).reduce((a, b) => a + b, 0);
const ordersByStateSorted = Object.entries(stats.ordersByState).sort(
([, a], [, b]) => b - a,
);
return (
<div className="p-8 space-y-6">
{/* KPI Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<KPICard
label="Pedidos hoy"
value={String(stats.ordersToday)}
sub="Órdenes del día"
icon="📦"
/>
<KPICard
label="Ingresos hoy"
value={formatCents(stats.revenueTodayCents)}
sub="Revenue del día"
icon="💶"
/>
<KPICard
label="Productos activos"
value={String(stats.totalActiveProducts)}
sub="En el catálogo"
icon="🌿"
/>
<KPICard
label="Sin stock"
value={String(stats.outOfStockVariants)}
sub="Variantes agotadas"
icon="⚠️"
/>
</div>
{/* Secondary KPIs */}
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
<KPICard
label="Clientes nuevos"
value={String(stats.newCustomersThisMonth)}
sub="Este mes"
icon="👥"
/>
<KPICard
label="Total pedidos"
value={String(totalOrders)}
sub="En el sistema"
icon="📋"
/>
<KPICard
label="Alertas"
value={
stats.outOfStockVariants > 0
? `${stats.outOfStockVariants} sin stock`
: 'Sin alertas'
}
sub={stats.outOfStockVariants > 0 ? 'Revisar inventario' : 'Todo OK'}
icon={stats.outOfStockVariants > 0 ? '🔴' : '✅'}
/>
</div>
{/* Orders by state */}
<div className="bg-white border border-gray-200 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-semibold text-gray-900">Pedidos por estado</h2>
<span className="text-sm text-gray-500">{totalOrders} total</span>
</div>
{totalOrders === 0 ? (
<div className="py-8 text-center text-gray-400 text-sm">
<p className="text-3xl mb-2">📋</p>
<p>No hay pedidos en el sistema</p>
</div>
) : (
<div>
{ordersByStateSorted.map(([state, count]) => (
<OrderStateBar
key={state}
state={state}
count={count}
total={totalOrders}
/>
))}
</div>
)}
</div>
{/* Quick actions */}
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="text-base font-semibold text-gray-900 mb-4">Acciones rápidas</h2>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[
{ href: '/products/new', label: '+ Nuevo producto', icon: '🌿' },
{ href: '/orders', label: 'Ver pedidos', icon: '📦' },
{ href: '/inventory', label: 'Revisar stock', icon: '📊' },
{ href: '/customers', label: 'Clientes', icon: '👥' },
].map(({ href, label, icon }) => (
<a
key={href}
href={href}
className="flex items-center gap-2 px-4 py-3 border border-gray-200 rounded-xl hover:bg-gray-50 hover:border-[#2D6A4F] transition-colors text-sm font-medium text-gray-700"
>
<span>{icon}</span>
<span>{label}</span>
</a>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,130 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { paymentsApi, type PaymentTransaction } from '@/lib/api-client';
const STATUS_COLORS: Record<string, string> = {
succeeded: 'bg-green-100 text-green-700',
requires_payment: 'bg-yellow-100 text-yellow-700',
failed: 'bg-red-100 text-red-700',
refunded: 'bg-gray-100 text-gray-600',
chargeback: 'bg-red-100 text-red-800',
};
const PAGE_SIZE = 50;
export default function PaymentsPage() {
const [items, setItems] = useState<PaymentTransaction[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filter, setFilter] = useState('');
const [debounced, setDebounced] = useState('');
const [page, setPage] = useState(0);
const [total, setTotal] = useState(0);
const [msg, setMsg] = useState('');
useEffect(() => { const t = setTimeout(() => setDebounced(filter), 400); return () => clearTimeout(t); }, [filter]);
useEffect(() => { setPage(0); }, [debounced]);
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const data = await paymentsApi.list({ limit: PAGE_SIZE, offset: page * PAGE_SIZE, q: debounced || undefined });
setItems(data.items ?? []); setTotal(data.total ?? 0);
} catch (e) { setError(e instanceof Error ? e.message : 'Error'); }
finally { setLoading(false); }
}, [page, debounced]);
useEffect(() => { load(); }, [load]);
const handleRefund = async (id: string) => {
if (!confirm('¿Reembolsar este pago? Esta acción no se puede deshacer.')) return;
try {
await paymentsApi.refund(id);
setMsg('Reembolso procesado'); setTimeout(() => setMsg(''), 3000); load();
} catch (er) { alert(er instanceof Error ? er.message : 'Error al reembolsar'); }
};
const fmt = (cents: number) => `${(cents / 100).toFixed(2)}`;
const fmtDate = (d: string) => new Date(d).toLocaleString('es-ES', { dateStyle: 'short', timeStyle: 'short' });
return (
<div className="p-8 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Pagos</h1>
<p className="text-sm text-gray-500 mt-0.5">{total > 0 ? `${total} transacción${total !== 1 ? 'es' : ''}` : ''}</p>
</div>
{msg && <span className="text-sm text-green-600 bg-green-50 px-3 py-1 rounded-full">{msg}</span>}
</div>
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-xs">
<input type="text" placeholder="Buscar por ID de pago..." value={filter} onChange={e => setFilter(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">🔍</span>
</div>
</div>
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-16 text-gray-400 text-sm">Cargando...</div>
) : error ? (
<div className="flex items-center justify-center py-16 text-red-500 text-sm">{error}</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400 text-sm gap-2">
<span className="text-3xl">💳</span><span>Sin transacciones</span>
</div>
) : (
<>
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Fecha</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Importe</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Estado</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Provider</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">ID Pago</th>
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{items.map(txn => (
<tr key={txn.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 text-sm text-gray-500 whitespace-nowrap">{fmtDate(txn.createdAt)}</td>
<td className="px-6 py-4 text-sm font-semibold text-gray-800">{fmt(txn.amountCents)}</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[txn.status] ?? 'bg-gray-100 text-gray-600'}`}>
{txn.status}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-600">{txn.provider}</td>
<td className="px-6 py-4 text-xs font-mono text-gray-400 max-w-[120px] truncate">{txn.providerPaymentId ?? '—'}</td>
<td className="px-6 py-4 text-right">
{txn.status === 'succeeded' && (
<button onClick={() => handleRefund(txn.id)}
className="text-sm text-amber-600 hover:text-amber-700 font-medium px-3 py-1.5 rounded-lg hover:bg-amber-50 transition-colors">
Reembolsar
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
{total > PAGE_SIZE && (
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200">
<span className="text-sm text-gray-500">{page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, total)} de {total}</span>
<div className="flex gap-2">
<button disabled={page === 0} onClick={() => setPage(p => p - 1)}
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">Anterior</button>
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => setPage(p => p + 1)}
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">Siguiente</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { ProductEditor } from '@/features/products/components/ProductEditor';
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function ProductEditPage({ params }: PageProps) {
const { id } = await params;
return <ProductEditor productId={id} />;
}

View File

@@ -0,0 +1,5 @@
import { ProductEditor } from '@/features/products/components/ProductEditor';
export default function NewProductPage() {
return <ProductEditor />;
}

View File

@@ -0,0 +1,224 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import type { Product } from '@/types';
import { productsApi } from '@/lib/api-client';
const PAGE_SIZE = 20;
function formatPrice(cents?: number) {
if (cents == null) return '—';
return `${(cents / 100).toFixed(2)}`;
}
function StateBadge({ state }: { state: string }) {
const map: Record<string, { label: string; cls: string }> = {
active: { label: 'Activo', cls: 'bg-green-100 text-green-800' },
archived: { label: 'Archivado', cls: 'bg-gray-100 text-gray-600' },
draft: { label: 'Borrador', cls: 'bg-amber-100 text-amber-800' },
};
const { label, cls } = map[state] ?? { label: state, cls: 'bg-gray-100 text-gray-600' };
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${cls}`}>
{label}
</span>
);
}
export default function ProductsPage() {
const router = useRouter();
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [page, setPage] = useState(0);
const [total, setTotal] = useState(0);
// Debounce search
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 400);
return () => clearTimeout(t);
}, [search]);
// Reset page on search change
useEffect(() => {
setPage(0);
}, [debouncedSearch]);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await productsApi.list({
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
q: debouncedSearch || undefined,
});
setProducts(data.items ?? []);
setTotal(data.items?.length ?? 0);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar');
} finally {
setLoading(false);
}
}, [page, debouncedSearch]);
useEffect(() => { load(); }, [load]);
const totalPages = Math.ceil(total / PAGE_SIZE) || 1;
return (
<div className="p-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Productos</h1>
<p className="text-sm text-gray-500 mt-0.5">{total} productos</p>
</div>
<Link
href="/products/new"
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl transition-colors"
>
+ Crear producto
</Link>
</div>
{/* Search */}
<div className="mb-6">
<div className="relative max-w-md">
<input
type="search"
placeholder="Buscar por nombre..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none"
/>
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
</div>
</div>
{/* Table */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{loading ? (
<div className="p-8 text-center text-gray-400">
<div className="inline-block animate-spin h-5 w-5 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full" />
<p className="mt-2 text-sm">Cargando...</p>
</div>
) : error ? (
<div className="p-8 text-center">
<p className="text-red-600 text-sm mb-3">{error}</p>
<button
onClick={load}
className="text-sm text-[#2D6A4F] hover:underline"
>
Reintentar
</button>
</div>
) : products.length === 0 ? (
<div className="p-12 text-center">
<p className="text-4xl mb-3">📦</p>
<p className="text-gray-500 text-sm">
{debouncedSearch ? 'No hay productos para esta búsqueda' : 'No hay productos'}
</p>
</div>
) : (
<>
<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">
Estado
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{products.map((p) => (
<tr
key={p.id}
className="hover:bg-gray-50 transition-colors cursor-pointer"
onClick={() => router.push(`/products/${p.id}`)}
>
<td className="px-4 py-3.5">
<div className="flex items-center gap-3">
{p.imageUrl ? (
<img
src={p.imageUrl}
alt={p.name}
className="w-10 h-10 rounded-lg object-cover bg-gray-100 flex-shrink-0"
/>
) : (
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center text-lg flex-shrink-0">
🌿
</div>
)}
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900 truncate max-w-xs">
{p.name}
</p>
<p className="text-xs text-gray-400 truncate max-w-xs">
{p.description?.slice(0, 60) ?? p.slug}
</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<p className="text-sm text-gray-600">{p.brand?.name ?? '—'}</p>
</td>
<td className="px-4 py-3">
<StateBadge state={p.state} />
</td>
<td className="px-4 py-3">
<span className="text-gray-300"></span>
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200 bg-gray-50">
<p className="text-sm text-gray-500">
Página {page + 1} de {totalPages}
</p>
<div className="flex gap-2">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-white transition-colors"
>
Anterior
</button>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-white transition-colors"
>
Siguiente
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,137 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { promotionsApi } from '@/lib/api-client';
interface Promo { code: string; type: string; value: number; startsAt: string; endsAt: string; active: boolean; usageLimit: number | null; usageCount: number; }
export default function PromotionsPage() {
const [items, setItems] = useState<Promo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showForm, setShowForm] = useState(false);
const [msg, setMsg] = useState('');
const [form, setForm] = useState({
code: '', type: 'percent', value: '',
startsAt: new Date().toISOString().split('T')[0],
endsAt: new Date(Date.now() + 30 * 86400000).toISOString().split('T')[0],
usageLimit: '',
});
const load = useCallback(async () => {
setLoading(true);
try {
const d = await promotionsApi.list() as { items: Promo[] };
setItems(d.items ?? []);
} catch (e) { setError(e instanceof Error ? e.message : 'Error'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const handleSave = async () => {
setMsg('');
try {
await promotionsApi.create({
code: form.code,
type: form.type,
value: parseInt(form.value, 10),
startsAt: new Date(form.startsAt).toISOString(),
endsAt: new Date(form.endsAt).toISOString(),
usageLimit: form.usageLimit ? parseInt(form.usageLimit, 10) : null,
});
setMsg('Promoción creada');
setShowForm(false);
load();
} catch (e) { setMsg(e instanceof Error ? e.message : 'Error'); }
};
const toggleActive = async (code: string, currentActive: boolean) => {
try {
await promotionsApi.update(code, { active: !currentActive });
load();
} catch (e) { alert(e instanceof Error ? e.message : 'Error'); }
};
const handleDelete = async (code: string) => {
if (!confirm(`¿Eliminar "${code}"?`)) return;
try { await promotionsApi.delete(code); load(); }
catch (e) { alert(e instanceof Error ? e.message : 'Error'); }
};
return (
<div className="p-8 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Promociones</h1>
<button onClick={() => setShowForm(true)} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl">+ Nueva promoción</button>
</div>
{msg && <div className={`p-4 rounded-xl text-sm ${msg.startsWith('Error') ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>{msg}</div>}
{showForm && (
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
<h2 className="font-semibold text-gray-900">Nueva promoción</h2>
<div className="grid grid-cols-2 gap-4">
{[['code','Código *','text'],['value','Valor *','number']].map(([k, label, t]) => (
<div key={k}>
<label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
<input type={t} value={(form as Record<string,string>)[k]} onChange={e => setForm({...form, [k]: e.target.value})} className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
))}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Tipo</label>
<select value={form.type} onChange={e => setForm({...form, type: e.target.value})} className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm bg-white focus:ring-2 focus:ring-[#2D6A4F] outline-none">
<option value="percent">Porcentaje</option><option value="fixed_amount">Cantidad fija</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Límite de uso</label>
<input type="number" value={form.usageLimit} onChange={e => setForm({...form, usageLimit: e.target.value})} placeholder="Sin límite" className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Fecha inicio</label>
<input type="date" value={form.startsAt} onChange={e => setForm({...form, startsAt: e.target.value})} className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Fecha fin</label>
<input type="date" value={form.endsAt} onChange={e => setForm({...form, endsAt: e.target.value})} className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
</div>
<div className="flex gap-3">
<button onClick={handleSave} className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl">Crear</button>
<button onClick={() => setShowForm(false)} className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl">Cancelar</button>
</div>
</div>
)}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{loading ? <div className="p-12 text-center text-gray-400">Cargando...</div> :
error ? <div className="p-8 text-center text-red-600">{error}</div> :
items.length === 0 ? <div className="p-12 text-center text-gray-400">No hay promociones</div> :
<table className="w-full">
<thead><tr className="bg-gray-50 border-b border-gray-200">
{['Código','Tipo','Valor','Activa','Límite','Usos','Fin'].map(h => <th key={h} className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">{h}</th>)}
<th className="px-4 py-3"></th>
</tr></thead>
<tbody className="divide-y divide-gray-50">
{items.map(p => (
<tr key={p.code} className="hover:bg-gray-50">
<td className="px-4 py-3.5 font-mono text-sm font-medium text-gray-900">{p.code}</td>
<td className="px-4 py-3.5 text-sm text-gray-600">{p.type === 'percent' ? '%' : 'Fijo'}</td>
<td className="px-4 py-3.5 text-sm font-medium text-gray-900">{p.type === 'percent' ? `${p.value / 100}%` : `${(p.value / 100).toFixed(2)}`}</td>
<td className="px-4 py-3.5">
<button onClick={() => toggleActive(p.code, p.active)} className={`px-2 py-0.5 rounded-full text-xs font-medium ${p.active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>{p.active ? 'Sí' : 'No'}</button>
</td>
<td className="px-4 py-3.5 text-sm text-gray-500">{p.usageLimit ?? '∞'}</td>
<td className="px-4 py-3.5 text-sm text-gray-500">{p.usageCount}</td>
<td className="px-4 py-3.5 text-sm text-gray-500">{new Date(p.endsAt).toLocaleDateString('es-ES')}</td>
<td className="px-4 py-3.5"><button onClick={() => handleDelete(p.code)} className="text-xs text-red-600 hover:underline">Eliminar</button></td>
</tr>
))}
</tbody>
</table>
}
</div>
</div>
);
}

View File

@@ -0,0 +1,96 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { reviewsApi } from '@/lib/api-client';
interface Review {
id: string; productId: string; userId: string; orderId: string;
rating: number; title: string; body: string; status: string; createdAt: string;
}
const STATUS_LABELS: Record<string, string> = { pending: 'Pendiente', published: 'Publicada', rejected: 'Rechazada' };
const STATUS_CLS: Record<string, string> = {
pending: 'bg-amber-100 text-amber-700',
published: 'bg-green-100 text-green-700',
rejected: 'bg-red-100 text-red-700',
};
function Stars({ n }: { n: number }) {
return (
<span className="text-amber-400 text-sm">
{'★'.repeat(n)}{'☆'.repeat(5 - n)}
</span>
);
}
export default function ReviewsPage() {
const [items, setItems] = useState<Review[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filter, setFilter] = useState<string>('');
const load = useCallback(async () => {
setLoading(true);
try {
const d = await reviewsApi.listAdmin({ status: filter || undefined, limit: 50 }) as { items: Review[]; total: number };
setItems(d.items ?? []);
setTotal(d.total ?? 0);
} catch (e) { setError(e instanceof Error ? e.message : 'Error'); }
finally { setLoading(false); }
}, [filter]);
useEffect(() => { load(); }, [load]);
const moderate = async (id: string, status: 'published' | 'rejected') => {
try {
await reviewsApi.moderate(id, status);
load();
} catch (e) { alert(e instanceof Error ? e.message : 'Error'); }
};
return (
<div className="p-8 space-y-6">
<div className="flex items-center justify-between">
<div><h1 className="text-2xl font-bold text-gray-900">Reseñas</h1><p className="text-sm text-gray-500 mt-0.5">{total} reseñas pendientes de moderación</p></div>
<div className="flex gap-2">
{['', 'pending', 'published', 'rejected'].map(s => (
<button key={s} onClick={() => setFilter(s)} className={`px-3 py-1.5 rounded-lg text-xs font-medium ${filter === s ? 'bg-[#2D6A4F] text-white' : 'bg-white border border-gray-300 text-gray-600 hover:bg-gray-50'}`}>
{s ? STATUS_LABELS[s] : 'Todas'}
</button>
))}
</div>
</div>
<div className="space-y-4">
{loading ? <div className="p-12 text-center text-gray-400">Cargando...</div> :
error ? <div className="p-8 text-center text-red-600">{error}</div> :
items.length === 0 ? <div className="p-12 text-center text-gray-400">No hay reseñas</div> :
items.map(r => (
<div key={r.id} className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-start justify-between mb-3">
<div>
<Stars n={r.rating} />
<p className="font-semibold text-gray-900 text-sm mt-1">{r.title}</p>
<p className="text-xs text-gray-400 mt-0.5">{new Date(r.createdAt).toLocaleString('es-ES')}</p>
</div>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_CLS[r.status] ?? 'bg-gray-100 text-gray-600'}`}>{STATUS_LABELS[r.status] ?? r.status}</span>
</div>
<p className="text-sm text-gray-600 leading-relaxed mb-4">{r.body}</p>
{r.status === 'pending' && (
<div className="flex gap-3">
<button onClick={() => moderate(r.id, 'published')} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-xs font-semibold rounded-lg"> Publicar</button>
<button onClick={() => moderate(r.id, 'rejected')} className="px-4 py-2 border border-red-200 text-red-600 hover:bg-red-50 text-xs font-semibold rounded-lg"> Rechazar</button>
</div>
)}
{r.status !== 'pending' && (
<button onClick={() => moderate(r.id, r.status === 'published' ? 'rejected' : 'published')} className="text-xs text-gray-400 hover:text-gray-600">
{r.status === 'published' ? 'Despublicar' : 'Aprobar'}
</button>
)}
</div>
))
}
</div>
</div>
);
}

View File

@@ -0,0 +1,109 @@
'use client';
import { useState, useEffect } from 'react';
import { settingsApi, type StoreSettings } from '@/lib/api-client';
type FormData = StoreSettings;
export default function SettingsPage() {
const [data, setData] = useState<FormData | null>(null);
const [form, setForm] = useState<FormData | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState('');
const [err, setErr] = useState('');
useEffect(() => {
settingsApi.get().then(d => {
setData(d); setForm(d);
}).catch(() => setErr('Error al cargar ajustes')).finally(() => setLoading(false));
}, []);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!form) return;
setSaving(true); setErr(''); setMsg('');
try {
const updated = await settingsApi.update(form);
setData(updated); setForm(updated);
setMsg('Cambios guardados correctamente');
setTimeout(() => setMsg(''), 4000);
} catch (er) {
setErr(er instanceof Error ? er.message : 'Error al guardar');
} finally {
setSaving(false);
}
};
const field = (key: keyof FormData, label: string, opts?: { type?: string; placeholder?: string; rows?: number }) => (
<div key={key}>
<label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
{opts?.rows ? (
<textarea value={form?.[key] ?? ''} onChange={e => setForm(f => f ? { ...f, [key]: e.target.value } : f)}
rows={opts.rows} placeholder={opts.placeholder}
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none resize-none" />
) : (
<input type={opts?.type ?? 'text'} value={form?.[key] ?? ''}
onChange={e => setForm(f => f ? { ...f, [key]: e.target.value } : f)}
placeholder={opts?.placeholder} maxLength={key === 'contactAddress' ? 400 : 200}
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
)}
</div>
);
return (
<div className="p-8 space-y-6 max-w-3xl">
<div>
<h1 className="text-2xl font-bold text-gray-900">Ajustes de tienda</h1>
<p className="text-sm text-gray-500 mt-0.5">Configuración general de la tienda visible para los clientes.</p>
</div>
{msg && <div className="bg-green-50 text-green-700 text-sm px-4 py-3 rounded-xl border border-green-200">{msg}</div>}
{err && <div className="bg-red-50 text-red-700 text-sm px-4 py-3 rounded-xl border border-red-200">{err}</div>}
{loading ? (
<div className="bg-white rounded-2xl border border-gray-200 p-12 flex items-center justify-center text-gray-400 text-sm">Cargando...</div>
) : (
<form onSubmit={handleSave} className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
<h2 className="text-base font-semibold text-gray-800">Información general</h2>
</div>
<div className="p-6 space-y-5">
{field('storeName', 'Nombre de la tienda', { placeholder: 'Mercado de Vida' })}
{field('storeTagline', 'Eslogan', { placeholder: 'Productos naturales y ecológicos' })}
</div>
<div className="px-6 py-4 bg-gray-50 border-t border-b border-gray-200">
<h2 className="text-base font-semibold text-gray-800">Contacto</h2>
</div>
<div className="p-6 space-y-5">
{field('contactEmail', 'Email de contacto', { type: 'email', placeholder: 'info@mercadodevida.es' })}
{field('contactPhone', 'Teléfono', { placeholder: '+34 600 000 000' })}
{field('contactAddress', 'Dirección', { placeholder: 'Calle ejemplo, ciudad', rows: 3 })}
</div>
<div className="px-6 py-4 bg-gray-50 border-t border-b border-gray-200">
<h2 className="text-base font-semibold text-gray-800">Redes sociales</h2>
</div>
<div className="p-6 space-y-5">
{field('facebookUrl', 'Facebook', { placeholder: 'https://facebook.com/...' })}
{field('instagramUrl', 'Instagram', { placeholder: 'https://instagram.com/...' })}
</div>
<div className="px-6 py-4 bg-gray-50 border-t border-b border-gray-200">
<h2 className="text-base font-semibold text-gray-800">Footer</h2>
</div>
<div className="p-6 space-y-5">
{field('footerText', 'Texto del pie de página', { placeholder: '© 2026 Mercado de Vida...', rows: 2 })}
</div>
<div className="px-6 py-5 bg-gray-50 border-t border-gray-200 flex justify-end">
<button type="submit" disabled={saving || !form}
className="px-6 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
{saving ? 'Guardando...' : 'Guardar cambios'}
</button>
</div>
</form>
)}
</div>
);
}

View File

@@ -0,0 +1,289 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { shippingApi, type ShippingZone, type ShippingMethod } from '@/lib/api-client';
type Tab = 'zones' | 'methods';
// ── Zone helpers ──────────────────────────────────────────────────────────────────
function ZoneRow({ zone, onEdit, onDelete }: { zone: ShippingZone; onEdit: () => void; onDelete: () => void }) {
return (
<tr className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 text-sm font-medium text-gray-900">{zone.name}</td>
<td className="px-6 py-4 text-sm text-gray-600">{zone.country}</td>
<td className="px-6 py-4 text-sm text-gray-500">{zone.postalCodePrefix ?? <span className="italic">Todos</span>}</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${zone.active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
{zone.active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td className="px-6 py-4 text-right">
<button onClick={onEdit} className="text-sm text-[#2D6A4F] hover:text-[#1B4332] font-medium px-3 py-1.5 rounded-lg hover:bg-green-50 mr-1">Editar</button>
<button onClick={onDelete} className="text-sm text-red-600 hover:text-red-700 font-medium px-3 py-1.5 rounded-lg hover:bg-red-50">Eliminar</button>
</td>
</tr>
);
}
function ZoneForm({ zone, onSave, onCancel }: { zone?: ShippingZone; onSave: () => void; onCancel: () => void }) {
const [name, setName] = useState(zone?.name ?? '');
const [country, setCountry] = useState(zone?.country ?? '');
const [prefix, setPrefix] = useState(zone?.postalCodePrefix ?? '');
const [active, setActive] = useState(zone?.active ?? true);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState('');
const handle = async (e: React.FormEvent) => {
e.preventDefault(); setSaving(true); setErr('');
try {
if (zone) {
await shippingApi.updateZone(zone.id, { name, country, postalCodePrefix: prefix || null, active });
} else {
await shippingApi.createZone({ name, country, postalCodePrefix: prefix || null, active });
}
onSave(); onCancel();
} catch (er) { setErr(er instanceof Error ? er.message : 'Error'); } finally { setSaving(false); }
};
return (
<tr className="bg-green-50/50 border-b border-green-100">
<td className="px-4 py-3"><input value={name} onChange={e => setName(e.target.value)} required placeholder="Nombre zona"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
<td className="px-4 py-3"><input value={country} onChange={e => setCountry(e.target.value)} required placeholder="ES, FR..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
<td className="px-4 py-3"><input value={prefix} onChange={e => setPrefix(e.target.value)} placeholder="Ej: 28"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
<td className="px-4 py-3">
<select value={String(active)} onChange={e => setActive(e.target.value === 'true')}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
<option value="true">Activo</option><option value="false">Inactivo</option></select></td>
<td className="px-4 py-3">
<div className="flex gap-1">
<button disabled={saving} onClick={handle}
className="px-3 py-1.5 bg-[#2D6A4F] text-white text-xs font-medium rounded-lg hover:bg-[#1B4332] disabled:opacity-50">
{saving ? '...' : 'Guardar'}
</button>
<button onClick={onCancel}
className="px-3 py-1.5 border border-gray-300 text-gray-600 text-xs rounded-lg hover:bg-white">Cancelar</button>
</div>
{err && <p className="text-xs text-red-600 mt-1">{err}</p>}
</td>
</tr>
);
}
// ── Method helpers ───────────────────────────────────────────────────────────────
function MethodRow({ method, onEdit, onDelete }: { method: ShippingMethod; onEdit: () => void; onDelete: () => void }) {
const fmt = (cents: number) => `${(cents / 100).toFixed(2)}`;
return (
<tr className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 text-sm font-medium text-gray-900">{method.name}</td>
<td className="px-6 py-4 text-sm text-gray-600">{method.zoneName}</td>
<td className="px-6 py-4 text-sm font-semibold text-gray-800">{fmt(method.baseCostCents)}</td>
<td className="px-6 py-4 text-sm text-gray-500">{method.freeShippingThresholdCents ? `Gratis desde ${fmt(method.freeShippingThresholdCents)}` : '—'}</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${method.active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
{method.active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td className="px-6 py-4 text-right">
<button onClick={onEdit} className="text-sm text-[#2D6A4F] hover:text-[#1B4332] font-medium px-3 py-1.5 rounded-lg hover:bg-green-50 mr-1">Editar</button>
<button onClick={onDelete} className="text-sm text-red-600 hover:text-red-700 font-medium px-3 py-1.5 rounded-lg hover:bg-red-50">Eliminar</button>
</td>
</tr>
);
}
function MethodForm({ zones, method, onSave, onCancel }: { zones: ShippingZone[]; method?: ShippingMethod; onSave: () => void; onCancel: () => void }) {
const [name, setName] = useState(method?.name ?? '');
const [zoneId, setZoneId] = useState(method?.zoneId ?? zones[0]?.id ?? '');
const [cost, setCost] = useState(method ? String(method.baseCostCents / 100) : '');
const [threshold, setThreshold] = useState(method?.freeShippingThresholdCents ? String(method.freeShippingThresholdCents / 100) : '');
const [active, setActive] = useState(method?.active ?? true);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState('');
const handle = async (e: React.FormEvent) => {
e.preventDefault(); setSaving(true); setErr('');
try {
const baseCostCents = Math.round(parseFloat(cost) * 100);
const freeThreshold = threshold ? Math.round(parseFloat(threshold) * 100) : null;
if (method) {
await shippingApi.updateMethod(method.id, { name, baseCostCents, freeShippingThresholdCents: freeThreshold, active });
} else {
await shippingApi.createMethod({ zoneId, name, baseCostCents, freeShippingThresholdCents: freeThreshold, active });
}
onSave(); onCancel();
} catch (er) { setErr(er instanceof Error ? er.message : 'Error'); } finally { setSaving(false); }
};
return (
<tr className="bg-green-50/50 border-b border-green-100">
<td className="px-4 py-3"><input value={name} onChange={e => setName(e.target.value)} required placeholder="Nombre método"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
<td className="px-4 py-3">
<select value={zoneId} onChange={e => setZoneId(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
{zones.map(z => <option key={z.id} value={z.id}>{z.name}</option>)}
</select></td>
<td className="px-4 py-3"><input type="number" step="0.01" value={cost} onChange={e => setCost(e.target.value)} required placeholder="0.00"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
<td className="px-4 py-3"><input type="number" step="0.01" value={threshold} onChange={e => setThreshold(e.target.value)} placeholder="Sin gratis"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
<td className="px-4 py-3">
<select value={String(active)} onChange={e => setActive(e.target.value === 'true')}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
<option value="true">Activo</option><option value="false">Inactivo</option></select></td>
<td className="px-4 py-3">
<div className="flex gap-1">
<button disabled={saving} onClick={handle}
className="px-3 py-1.5 bg-[#2D6A4F] text-white text-xs font-medium rounded-lg hover:bg-[#1B4332] disabled:opacity-50">
{saving ? '...' : 'Guardar'}
</button>
<button onClick={onCancel}
className="px-3 py-1.5 border border-gray-300 text-gray-600 text-xs rounded-lg hover:bg-white">Cancelar</button>
</div>
{err && <p className="text-xs text-red-600 mt-1">{err}</p>}
</td>
</tr>
);
}
// ── Main page ─────────────────────────────────────────────────────────────────────
export default function ShippingPage() {
const [tab, setTab] = useState<Tab>('zones');
const [zones, setZones] = useState<ShippingZone[]>([]);
const [methods, setMethods] = useState<ShippingMethod[]>([]);
const [loading, setLoading] = useState(true);
const [editingZone, setEditingZone] = useState<ShippingZone | null>(null);
const [editingMethod, setEditingMethod] = useState<ShippingMethod | null>(null);
const [showZoneForm, setShowZoneForm] = useState(false);
const [showMethodForm, setShowMethodForm] = useState(false);
const [msg, setMsg] = useState('');
const loadZones = useCallback(async () => {
try { const d = await shippingApi.listZones(); setZones(d.items ?? []); }
catch { /* silent */ }
}, []);
const loadMethods = useCallback(async () => {
try { const d = await shippingApi.listMethods(); setMethods(d.items ?? []); }
catch { /* silent */ }
}, []);
const load = useCallback(async () => {
setLoading(true);
await Promise.all([loadZones(), loadMethods()]);
setLoading(false);
}, [loadZones, loadMethods]);
useEffect(() => { load(); }, [load]);
const handleDeleteZone = async (id: string) => {
if (!confirm('¿Eliminar esta zona y todos sus métodos?')) return;
try { await shippingApi.deleteZone(id); setMsg('Zona eliminada'); setTimeout(() => setMsg(''), 3000); loadZones(); }
catch (er) { alert(er instanceof Error ? er.message : 'Error'); }
};
const handleDeleteMethod = async (id: string) => {
if (!confirm('¿Eliminar este método de envío?')) return;
try { await shippingApi.deleteMethod(id); setMsg('Método eliminado'); setTimeout(() => setMsg(''), 3000); loadMethods(); }
catch (er) { alert(er instanceof Error ? er.message : 'Error'); }
};
return (
<div className="p-8 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Envíos</h1>
{msg && <span className="text-sm text-green-600 bg-green-50 px-3 py-1 rounded-full">{msg}</span>}
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-gray-200">
{([['zones', 'Zonas de envío'], ['methods', 'Métodos de envío']] as [Tab, string][]).map(([t, label]) => (
<button key={t} onClick={() => setTab(t)}
className={`px-5 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${tab === t ? 'border-[#2D6A4F] text-[#2D6A4F]' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
{label}
</button>
))}
</div>
{loading ? (
<div className="flex items-center justify-center py-16 text-gray-400 text-sm">Cargando...</div>
) : tab === 'zones' ? (
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b flex items-center justify-between">
<h2 className="text-base font-semibold text-gray-800">Zonas ({zones.length})</h2>
<button onClick={() => { setShowZoneForm(true); setEditingZone(null); }}
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl transition-colors">
+ Nueva zona
</button>
</div>
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Nombre</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">País</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">CP prefijo</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Estado</th>
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{showZoneForm && !editingZone && (
<ZoneForm onSave={loadZones} onCancel={() => setShowZoneForm(false)} />
)}
{editingZone && (
<ZoneForm zone={editingZone} onSave={() => { setEditingZone(null); loadZones(); }} onCancel={() => setEditingZone(null)} />
)}
{zones.length === 0 && !showZoneForm ? (
<tr><td colSpan={5} className="px-6 py-12 text-center text-gray-400 text-sm">Sin zonas de envío</td></tr>
) : zones.map(z => (
<ZoneRow key={z.id} zone={z} onEdit={() => { setEditingZone(z); setShowZoneForm(false); }}
onDelete={() => handleDeleteZone(z.id)} />
))}
</tbody>
</table>
</div>
) : (
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b flex items-center justify-between">
<h2 className="text-base font-semibold text-gray-800">Métodos ({methods.length})</h2>
<button onClick={() => { setShowMethodForm(true); setEditingMethod(null); }}
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl transition-colors"
disabled={zones.length === 0}>
+ Nuevo método
</button>
</div>
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Nombre</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Zona</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Coste</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Envío gratis</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Estado</th>
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{showMethodForm && !editingMethod && (
<MethodForm zones={zones} onSave={loadMethods} onCancel={() => setShowMethodForm(false)} />
)}
{editingMethod && (
<MethodForm zones={zones} method={editingMethod} onSave={() => { setEditingMethod(null); loadMethods(); }} onCancel={() => setEditingMethod(null)} />
)}
{methods.length === 0 && !showMethodForm ? (
<tr><td colSpan={6} className="px-6 py-12 text-center text-gray-400 text-sm">
{zones.length === 0 ? 'Crea primero una zona de envío' : 'Sin métodos de envío'}
</td></tr>
) : methods.map(m => (
<MethodRow key={m.id} method={m} onEdit={() => { setEditingMethod(m); setShowMethodForm(false); }}
onDelete={() => handleDeleteMethod(m.id)} />
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,128 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { taxApi, type TaxRate } from '@/lib/api-client';
export default function TaxRatesPage() {
const [rates, setRates] = useState<TaxRate[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [editRate, setEditRate] = useState('');
const [editActive, setEditActive] = useState(true);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState('');
const load = useCallback(async () => {
setLoading(true);
try { const d = await taxApi.list(); setRates(d.items ?? []); }
catch { /* silent */ }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const startEdit = (r: TaxRate) => {
setEditing(r.id); setEditName(r.name); setEditRate(String(r.ratePercent)); setEditActive(r.active);
};
const handleSave = async (id: string) => {
setSaving(true);
try {
await taxApi.update(id, { name: editName, ratePercent: parseFloat(editRate), active: editActive });
setEditing(null); setMsg('Tipo impositivo actualizado'); setTimeout(() => setMsg(''), 3000); load();
} catch (er) { alert(er instanceof Error ? er.message : 'Error'); }
finally { setSaving(false); }
};
const fmt = (r: TaxRate) => `${r.ratePercent}%`;
return (
<div className="p-8 space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Tipos impositivos (IVA)</h1>
<p className="text-sm text-gray-500 mt-0.5">Configura los tipos de IVA aplicables a los productos.</p>
</div>
{msg && <div className="bg-green-50 text-green-700 text-sm px-4 py-2.5 rounded-xl border border-green-200">{msg}</div>}
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b bg-gray-50">
<h2 className="text-base font-semibold text-gray-800">IVA en España (ES)</h2>
</div>
{loading ? (
<div className="flex items-center justify-center py-16 text-gray-400 text-sm">Cargando...</div>
) : (
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Nombre</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Tipo</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Tasa</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Estado</th>
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{rates.map(r => (
<tr key={r.id} className="hover:bg-gray-50 transition-colors">
{editing === r.id ? (
<>
<td className="px-4 py-3">
<input value={editName} onChange={e => setEditName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</td>
<td className="px-4 py-3 text-sm text-gray-500">{r.appliesTo}</td>
<td className="px-4 py-3">
<input type="number" step="0.01" value={editRate} onChange={e => setEditRate(e.target.value)}
className="w-24 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</td>
<td className="px-4 py-3">
<select value={String(editActive)} onChange={e => setEditActive(e.target.value === 'true')}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
<option value="true">Activo</option><option value="false">Inactivo</option></select>
</td>
<td className="px-4 py-3">
<div className="flex gap-1 justify-end">
<button disabled={saving} onClick={() => handleSave(r.id)}
className="px-3 py-1.5 bg-[#2D6A4F] text-white text-xs font-medium rounded-lg hover:bg-[#1B4332] disabled:opacity-50">
{saving ? '...' : 'Guardar'}
</button>
<button onClick={() => setEditing(null)}
className="px-3 py-1.5 border border-gray-300 text-gray-600 text-xs rounded-lg hover:bg-gray-50">Cancelar</button>
</div>
</td>
</>
) : (
<>
<td className="px-6 py-4 text-sm font-medium text-gray-900">{r.name}</td>
<td className="px-6 py-4 text-sm text-gray-500 capitalize">{r.appliesTo}</td>
<td className="px-6 py-4 text-sm font-bold text-gray-800">{fmt(r)}</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${r.active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
{r.active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td className="px-6 py-4 text-right">
<button onClick={() => startEdit(r)}
className="text-sm text-[#2D6A4F] hover:text-[#1B4332] font-medium px-3 py-1.5 rounded-lg hover:bg-green-50 transition-colors">
Editar
</button>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="bg-amber-50 rounded-xl border border-amber-200 px-5 py-4">
<p className="text-sm text-amber-800">
<strong>España:</strong> IVA General 21%, IVA Reducido 10%, IVA Superreducido 4%. Los tipos se aplican a los precios sin IVA (netos) del producto.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,236 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { adminUsersApi } from '@/lib/api-client';
interface AdminUser { id: string; email: string; role: string; createdAt: string; }
function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b">
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
</div>
<div className="p-6">{children}</div>
</div>
</div>
);
}
function CreateForm({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState('editor');
const [saving, setSaving] = useState(false);
const [err, setErr] = useState('');
const handle = async (e: React.FormEvent) => {
e.preventDefault(); setSaving(true); setErr('');
try { await adminUsersApi.create({ email, password, role }); onCreated(); onClose(); }
catch (er) { setErr(er instanceof Error ? er.message : 'Error'); } finally { setSaving(false); }
};
return (
<form onSubmit={handle} className="space-y-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">Email *</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">Contraseña *</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required minLength={8}
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">Rol *</label>
<select value={role} onChange={e => setRole(e.target.value)}
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
<option value="editor">Editor</option><option value="admin">Admin</option></select></div>
{err && <p className="text-sm text-red-600 bg-red-50 rounded-xl px-4 py-2">{err}</p>}
<div className="flex gap-3 pt-2">
<button type="submit" disabled={saving}
className="flex-1 px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
{saving ? 'Creando...' : 'Crear usuario'}</button>
<button type="button" onClick={onClose}
className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl hover:bg-gray-50">Cancelar</button>
</div>
</form>
);
}
function EditForm({ user, onClose, onSaved }: { user: AdminUser; onClose: () => void; onSaved: () => void }) {
const [role, setRole] = useState(user.role);
const [password, setPassword] = useState('');
const [saving, setSaving] = useState(false);
const [err, setErr] = useState('');
const handle = async (e: React.FormEvent) => {
e.preventDefault(); setSaving(true); setErr('');
try {
const data: { role?: string; password?: string } = { role };
if (password) data.password = password;
await adminUsersApi.update(user.id, data); onSaved(); onClose();
} catch (er) { setErr(er instanceof Error ? er.message : 'Error'); } finally { setSaving(false); }
};
return (
<form onSubmit={handle} className="space-y-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input type="email" value={user.email} disabled
className="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50 text-gray-400" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">Rol *</label>
<select value={role} onChange={e => setRole(e.target.value)}
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
<option value="admin">Admin</option><option value="editor">Editor</option></select></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">Nueva contraseña</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} minLength={8} placeholder="Dejar vacío para no cambiar"
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></div>
{err && <p className="text-sm text-red-600 bg-red-50 rounded-xl px-4 py-2">{err}</p>}
<div className="flex gap-3 pt-2">
<button type="submit" disabled={saving}
className="flex-1 px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
{saving ? 'Guardando...' : 'Guardar'}</button>
<button type="button" onClick={onClose}
className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl hover:bg-gray-50">Cancelar</button>
</div>
</form>
);
}
const PAGE_SIZE = 20;
const ROLE_COLORS: Record<string, string> = { admin: 'bg-purple-100 text-purple-700', editor: 'bg-amber-100 text-amber-700', customer: 'bg-blue-100 text-blue-700' };
export default function AdminUsersPage() {
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filterRole, setFilterRole] = useState('');
const [search, setSearch] = useState('');
const [debounced, setDebounced] = useState('');
const [page, setPage] = useState(0);
const [total, setTotal] = useState(0);
const [showCreate, setShowCreate] = useState(false);
const [editing, setEditing] = useState<AdminUser | null>(null);
const [msg, setMsg] = useState('');
useEffect(() => { const t = setTimeout(() => setDebounced(search), 400); return () => clearTimeout(t); }, [search]);
useEffect(() => { setPage(0); }, [debounced, filterRole]);
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const data = await adminUsersApi.list({ limit: PAGE_SIZE, offset: page * PAGE_SIZE, role: filterRole || undefined, q: debounced || undefined });
setUsers(data.items ?? []); setTotal(data.total ?? 0);
} catch (e) { setError(e instanceof Error ? e.message : 'Error'); } finally { setLoading(false); }
}, [page, debounced, filterRole]);
useEffect(() => { load(); }, [load]);
const handleDelete = async (id: string) => {
if (!confirm('¿Eliminar este usuario? No se puede deshacer.')) return;
try { await adminUsersApi.delete(id); setMsg('Usuario eliminado'); setTimeout(() => setMsg(''), 3000); load(); }
catch (er) { alert(er instanceof Error ? er.message : 'Error al eliminar'); }
};
return (
<div className="p-8 space-y-6">
<div className="flex items-center justify-between">
<div><h1 className="text-2xl font-bold text-gray-900">Usuarios backoffice</h1>
<p className="text-sm text-gray-500 mt-0.5">{total > 0 ? `${total} usuario${total !== 1 ? 's' : ''}` : ''}</p></div>
<button onClick={() => setShowCreate(true)}
className="flex items-center gap-2 px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl transition-colors">
+ Nuevo usuario</button>
</div>
{msg && <div className="bg-green-50 text-green-700 text-sm px-4 py-2.5 rounded-xl border border-green-200">{msg}</div>}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-xs">
<input type="text" placeholder="Buscar por email..." value={search} onChange={e => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">🔍</span>
</div>
<select value={filterRole} onChange={e => setFilterRole(e.target.value)}
className="px-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
<option value="">Todos los roles</option>
<option value="admin">Admin</option><option value="editor">Editor</option><option value="customer">Customer</option>
</select>
</div>
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-16 text-gray-400 text-sm">Cargando...</div>
) : error ? (
<div className="flex items-center justify-center py-16 text-red-500 text-sm">{error}</div>
) : users.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400 text-sm gap-2">
<span className="text-3xl">🔐</span><span>No hay usuarios backoffice</span>
</div>
) : (
<>
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Email</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Rol</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Creado</th>
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{users.map(u => (
<tr key={u.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 text-sm text-gray-900">{u.email}</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${ROLE_COLORS[u.role] ?? 'bg-gray-100 text-gray-600'}`}>
{u.role}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-500">{new Date(u.createdAt).toLocaleDateString('es-ES')}</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-1">
<button onClick={() => setEditing(u)}
className="p-2 text-gray-400 hover:text-[#2D6A4F] hover:bg-green-50 rounded-lg transition-colors" title="Editar">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button onClick={() => handleDelete(u.id)}
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="Eliminar">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{total > PAGE_SIZE && (
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200">
<span className="text-sm text-gray-500">
Mostrando {page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, total)} de {total}
</span>
<div className="flex gap-2">
<button disabled={page === 0} onClick={() => setPage(p => p - 1)}
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">
Anterior
</button>
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => setPage(p => p + 1)}
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">
Siguiente
</button>
</div>
</div>
)}
</>
)}
</div>
{showCreate && (
<Modal title="Nuevo usuario backoffice" onClose={() => setShowCreate(false)}>
<CreateForm onClose={() => setShowCreate(false)} onCreated={() => { setMsg('Usuario creado correctamente'); setTimeout(() => setMsg(''), 3000); load(); }} />
</Modal>
)}
{editing && (
<Modal title="Editar usuario" onClose={() => setEditing(null)}>
<EditForm user={editing} onClose={() => setEditing(null)} onSaved={() => { setMsg('Usuario actualizado'); setTimeout(() => setMsg(''), 3000); load(); }} />
</Modal>
)}
</div>
);
}

View File

@@ -0,0 +1,99 @@
import { NextRequest, NextResponse } from 'next/server';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
/**
* Catch-all proxy: forwards ALL requests to the backend API.
* This avoids CORS preflight issues since requests stay within the
* same origin (localhost:3004 -> localhost:3004 proxy -> 127.0.0.1:3000 backend).
*
* More specific routes (e.g. /api/auth/login) take precedence in Next.js,
* so they are NOT served by this handler.
*/
export async function GET(req: NextRequest) {
const path = req.nextUrl.pathname.replace('/api/', '');
const cookies = req.headers.get('cookie') ?? '';
try {
const backendRes = await fetch(`${API}/${path}`, {
headers: { Cookie: cookies },
});
const data = await backendRes.json().catch(() => null);
const resp = NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
return resp;
} catch {
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
}
}
export async function POST(req: NextRequest) {
const path = req.nextUrl.pathname.replace('/api/', '');
const cookies = req.headers.get('cookie') ?? '';
const body = await req.text();
try {
const backendRes = await fetch(`${API}/${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookies },
body,
});
const setCookie = backendRes.headers.get('set-cookie');
const data = await backendRes.json().catch(() => null);
const resp = NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
if (setCookie) {
resp.headers.set(
'Set-Cookie',
setCookie.replace(/;\s*Secure/gi, '').replace(/;\s*SameSite=Lax/gi, '').trim(),
);
}
return resp;
} catch {
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
}
}
export async function PATCH(req: NextRequest) {
const path = req.nextUrl.pathname.replace('/api/', '');
const cookies = req.headers.get('cookie') ?? '';
const body = await req.text();
try {
const backendRes = await fetch(`${API}/${path}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Cookie: cookies },
body,
});
const data = await backendRes.json().catch(() => null);
return NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
} catch {
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
}
}
export async function PUT(req: NextRequest) {
const path = req.nextUrl.pathname.replace('/api/', '');
const cookies = req.headers.get('cookie') ?? '';
const body = await req.text();
try {
const backendRes = await fetch(`${API}/${path}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Cookie: cookies },
body,
});
const data = await backendRes.json().catch(() => null);
return NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
} catch {
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
}
}
export async function DELETE(req: NextRequest) {
const path = req.nextUrl.pathname.replace('/api/', '');
const cookies = req.headers.get('cookie') ?? '';
try {
const backendRes = await fetch(`${API}/${path}`, {
method: 'DELETE',
headers: { Cookie: cookies },
});
return NextResponse.json({ ok: backendRes.ok }, { status: backendRes.status });
} catch {
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
}
}

View File

@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
/**
* Strip the Secure flag from the backend's Set-Cookie so the browser
* (which connects over HTTP) actually stores the session cookie.
* Also drop SameSite=Lax to avoid browser restrictions.
*/
function makeLocalhostCompatible(cookie: string): string {
return cookie
.replace(/;\s*Secure/gi, '')
.replace(/;\s*SameSite=Lax/gi, '')
.trim();
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { email, password } = body;
const backendRes = await fetch(`${API}/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 });
}
const setCookie = backendRes.headers.get('set-cookie');
const response = NextResponse.json(data, { status: 200 });
if (setCookie) {
response.headers.set('Set-Cookie', makeLocalhostCompatible(setCookie));
}
return response;
} catch {
return NextResponse.json(
{ statusCode: 500, code: 'SERVER_ERROR', message: 'Error del servidor' },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
export async function POST(req: NextRequest) {
try {
const cookies = req.headers.get('cookie') ?? '';
await fetch(`${API}/auth/logout`, {
method: 'POST',
headers: { Cookie: cookies },
});
} catch {
// Best-effort
}
const response = NextResponse.json({ ok: true });
response.cookies.delete('mdv_session');
return response;
}

View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from 'next/server';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
export async function GET(req: NextRequest) {
const cookies = req.headers.get('cookie') ?? '';
try {
const backendRes = await fetch(`${API}/auth/me`, {
headers: { Cookie: cookies },
});
if (!backendRes.ok) return NextResponse.json({ user: null });
return NextResponse.json(await backendRes.json());
} catch {
return NextResponse.json({ user: null });
}
}

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server';
import { writeFile, mkdir } from 'fs/promises';
import path from 'path';
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif'];
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get('file') as File | null;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json(
{ error: `Tipo no permitido. Usa: ${ALLOWED_TYPES.join(', ')}` },
{ status: 400 },
);
}
if (file.size > MAX_SIZE) {
return NextResponse.json({ error: 'El archivo excede 10MB' }, { status: 400 });
}
// Unique filename
const ext = file.name.split('.').pop() ?? 'jpg';
const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
const uploadDir = path.join(process.cwd(), 'public', 'uploads');
const filePath = path.join(uploadDir, filename);
await mkdir(uploadDir, { recursive: true });
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(filePath, buffer);
const url = `/uploads/${filename}`;
return NextResponse.json({ url, filename, size: file.size });
} catch (error) {
console.error('Upload error:', error);
return NextResponse.json({ error: 'Error al subir el archivo' }, { status: 500 });
}
}

View File

@@ -0,0 +1,51 @@
@import "tailwindcss";
@theme {
--color-primary: #2D6A4F;
--color-primary-dark: #1B4332;
--color-primary-light: #40916C;
--color-secondary: #F5F0E8;
--color-accent: #E76F51;
--color-text: #111827;
--color-muted: #6B7280;
--color-border: #E5E7EB;
--color-bg: #F9FAFB;
--color-surface: #FFFFFF;
--color-danger: #DC2626;
--color-warning: #D97706;
--color-success: #059669;
--font-sans: "Inter", system-ui, sans-serif;
--font-heading: "Playfair Display", Georgia, serif;
}
:root {
--background: #F9FAFB;
--foreground: #111827;
}
* {
box-sizing: border-box;
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #D1D5DB;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #9CA3AF;
}

View File

@@ -0,0 +1,18 @@
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: {
default: 'MercadoDeVida Admin',
template: '%s | MercadoDeVida Admin',
},
robots: { index: false, follow: false },
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="es" suppressHydrationWarning>
<body>{children}</body>
</html>
);
}

View File

@@ -0,0 +1,68 @@
'use client';
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import type { AuthUser, Role } from '@/types';
import { authApi } from '@/lib/api-client';
interface AuthContextValue {
user: AuthUser | null;
loading: boolean;
login: (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<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
// Load session on mount
useEffect(() => {
authApi
.me()
.then((data) => {
if ('id' in data) {
setUser({ id: data.id, email: data.email, role: data.role as Role });
}
})
.catch(() => setUser(null))
.finally(() => setLoading(false));
}, []);
const login = useCallback(async (email: string, password: string) => {
try {
const data = await authApi.login(email, password);
// The backend sets the session cookie via Set-Cookie header.
// We also set it client-side for immediate access.
setUser({ id: data.id, email: data.email, role: data.role as Role });
return { ok: true };
} catch (err: unknown) {
const msg =
err instanceof Error
? (err as { message?: string }).message ?? 'Error de login'
: 'Error de login';
return { ok: false, error: msg };
}
}, []);
const logout = useCallback(async () => {
try {
await authApi.logout();
} catch {
// ignore
}
setUser(null);
}, []);
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}

View File

@@ -0,0 +1,395 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useRouter } from 'next/navigation';
import type { Product, Brand, Category } from '@/types';
import { productsApi, brandsApi, categoriesApi } from '@/lib/api-client';
import { ImagesSection } from './sections/ImagesSection';
import { InventorySection } from './sections/InventorySection';
import { PricingSection } from './sections/PricingSection';
interface ProductEditorProps {
productId?: string;
}
const ATTRIBUTE_LABELS: Record<string, string> = {
bio: '🌿 Bio',
'comercio-justo': '⚖️ Comercio Justo',
congelado: '❄️ Congelado',
'cruelty-free': '🐰 Cruelty Free',
'de-temporada': '🍂 De Temporada',
demeter: '🌱 Demeter',
'fruta-verdura': '🥕 Fruta y Verdura',
keto: '🥑 Keto',
kosher: '✡️ Kosher',
'low-carb': '🍖 Low Carb',
'raw-food': '🥗 Raw Food',
'sin-azucar': '🚫 Sin Azúcar',
'sin-gluten': '🌾 Sin Gluten',
'sin-lactosa': '🥛 Sin Lactosa',
vegano: '🌱 Vegano',
'zero-waste': '♻️ Zero Waste',
};
const CHANNEL_OPTIONS = [
{ value: 'all', label: 'Todos los canales' },
{ value: 'online', label: 'Solo online' },
{ value: 'offline', label: 'Solo offline' },
] as const;
function slugify(text: string): string {
return text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
export function ProductEditor({ productId }: ProductEditorProps) {
const router = useRouter();
const isCreate = !productId;
const [tab, setTab] = useState<'general' | 'pricing' | 'inventory' | 'images' | 'seo' | 'publish'>('general');
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(!isCreate);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [slugManual, setSlugManual] = useState(false);
const [desc, setDesc] = useState('');
const [brandId, setBrandId] = useState('');
const [categoryIds, setCategoryIds] = useState<string[]>([]);
const [channels, setChannels] = useState<'online' | 'offline' | 'all'>('all');
const [featured, setFeatured] = useState(false);
const [attributes, setAttributes] = useState<string[]>([]);
const [state, setState] = useState('active');
const [seoTitle, setSeoTitle] = useState('');
const [seoTitleManual, setSeoTitleManual] = useState(false);
const [seoDesc, setSeoDesc] = useState('');
const [seoDescManual, setSeoDescManual] = useState(false);
const [brands, setBrands] = useState<Brand[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
useEffect(() => {
brandsApi.list().then(({ items }) => setBrands(items ?? [])).catch(() => {});
categoriesApi.list().then((data) => {
const tree = (data as { items?: Category[] }).items ?? [];
const flat = (cats: Category[]): Category[] => cats.flatMap(c => [c, ...flat(c.children ?? [])]);
setCategories(flat(tree));
}).catch(() => {});
}, []);
const snapRef = useRef('');
const dirtyRef = useRef(false);
const getSnap = useCallback(() => JSON.stringify({
name, slug, desc, brandId, categoryIds, channels, featured, attributes, state, seoTitle, seoDesc,
}), [name, slug, desc, brandId, categoryIds, channels, featured, attributes, state, seoTitle, seoDesc]);
useEffect(() => {
if (!productId) { setLoading(false); return; }
productsApi.get(productId).then((p: Product) => {
setName(p.name); setSlug(p.slug); setDesc(p.description ?? '');
setBrandId(p.brandId ?? ''); setCategoryIds(p.categoryIds ?? []);
setChannels((p as any).channels ?? 'all');
setFeatured((p as any).featured ?? false);
setAttributes((p as any).attributes ?? []);
setState(p.state);
setSeoTitle((p as any).seoTitle ?? ''); setSeoTitleManual(true);
setSeoDesc((p as any).seoDescription ?? ''); setSeoDescManual(true);
snapRef.current = getSnap();
setLoading(false);
}).catch(() => { setError('No se pudo cargar el producto'); setLoading(false); });
}, [productId, getSnap]);
useEffect(() => {
if (loading) return;
dirtyRef.current = getSnap() !== snapRef.current;
}, [name, slug, desc, brandId, categoryIds, channels, featured, attributes, state, seoTitle, seoDesc, loading, getSnap]);
useEffect(() => {
const h = (e: BeforeUnloadEvent) => { if (dirtyRef.current) { e.preventDefault(); e.returnValue = ''; } };
window.addEventListener('beforeunload', h);
return () => window.removeEventListener('beforeunload', h);
}, []);
const handleNameChange = (v: string) => {
setName(v);
if (!slugManual) setSlug(slugify(v));
if (!seoTitleManual) setSeoTitle(v);
if (!seoDescManual) setSeoDesc(`${v} — Compra online en MercadoDeVida. Productos naturales y ecológicos.`);
};
const handleSave = async () => {
setSaving(true); setError(''); setSuccess('');
try {
const payload = {
name, slug,
description: desc || undefined,
brandId: brandId || undefined,
categoryIds,
channels,
featured,
attributes,
state,
seoTitle: seoTitle || undefined,
seoDescription: seoDesc || undefined,
};
let saved: Product;
if (isCreate) saved = await productsApi.create(payload);
else saved = await productsApi.update(productId, payload);
snapRef.current = getSnap();
dirtyRef.current = false;
setSuccess(isCreate ? '¡Producto creado!' : 'Cambios guardados');
if (isCreate) router.push(`/products/${saved.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al guardar');
} finally {
setSaving(false);
}
};
const toggleAttr = (key: string) => {
setAttributes(prev => prev.includes(key) ? prev.filter(a => a !== key) : [...prev, key]);
};
const saveState = async (s: string) => {
setState(s);
if (productId) {
try {
await productsApi.setState(productId, s as 'active' | 'archived');
setSuccess(`Estado actualizado a: ${s}`);
} catch {
setError('Error al cambiar estado');
}
}
};
if (loading) return (
<div className="p-8 flex justify-center">
<div className="h-6 w-6 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full animate-spin" />
</div>
);
return (
<div className="p-8 max-w-4xl">
<div className="flex items-center justify-between mb-8">
<div>
<button onClick={() => router.push('/products')} className="text-sm text-gray-500 hover:text-gray-700 mb-1 flex items-center gap-1"> Productos</button>
<h1 className="text-2xl font-bold text-gray-900">{isCreate ? 'Nuevo producto' : `Editar: ${name}`}</h1>
</div>
<button onClick={handleSave} disabled={saving}
className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
{saving ? 'Guardando...' : isCreate ? 'Crear producto' : 'Guardar cambios'}
</button>
</div>
{error && <div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{error}</div>}
{success && <div className="mb-4 p-4 bg-green-50 border border-green-200 rounded-xl text-sm text-green-700">{success}</div>}
{/* Tabs */}
<div className="hidden md:flex border-b border-gray-200 mb-8">
{(['general', 'pricing', 'inventory', 'images', 'seo', 'publish'] as const).map(t => (
<button key={t} onClick={() => setTab(t)}
className={`px-5 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
tab === t ? 'border-[#2D6A4F] text-[#2D6A4F]' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}>
{t === 'general' ? 'General' : t === 'pricing' ? 'Precios' : t === 'inventory' ? 'Inventario' : t === 'images' ? 'Imágenes' : t === 'seo' ? 'SEO' : 'Publicar'}
</button>
))}
</div>
{/* ── GENERAL ── */}
{tab === 'general' && (
<section className="space-y-6">
<div>
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Nombre del producto *</label>
<input type="text" value={name} onChange={e => handleNameChange(e.target.value)} required
placeholder="Ej: Almendras Crudas Ecológicas"
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-semibold text-gray-900">Slug (URL)</label>
<span className={`text-xs ${slugManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
{slugManual ? 'editado manualmente' : 'auto-generado'}
</span>
</div>
<input type="text" value={slug}
onChange={e => { setSlugManual(true); setSlug(e.target.value); }}
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
<div>
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Descripción</label>
<textarea value={desc} onChange={e => setDesc(e.target.value)} rows={4}
placeholder="Descripción detallada del producto..."
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none resize-none" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div>
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Marca</label>
<select value={brandId} onChange={e => setBrandId(e.target.value)}
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white">
<option value="">Sin marca</option>
{brands.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Canal de venta</label>
<select value={channels} onChange={e => setChannels(e.target.value as typeof channels)}
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white">
{CHANNEL_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
</div>
<div>
<label className="flex items-center gap-2 mb-3">
<input type="checkbox" checked={featured} onChange={e => setFeatured(e.target.checked)}
className="rounded text-[#2D6A4F] focus:ring-[#2D6A4F]" />
<span className="text-sm font-semibold text-gray-900"> Producto destacado</span>
<span className="text-xs text-gray-400">(aparece en la home)</span>
</label>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<label className="text-sm font-semibold text-gray-900">Categorías</label>
</div>
<div className="border border-gray-200 rounded-xl p-3 space-y-2 max-h-52 overflow-y-auto">
{categories.map(cat => (
<label key={cat.id} className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={categoryIds.includes(cat.id)}
onChange={e => {
if (e.target.checked) setCategoryIds(prev => [...prev, cat.id]);
else setCategoryIds(prev => prev.filter(id => id !== cat.id));
}}
className="rounded text-[#2D6A4F] focus:ring-[#2D6A4F]" />
<span className={`text-sm ${cat.parentId ? 'text-gray-500' : 'font-medium text-gray-700'}`}>
{cat.parentId ? `${cat.name}` : cat.name}
</span>
</label>
))}
</div>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<label className="text-sm font-semibold text-gray-900">Atributos</label>
<span className="text-xs text-gray-400">{attributes.length} / 16</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{Object.entries(ATTRIBUTE_LABELS).map(([key, label]) => (
<label key={key}
className={`flex items-center gap-2 px-3 py-2 border rounded-xl cursor-pointer transition-colors text-sm ${
attributes.includes(key)
? 'border-[#2D6A4F] bg-[#2D6A4F]/5 text-[#2D6A4F]'
: 'border-gray-200 hover:border-gray-300 text-gray-600'
}`}>
<input type="checkbox" checked={attributes.includes(key)}
onChange={() => toggleAttr(key)} className="hidden" />
{label}
</label>
))}
</div>
</div>
</section>
)}
{/* ── PRICING ── */}
{tab === 'pricing' && (
<section>
{!productId ? (
<div className="p-6 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
Guarda primero el producto para configurar precios.
</div>
) : (
<PricingSection productId={productId} />
)}
</section>
)}
{/* ── INVENTORY ── */}
{tab === 'inventory' && (
<section>
{!productId ? (
<div className="p-6 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
Guarda primero el producto para gestionar inventario.
</div>
) : (
<InventorySection productId={productId} />
)}
</section>
)}
{/* ── IMAGES ── */}
{tab === 'images' && (
<section>
{!productId ? (
<div className="p-6 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
Guarda primero el producto para subir imágenes.
</div>
) : (
<ImagesSection productId={productId} />
)}
</section>
)}
{/* ── SEO ── */}
{tab === 'seo' && (
<section className="space-y-6">
<div className="p-4 bg-blue-50 border border-blue-100 rounded-xl text-xs text-blue-700 space-y-1">
<p>El título y descripción SEO se usan en Google. Si están vacíos, se usan automáticamente.</p>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-semibold text-gray-900">Título SEO (Google)</label>
<span className={`text-xs ${seoTitleManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
{seoTitleManual ? 'editado manualmente' : 'copiado del nombre'}
</span>
</div>
<input type="text" value={seoTitle}
onChange={e => { setSeoTitleManual(true); setSeoTitle(e.target.value); }}
maxLength={60}
placeholder="Título para Google (max 60 caracteres)"
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
<div className="mt-1 text-xs text-gray-400">{seoTitle.length}/60</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-semibold text-gray-900">Descripción SEO (Google)</label>
<span className={`text-xs ${seoDescManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
{seoDescManual ? 'editada manualmente' : 'auto-generada'}
</span>
</div>
<textarea value={seoDesc}
onChange={e => { setSeoDescManual(true); setSeoDesc(e.target.value); }}
rows={3} maxLength={160}
placeholder="Descripción para Google (max 160 caracteres)"
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none resize-none" />
<div className="mt-1 text-xs text-gray-400">{seoDesc.length}/160</div>
</div>
</section>
)}
{/* ── PUBLISH ── */}
{tab === 'publish' && (
<section className="space-y-5">
<div>
<label className="block text-sm font-semibold text-gray-900 mb-3">Estado del producto</label>
<div className="grid grid-cols-3 gap-3">
{[['draft', 'Borrador', 'gray'], ['active', 'Activo', 'green'], ['archived', 'Archivado', 'amber']].map(([s, label, color]) => (
<button key={s} onClick={() => saveState(s as string)}
className={`px-4 py-3 rounded-xl border-2 text-sm font-medium transition-all ${
state === s
? color === 'green' ? 'border-[#2D6A4F] bg-[#2D6A4F]/5 text-[#2D6A4F]' : color === 'amber' ? 'border-amber-400 bg-amber-50 text-amber-700' : 'border-gray-400 bg-gray-100 text-gray-700'
: 'border-gray-200 text-gray-500 hover:border-gray-300'
}`}>
{label}
</button>
))}
</div>
</div>
<div className="p-5 bg-gray-50 border border-gray-200 rounded-xl text-sm text-gray-600 space-y-2">
<div className="flex justify-between"><span>Borrador</span><span>No visible en la tienda</span></div>
<div className="flex justify-between"><span>Activo</span><span>Visible y comprable online</span></div>
<div className="flex justify-between"><span>Archivado</span><span>Oculto pero conservado</span></div>
</div>
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,211 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { productsApi } from '@/lib/api-client';
import type { ProductImage } from '@/types';
interface ImagesSectionProps {
productId: string;
}
export function ImagesSection({ productId }: ImagesSectionProps) {
const [images, setImages] = useState<ProductImage[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [urlInput, setUrlInput] = useState('');
const [savingUrl, setSavingUrl] = useState(false);
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const load = useCallback(async () => {
try {
const p = await productsApi.get(productId);
setImages(p.images ?? []);
} catch {
setError('Error al cargar imágenes');
} finally {
setLoading(false);
}
}, [productId]);
useEffect(() => { load(); }, [load]);
const addImageByUrl = async (url: string) => {
if (!url.trim()) return;
setSavingUrl(true);
try {
const p = await productsApi.update(productId, {});
// Attach via images array — for now use the attach endpoint
await fetch(`/api/products/${productId}/images`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', credentials: 'include' },
body: JSON.stringify({ url: url.trim(), altText: '', role: 'gallery' }),
});
setUrlInput('');
load();
} catch {
setError('Error al añadir imagen');
} finally {
setSavingUrl(false);
}
};
const uploadFile = async (file: File) => {
setUploading(true);
setError('');
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch('/api/upload', { method: 'POST', body: fd, credentials: 'include' });
if (!res.ok) {
const data = await res.json();
throw new Error(data.error ?? 'Error al subir');
}
const { url } = await res.json() as { url: string };
await fetch(`/api/products/${productId}/images`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', credentials: 'include' },
body: JSON.stringify({ url, altText: '', role: 'gallery' }),
});
load();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al subir');
} finally {
setUploading(false);
}
};
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) uploadFile(file);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
uploadFile(file);
}
};
const setMain = async (imageId: string) => {
// Reorder: put this image first
await fetch(`/api/products/${productId}/images/reorder`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', credentials: 'include' },
body: JSON.stringify({
items: [
{ imageId, position: 0 },
...images.filter(i => i.id !== imageId).map((img, idx) => ({ imageId: img.id, position: idx + 1 })),
],
}),
});
load();
};
const deleteImage = async (imageId: string) => {
await fetch(`/api/products/${productId}/images/${imageId}`, { method: 'DELETE', credentials: 'include' });
load();
};
if (!productId) {
return <div className="p-4 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
Guarda primero el producto para gestionar imágenes.
</div>;
}
if (loading) return <div className="p-8 text-gray-400 text-sm">Cargando imágenes...</div>;
return (
<div className="space-y-5">
{/* Upload / URL / Drag&drop */}
<div className="flex flex-col gap-3 sm:flex-row">
<input
type="text"
value={urlInput}
onChange={e => setUrlInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addImageByUrl(urlInput)}
placeholder="Pega una URL de imagen..."
className="flex-1 px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
/>
<button
onClick={() => addImageByUrl(urlInput)}
disabled={savingUrl || !urlInput.trim()}
className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors"
>
{savingUrl ? 'Añadiendo...' : 'Añadir URL'}
</button>
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="px-5 py-2.5 border border-gray-300 hover:border-[#2D6A4F] text-gray-700 text-sm font-medium rounded-xl transition-colors"
>
{uploading ? 'Subiendo...' : '📤 Subir imagen'}
</button>
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleFileInput} />
</div>
{/* Drop zone */}
<div
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
dragOver ? 'border-[#2D6A4F] bg-[#2D6A4F]/5' : 'border-gray-200'
}`}
>
<p className="text-gray-400 text-sm">
🖼 Arrastra imágenes aquí para añadirlas al producto
</p>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{error}</div>
)}
{/* Gallery */}
{images.length === 0 ? (
<div className="p-8 text-center text-gray-400 text-sm border border-dashed border-gray-300 rounded-xl">
No hay imágenes para este producto
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{images.map((img, idx) => (
<div key={img.id} className="relative group">
<img
src={img.url}
alt={img.altText ?? img.url}
className="w-full aspect-square object-cover rounded-xl bg-gray-100"
/>
{/* Main badge */}
{idx === 0 && (
<span className="absolute top-2 left-2 px-2 py-0.5 bg-[#2D6A4F] text-white text-xs font-medium rounded-full">
Principal
</span>
)}
{/* Actions */}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity rounded-xl flex items-center justify-center gap-2">
{idx !== 0 && (
<button
onClick={() => setMain(img.id)}
className="px-2 py-1 bg-white text-gray-800 text-xs rounded-lg hover:bg-gray-100"
>
Principal
</button>
)}
<button
onClick={() => deleteImage(img.id)}
className="p-2 bg-white text-red-600 rounded-lg hover:bg-red-50"
title="Eliminar"
>
🗑
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,459 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { productsApi, inventoryApi, pricingApi } from '@/lib/api-client';
import type { ProductVariant, VariantPrice, StockAvailability } from '@/types';
interface VariantRow {
variant: ProductVariant;
price: VariantPrice | null;
stock: StockAvailability | null;
loadingStock: boolean;
loadingPrice: boolean;
editingStock: boolean;
editingPrice: boolean;
stockValue: string;
priceValue: string;
vatRate: 'general' | 'reduced';
}
function formatCents(cents: number): string {
return `${(cents / 100).toFixed(2)}`;
}
function StockStatusBadge({ available, quantity }: { available: boolean; quantity: number }) {
if (!available || quantity === 0) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
Sin stock
</span>
);
}
if (quantity < 5) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700">
<span className="w-1.5 h-1.5 rounded-full bg-amber-400" />
Bajo stock ({quantity})
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
En stock ({quantity})
</span>
);
}
interface InventorySectionProps {
productId: string;
}
export function InventorySection({ productId }: InventorySectionProps) {
const [variants, setVariants] = useState<ProductVariant[]>([]);
const [loadingVariants, setLoadingVariants] = useState(true);
const [variantsError, setVariantsError] = useState('');
const [rows, setRows] = useState<Record<string, VariantRow>>({});
const [savingVariant, setSavingVariant] = useState<string | null>(null);
const [saveMsg, setSaveMsg] = useState<Record<string, string>>({});
// Load variants
useEffect(() => {
if (!productId) return;
setLoadingVariants(true);
productsApi.getVariants(productId)
.then(({ items }) => {
setVariants(items ?? []);
const initial: Record<string, VariantRow> = {};
for (const variant of items ?? []) {
initial[variant.id] = {
variant,
price: null,
stock: null,
loadingStock: true,
loadingPrice: true,
editingStock: false,
editingPrice: false,
stockValue: '',
priceValue: '',
vatRate: 'general',
};
}
setRows(initial);
setLoadingVariants(false);
})
.catch(() => {
setVariantsError('No se pudieron cargar las variantes');
setLoadingVariants(false);
});
}, [productId]);
// Load stock and price for each variant
useEffect(() => {
for (const variant of variants) {
// Stock
inventoryApi.getAvailability(variant.id)
.then((stock) => {
setRows((prev) => {
const current = prev[variant.id];
if (!current) return prev;
return {
...prev,
[variant.id]: {
...current,
stock,
loadingStock: false,
stockValue: String(stock.availableQuantity),
},
};
});
})
.catch(() => {
setRows((prev) => {
const current = prev[variant.id];
if (!current) return prev;
return { ...prev, [variant.id]: { ...current, loadingStock: false } };
});
});
// Price
pricingApi.getVariantPrice(variant.id)
.then((price) => {
setRows((prev) => {
const current = prev[variant.id];
if (!current) return prev;
return {
...prev,
[variant.id]: {
...current,
price,
loadingPrice: false,
priceValue: String(price.netUnitAmountCents),
vatRate: price.vatRate,
},
};
});
})
.catch(() => {
setRows((prev) => {
const current = prev[variant.id];
if (!current) return prev;
return { ...prev, [variant.id]: { ...current, loadingPrice: false } };
});
});
}
}, [variants]);
const startEditStock = (variantId: string) => {
setRows((prev) => ({
...prev,
[variantId]: { ...prev[variantId], editingStock: true },
}));
};
const startEditPrice = (variantId: string) => {
setRows((prev) => ({
...prev,
[variantId]: { ...prev[variantId], editingPrice: true },
}));
};
const cancelEditStock = (variantId: string) => {
const r = rows[variantId];
setRows((prev) => ({
...prev,
[variantId]: { ...r, editingStock: false, stockValue: String(r.stock?.availableQuantity ?? 0) },
}));
};
const cancelEditPrice = (variantId: string) => {
const r = rows[variantId];
setRows((prev) => ({
...prev,
[variantId]: {
...r,
editingPrice: false,
priceValue: String(r.price?.netUnitAmountCents ?? 0),
vatRate: r.price?.vatRate ?? 'general',
},
}));
};
const saveStock = async (variantId: string) => {
const r = rows[variantId];
const qty = parseInt(r.stockValue, 10);
if (isNaN(qty) || qty < 0) return;
setSavingVariant(variantId);
setSaveMsg((prev) => ({ ...prev, [variantId]: '' }));
try {
const result = await inventoryApi.setStock(variantId, qty);
setRows((prev) => ({
...prev,
[variantId]: {
...prev[variantId],
stock: {
available: result.available > 0,
availableQuantity: result.available,
},
editingStock: false,
},
}));
setSaveMsg((prev) => ({ ...prev, [variantId]: '✓ Guardado' }));
setTimeout(() => setSaveMsg((prev) => ({ ...prev, [variantId]: '' })), 3000);
} catch {
setSaveMsg((prev) => ({ ...prev, [variantId]: 'Error' }));
} finally {
setSavingVariant(null);
}
};
const savePrice = async (variantId: string) => {
const r = rows[variantId];
const cents = parseInt(r.priceValue, 10);
if (isNaN(cents) || cents < 0) return;
setSavingVariant(variantId);
setSaveMsg((prev) => ({ ...prev, [variantId]: '' }));
try {
const result = await pricingApi.setVariantPrice(variantId, cents, r.vatRate);
setRows((prev) => ({
...prev,
[variantId]: {
...prev[variantId],
price: result,
editingPrice: false,
},
}));
setSaveMsg((prev) => ({ ...prev, [variantId]: '✓ Guardado' }));
setTimeout(() => setSaveMsg((prev) => ({ ...prev, [variantId]: '' })), 3000);
} catch {
setSaveMsg((prev) => ({ ...prev, [variantId]: 'Error' }));
} finally {
setSavingVariant(null);
}
};
if (loadingVariants) {
return (
<div className="flex items-center gap-3 p-8 text-gray-400 text-sm">
<div className="h-4 w-4 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full animate-spin" />
Cargando inventario...
</div>
);
}
if (variantsError) {
return (
<div className="p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">
{variantsError}
</div>
);
}
if (variants.length === 0) {
return (
<div className="p-8 text-center">
<p className="text-4xl mb-3">📦</p>
<p className="text-gray-500 text-sm">Este producto no tiene variantes</p>
<p className="text-gray-400 text-xs mt-1">
Las variantes se crean desde la pestaña Publicar
</p>
</div>
);
}
return (
<div className="space-y-4">
<div className="overflow-x-auto rounded-xl border border-gray-200">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b border-gray-200 text-left">
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">SKU</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">EAN</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Precio neto</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">IVA</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Stock</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Estado</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{variants.map((variant) => {
const r = rows[variant.id];
if (!r) return null;
const grossPrice = r.price
? (r.price.netUnitAmountCents * (r.price.vatRate === 'general' ? 1.21 : 1.1)) / 100
: null;
return (
<tr key={variant.id} className="hover:bg-gray-50/50 transition-colors">
{/* SKU */}
<td className="px-4 py-3 font-mono text-xs text-gray-600">{variant.sku}</td>
{/* EAN */}
<td className="px-4 py-3 font-mono text-xs text-gray-500">{variant.ean ?? '—'}</td>
{/* Precio */}
<td className="px-4 py-3">
{r.loadingPrice ? (
<span className="text-gray-300"></span>
) : r.editingPrice ? (
<div className="flex items-center gap-1">
<span className="text-gray-400"></span>
<input
type="number"
min={0}
value={r.priceValue}
onChange={(e) =>
setRows((prev) => ({
...prev,
[variant.id]: { ...prev[variant.id], priceValue: e.target.value },
}))
}
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-sm focus:ring-1 focus:ring-[#2D6A4F] outline-none"
/>
</div>
) : (
<div className="flex items-center gap-1">
<span className="font-medium text-gray-900">
{r.price ? formatCents(r.price.netUnitAmountCents) : '—'}
</span>
{r.price && (
<button
onClick={() => startEditPrice(variant.id)}
className="ml-1 text-gray-400 hover:text-[#2D6A4F] text-xs"
title="Editar precio"
>
</button>
)}
</div>
)}
</td>
{/* IVA */}
<td className="px-4 py-3">
{r.editingPrice ? (
<select
value={r.vatRate}
onChange={(e) =>
setRows((prev) => ({
...prev,
[variant.id]: {
...prev[variant.id],
vatRate: e.target.value as 'general' | 'reduced',
},
}))
}
className="px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none"
>
<option value="general">21% (general)</option>
<option value="reduced">10% (reducido)</option>
</select>
) : (
<span className="text-xs text-gray-500">
{r.price?.vatRate === 'reduced' ? '10%' : '21%'}
</span>
)}
</td>
{/* Stock */}
<td className="px-4 py-3">
{r.loadingStock ? (
<span className="text-gray-300"></span>
) : r.editingStock ? (
<div className="flex items-center gap-1">
<input
type="number"
min={0}
value={r.stockValue}
onChange={(e) =>
setRows((prev) => ({
...prev,
[variant.id]: { ...prev[variant.id], stockValue: e.target.value },
}))
}
className="w-16 px-2 py-1 border border-gray-300 rounded-lg text-sm focus:ring-1 focus:ring-[#2D6A4F] outline-none"
/>
<button
onClick={() => saveStock(variant.id)}
disabled={savingVariant === variant.id}
className="px-2 py-1 bg-[#2D6A4F] text-white text-xs rounded-lg hover:bg-[#1B4332] disabled:opacity-50"
>
{savingVariant === variant.id ? '...' : 'OK'}
</button>
<button
onClick={() => cancelEditStock(variant.id)}
className="text-gray-400 hover:text-gray-600 text-xs"
>
</button>
</div>
) : (
<div className="flex items-center gap-1">
<span className="font-medium text-gray-900">
{r.stock?.availableQuantity ?? '—'}
</span>
<button
onClick={() => startEditStock(variant.id)}
className="ml-1 text-gray-400 hover:text-[#2D6A4F] text-xs"
title="Editar stock"
>
</button>
</div>
)}
</td>
{/* Estado + acciones */}
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<StockStatusBadge
available={r.stock?.available ?? false}
quantity={r.stock?.availableQuantity ?? 0}
/>
{r.editingPrice && (
<button
onClick={() => savePrice(variant.id)}
disabled={savingVariant === variant.id}
className="px-2 py-1 bg-[#2D6A4F] text-white text-xs rounded-lg hover:bg-[#1B4332] disabled:opacity-50"
>
{savingVariant === variant.id ? '...' : 'OK'}
</button>
)}
{r.editingPrice && (
<button
onClick={() => cancelEditPrice(variant.id)}
className="text-gray-400 hover:text-gray-600 text-xs"
>
</button>
)}
{saveMsg[variant.id] && !r.editingStock && !r.editingPrice && (
<span className={`text-xs ${saveMsg[variant.id].startsWith('✓') ? 'text-green-600' : 'text-red-600'}`}>
{saveMsg[variant.id]}
</span>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<p className="text-xs text-gray-400">
* Precio con IVA:{' '}
{variants[0] && rows[variants[0].id]?.price
? formatCents(
Math.round(
rows[variants[0].id].price!.netUnitAmountCents *
(rows[variants[0].id].vatRate === 'general' ? 1.21 : 1.1),
),
)
: '—'}
</p>
</div>
);
}

View File

@@ -0,0 +1,261 @@
'use client';
import { useState, useEffect } from 'react';
import { productsApi, pricingApi } from '@/lib/api-client';
import type { ProductVariant, VariantPrice } from '@/types';
const VAT_GENERAL = 1.21;
const VAT_REDUCED = 1.10;
function fmt(cents: number): string {
return `${(cents / 100).toFixed(2)}`;
}
function calcGross(netCents: number, vatRate: 'general' | 'reduced'): number {
return Math.round(netCents * (vatRate === 'general' ? VAT_GENERAL : VAT_REDUCED));
}
function calcMarginBruto(grossCents: number, costCents: number): number {
if (grossCents === 0) return 0;
return Math.round(((grossCents - costCents) / grossCents) * 100);
}
interface PricingSectionProps {
productId: string;
}
export function PricingSection({ productId }: PricingSectionProps) {
const [variants, setVariants] = useState<ProductVariant[]>([]);
const [loadingVariants, setLoadingVariants] = useState(true);
const [loadingPrices, setLoadingPrices] = useState(true);
const [prices, setPrices] = useState<Record<string, VariantPrice>>({});
const [saving, setSaving] = useState<string | null>(null);
const [msg, setMsg] = useState<Record<string, string>>({});
// Edit state per variant
const [net, setNet] = useState<Record<string, string>>({});
const [offer, setOffer] = useState<Record<string, string>>({});
const [cost, setCost] = useState<Record<string, string>>({});
const [vatRate, setVatRate] = useState<Record<string, 'general' | 'reduced'>>({});
useEffect(() => {
if (!productId) { setLoadingVariants(false); return; }
productsApi.getVariants(productId)
.then(({ items }) => {
setVariants(items ?? []);
setLoadingVariants(false);
})
.catch(() => setLoadingVariants(false));
}, [productId]);
useEffect(() => {
if (variants.length === 0) { setLoadingPrices(false); return; }
let done = 0;
for (const v of variants) {
pricingApi.getVariantPrice(v.id)
.then((p) => {
setPrices(prev => ({ ...prev, [v.id]: p }));
setNet(prev => ({ ...prev, [v.id]: String(p.netUnitAmountCents) }));
setOffer(prev => ({ ...prev, [v.id]: p.offerCents !== null ? String(p.offerCents) : '' }));
setCost(prev => ({ ...prev, [v.id]: p.costCents !== null ? String(p.costCents) : '' }));
setVatRate(prev => ({ ...prev, [v.id]: p.vatRate }));
})
.catch(() => {
setNet(prev => ({ ...prev, [v.id]: '0' }));
setOffer(prev => ({ ...prev, [v.id]: '' }));
setCost(prev => ({ ...prev, [v.id]: '' }));
setVatRate(prev => ({ ...prev, [v.id]: 'general' }));
})
.finally(() => {
done++;
if (done >= variants.length) setLoadingPrices(false);
});
}
}, [variants]);
const savePrice = async (variantId: string) => {
const netCents = parseInt(net[variantId] ?? '0', 10);
const offerCentsVal = offer[variantId] ? parseInt(offer[variantId], 10) : null;
const costCentsVal = cost[variantId] ? parseInt(cost[variantId], 10) : null;
if (isNaN(netCents) || netCents < 0) return;
if (offerCentsVal !== null && (isNaN(offerCentsVal) || offerCentsVal < 0)) return;
if (costCentsVal !== null && (isNaN(costCentsVal) || costCentsVal < 0)) return;
setSaving(variantId);
setMsg(prev => ({ ...prev, [variantId]: '' }));
try {
const updated = await pricingApi.setVariantPrice(variantId, netCents, vatRate[variantId]);
if (offerCentsVal !== null) {
// set offer via separate update
const offerUpdated = await pricingApi.setVariantPrice(variantId, netCents, vatRate[variantId], offerCentsVal, costCentsVal);
setPrices(prev => ({ ...prev, [variantId]: offerUpdated }));
} else {
setPrices(prev => ({ ...prev, [variantId]: updated }));
}
setMsg(prev => ({ ...prev, [variantId]: '✓' }));
setTimeout(() => setMsg(prev => ({ ...prev, [variantId]: '' })), 3000);
} catch {
setMsg(prev => ({ ...prev, [variantId]: 'Error' }));
} finally {
setSaving(null);
}
};
if (loadingVariants) return <div className="p-8 text-gray-400 text-sm">Cargando precios...</div>;
if (variants.length === 0) {
return (
<div className="p-6 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
Este producto no tiene variantes. Las variantes se crean desde la pestaña Publicar.
</div>
);
}
return (
<div className="space-y-4">
<div className="overflow-x-auto rounded-xl border border-gray-200">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b border-gray-200 text-left">
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">SKU</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Coste (sin IVA)</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">PVP (IVA incl.)</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Oferta (IVA incl.)</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">IVA</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Margen bruto %</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Neto (sin IVA)</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{variants.map(v => {
const p = prices[v.id];
const netCents = parseInt(net[v.id] ?? '0', 10);
const costCents = cost[v.id] ? parseInt(cost[v.id], 10) : 0;
const vr = vatRate[v.id] ?? 'general';
const grossCents = calcGross(netCents, vr);
const marginBruto = calcMarginBruto(grossCents, costCents);
const editing = saving === v.id;
return (
<tr key={v.id} className="hover:bg-gray-50/50">
<td className="px-4 py-3 font-mono text-xs text-gray-600">{v.sku}</td>
{/* Coste */}
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<span className="text-gray-400 text-xs"></span>
<input
type="number" min={0} step={1}
value={cost[v.id] ?? ''}
disabled={editing}
onChange={e => setCost(prev => ({ ...prev, [v.id]: e.target.value }))}
placeholder="0.00"
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
/>
</div>
</td>
{/* PVP (gross) */}
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<span className="text-gray-400 text-xs"></span>
<input
type="number" min={0} step={1}
value={grossCents}
disabled={editing}
onChange={e => {
const gross = parseInt(e.target.value, 10) || 0;
const newNet = Math.round(gross / (vr === 'general' ? VAT_GENERAL : VAT_REDUCED));
setNet(prev => ({ ...prev, [v.id]: String(newNet) }));
}}
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50 font-semibold text-[#2D6A4F]"
/>
</div>
</td>
{/* Oferta */}
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<span className="text-gray-400 text-xs"></span>
<input
type="number" min={0} step={1}
value={offer[v.id] ?? ''}
disabled={editing}
onChange={e => setOffer(prev => ({ ...prev, [v.id]: e.target.value }))}
placeholder="—"
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
/>
</div>
</td>
{/* IVA */}
<td className="px-4 py-3">
<select
value={vatRate[v.id] ?? 'general'}
disabled={editing}
onChange={e => setVatRate(prev => ({ ...prev, [v.id]: e.target.value as 'general' | 'reduced' }))}
className="px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
>
<option value="general">21% gen.</option>
<option value="reduced">10% red.</option>
</select>
</td>
{/* Margen bruto */}
<td className="px-4 py-3">
{costCents > 0 ? (
<span className={`text-xs font-bold ${marginBruto > 30 ? 'text-green-600' : marginBruto > 10 ? 'text-amber-600' : 'text-red-600'}`}>
{marginBruto}%
</span>
) : (
<span className="text-xs text-gray-300"></span>
)}
</td>
{/* Neto */}
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<span className="text-gray-400 text-xs"></span>
<input
type="number" min={0} step={1}
value={netCents}
disabled={editing}
onChange={e => setNet(prev => ({ ...prev, [v.id]: e.target.value }))}
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
/>
</div>
</td>
{/* Guardar */}
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
onClick={() => savePrice(v.id)}
disabled={editing}
className="px-3 py-1 bg-[#2D6A4F] text-white text-xs rounded-lg hover:bg-[#1B4332] disabled:opacity-50 transition-colors"
>
{editing ? '...' : 'Guardar'}
</button>
{msg[v.id] && (
<span className={`text-xs ${msg[v.id] === '✓' ? 'text-green-600' : 'text-red-600'}`}>
{msg[v.id]}
</span>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="p-4 bg-blue-50 border border-blue-100 rounded-xl text-xs text-blue-700 space-y-1">
<p><strong>Coste:</strong> precio de compra sin IVA (uso interno, no se muestra al cliente).</p>
<p><strong>PVP:</strong> precio de venta al público con IVA incluido.</p>
<p><strong>Oferta:</strong> precio promocional opcional. Dejar vacío si no hay oferta.</p>
<p><strong>Margen bruto:</strong> (PVP Coste) ÷ PVP × 100. Verde &gt;30%, ámbar 10-30%, rojo &lt;10%.</p>
<p><strong>IVA:</strong> 21% general (alimentación procesada) · 10% reducido (alimentos básicos, frutas, verduras).</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,314 @@
import { ApiError } from '@/types';
/**
* All requests go to /api/* (relative paths) — the Next.js catch-all
* route handler proxies them to the backend. This keeps all traffic
* within the same origin, avoiding CORS preflights entirely.
*/
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(path, {
method,
headers: { 'Content-Type': 'application/json' },
body: body != null ? JSON.stringify(body) : undefined,
credentials: 'include',
});
if (res.status === 401) {
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
throw new ApiError(401, 'UNAUTHORIZED', 'Authentication required');
}
if (res.status === 403) {
throw new ApiError(403, 'FORBIDDEN', 'Insufficient permissions');
}
if (!res.ok) {
const body = await res.json().catch(() => ({ message: 'Request failed' }));
throw new ApiError(
res.status,
(body as { code?: string }).code ?? 'REQUEST_FAILED',
(body as { message?: string }).message ?? 'Request failed',
);
}
return res.json() as Promise<T>;
}
export const api = {
get: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
delete: <T>(path: string) => request<T>('DELETE', path),
};
// ── Auth ──────────────────────────────────────────────────────────────────────
export const authApi = {
login: (email: string, password: string) =>
api.post<{ id: string; email: string; role: string }>('/api/auth/login', { email, password }),
logout: () => api.post('/api/auth/logout'),
me: () =>
api.get<{ id: string; email: string; role: string } | { user: null }>('/api/auth/me'),
};
// ── Products ──────────────────────────────────────────────────────────────────
export const productsApi = {
list: (params?: { limit?: number; offset?: number; q?: string }) => {
const sp = new URLSearchParams();
if (params?.limit) sp.set('limit', String(params.limit));
if (params?.offset) sp.set('offset', String(params.offset));
if (params?.q) sp.set('q', params.q);
const qs = sp.toString();
return api.get<{ items: import('@/types').Product[]; total: number }>(
`/api/catalog/products${qs ? `?${qs}` : ''}`,
);
},
get: (id: string) => api.get<import('@/types').Product>(`/api/catalog/products/${id}`),
getVariants: (id: string) =>
api.get<{ items: import('@/types').ProductVariant[] }>(`/api/catalog/products/${id}/variants`),
create: (data: unknown) => api.post<import('@/types').Product>('/api/catalog/products', data),
update: (id: string, data: unknown) =>
api.patch<import('@/types').Product>(`/api/catalog/products/${id}`, data),
setState: (id: string, state: 'active' | 'archived') =>
api.patch(`/api/catalog/products/${id}/state`, { state }),
delete: (id: string) => api.delete(`/api/catalog/products/${id}`),
};
// ── Orders ────────────────────────────────────────────────────────────────────
export const ordersApi = {
list: (params?: { limit?: number; offset?: number; status?: string; q?: string }) => {
const sp = new URLSearchParams();
if (params?.limit) sp.set('limit', String(params.limit));
if (params?.offset) sp.set('offset', String(params.offset));
if (params?.status) sp.set('status', params.status);
if (params?.q) sp.set('q', params.q);
const qs = sp.toString();
return api.get<{ items: import('@/types').Order[]; total: number }>(
`/api/orders${qs ? `?${qs}` : ''}`,
);
},
get: (id: string) => api.get<import('@/types').Order>(`/api/orders/${id}`),
transition: (id: string, state: string) =>
api.post<import('@/types').Order>(`/api/orders/${id}/transitions`, { state }),
};
// ── Customers ─────────────────────────────────────────────────────────────────
export const customersApi = {
list: (params?: { offset?: number; limit?: number; q?: string }) => {
const sp = new URLSearchParams();
if (params?.offset !== undefined) sp.set('offset', String(params.offset));
if (params?.limit !== undefined) sp.set('limit', String(params.limit));
if (params?.q) sp.set('q', params.q);
const qs = sp.toString();
return api.get<{ items: import('@/types').Customer[]; total: number }>(`/api/users${qs ? `?${qs}` : ''}`);
},
get: (id: string) => api.get<import('@/types').Customer>(`/api/users/${id}`),
update: (id: string, data: { displayName?: string; phone?: string }) =>
api.patch<import('@/types').Customer>(`/api/users/${id}`, data),
create: (data: { email: string; password: string; displayName?: string; phone?: string }) =>
api.post<import('@/types').Customer>('/api/auth/register', data),
};
// ── Brands ────────────────────────────────────────────────────────────────────
export const brandsApi = {
list: () => api.get<{ items: import('@/types').Brand[] }>('/api/brands'),
create: (data: unknown) => api.post<import('@/types').Brand>('/api/brands', data),
update: (id: string, data: unknown) =>
api.patch<import('@/types').Brand>(`/api/brands/${id}`, data),
delete: (id: string) => api.delete<void>(`/api/brands/${id}`),
};
// ── Categories ────────────────────────────────────────────────────────────────
export const categoriesApi = {
list: () => api.get<{ items: import('@/types').Category[] }>('/api/categories/tree'),
create: (data: unknown) => api.post<import('@/types').Category>('/api/categories', data),
update: (id: string, data: unknown) =>
api.patch<import('@/types').Category>(`/api/categories/${id}`, data),
delete: (id: string) => api.delete<void>(`/api/categories/${id}`),
};
// ── Inventory ─────────────────────────────────────────────────────────────────
export const inventoryApi = {
getAvailability: (variantId: string) =>
api.get<import('@/types').StockAvailability>(`/api/inventory/${variantId}/availability`),
setStock: (id: string, quantity: number) =>
api.put<import('@/types').StockItem>(`/api/inventory/${id}/stock`, { quantity }),
};
// ── Pricing ───────────────────────────────────────────────────────────────────
export const pricingApi = {
getVariantPrice: (id: string) => api.get<import('@/types').VariantPrice>(`/api/pricing/variants/${id}`),
setVariantPrice: (
id: string,
netUnitAmountCents: number,
vatRate: 'general' | 'reduced',
offerCents?: number | null,
costCents?: number | null,
) =>
api.put<import('@/types').VariantPrice>(`/api/pricing/variants/${id}`, {
netUnitAmountCents,
vatRate,
offerCents: offerCents ?? null,
costCents: costCents ?? null,
}),
};
// ── Promotions ────────────────────────────────────────────────────────────────
export const promotionsApi = {
list: () => api.get<{ items: unknown[] }>('/api/promotions'),
create: (data: unknown) => api.post('/api/promotions', data),
update: (code: string, data: unknown) => api.patch(`/api/promotions/${code}`, data),
delete: (code: string) => api.delete(`/api/promotions/${code}`),
};
// ── Reviews ───────────────────────────────────────────────────────────────────
export const reviewsApi = {
listAdmin: (params?: { status?: string; limit?: number; offset?: number }) => {
const sp = new URLSearchParams();
if (params?.status) sp.set('status', params.status);
if (params?.limit) sp.set('limit', String(params.limit));
if (params?.offset) sp.set('offset', String(params.offset));
const qs = sp.toString();
return api.get<{ items: unknown[]; total: number }>(`/api/reviews/admin${qs ? `?${qs}` : ''}`);
},
moderate: (id: string, status: 'published' | 'rejected') =>
api.patch(`/api/reviews/${id}/moderate`, { status }),
};
// ── CMS ───────────────────────────────────────────────────────────────────────
export const cmsApi = {
list: () => api.get<{ items: unknown[] }>('/api/cms/pages'),
get: (slug: string) => api.get(`/api/cms/pages/${slug}`),
create: (data: unknown) => api.post('/api/cms/pages', data),
update: (id: string, data: unknown) => api.patch(`/api/cms/pages/${id}`, data),
publish: (id: string) => api.post(`/api/cms/pages/${id}/publish`, {}),
unpublish: (id: string) => api.post(`/api/cms/pages/${id}/unpublish`, {}),
};
// ── Admin Users ─────────────────────────────────────────────────────────────────
export const adminUsersApi = {
list: (params?: { limit?: number; offset?: number; role?: string; q?: string }) => {
const sp = new URLSearchParams();
if (params?.limit) sp.set('limit', String(params.limit));
if (params?.offset) sp.set('offset', String(params.offset));
if (params?.role) sp.set('role', params.role);
if (params?.q) sp.set('q', params.q);
const qs = sp.toString();
return api.get<{ items: { id: string; email: string; role: string; createdAt: string }[]; total: number }>(
`/api/admin/users${qs ? `?${qs}` : ''}`,
);
},
create: (data: { email: string; password: string; role: string }) =>
api.post<{ id: string; email: string; role: string; createdAt: string }>('/api/admin/users', data),
update: (id: string, data: { role?: string; password?: string }) =>
api.patch<{ id: string; email: string; role: string; createdAt: string }>(`/api/admin/users/${id}`, data),
delete: (id: string) => api.delete<void>(`/api/admin/users/${id}`),
};
// ── Tax Rates ────────────────────────────────────────────────────────────────────
export interface TaxRate {
id: string; name: string; ratePercent: number; country: string; appliesTo: string; active: boolean;
}
export const taxApi = {
list: () => api.get<{ items: TaxRate[] }>('/api/admin/tax-rates'),
update: (id: string, data: Partial<{ name: string; ratePercent: number; active: boolean }>) =>
api.patch('/api/admin/tax-rates/' + id, data),
};
// ── Payments ──────────────────────────────────────────────────────────────────────
export interface PaymentTransaction {
id: string; provider: string; providerPaymentId: string | null;
orderId: string | null; amountCents: number; currency: string;
status: string; raw: unknown; createdAt: string;
}
export const paymentsApi = {
list: (params?: { limit?: number; offset?: number; status?: string; q?: string }) => {
const sp = new URLSearchParams();
if (params?.limit) sp.set('limit', String(params.limit));
if (params?.offset) sp.set('offset', String(params.offset));
if (params?.status) sp.set('status', params.status);
if (params?.q) sp.set('q', params.q);
const qs = sp.toString();
return api.get<{ items: PaymentTransaction[]; total: number }>(`/api/admin/payments${qs ? `?${qs}` : ''}`);
},
refund: (id: string) => api.post<{ ok: boolean }>(`/api/admin/payments/${id}/refund`, {}),
};
// ── Shipping ───────────────────────────────────────────────────────────────────────
export interface ShippingZone {
id: string; name: string; country: string; postalCodePrefix: string | null; active: boolean;
}
export interface ShippingMethod {
id: string; zoneId: string; zoneName: string; name: string;
baseCostCents: number; freeShippingThresholdCents: number | null; active: boolean;
}
export const shippingApi = {
listZones: () => api.get<{ items: ShippingZone[] }>('/api/admin/shipping/zones'),
createZone: (data: { name: string; country: string; postalCodePrefix?: string | null; active?: boolean }) =>
api.post<{ id: string }>('/api/admin/shipping/zones', data),
updateZone: (id: string, data: Partial<{ name: string; country: string; postalCodePrefix?: string | null; active: boolean }>) =>
api.patch('/api/admin/shipping/zones/' + id, data),
deleteZone: (id: string) => api.delete<void>('/api/admin/shipping/zones/' + id),
listMethods: () => api.get<{ items: ShippingMethod[] }>('/api/admin/shipping/methods'),
createMethod: (data: { zoneId: string; name: string; baseCostCents: number; freeShippingThresholdCents?: number | null; active?: boolean }) =>
api.post<{ id: string }>('/api/admin/shipping/methods', data),
updateMethod: (id: string, data: Partial<{ name: string; baseCostCents: number; freeShippingThresholdCents?: number | null; active: boolean }>) =>
api.patch('/api/admin/shipping/methods/' + id, data),
deleteMethod: (id: string) => api.delete<void>('/api/admin/shipping/methods/' + id),
};
// ── Store Settings ─────────────────────────────────────────────────────────────────
export const auditApi = {
list: (params?: { actorId?: string; action?: string; limit?: number; offset?: number }) => {
const sp = new URLSearchParams();
if (params?.action) sp.set('action', params.action);
if (params?.limit) sp.set('limit', String(params.limit));
if (params?.offset) sp.set('offset', String(params.offset));
const qs = sp.toString();
return api.get<{ items: AuditEntry[]; total: number }>(`/api/admin/audit${qs ? `?${qs}` : ''}`);
},
};
export interface AuditEntry {
id: string;
actorId: string | null;
action: string;
target: string;
metadata: Record<string, unknown>;
createdAt: string;
}
export interface StoreSettings {
storeName: string;
storeTagline: string;
contactEmail: string;
contactPhone: string;
contactAddress: string;
footerText: string;
facebookUrl: string;
instagramUrl: string;
}
export const settingsApi = {
get: () => api.get<StoreSettings>('/api/admin/settings'),
update: (data: Partial<StoreSettings>) => api.patch<StoreSettings>('/api/admin/settings', data),
};

View File

@@ -0,0 +1,63 @@
import type { Role } from '@/types';
export type Permission =
| 'dashboard'
| 'products.read'
| 'products.write'
| 'orders.read'
| 'orders.write'
| 'inventory.read'
| 'inventory.write'
| 'customers.read'
| 'customers.write'
| 'categories.read'
| 'categories.write'
| 'categories.delete'
| 'brands.read'
| 'brands.write'
| 'promotions.read'
| 'promotions.write'
| 'reviews.read'
| 'reviews.moderate'
| 'cms.read'
| 'cms.write'
| 'admin-users.read'
| 'admin-users.write'
| 'audit.read';
export function can(role: Role, permission: Permission): boolean {
if (role === 'admin') return true;
// Future: granular permission checks when backend supports them
return false;
}
export interface NavItem {
href: string;
label: string;
icon: string;
permission: Permission;
badge?: number;
}
export const NAV_ITEMS: NavItem[] = [
{ href: '/', label: 'Dashboard', icon: '📊', permission: 'dashboard' },
{ href: '/products', label: 'Productos', icon: '📦', permission: 'products.read' },
{ href: '/orders', label: 'Pedidos', icon: '🧾', permission: 'orders.read' },
{ href: '/payments', label: 'Pagos', icon: '💳', permission: 'orders.read' },
{ href: '/inventory', label: 'Inventario', icon: '📊', permission: 'inventory.read' },
{ href: '/customers', label: 'Clientes', icon: '👥', permission: 'customers.read' },
{ href: '/categories', label: 'Categorías', icon: '🏷️', permission: 'categories.read' },
{ href: '/brands', label: 'Marcas', icon: '🏷️', permission: 'brands.read' },
{ href: '/promotions', label: 'Promociones', icon: '🏷️', permission: 'promotions.read' },
{ href: '/shipping', label: 'Envíos', icon: '📦', permission: 'orders.read' },
{ href: '/reviews', label: 'Reseñas', icon: '⭐', permission: 'reviews.read' },
{ href: '/cms', label: 'CMS', icon: '📄', permission: 'cms.read' },
{ href: '/users', label: 'Usuarios', icon: '🔐', permission: 'admin-users.read' },
{ href: '/tax-rates', label: 'IVA', icon: '📊', permission: 'orders.read' },
{ href: '/audit', label: 'Auditoría', icon: '📋', permission: 'audit.read' },
{ href: '/settings', label: 'Ajustes', icon: '⚙️', permission: 'dashboard' },
];
export function visibleNavItems(role: Role): NavItem[] {
return NAV_ITEMS.filter((item) => can(role, item.permission));
}

View File

@@ -0,0 +1,178 @@
// ── User / Auth ──────────────────────────────────────────────────────────────
export type Role = 'customer' | 'admin';
export interface AuthUser {
id: string;
email: string;
role: Role;
}
// ── Products ─────────────────────────────────────────────────────────────────
export interface ProductImage {
id: string;
url: string;
altText?: string;
position?: number;
role?: 'main' | 'gallery';
}
export interface Product {
id: string;
name: string;
slug: string;
description?: string;
state: string;
channels: 'online' | 'offline' | 'all';
featured: boolean;
attributes: string[];
seoTitle?: string;
seoDescription?: string;
images: ProductImage[];
brandId?: string;
categoryIds?: string[];
brand?: { id: string; name: string; slug: string };
imageUrl?: string;
createdAt?: string;
updatedAt?: string;
}
export interface ProductVariant {
id: string;
productId: string;
sku: string;
ean: string | null;
attributes: Record<string, unknown>;
}
export interface VariantPrice {
variantId: string;
netUnitAmountCents: number;
offerCents: number | null;
costCents: number | null;
vatRate: 'general' | 'reduced';
currency: string;
}
export interface StockAvailability {
available: boolean;
availableQuantity: number;
}
export interface StockItem {
id: string;
variantId: string;
available: number;
reserved: number;
sold: number;
incoming: number;
createdAt: string;
updatedAt: string;
}
// ── Orders ────────────────────────────────────────────────────────────────────
export type OrderState =
| 'PENDING'
| 'AWAITING_PAYMENT'
| 'PAID'
| 'PROCESSING'
| 'SHIPPED'
| 'DELIVERED'
| 'CANCELLED'
| 'REFUNDED'
| 'PARTIALLY_REFUNDED';
export interface OrderItem {
id: string;
productId: string;
variantId: string;
sku: string;
ean: string | null;
name: string;
unitPriceCents: number;
discountCents: number;
taxCents: number;
quantity: number;
createdAt: string;
}
export interface Order {
id: string;
userId: string;
state: OrderState;
currency: 'EUR';
subtotalCents: number;
discountCents: number;
taxCents: number;
totalCents: number;
idempotencyKey: string | null;
items: OrderItem[];
createdAt: string;
updatedAt: string;
}
export interface OrderSummary {
id: string;
userId: string;
state: OrderState;
totalCents: number;
currency: 'EUR';
itemCount: number;
createdAt: string;
}
// ── Customers ─────────────────────────────────────────────────────────────────
export interface Customer {
id: string;
email: string;
role: Role;
displayName?: string;
phone?: string;
createdAt: string;
updatedAt?: string;
}
// ── Categories & Brands ────────────────────────────────────────────────────────
export interface Category {
id: string;
parentId: string | null;
name: string;
slug: 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;
}
// ── API Errors ────────────────────────────────────────────────────────────────
export interface ApiErrorBody {
statusCode: number;
code: string;
message: string;
}
export class ApiError extends Error {
constructor(
public readonly statusCode: number,
public readonly code: string,
message: string,
) {
super(message);
this.name = 'ApiError';
}
}

View File

@@ -0,0 +1,42 @@
{
"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"
]
}

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,14 @@ import prettier from 'eslint-config-prettier';
export default tseslint.config(
{
ignores: ['dist/**', 'node_modules/**', 'coverage/**', 'scripts/tests/fixtures/**'],
ignores: [
'dist/**',
'node_modules/**',
'**/node_modules/**',
'coverage/**',
'scripts/tests/fixtures/**',
'storefront/**',
],
},
eslint.configs.recommended,
...tseslint.configs.recommended,

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 391 B

View File

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

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

After

Width:  |  Height:  |  Size: 128 B

View File

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

After

Width:  |  Height:  |  Size: 385 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More