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

24
.gitignore vendored
View File

@@ -5,4 +5,28 @@ __pycache__/
.pytest_cache/
.codegraph/
.atl/
# Build artifacts
.next/
dist/
build/
*.log
# Dependencies
node_modules/
# Environment
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# END ORQUESTRA

View File

@@ -1,3 +1,4 @@
import { execFileSync } from "node:child_process";
import { readFileSync, watchFile, unwatchFile } from "node:fs";
import { join, relative, resolve, sep } from "node:path";
import type { ExtensionAPI, ExtensionContext, ToolCallEvent } from "@earendil-works/pi-coding-agent";
@@ -50,6 +51,19 @@ type RuntimeStatus = {
const ROOT_PRODUCT_EXTENSIONS = new Set([".py", ".js", ".ts", ".go", ".rs", ".java", ".php", ".rb"]);
// Carpetas permitidas para escritura (Orquestra + proyecto)
const ALLOWED_WRITE_DIRS = new Set([
"project", // código del proyecto
"tests", // tests del proyecto
"work", // artifacts, current.md, runtime-status.json
"backlog", // features.json
"spec", // specs globales
"harness", // configuración Orquestra (solo leader)
"scripts", // scripts Orquestra (solo leader)
"platforms", // extensiones Pi (solo leader)
"docs", // documentación (solo leader)
]);
function safeReadJson(root: string, path: string): RuntimeStatus | null {
try {
return JSON.parse(readFileSync(join(root, path), "utf8")) as RuntimeStatus;
@@ -165,12 +179,50 @@ function hasActiveBuildRuntime(status: RuntimeStatus | null): boolean {
);
}
function parseStageCommand(args: string, status: RuntimeStatus | null): { stage?: string; featureId?: string; error?: string } {
const parts = args.trim().split(/\s+/).filter(Boolean);
const stage = parts[0];
const featureFlag = parts.indexOf("--feature-id");
const featureId = featureFlag >= 0 ? parts[featureFlag + 1] : parts[1] || status?.feature_id || undefined;
if (!stage) return { error: "Uso: /orquestra-stage <stage> [feature_id]" };
if (!featureId) return { error: "Falta feature_id. Uso: /orquestra-stage <stage> <feature_id>" };
return { stage, featureId };
}
function lastUsefulLine(text: string): string {
return text
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.slice(-1)[0] || "stage terminado";
}
function guardWriteOrEdit(event: ToolCallEvent, ctx: ExtensionContext): { block?: boolean; reason?: string } | undefined {
if (event.toolName !== "write" && event.toolName !== "edit") return undefined;
const { relPath, reason } = normalizeWritePath(ctx.cwd, (event.input as { path?: unknown }).path);
if (!relPath) return { block: true, reason };
// Extraer el directorio raíz del path
const slashIndex = relPath.indexOf("/");
const rootDir = slashIndex === -1 ? relPath : relPath.slice(0, slashIndex);
// Bloquear escrituras fuera de carpetas permitidas
if (!ALLOWED_WRITE_DIRS.has(rootDir)) {
return {
block: true,
reason: `Orquestra bloqueó '${relPath}': solo se permite escribir en ${Array.from(ALLOWED_WRITE_DIRS).join(", ")}. No crees carpetas ni archivos en la raíz ni en ubicaciones no autorizadas.`,
};
}
// Bloquear escrituras directas a backlog/features.json (debe usar close_feature.py)
if (relPath === "backlog/features.json") {
return {
block: true,
reason: `Orquestra bloqueó '${relPath}': NUNCA edites backlog/features.json directamente. Usa scripts/close_feature.py para cerrar features. Este script valida gates y actualiza el backlog automáticamente.`,
};
}
if (isRootProductFile(relPath)) {
return {
block: true,
@@ -285,4 +337,34 @@ export default function orquestraStatusExtension(pi: ExtensionAPI) {
ctx.ui.notify("Orquestra status refrescado", "info");
},
});
pi.registerCommand("orquestra-stage", {
description: "Ejecuta un stage Orquestra en un proceso Pi fresco: /orquestra-stage <stage> [feature_id]",
handler: async (args, ctx) => {
const parsed = parseStageCommand(args, safeReadJson(ctx.cwd, STATUS_FILE));
if (parsed.error) {
ctx.ui.notify(parsed.error, "error");
return;
}
ctx.ui.notify(`Orquestra ejecutando ${parsed.stage} en Pi fresco…`, "info");
try {
const output = execFileSync("python3", ["scripts/run_stage.py", parsed.stage!, "--feature-id", parsed.featureId!], {
cwd: ctx.cwd,
encoding: "utf8",
maxBuffer: 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
currentCtx = ctx;
apply(ctx);
ctx.ui.notify(`Orquestra stage terminado: ${lastUsefulLine(output)}`, "info");
} catch (error) {
const failed = error as { stdout?: string; stderr?: string; message?: string };
const detail = lastUsefulLine(`${failed.stdout || ""}\n${failed.stderr || ""}`) || failed.message || "error desconocido";
currentCtx = ctx;
apply(ctx);
ctx.ui.notify(`Orquestra stage falló: ${detail}`, "error");
}
},
});
}

View File

@@ -15,6 +15,9 @@
- `implementer` nunca marca `done`.
- `done` requiere gates aprobados: `reviewer`, `security`, `qa`.
- Si `verify.sh` falla, no se cierra la feature.
- **Solo se puede escribir en carpetas permitidas**: `project/`, `tests/`, `work/`, `backlog/`, `spec/`, `harness/`, `scripts/`, `platforms/`, `docs/`. Cualquier otra escritura será bloqueada por la extensión `orquestra-status`.
- **Al cerrar una feature, el leader ejecuta `scripts/close_feature.py <feature_id>`** que valida gates, actualiza el backlog y hace commit/push automáticamente.
- **NUNCA editar `backlog/features.json` directamente**. Usar siempre `scripts/close_feature.py` para cerrar features.
## Reentrada
- Releer `work/current.md`, `work/runtime-status.json` y artefactos de la feature activa.

File diff suppressed because it is too large Load Diff

276
docs/context-handoff.md Normal file
View File

@@ -0,0 +1,276 @@
# Context Handoff in Orquestra
Orquestra keeps agents focused by making context explicit. Agents do not pass a whole chat history to the next agent. They pass durable artifacts on disk, and each new stage reads only the files declared for that stage.
## Quick path
1. The current agent writes its result to `work/artifacts/<feature_id>/`.
2. From the active Pi session, `/orquestra-stage <stage> [feature_id]` calls `scripts/run_stage.py`.
3. `run_stage.py` starts a fresh Pi process.
4. The new agent receives only the `input` paths declared in `harness/workflow.stages.yml`.
5. The new agent writes its own output artifact.
6. `scripts/agent_status.py` and `scripts/verify.sh` enforce stage order and gates.
7. When the `close` stage completes, `scripts/close_feature.py <feature_id>` runs automatically to validate gates, update the backlog, commit and push if a remote repo is configured.
## Mental model
```text
Agent A
reads its stage inputs
writes one durable artifact
work/artifacts/F-001/agent-a-output
run_stage.py starts a fresh Pi process
Agent B
reads only declared inputs, including Agent A's artifact if relevant
writes its own artifact
```
The agents “talk” through files, not through memory-heavy chat context.
## Why fresh processes matter
A single long Pi session naturally accumulates chat, tool output, false starts, and old assumptions. That causes two problems:
| Problem | Orquestra answer |
|---|---|
| The next agent gets distracted by old context | Start the next stage with `--no-session --no-context-files` |
| Token usage grows until compaction | Each stage starts with a small prompt and declared files |
| Claims become hard to verify | Only artifacts on disk count as handoff evidence |
| Agents can skip gates in chat | `agent_status.py` validates stage owner and prerequisites |
## What a stage receives
Each stage receives three kinds of context:
1. **Harness rules** — always read first.
- `AGENTS.md`
- `harness/agents.matrix.yml`
- `harness/workflow.stages.yml`
- `harness/contracts/handoff.md`
2. **Declared stage inputs** — from `harness/workflow.stages.yml`.
3. **Selected Engram memory** — only when the agent performs a narrow search for a concrete need.
The full previous chat is not valid input.
## Example: design to build
`architect` receives product/spec context:
```yaml
- name: design
owner: architect
input:
- work/current.md
- spec/product.md
- spec/tech.md
- spec/acceptance.md
output:
- work/artifacts/<feature_id>/architect.md
```
It writes:
```text
work/artifacts/F-001/architect.md
```
Then `implementer` starts fresh from the active Pi session:
```text
/orquestra-stage build F-001
```
Equivalent shell form:
```bash
python3 scripts/run_stage.py build --feature-id F-001
```
The generated prompt gives it only:
```yaml
- name: build
owner: implementer
input:
- work/current.md
- spec/product.md
- spec/tech.md
- spec/acceptance.md
- work/artifacts/<feature_id>/architect.md
output:
- work/artifacts/<feature_id>/implementer.md
```
So the implementer knows the design result, but not the architect's full chat.
## Example: build to review
The implementer writes evidence:
```text
work/artifacts/F-001/implementer.md
```
A minimal implementer artifact should answer:
```markdown
# Implementer Evidence
## Changed files
- project/app.py
- tests/test_app.py
## What changed
- Added ticket creation validation.
## Checks
- `python3 -m unittest discover -s tests -v` passed.
## Notes for reviewer
- Main behavior lives in `project/app.py:create_ticket`.
```
Then reviewer starts fresh:
```text
/orquestra-stage review_gate F-001
```
Reviewer input is only:
```yaml
input:
- work/current.md
- spec/acceptance.md
- work/artifacts/<feature_id>/implementer.md
```
Reviewer does not need the architect's whole reasoning unless the workflow explicitly declares it. If reviewer needs more, it blocks and asks for a specific artifact/path.
## Example: security and QA reuse only relevant context
Security does not need all acceptance prose plus every design note. It needs the implementation evidence and review result:
```yaml
- name: security_gate
owner: security
input:
- work/current.md
- work/artifacts/<feature_id>/implementer.md
- work/artifacts/<feature_id>/reviewer.json
```
QA needs acceptance criteria and approved gate context:
```yaml
- name: qa_gate
owner: qa
input:
- work/current.md
- spec/acceptance.md
- work/artifacts/<feature_id>/implementer.md
- work/artifacts/<feature_id>/reviewer.json
- work/artifacts/<feature_id>/security.json
```
That is the core rule: give each agent the smallest context that lets it do its job.
## Anti-cheating controls
```text
run_stage.py
├─ starts fresh Pi process
├─ disables previous session/context files
└─ injects only declared input/output paths
agent_status.py
├─ validates stage owner
├─ requires feature_id for real stages
├─ rejects gated stages when prerequisite artifacts are missing
└─ keeps visible runtime status
orquestra-status extension
├─ blocks writes outside allowed directories: project/, tests/, work/, backlog/, spec/, harness/, scripts/, platforms/, docs/
├─ blocks product/test writes outside implementer build stage
└─ blocks product code in repository root
verify.sh
├─ validates harness structure
├─ validates backlog/runtime JSON
├─ validates Engram availability
└─ rejects done features without approved gate artifacts
```
Chat-only claims do not pass any gate. The agent must write evidence to disk.
## What Engram is for
Engram is durable memory for reusable facts, not a replacement for stage artifacts.
Each stage must save to Engram only when it produced durable knowledge.
Use Engram for:
- stable project decisions,
- bug fixes,
- non-obvious discoveries,
- reusable conventions or patterns,
- configuration changes,
- repeated gotchas,
- environment facts,
- lessons learned across features.
Do not use Engram for:
- replacing `implementer.md`,
- hiding gate evidence,
- routine progress,
- command output dumps,
- artifact summaries,
- passing huge summaries to every stage,
- bypassing declared workflow inputs.
A good Engram lookup is narrow:
```text
Search: "orquestra project root product files rule"
Use result only if it affects this stage.
```
A bad lookup is broad:
```text
Search: "everything about this project"
```
## Updating an installed project safely
From the Orquestra source checkout:
```bash
/path/to/orquestra/scripts/install.sh /path/to/project
cd /path/to/project
./scripts/verify.sh
```
The installer updates harness-owned files and preserves project-owned progress files when they already exist:
```text
backlog/features.json
spec/*
work/current.md
work/history.md
work/runtime-status.json
project/*
work/artifacts/*
```
That means an existing project can adopt fresh-stage handoff without losing its current feature state.

View File

@@ -92,6 +92,8 @@ stages:
output:
- work/artifacts/<feature_id>/leader-close.json
- work/history.md
post_actions:
- scripts/close_feature.py <feature_id>
close_requirements:
- reviewer.json.verdict == "APPROVED"

View File

@@ -22,13 +22,14 @@ No debe crear `.pi/subagents/` ni `.pi/subagents.json`.
- `verify.sh` falla si `~/.pi/agent/npm/node_modules/gentle-engram/index.ts` no existe.
- `pi_orquestra.sh` carga Engram con `-e` aunque Pi arranque con `--no-extensions`; así se evita cargar extensiones globales no declaradas sin perder memoria.
- Cada rol trabaja desde los `input` declarados en `harness/workflow.stages.yml`; el chat completo no es un handoff válido.
- Para aislamiento real, ejecutar cada stage con `python3 scripts/run_stage.py <stage> --feature-id <feature_id>`; usa `pi --no-session --no-context-files` y carga solo Engram + extensiones Orquestra.
- Para aislamiento real desde la sesión Pi, ejecutar cada stage con `/orquestra-stage <stage> [feature_id]`; internamente usa `run_stage.py`, `pi --no-session --no-context-files` y carga solo Engram + extensiones Orquestra.
- Detalle completo del handoff: `docs/context-handoff.md`.
## Flujo secuencial
1. Ejecutar `./scripts/verify.sh`.
2. Abrir Pi limpio desde la raíz con `./scripts/pi_orquestra.sh`.
3. Confirmar el widget con `/orquestra-status`.
4. Ejecutar cada stage como proceso fresco: `python3 scripts/run_stage.py <stage> --feature-id <feature_id>`.
4. Ejecutar cada stage como proceso fresco desde Pi: `/orquestra-stage <stage> [feature_id]`.
5. `run_stage.py` genera un prompt mínimo con las rutas `input`/`output` del stage y no hereda la sesión anterior.
6. `agent_status.py` rechaza saltos de stage sin artefactos previos obligatorios.
7. Durante `build`, escribir producto en `project/` y tests en `tests/`; requiere `feature_id`, `stage=build`, `agent=implementer` y `state=running` en `work/runtime-status.json`.

View File

@@ -1,3 +1,4 @@
import { execFileSync } from "node:child_process";
import { readFileSync, watchFile, unwatchFile } from "node:fs";
import { join, relative, resolve, sep } from "node:path";
import type { ExtensionAPI, ExtensionContext, ToolCallEvent } from "@earendil-works/pi-coding-agent";
@@ -50,6 +51,19 @@ type RuntimeStatus = {
const ROOT_PRODUCT_EXTENSIONS = new Set([".py", ".js", ".ts", ".go", ".rs", ".java", ".php", ".rb"]);
// Carpetas permitidas para escritura (Orquestra + proyecto)
const ALLOWED_WRITE_DIRS = new Set([
"project", // código del proyecto
"tests", // tests del proyecto
"work", // artifacts, current.md, runtime-status.json
"backlog", // features.json
"spec", // specs globales
"harness", // configuración Orquestra (solo leader)
"scripts", // scripts Orquestra (solo leader)
"platforms", // extensiones Pi (solo leader)
"docs", // documentación (solo leader)
]);
function safeReadJson(root: string, path: string): RuntimeStatus | null {
try {
return JSON.parse(readFileSync(join(root, path), "utf8")) as RuntimeStatus;
@@ -165,12 +179,50 @@ function hasActiveBuildRuntime(status: RuntimeStatus | null): boolean {
);
}
function parseStageCommand(args: string, status: RuntimeStatus | null): { stage?: string; featureId?: string; error?: string } {
const parts = args.trim().split(/\s+/).filter(Boolean);
const stage = parts[0];
const featureFlag = parts.indexOf("--feature-id");
const featureId = featureFlag >= 0 ? parts[featureFlag + 1] : parts[1] || status?.feature_id || undefined;
if (!stage) return { error: "Uso: /orquestra-stage <stage> [feature_id]" };
if (!featureId) return { error: "Falta feature_id. Uso: /orquestra-stage <stage> <feature_id>" };
return { stage, featureId };
}
function lastUsefulLine(text: string): string {
return text
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.slice(-1)[0] || "stage terminado";
}
function guardWriteOrEdit(event: ToolCallEvent, ctx: ExtensionContext): { block?: boolean; reason?: string } | undefined {
if (event.toolName !== "write" && event.toolName !== "edit") return undefined;
const { relPath, reason } = normalizeWritePath(ctx.cwd, (event.input as { path?: unknown }).path);
if (!relPath) return { block: true, reason };
// Extraer el directorio raíz del path
const slashIndex = relPath.indexOf("/");
const rootDir = slashIndex === -1 ? relPath : relPath.slice(0, slashIndex);
// Bloquear escrituras fuera de carpetas permitidas
if (!ALLOWED_WRITE_DIRS.has(rootDir)) {
return {
block: true,
reason: `Orquestra bloqueó '${relPath}': solo se permite escribir en ${Array.from(ALLOWED_WRITE_DIRS).join(", ")}. No crees carpetas ni archivos en la raíz ni en ubicaciones no autorizadas.`,
};
}
// Bloquear escrituras directas a backlog/features.json (debe usar close_feature.py)
if (relPath === "backlog/features.json") {
return {
block: true,
reason: `Orquestra bloqueó '${relPath}': NUNCA edites backlog/features.json directamente. Usa scripts/close_feature.py para cerrar features. Este script valida gates y actualiza el backlog automáticamente.`,
};
}
if (isRootProductFile(relPath)) {
return {
block: true,
@@ -285,4 +337,34 @@ export default function orquestraStatusExtension(pi: ExtensionAPI) {
ctx.ui.notify("Orquestra status refrescado", "info");
},
});
pi.registerCommand("orquestra-stage", {
description: "Ejecuta un stage Orquestra en un proceso Pi fresco: /orquestra-stage <stage> [feature_id]",
handler: async (args, ctx) => {
const parsed = parseStageCommand(args, safeReadJson(ctx.cwd, STATUS_FILE));
if (parsed.error) {
ctx.ui.notify(parsed.error, "error");
return;
}
ctx.ui.notify(`Orquestra ejecutando ${parsed.stage} en Pi fresco…`, "info");
try {
const output = execFileSync("python3", ["scripts/run_stage.py", parsed.stage!, "--feature-id", parsed.featureId!], {
cwd: ctx.cwd,
encoding: "utf8",
maxBuffer: 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
currentCtx = ctx;
apply(ctx);
ctx.ui.notify(`Orquestra stage terminado: ${lastUsefulLine(output)}`, "info");
} catch (error) {
const failed = error as { stdout?: string; stderr?: string; message?: string };
const detail = lastUsefulLine(`${failed.stdout || ""}\n${failed.stderr || ""}`) || failed.message || "error desconocido";
currentCtx = ctx;
apply(ctx);
ctx.ui.notify(`Orquestra stage falló: ${detail}`, "error");
}
},
});
}

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

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