feat(ADM-018): completed feature
@@ -4,3 +4,4 @@ coverage/
|
||||
package-lock.json
|
||||
scripts/tests/fixtures/
|
||||
design_prompt.md
|
||||
storefront/
|
||||
|
||||
248
project/CAVEMAN.md
Normal 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
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
|
||||
9
project/apps/admin/AGENTS.md
Normal file
@@ -0,0 +1,9 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
1
project/apps/admin/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
18
project/apps/admin/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
7
project/apps/admin/next-env.d.ts
vendored
Normal 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.
|
||||
12
project/apps/admin/next.config.ts
Normal 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
27
project/apps/admin/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
project/apps/admin/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
BIN
project/apps/admin/public/images/logo-main.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
178
project/apps/admin/src/app/(auth)/login/page.tsx
Normal 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">
|
||||
© {new Date().getFullYear()} MercadoDeVida
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
project/apps/admin/src/app/(dashboard)/audit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
244
project/apps/admin/src/app/(dashboard)/brands/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
272
project/apps/admin/src/app/(dashboard)/categories/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
project/apps/admin/src/app/(dashboard)/cms/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
298
project/apps/admin/src/app/(dashboard)/customers/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
348
project/apps/admin/src/app/(dashboard)/inventory/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
project/apps/admin/src/app/(dashboard)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
273
project/apps/admin/src/app/(dashboard)/orders/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
203
project/apps/admin/src/app/(dashboard)/orders/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
248
project/apps/admin/src/app/(dashboard)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
project/apps/admin/src/app/(dashboard)/payments/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ProductEditor } from '@/features/products/components/ProductEditor';
|
||||
|
||||
export default function NewProductPage() {
|
||||
return <ProductEditor />;
|
||||
}
|
||||
224
project/apps/admin/src/app/(dashboard)/products/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
project/apps/admin/src/app/(dashboard)/promotions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
project/apps/admin/src/app/(dashboard)/reviews/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
project/apps/admin/src/app/(dashboard)/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
289
project/apps/admin/src/app/(dashboard)/shipping/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
project/apps/admin/src/app/(dashboard)/tax-rates/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
236
project/apps/admin/src/app/(dashboard)/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
project/apps/admin/src/app/api/[...path]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
46
project/apps/admin/src/app/api/auth/login/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
19
project/apps/admin/src/app/api/auth/logout/route.ts
Normal 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;
|
||||
}
|
||||
16
project/apps/admin/src/app/api/auth/me/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
44
project/apps/admin/src/app/api/upload/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
51
project/apps/admin/src/app/globals.css
Normal 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;
|
||||
}
|
||||
18
project/apps/admin/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 >30%, ámbar 10-30%, rojo <10%.</p>
|
||||
<p><strong>IVA:</strong> 21% general (alimentación procesada) · 10% reducido (alimentos básicos, frutas, verduras).</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
314
project/apps/admin/src/lib/api-client.ts
Normal 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),
|
||||
};
|
||||
63
project/apps/admin/src/lib/permissions.ts
Normal 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));
|
||||
}
|
||||
178
project/apps/admin/src/types/index.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
42
project/apps/admin/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
1
project/apps/admin/tsconfig.tsbuildinfo
Normal 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
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
9
project/frontend/AGENTS.md
Normal file
@@ -0,0 +1,9 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
1
project/frontend/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
36
project/frontend/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
18
project/frontend/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
11
project/frontend/next.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{ protocol: 'https', hostname: '**' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
26
project/frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.3.1",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
project/frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
project/frontend/public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
project/frontend/public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
BIN
project/frontend/public/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
project/frontend/public/images/logo-main.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
project/frontend/public/images/logo-small.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
1
project/frontend/public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
project/frontend/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
project/frontend/public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
39
project/frontend/src/app/about/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import ContentPage from '@/components/content/ContentPage';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchPage } from '@/lib/api';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Quiénes somos',
|
||||
description: 'Conoce la historia, misión y valores de MercadoDeVida. Productos naturales y orgánicos de confianza.',
|
||||
};
|
||||
|
||||
const FALLBACK_HTML = `
|
||||
<h2>Nuestra historia</h2>
|
||||
<p>MercadoDeVida nació de la convicción de que comer bien no debería ser complicado. Seleccionamos cuidadosamente cada producto para asegurar la máxima calidad y transparencia.</p>
|
||||
<h2>Nuestra misión</h2>
|
||||
<p>Facilitar el acceso a productos naturales y orgánicos de alta calidad, directamente desde productores certificados, sin intermediarios.</p>
|
||||
<h2>Valores</h2>
|
||||
<ul>
|
||||
<li>Transparencia total en el origen de los productos</li>
|
||||
<li>Compromiso con la agricultura ecológica y sostenible</li>
|
||||
<li>Selección rigurosa de proveedores certificados</li>
|
||||
<li>Envío responsable con packaging reciclable</li>
|
||||
<li>Atención al cliente cercana y personalizada</li>
|
||||
</ul>
|
||||
<h2>Dónde estamos</h2>
|
||||
<p>Operamos exclusivamente online, enviando a toda España peninsular. Nuestros productos proceden de explotaciones ecológicas certificadas tanto nacionales como europeas.</p>
|
||||
`;
|
||||
|
||||
export default async function AboutPage() {
|
||||
const cms = await fetchPage('about').catch(() => null);
|
||||
const body = cms?.body ?? FALLBACK_HTML;
|
||||
|
||||
return (
|
||||
<ContentPage
|
||||
title={cms?.title ?? 'Quiénes somos'}
|
||||
description="Conoce la historia, misión y valores de MercadoDeVida. Productos naturales y orgánicos de confianza."
|
||||
>
|
||||
<div dangerouslySetInnerHTML={{ __html: body }} />
|
||||
</ContentPage>
|
||||
);
|
||||
}
|
||||
5
project/frontend/src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminLayout from '@/components/admin/AdminLayout';
|
||||
|
||||
export default function AdminRootLayout({ children }: { children: React.ReactNode }) {
|
||||
return <AdminLayout>{children}</AdminLayout>;
|
||||
}
|
||||
21
project/frontend/src/app/admin/orders/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Admin Pedidos — MercadoDeVida',
|
||||
};
|
||||
|
||||
export default function AdminOrdersPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Pedidos</h1>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-12 text-center">
|
||||
<div className="text-5xl mb-4">🧾</div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-2">Sin pedidos aún</h2>
|
||||
<p className="text-gray-500 text-sm">Los pedidos que realicen los clientes aparecerán aquí.</p>
|
||||
<a href="/admin" className="mt-6 inline-block text-sm text-[#70ad47] hover:underline">
|
||||
← Volver al dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
project/frontend/src/app/admin/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Admin — MercadoDeVida',
|
||||
};
|
||||
|
||||
export default function AdminDashboard() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Dashboard</h1>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||
{[
|
||||
{ label: 'Productos', value: '—', icon: '📦', color: 'bg-blue-50 border-blue-100' },
|
||||
{ label: 'Pedidos', value: '—', icon: '🧾', color: 'bg-green-50 border-green-100' },
|
||||
{ label: 'Usuarios', value: '—', icon: '👥', color: 'bg-orange-50 border-orange-100' },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} className={`${stat.color} border rounded-xl p-6`}>
|
||||
<div className="text-3xl mb-2">{stat.icon}</div>
|
||||
<p className="text-3xl font-bold text-gray-900">{stat.value}</p>
|
||||
<p className="text-sm text-gray-500 mt-1">{stat.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h2 className="font-bold text-gray-900 mb-4">Acciones rápidas</h2>
|
||||
<div className="space-y-2">
|
||||
<a href="/admin/products" className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition-colors text-gray-700">
|
||||
<span>📦</span>
|
||||
<span className="text-sm font-medium">Gestionar productos</span>
|
||||
</a>
|
||||
<a href="/admin/orders" className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition-colors text-gray-700">
|
||||
<span>🧾</span>
|
||||
<span className="text-sm font-medium">Ver pedidos</span>
|
||||
</a>
|
||||
<Link href="/" className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition-colors text-gray-700">
|
||||
<span>🌿</span>
|
||||
<span className="text-sm font-medium">Ver tienda</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
project/frontend/src/app/admin/products/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchProducts } from '@/lib/api';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Admin Productos — MercadoDeVida',
|
||||
};
|
||||
|
||||
export default async function AdminProductsPage() {
|
||||
const products = await fetchProducts();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Productos</h1>
|
||||
<span className="text-sm text-gray-500">{products.length} productos</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Producto</th>
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Marca</th>
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Precio</th>
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Stock</th>
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{products.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<a href={`/products/${p.slug}`} className="text-sm font-medium text-gray-900 hover:text-[#70ad47] transition-colors">
|
||||
{p.name}
|
||||
</a>
|
||||
<p className="text-xs text-gray-400 truncate max-w-xs">{p.description}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{p.brand?.name ?? '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm font-bold text-[#70ad47]">—</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Activo
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<a href={`/products/${p.slug}`} className="text-sm text-[#70ad47] hover:underline">
|
||||
Ver
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
project/frontend/src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { email, password } = body;
|
||||
|
||||
const backendRes = await fetch('http://127.0.0.1:3000/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await backendRes.json();
|
||||
|
||||
if (!backendRes.ok) {
|
||||
return NextResponse.json(data, { status: backendRes.status });
|
||||
}
|
||||
|
||||
// Forward session cookie from backend
|
||||
const backendSetCookie = backendRes.headers.get('set-cookie');
|
||||
const response = NextResponse.json(data, { status: 200 });
|
||||
if (backendSetCookie) {
|
||||
response.headers.set('Set-Cookie', backendSetCookie);
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'SERVER_ERROR', message: 'Error del servidor' } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
12
project/frontend/src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function POST() {
|
||||
const response = NextResponse.json({ ok: true });
|
||||
response.cookies.set('session_token', '', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 0,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
30
project/frontend/src/app/api/auth/me/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const sessionToken = request.cookies.get('session_token')?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
return NextResponse.json({ user: null });
|
||||
}
|
||||
|
||||
try {
|
||||
const backendRes = await fetch('http://127.0.0.1:3000/auth/me', {
|
||||
headers: {
|
||||
Cookie: `session_token=${sessionToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await backendRes.json();
|
||||
|
||||
// Backend returns { id, email, role } when authenticated,
|
||||
// or { user: null } when not (AppError 401 → reply.send({ user: null }))
|
||||
// Normalize into { user: ... }
|
||||
if (!backendRes.ok || !data.id) {
|
||||
return NextResponse.json({ user: null });
|
||||
}
|
||||
|
||||
return NextResponse.json({ user: data });
|
||||
} catch {
|
||||
return NextResponse.json({ user: null });
|
||||
}
|
||||
}
|
||||
32
project/frontend/src/app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { email, password } = body;
|
||||
|
||||
const backendRes = await fetch('http://127.0.0.1:3000/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await backendRes.json();
|
||||
|
||||
if (!backendRes.ok) {
|
||||
return NextResponse.json(data, { status: backendRes.status });
|
||||
}
|
||||
|
||||
const backendSetCookie = backendRes.headers.get('set-cookie');
|
||||
const response = NextResponse.json(data, { status: 201 });
|
||||
if (backendSetCookie) {
|
||||
response.headers.set('Set-Cookie', backendSetCookie);
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'SERVER_ERROR', message: 'Error del servidor' } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
76
project/frontend/src/app/auth/login/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login } = useAuth();
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
const result = await login(email, password);
|
||||
setLoading(false);
|
||||
if (result.ok) {
|
||||
router.push('/');
|
||||
} else {
|
||||
setError(result.error || 'Credenciales inválidas');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto px-4 py-16">
|
||||
<div className="bg-white border border-gray-200 rounded-2xl p-8 shadow-sm">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6 text-center" style={{ fontFamily: 'var(--font-heading)' }}>
|
||||
Iniciar sesión
|
||||
</h1>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg px-4 py-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="tu@email.com"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Contraseña</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-[#70ad47] hover:bg-[#5a9040] disabled:opacity-60 text-white font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
{loading ? 'Entrando...' : 'Iniciar sesión'}
|
||||
</button>
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
¿No tienes cuenta? <Link href="/auth/register" className="text-[#70ad47] hover:underline font-medium">Créala aquí</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
project/frontend/src/app/auth/register/page.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { register } = useAuth();
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (password !== confirm) {
|
||||
setError('Las contraseñas no coinciden');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const result = await register(email, password);
|
||||
setLoading(false);
|
||||
if (result.ok) {
|
||||
router.push('/');
|
||||
} else {
|
||||
setError(result.error || 'Error al crear cuenta');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto px-4 py-16">
|
||||
<div className="bg-white border border-gray-200 rounded-2xl p-8 shadow-sm">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6 text-center" style={{ fontFamily: 'var(--font-heading)' }}>
|
||||
Crear cuenta
|
||||
</h1>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg px-4 py-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Contraseña</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
minLength={8}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Confirmar contraseña</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-[#70ad47] hover:bg-[#5a9040] disabled:opacity-60 text-white font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
{loading ? 'Creando...' : 'Crear cuenta'}
|
||||
</button>
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
¿Ya tienes cuenta? <Link href="/auth/login" className="text-[#70ad47] hover:underline font-medium">Inicia sesión</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
project/frontend/src/app/brands/[slug]/page.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchBrandBySlug, fetchProducts, formatPrice } from '@/lib/api';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const brand = await fetchBrandBySlug(slug);
|
||||
if (!brand) return { title: 'Marca no encontrada' };
|
||||
return {
|
||||
title: brand.seoTitle ?? brand.name,
|
||||
description: brand.seoDescription ?? `Productos ${brand.name} en MercadoDeVida.`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BrandPage({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
const [brand, products] = await Promise.all([
|
||||
fetchBrandBySlug(slug),
|
||||
fetchProducts({ brandSlug: slug, limit: 24 }),
|
||||
]);
|
||||
|
||||
if (!brand) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-16 text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Marca no encontrada</h1>
|
||||
<Link href="/brands" className="text-[#70ad47] font-medium hover:underline">
|
||||
Ver todas las marcas →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-6" aria-label="Breadcrumb">
|
||||
<ol className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<li><Link href="/" className="hover:text-[#70ad47]">Inicio</Link></li>
|
||||
<li><span className="text-gray-300">/</span></li>
|
||||
<li><Link href="/brands" className="hover:text-[#70ad47]">Marcas</Link></li>
|
||||
<li><span className="text-gray-300">/</span></li>
|
||||
<li className="text-gray-900 font-medium">{brand.name}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center gap-4">
|
||||
<div className="w-16 h-16 bg-[#70ad47]/10 rounded-2xl flex items-center justify-center">
|
||||
<span className="text-2xl font-bold text-[#70ad47]">
|
||||
{brand.name.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900" style={{ fontFamily: 'var(--font-heading)' }}>
|
||||
{brand.name}
|
||||
</h1>
|
||||
{brand.seoDescription && (
|
||||
<p className="mt-1 text-gray-600">{brand.seoDescription}</p>
|
||||
)}
|
||||
<p className="mt-1 text-sm text-gray-500">{products.length} producto{products.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Products */}
|
||||
{products.length === 0 ? (
|
||||
<div className="py-16 text-center">
|
||||
<p className="text-gray-500">No hay productos de esta marca todavía.</p>
|
||||
<Link href="/brands" className="text-[#70ad47] font-medium hover:underline mt-4 inline-block">
|
||||
Ver otras marcas →
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 justify-items-center">
|
||||
{products.map((product) => (
|
||||
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
|
||||
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-all hover:shadow-md">
|
||||
<div className="aspect-square relative bg-white flex items-center justify-center">
|
||||
{product.images?.[0] ? (
|
||||
<Image src={product.images[0].url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
|
||||
) : (
|
||||
<span className="text-5xl">🌿</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="font-semibold text-gray-900 group-hover:text-[#70ad47] transition-colors line-clamp-2 text-sm">
|
||||
{product.name}
|
||||
</h3>
|
||||
<p className="text-gray-500 text-xs mt-1 line-clamp-2">{product.description}</p>
|
||||
<div className="mt-3 pr-2">
|
||||
<span className="text-lg font-bold text-[#70ad47]">{formatPrice(0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
project/frontend/src/app/brands/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchBrands } from '@/lib/api';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Marcas — MercadoDeVida',
|
||||
description: 'Todas las marcas de productos naturales y ecológicos.',
|
||||
};
|
||||
|
||||
export default async function BrandsPage() {
|
||||
const brands = await fetchBrands();
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900" style={{ fontFamily: 'var(--font-heading)' }}>
|
||||
Nuestras marcas
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Descubre las marcas de confianza que trabajan con nosotros.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4 justify-center">
|
||||
{brands.map((brand) => (
|
||||
<Link key={brand.id} href={`/brands/${brand.slug}`}>
|
||||
<div className="p-6 bg-gray-50 hover:bg-[#70ad47] hover:text-white rounded-xl border border-gray-200 hover:border-[#70ad47] transition-all text-center group">
|
||||
<div className="w-14 h-14 mx-auto mb-3 bg-[#70ad47]/10 group-hover:bg-white/20 rounded-full flex items-center justify-center">
|
||||
<span className="text-xl font-bold text-[#70ad47] group-hover:text-white">
|
||||
{brand.name.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-semibold text-sm">{brand.name}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
project/frontend/src/app/cart/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from 'next';
|
||||
import CartPageContent from '@/components/cart/CartPageContent';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Carrito — MercadoDeVida',
|
||||
};
|
||||
|
||||
export default function CartPage() {
|
||||
return <CartPageContent />;
|
||||
}
|
||||
118
project/frontend/src/app/categories/[slug]/page.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchCategoryBySlug, fetchProducts, fetchBrands } from '@/lib/api';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const category = await fetchCategoryBySlug(slug);
|
||||
if (!category) return { title: 'Categoría no encontrada' };
|
||||
return {
|
||||
title: category.seoTitle ?? category.name,
|
||||
description: category.seoDescription ?? `${category.name} — Productos naturales y orgánicos en MercadoDeVida.`,
|
||||
};
|
||||
}
|
||||
|
||||
function formatPrice(cents: number): string {
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
export default async function CategoryPage({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
const [category, products, brands] = await Promise.all([
|
||||
fetchCategoryBySlug(slug),
|
||||
fetchProducts({ categorySlug: slug, limit: 20 }),
|
||||
fetchBrands(),
|
||||
]);
|
||||
|
||||
if (!category) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-16 text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Categoría no encontrada</h1>
|
||||
<p className="text-gray-500 mb-8">La categoría que buscas no existe.</p>
|
||||
<Link href="/categories" className="text-[#70ad47] font-medium hover:underline">
|
||||
Ver todas las categorías →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const breadcrumb = [
|
||||
{ label: 'Inicio', href: '/' },
|
||||
{ label: 'Categorías', href: '/categories' },
|
||||
{ label: category.name, href: `/categories/${category.slug}` },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-6" aria-label="Breadcrumb">
|
||||
<ol className="flex items-center gap-2 text-sm text-gray-500">
|
||||
{breadcrumb.map((item, i) => (
|
||||
<li key={item.href} className="flex items-center gap-2">
|
||||
{i > 0 && <span className="text-gray-300">/</span>}
|
||||
<Link href={item.href} className="hover:text-[#70ad47] transition-colors">
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900" style={{ fontFamily: 'var(--font-heading)' }}>
|
||||
{category.name}
|
||||
</h1>
|
||||
{category.seoDescription && (
|
||||
<p className="mt-2 text-gray-600">{category.seoDescription}</p>
|
||||
)}
|
||||
<p className="mt-1 text-sm text-gray-500">{products.length} producto{products.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
|
||||
{/* Products grid */}
|
||||
{products.length === 0 ? (
|
||||
<div className="py-16 text-center">
|
||||
<p className="text-gray-500 mb-4">No hay productos en esta categoría todavía.</p>
|
||||
<Link href="/categories" className="text-[#70ad47] font-medium hover:underline">
|
||||
Explorar otras categorías →
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 justify-items-center">
|
||||
{products.map((product) => (
|
||||
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
|
||||
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-all hover:shadow-md">
|
||||
<div className="aspect-square relative bg-white flex items-center justify-center">
|
||||
{product.images?.[0] ? (
|
||||
<Image src={product.images[0].url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
|
||||
) : (
|
||||
<span className="text-5xl">🌿</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{product.brandId && (
|
||||
<p className="text-xs text-[#E76F51] font-medium uppercase tracking-wide mb-1">
|
||||
{brands.find((b) => b.id === product.brandId)?.name ?? 'Marca'}
|
||||
</p>
|
||||
)}
|
||||
<h3 className="font-semibold text-gray-900 group-hover:text-[#70ad47] transition-colors line-clamp-2 text-sm">
|
||||
{product.name}
|
||||
</h3>
|
||||
<p className="text-gray-500 text-xs mt-1 line-clamp-2">{product.description}</p>
|
||||
<div className="mt-3 pr-2">
|
||||
<span className="text-lg font-bold text-[#70ad47]">{formatPrice(0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
project/frontend/src/app/categories/page.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchCategories } from '@/lib/api';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Categorías — MercadoDeVida',
|
||||
description: 'Explora todas las categorías de productos naturales y orgánicos.',
|
||||
};
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
alimentacion: '🥜',
|
||||
suplementos: '💊',
|
||||
'cosmetica-natural': '🌸',
|
||||
'limpieza-ecologica': '🌿',
|
||||
};
|
||||
|
||||
const colors = [
|
||||
'from-[#70ad47] to-[#40916C]',
|
||||
'from-[#E76F51] to-[#F4A261]',
|
||||
'from-[#52B788] to-[#74C69D]',
|
||||
'from-[#5a9040] to-[#70ad47]',
|
||||
];
|
||||
|
||||
export default async function CategoriesPage() {
|
||||
const tree = await fetchCategories();
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900" style={{ fontFamily: 'var(--font-heading)' }}>
|
||||
Categorías
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Explora nuestra selección de productos naturales y ecológicos organizados por categoría.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 justify-center">
|
||||
{tree.map((cat, i) => (
|
||||
<Link key={cat.id} href={`/categories/${cat.slug}`} className="group block">
|
||||
<div className={`relative overflow-hidden rounded-2xl bg-gradient-to-br ${colors[i % colors.length]} p-6 text-white min-h-[140px] flex flex-col justify-between`}>
|
||||
<div className="absolute top-4 right-4 text-5xl opacity-20">{icons[cat.slug] ?? '📦'}</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold group-hover:underline">{cat.name}</h2>
|
||||
{cat.seoDescription && (
|
||||
<p className="mt-1 text-sm text-white/80 line-clamp-2">{cat.seoDescription}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{cat.children?.map((child) => (
|
||||
<span key={child.id} className="text-xs bg-white/20 px-2 py-1 rounded-full backdrop-blur-sm">
|
||||
{child.name}
|
||||
</span>
|
||||
))}
|
||||
{(!cat.children || cat.children.length === 0) && (
|
||||
<span className="text-xs bg-white/20 px-2 py-1 rounded-full backdrop-blur-sm">
|
||||
Ver productos
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
project/frontend/src/app/checkout/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from 'next';
|
||||
import CheckoutClient from '@/components/checkout/CheckoutClient';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Checkout — MercadoDeVida',
|
||||
};
|
||||
|
||||
export default function CheckoutPage() {
|
||||
return <CheckoutClient />;
|
||||
}
|
||||
36
project/frontend/src/app/contact/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import ContentPage from '@/components/content/ContentPage';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchPage } from '@/lib/api';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Contacto',
|
||||
description: 'Ponte en contacto con el equipo de MercadoDeVida. Resolvemos tus dudas sobre productos, pedidos y envíos.',
|
||||
};
|
||||
|
||||
const FALLBACK_HTML = `
|
||||
<h2>Email</h2>
|
||||
<p><a href="mailto:hola@mercadodevida.es" class="text-[#70ad47] hover:underline">hola@mercadodevida.es</a></p>
|
||||
<p>Intentamos responder en un plazo de 24-48 horas laborables.</p>
|
||||
<h2>Horario de atención</h2>
|
||||
<p>Lunes a viernes: 9:00 – 18:00h</p>
|
||||
<p>Sábados: 10:00 – 14:00h</p>
|
||||
<p>Domingos y festivos: cerrado</p>
|
||||
<h2>Preguntas frecuentes</h2>
|
||||
<p>Antes de escribirnos, puede que tu duda ya esté resuelta en nuestra sección de <a href="/shipping" class="text-[#70ad47] hover:underline">envíos</a>.</p>
|
||||
<h2>Redes sociales</h2>
|
||||
<p>Síguenos en nuestras redes para estar al día de nuevas incorporaciones, ofertas y recetas saludables.</p>
|
||||
`;
|
||||
|
||||
export default async function ContactPage() {
|
||||
const cms = await fetchPage('contact').catch(() => null);
|
||||
const body = cms?.body ?? FALLBACK_HTML;
|
||||
|
||||
return (
|
||||
<ContentPage
|
||||
title={cms?.title ?? 'Contacto'}
|
||||
description="Estamos aquí para ayudarte. Contáctanos por cualquiera de estos canales."
|
||||
>
|
||||
<div dangerouslySetInnerHTML={{ __html: body }} />
|
||||
</ContentPage>
|
||||
);
|
||||
}
|
||||
71
project/frontend/src/app/cookies/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import ContentPage from '@/components/content/ContentPage';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Política de cookies',
|
||||
description: 'Información sobre el uso de cookies en MercadoDeVida.',
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function CookiesPage() {
|
||||
return (
|
||||
<ContentPage
|
||||
title="Política de cookies"
|
||||
description="Última actualización: agosto de 2026. Esta página requiere revisión por un asesor legal antes de uso en producción."
|
||||
>
|
||||
<p className="bg-amber-50 border border-amber-200 text-amber-800 rounded-lg p-4 mb-6 text-sm">
|
||||
⚠️ <strong>Placeholder:</strong> Este texto es un marcador. El contenido legal real debe ser
|
||||
redactado o aprobado por un profesional jurídico antes de публикации en producción.
|
||||
</p>
|
||||
|
||||
<h2>¿Qué son las cookies?</h2>
|
||||
<p>
|
||||
Las cookies son pequeños archivos de texto que se almacenan en tu dispositivo cuando visitas
|
||||
una página web.
|
||||
</p>
|
||||
|
||||
<h2>Tipos de cookies que usamos</h2>
|
||||
|
||||
<h3>Cookies necesarias</h3>
|
||||
<p>
|
||||
Requeridas para el funcionamiento básico de la tienda: carrito de compra, sesión de usuario,
|
||||
seguridad. No requieren consentimiento.
|
||||
</p>
|
||||
|
||||
<h3>Cookies de análisis</h3>
|
||||
<p>
|
||||
Usamos herramientas de análisis para entender cómo los visitantes usan nuestra web. Estas
|
||||
cookies son anónimas y nos ayudan a mejorar la experiencia.
|
||||
</p>
|
||||
|
||||
<h3>Cookies de preferencias</h3>
|
||||
<p>
|
||||
Recuerdan tus preferencias de idioma, región y otros ajustes para personalizar tu experiencia.
|
||||
</p>
|
||||
|
||||
<h2>Gestión de cookies</h2>
|
||||
<p>
|
||||
Puedes aceptar o rechazar cookies no esenciales desde el banner de cookies que aparece al
|
||||
visitar nuestra web por primera vez.
|
||||
</p>
|
||||
<p>
|
||||
También puedes configurar tu navegador para bloquear cookies. Ten en cuenta que bloquear
|
||||
algunas cookies puede afectar al funcionamiento de la tienda.
|
||||
</p>
|
||||
|
||||
<h2>Más información</h2>
|
||||
<p>
|
||||
Para más información sobre cookies, visita{' '}
|
||||
<a
|
||||
href="https://www.allaboutcookies.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#70ad47] hover:underline"
|
||||
>
|
||||
www.allaboutcookies.org
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</ContentPage>
|
||||
);
|
||||
}
|
||||
BIN
project/frontend/src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
25
project/frontend/src/app/globals.css
Normal file
@@ -0,0 +1,25 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-primary: #70ad47;
|
||||
--color-primary-dark: #5a9040;
|
||||
--color-secondary: #F5F0E8;
|
||||
--color-accent: #E76F51;
|
||||
--color-text: #1a1a1a;
|
||||
--color-muted: #6b7280;
|
||||
--color-footer-bg: #ffffff;
|
||||
--color-footer-text: #1a1a1a;
|
||||
--color-footer-muted: #6b7280;
|
||||
--font-sans: "Open Sans", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #1a1a1a;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans), system-ui, sans-serif;
|
||||
}
|
||||
43
project/frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Open_Sans } from 'next/font/google';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { Footer } from '@/components/layout/Footer';
|
||||
import { CartProvider } from '@/contexts/CartContext';
|
||||
import { AuthProvider } from '@/contexts/AuthContext';
|
||||
import './globals.css';
|
||||
|
||||
const opensans = Open_Sans({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-sans',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'MercadoDeVida — Productos naturales y orgánicos',
|
||||
description:
|
||||
'Tienda online de productos naturales, orgánicos y saludables. Envío a toda España. Calidad certificada.',
|
||||
icons: {
|
||||
icon: '/images/favicon.png',
|
||||
},
|
||||
openGraph: {
|
||||
title: 'MercadoDeVida — Productos naturales y orgánicos',
|
||||
description: 'Tienda online de productos naturales, orgánicos y saludables.',
|
||||
type: 'website',
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="es" suppressHydrationWarning className={opensans.variable}>
|
||||
<body className="min-h-screen flex flex-col">
|
||||
<CartProvider>
|
||||
<AuthProvider>
|
||||
<Header />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
</AuthProvider>
|
||||
</CartProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
32
project/frontend/src/app/not-found.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 py-24 text-center">
|
||||
<div className="text-7xl mb-6">🔍</div>
|
||||
<h1
|
||||
className="text-4xl font-bold text-gray-900 mb-4"
|
||||
style={{ fontFamily: 'var(--font-heading)' }}
|
||||
>
|
||||
Página no encontrada
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 mb-8">
|
||||
Lo sentimos, la página que buscas no existe o ha sido movida.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="px-6 py-3 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
Volver al inicio
|
||||
</Link>
|
||||
<Link
|
||||
href="/products"
|
||||
className="px-6 py-3 border border-gray-300 hover:border-[#70ad47] text-gray-700 font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
Ver productos
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
project/frontend/src/app/order-confirmation/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
interface Props {
|
||||
searchParams: Promise<{ orderId?: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
|
||||
const { orderId } = await searchParams;
|
||||
return {
|
||||
title: orderId
|
||||
? `Pedido ${orderId.slice(0, 8).toUpperCase()} confirmado — MercadoDeVida`
|
||||
: 'Pedido confirmado — MercadoDeVida',
|
||||
};
|
||||
}
|
||||
|
||||
export default async function OrderConfirmationPage({ searchParams }: Props) {
|
||||
const { orderId } = await searchParams;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 py-16 text-center">
|
||||
<div className="text-7xl mb-6">✅</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-3" style={{ fontFamily: 'var(--font-heading)' }}>
|
||||
¡Pedido confirmado!
|
||||
</h1>
|
||||
{orderId && (
|
||||
<p className="text-sm text-gray-500 mb-2 font-mono">
|
||||
Referencia: {orderId.slice(0, 8).toUpperCase()}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-lg text-gray-600 mb-2">
|
||||
Tu pedido ha sido recibido correctamente.
|
||||
</p>
|
||||
<p className="text-gray-500 mb-8">
|
||||
Te hemos enviado un email de confirmación con los detalles.
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl border border-gray-200 p-6 mb-8 text-left">
|
||||
<h2 className="font-bold text-gray-900 mb-4">Próximos pasos</h2>
|
||||
<ul className="space-y-3 text-sm text-gray-600">
|
||||
<li className="flex gap-3">
|
||||
<span className="w-6 h-6 bg-[#70ad47] text-white rounded-full flex items-center justify-center flex-shrink-0 text-xs font-bold">1</span>
|
||||
<span>Recibirás un email de confirmación en tu bandeja de entrada.</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="w-6 h-6 bg-[#70ad47] text-white rounded-full flex items-center justify-center flex-shrink-0 text-xs font-bold">2</span>
|
||||
<span>Prepararemos tu pedido en 24-48 horas laborables.</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="w-6 h-6 bg-[#70ad47] text-white rounded-full flex items-center justify-center flex-shrink-0 text-xs font-bold">3</span>
|
||||
<span>Recibirás un email con el número de seguimiento.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="px-6 py-3 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
Volver al inicio
|
||||
</Link>
|
||||
<Link
|
||||
href="/products"
|
||||
className="px-6 py-3 border border-gray-300 hover:border-[#70ad47] text-gray-700 font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
Seguir comprando
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
project/frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Hero } from '@/components/home/Hero';
|
||||
import FeaturedProducts from '@/components/home/FeaturedProducts';
|
||||
import CategoriesGrid from '@/components/home/CategoriesGrid';
|
||||
import BrandsSection from '@/components/home/BrandsSection';
|
||||
|
||||
export const revalidate = 3600; // ISR: revalidate every hour
|
||||
|
||||
export default async function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<FeaturedProducts />
|
||||
<CategoriesGrid />
|
||||
<BrandsSection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
63
project/frontend/src/app/privacy/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import ContentPage from '@/components/content/ContentPage';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Política de privacidad',
|
||||
description: 'Información sobre cómo MercadoDeVida recopila, usa y protege tus datos personales.',
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<ContentPage
|
||||
title="Política de privacidad"
|
||||
description="Última actualización: agosto de 2026. Esta página requiere revisión por un asesor legal antes de uso en producción."
|
||||
>
|
||||
<p className="bg-amber-50 border border-amber-200 text-amber-800 rounded-lg p-4 mb-6 text-sm">
|
||||
⚠️ <strong>Placeholder:</strong> Este texto es un marcador. El contenido legal real debe ser
|
||||
redactado o aprobado por un profesional jurídico antes de публикации en producción.
|
||||
</p>
|
||||
|
||||
<h2>Responsable del tratamiento</h2>
|
||||
<p>
|
||||
MercadoDeVida<br />
|
||||
Email: hola@mercadodevida.es
|
||||
</p>
|
||||
|
||||
<h2>Datos que recopilamos</h2>
|
||||
<p>
|
||||
Recopilamos datos de registro (nombre, email, dirección), datos de pedido (productos,
|
||||
importe, dirección de entrega) y datos de navegación con tu consentimiento.
|
||||
</p>
|
||||
|
||||
<h2>Finalidad del tratamiento</h2>
|
||||
<ul>
|
||||
<li>Gestión de pedidos y entregas</li>
|
||||
<li>Atención al cliente</li>
|
||||
<li>Envío de comunicaciones comerciales (solo con consentimiento)</li>
|
||||
<li>Cumplimiento de obligaciones fiscales</li>
|
||||
</ul>
|
||||
|
||||
<h2>Tus derechos</h2>
|
||||
<p>
|
||||
Puedes ejercer tus derechos de acceso, rectificación, supresión, portabilidad y oposición
|
||||
escribiéndonos a hola@mercadodevida.es.
|
||||
</p>
|
||||
|
||||
<h2>Conservación de datos</h2>
|
||||
<p>
|
||||
Conservamos tus datos mientras mantengas una cuenta activa. Los datos de pedidos se conservan
|
||||
durante el período legalmente exigido para cumplir obligaciones fiscales.
|
||||
</p>
|
||||
|
||||
<h2>Cookies</h2>
|
||||
<p>
|
||||
Consulta nuestra{' '}
|
||||
<a href="/cookies" className="text-[#70ad47] hover:underline">
|
||||
política de cookies
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</ContentPage>
|
||||
);
|
||||
}
|
||||
224
project/frontend/src/app/products/[slug]/page.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import type { Metadata } from 'next';
|
||||
import {
|
||||
fetchProductBySlug,
|
||||
fetchProductVariants,
|
||||
fetchVariantPrice,
|
||||
fetchStockAvailability,
|
||||
fetchCategories,
|
||||
fetchBrands,
|
||||
formatPrice,
|
||||
calcGrossPrice,
|
||||
} from '@/lib/api';
|
||||
import ProductAddToCart from '@/components/cart/ProductAddToCart';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const product = await fetchProductBySlug(slug);
|
||||
if (!product) return { title: 'Producto no encontrado' };
|
||||
return {
|
||||
title: product.seoTitle ?? product.name,
|
||||
description: product.seoDescription ?? product.description,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProductPage({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
const [product, brands] = await Promise.all([
|
||||
fetchProductBySlug(slug),
|
||||
fetchBrands(),
|
||||
]);
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-16 text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Producto no encontrado</h1>
|
||||
<p className="text-gray-500 mb-8">El producto que buscas no existe.</p>
|
||||
<Link href="/products" className="text-[#70ad47] font-medium hover:underline">
|
||||
Ver todos los productos →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const brand = brands.find((b) => b.id === product.brandId);
|
||||
|
||||
// Get primary variant + price + stock
|
||||
const variants = await fetchProductVariants(product.id);
|
||||
const primaryVariant = variants[0];
|
||||
|
||||
let price = null;
|
||||
let stock = null;
|
||||
if (primaryVariant) {
|
||||
[price, stock] = await Promise.all([
|
||||
fetchVariantPrice(primaryVariant.id),
|
||||
fetchStockAvailability(primaryVariant.id),
|
||||
]);
|
||||
}
|
||||
|
||||
// Build category links from all category IDs
|
||||
const tree = await fetchCategories();
|
||||
const flatCats: Array<{ id: string; name: string; slug: string; parentSlug?: string }> = [];
|
||||
function flatten(cats: typeof tree, parentSlug?: string) {
|
||||
for (const cat of cats) {
|
||||
flatCats.push({ id: cat.id, name: cat.name, slug: cat.slug, parentSlug });
|
||||
if (cat.children?.length) flatten(cat.children, cat.slug);
|
||||
}
|
||||
}
|
||||
flatten(tree);
|
||||
const productCats = flatCats.filter((c) => product.categoryIds?.includes(c.id));
|
||||
|
||||
const netCents = price?.netUnitAmountCents ?? 0;
|
||||
const vatRate = price?.vatRate ?? 'general';
|
||||
const grossCents = calcGrossPrice(netCents, vatRate);
|
||||
const vatPercent = vatRate === 'general' ? 21 : 10;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-6" aria-label="Breadcrumb">
|
||||
<ol className="flex items-center gap-2 text-sm text-gray-500 flex-wrap">
|
||||
<li><Link href="/" className="hover:text-[#70ad47]">Inicio</Link></li>
|
||||
<li><span className="text-gray-300">/</span></li>
|
||||
<li><Link href="/products" className="hover:text-[#70ad47]">Productos</Link></li>
|
||||
{productCats[0] && (
|
||||
<>
|
||||
<li><span className="text-gray-300">/</span></li>
|
||||
<li><Link href={`/categories/${productCats[0].slug}`} className="hover:text-[#70ad47]">{productCats[0].name}</Link></li>
|
||||
</>
|
||||
)}
|
||||
<li><span className="text-gray-300">/</span></li>
|
||||
<li className="text-gray-900 font-medium truncate max-w-xs">{product.name}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
{/* Image */}
|
||||
<div>
|
||||
<div className="aspect-square bg-gray-50 rounded-2xl border border-gray-100 flex items-center justify-center overflow-hidden">
|
||||
{product.images?.[0] ? (
|
||||
<Image
|
||||
src={product.images[0].url}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-8xl">🌿</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div>
|
||||
{brand && (
|
||||
<Link href={`/brands/${brand.slug}`} className="text-sm text-[#E76F51] font-medium uppercase tracking-wide hover:underline">
|
||||
{brand.name}
|
||||
</Link>
|
||||
)}
|
||||
<h1 className="text-3xl font-bold text-gray-900 mt-2" style={{ fontFamily: 'var(--font-heading)' }}>
|
||||
{product.name}
|
||||
</h1>
|
||||
|
||||
{/* Price */}
|
||||
{price ? (
|
||||
<div className="mt-6 bg-gray-50 rounded-xl p-6">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="text-4xl font-bold text-[#70ad47]">{formatPrice(grossCents)}</span>
|
||||
<span className="text-lg text-gray-500">inc. IVA {vatPercent}%</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
{formatPrice(netCents)} sin IVA · IVA {vatPercent}%
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 bg-gray-50 rounded-xl p-6">
|
||||
<span className="text-2xl text-gray-400">Precio no disponible</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stock */}
|
||||
{stock && (
|
||||
<div className="mt-4">
|
||||
{stock.available ? (
|
||||
<div className="flex items-center gap-2 text-[#70ad47]">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">En stock — {stock.availableQuantity} unidades</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-red-500">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Sin stock</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add to cart */}
|
||||
{stock?.available && primaryVariant ? (
|
||||
<ProductAddToCart
|
||||
variantId={primaryVariant.id}
|
||||
productId={product.id}
|
||||
productName={product.name}
|
||||
priceCents={grossCents}
|
||||
imageUrl={product.images?.[0]?.url}
|
||||
available={true}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
disabled
|
||||
className="mt-6 px-8 py-3.5 bg-gray-200 text-gray-500 font-semibold rounded-xl cursor-not-allowed"
|
||||
>
|
||||
Agotado
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{product.description && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-2">Descripción</h2>
|
||||
<p className="text-gray-600 leading-relaxed">{product.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Categories */}
|
||||
{productCats.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-2">Categorías</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{productCats.map((cat) => (
|
||||
<Link
|
||||
key={cat.id}
|
||||
href={`/categories/${cat.slug}`}
|
||||
className="px-3 py-1 bg-gray-100 hover:bg-[#70ad47] hover:text-white text-sm rounded-full transition-colors"
|
||||
>
|
||||
{cat.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SKU */}
|
||||
{primaryVariant && (
|
||||
<div className="mt-4 text-xs text-gray-400">
|
||||
SKU: {primaryVariant.sku}
|
||||
{primaryVariant.ean && ` · EAN: ${primaryVariant.ean}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
project/frontend/src/app/products/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchProducts, fetchBrands, fetchCategories, formatPrice } from '@/lib/api';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Productos — MercadoDeVida',
|
||||
description: 'Todos los productos naturales y orgánicos.',
|
||||
};
|
||||
|
||||
function findCatName(cats: ReturnType<typeof flattenCats>, id: string): string | undefined {
|
||||
return cats.find((c) => c.id === id)?.name;
|
||||
}
|
||||
|
||||
function flattenCats(cats: Awaited<ReturnType<typeof fetchCategories>>): Array<{ id: string; name: string; slug: string }> {
|
||||
const flat: Array<{ id: string; name: string; slug: string }> = [];
|
||||
function walk(c: typeof cats[number]) {
|
||||
flat.push({ id: c.id, name: c.name, slug: c.slug });
|
||||
if (c.children?.length) c.children.forEach(walk);
|
||||
}
|
||||
cats.forEach(walk);
|
||||
return flat;
|
||||
}
|
||||
|
||||
export default async function ProductsPage() {
|
||||
const [products, brands, categories] = await Promise.all([
|
||||
fetchProducts({ limit: 24 }),
|
||||
fetchBrands(),
|
||||
fetchCategories(),
|
||||
]);
|
||||
const flatCats = flattenCats(categories);
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900" style={{ fontFamily: 'var(--font-heading)' }}>
|
||||
Todos los productos
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600">{products.length} productos disponibles</p>
|
||||
</div>
|
||||
|
||||
{products.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-16">No hay productos disponibles.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{products.map((product) => {
|
||||
const brand = brands.find((b) => b.id === product.brandId);
|
||||
return (
|
||||
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
|
||||
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-all hover:shadow-md">
|
||||
<div className="aspect-square relative bg-white flex items-center justify-center">
|
||||
{product.images?.[0] ? (
|
||||
<Image src={product.images[0].url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
|
||||
) : (
|
||||
<span className="text-5xl">🌿</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{brand && (
|
||||
<p className="text-xs text-[#E76F51] font-medium uppercase tracking-wide mb-1">{brand.name}</p>
|
||||
)}
|
||||
<h3 className="font-semibold text-gray-900 group-hover:text-[#70ad47] transition-colors line-clamp-2 text-sm">
|
||||
{product.name}
|
||||
</h3>
|
||||
<p className="text-gray-500 text-xs mt-1 line-clamp-2">{product.description}</p>
|
||||
<div className="mt-3 pr-2">
|
||||
<span className="text-lg font-bold text-[#70ad47]">{formatPrice(0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
project/frontend/src/app/robots.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: ['/admin/', '/api/', '/auth/', '/cart', '/checkout', '/order-confirmation'],
|
||||
},
|
||||
],
|
||||
sitemap: 'https://mercadodevida.es/sitemap.xml',
|
||||
};
|
||||
}
|
||||
125
project/frontend/src/app/search/page.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchProducts, fetchBrands, fetchCategories, formatPrice } from '@/lib/api';
|
||||
|
||||
interface Props {
|
||||
searchParams: Promise<{ q?: string; brand?: string; category?: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
|
||||
const { q } = await searchParams;
|
||||
const title = q ? `Buscar: "${q}"` : 'Buscar productos';
|
||||
return { title, description: `${title} en MercadoDeVida.` };
|
||||
}
|
||||
|
||||
export default async function SearchPage({ searchParams }: Props) {
|
||||
const { q, brand, category } = await searchParams;
|
||||
const query = q?.trim() ?? '';
|
||||
|
||||
const [products, brands, categories] = await Promise.all([
|
||||
fetchProducts({ q: query || undefined, brandSlug: brand, categorySlug: category, limit: 24 }),
|
||||
fetchBrands(),
|
||||
fetchCategories(),
|
||||
]);
|
||||
|
||||
// Flatten categories for display
|
||||
const flatCats: Array<{ id: string; name: string; slug: string }> = [];
|
||||
function flatten(cats: typeof categories) {
|
||||
for (const c of cats) {
|
||||
flatCats.push({ id: c.id, name: c.name, slug: c.slug });
|
||||
if (c.children?.length) flatten(c.children);
|
||||
}
|
||||
}
|
||||
flatten(categories);
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Search form */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4" style={{ fontFamily: 'var(--font-heading)' }}>
|
||||
Buscar productos
|
||||
</h1>
|
||||
<form method="GET" action="/search" className="flex gap-3">
|
||||
<input
|
||||
name="q"
|
||||
type="search"
|
||||
defaultValue={query}
|
||||
placeholder="Buscar productos, marcas, categorías..."
|
||||
className="flex-1 px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-[#70ad47] focus:border-transparent outline-none text-gray-900"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-3 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
Buscar
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{query && (
|
||||
<div className="mb-6">
|
||||
<p className="text-gray-600">
|
||||
{products.length > 0
|
||||
? `${products.length} resultado${products.length !== 1 ? 's' : ''} para "${query}"`
|
||||
: `Sin resultados para "${query}"`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{products.length === 0 && query ? (
|
||||
<div className="py-16 text-center">
|
||||
<p className="text-gray-500 text-lg mb-4">No encontramos productos para tu búsqueda.</p>
|
||||
<p className="text-gray-400 mb-8">Prueba con otros términos o explora nuestras categorías.</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{['Almendras', 'Aceite', 'Vitamina', 'Crema', 'Jabón', 'Matcha'].map((term) => (
|
||||
<Link key={term} href={`/search?q=${encodeURIComponent(term)}`}
|
||||
className="px-4 py-2 bg-gray-100 hover:bg-[#70ad47] hover:text-white rounded-full text-sm transition-colors">
|
||||
{term}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : products.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 justify-items-center">
|
||||
{products.map((product) => {
|
||||
const brand_ = brands.find((b) => b.id === product.brandId);
|
||||
const cats = product.categoryIds?.map((id) => flatCats.find((c) => c.id === id)).filter(Boolean) ?? [];
|
||||
return (
|
||||
<Link key={product.id} href={`/products/${product.slug}`} className="group block">
|
||||
<div className="bg-gray-50 rounded-xl overflow-hidden border border-gray-100 hover:border-[#70ad47] transition-all hover:shadow-md">
|
||||
<div className="aspect-square relative bg-white flex items-center justify-center">
|
||||
{product.images?.[0] ? (
|
||||
<Image src={product.images[0].url} alt={product.name} fill className="object-cover" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" />
|
||||
) : (
|
||||
<span className="text-5xl">🌿</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{brand_ && (
|
||||
<p className="text-xs text-[#E76F51] font-medium uppercase tracking-wide mb-1">{brand_.name}</p>
|
||||
)}
|
||||
<h3 className="font-semibold text-gray-900 group-hover:text-[#70ad47] transition-colors line-clamp-2 text-sm">
|
||||
{product.name}
|
||||
</h3>
|
||||
<p className="text-gray-500 text-xs mt-1 line-clamp-2">{product.description}</p>
|
||||
<div className="mt-3 pr-2">
|
||||
<span className="text-lg font-bold text-[#70ad47]">{formatPrice(0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-16 text-center text-gray-500">
|
||||
<p>Escribe un término de búsqueda y presiona Enter.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
project/frontend/src/app/shipping/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import ContentPage from '@/components/content/ContentPage';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchPage } from '@/lib/api';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Envíos y entregas',
|
||||
description: 'Información sobre métodos de envío, plazos de entrega y costes. Envío a toda España peninsular.',
|
||||
};
|
||||
|
||||
const FALLBACK_HTML = `
|
||||
<h2>Zonas de envío</h2>
|
||||
<p>Realizamos envíos a toda España peninsular. Para Canarias, Ceuta y Melilla, contacta con nosotros antes de realizar tu pedido.</p>
|
||||
<h2>Métodos de envío</h2>
|
||||
<h3>Envío estándar (3-5 días laborables)</h3>
|
||||
<p>Entrega en 3-5 días laborables. Coste según peso del pedido.</p>
|
||||
<h3>Envío express 24h</h3>
|
||||
<p>Entrega al día siguiente laborable para pedidos realizados antes de las 13:00h. Disponible para productos en stock.</p>
|
||||
<h2>Seguimiento del pedido</h2>
|
||||
<p>Una vez despachado tu pedido, recibirás un email con el número de seguimiento. Puedes rastrear tu paquete en la web del transportista.</p>
|
||||
<h2>Costes de envío</h2>
|
||||
<p>El coste exacto se calcula al finalizar tu pedido en función del peso y la dirección de entrega. Para pedidos superiores a un umbral mínimo, el envío estándar es gratuito.</p>
|
||||
<h2>Problemas con la entrega</h2>
|
||||
<p>Si tu pedido no llega en el plazo indicado, ponte en contacto con nosotros en <a href="mailto:hola@mercadodevida.es" class="text-[#70ad47] hover:underline">hola@mercadodevida.es</a>.</p>
|
||||
`;
|
||||
|
||||
export default async function ShippingPage() {
|
||||
const cms = await fetchPage('shipping').catch(() => null);
|
||||
const body = cms?.body ?? FALLBACK_HTML;
|
||||
|
||||
return (
|
||||
<ContentPage
|
||||
title={cms?.title ?? 'Envíos y entregas'}
|
||||
description="Información sobre cómo enviamos tu pedido y los plazos de entrega estimados."
|
||||
>
|
||||
<div dangerouslySetInnerHTML={{ __html: body }} />
|
||||
</ContentPage>
|
||||
);
|
||||
}
|
||||
16
project/frontend/src/app/sitemap.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
const BASE_URL = 'https://mercadodevida.es';
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{ url: BASE_URL, lastModified: new Date(), changeFrequency: 'weekly', priority: 1 },
|
||||
{ url: `${BASE_URL}/products`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.9 },
|
||||
{ url: `${BASE_URL}/categories`, lastModified: new Date(), changeFrequency: 'weekly', priority: 0.8 },
|
||||
{ url: `${BASE_URL}/brands`, lastModified: new Date(), changeFrequency: 'weekly', priority: 0.8 },
|
||||
{ url: `${BASE_URL}/search`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.7 },
|
||||
{ url: `${BASE_URL}/about`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.5 },
|
||||
{ url: `${BASE_URL}/contact`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.5 },
|
||||
{ url: `${BASE_URL}/shipping`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.5 },
|
||||
];
|
||||
}
|
||||