mirror of
https://github.com/marcogll/AnchorOS.git
synced 2026-03-15 21:24:35 +00:00
Compare commits
46 Commits
b0ea5548ef
...
feature/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
012f45f451 | ||
|
|
d27354fd5a | ||
|
|
24e5af3860 | ||
|
|
bff1edf04f | ||
|
|
ef3d5f421a | ||
|
|
68dfe54fd2 | ||
|
|
28e4a73cdf | ||
|
|
1e93188783 | ||
|
|
e0d0cd1055 | ||
|
|
7b0a2b0c40 | ||
|
|
1b9230f2be | ||
|
|
88ea79f496 | ||
|
|
e3952bf8ea | ||
|
|
37547ea1bb | ||
|
|
35d5cd058c | ||
|
|
dbac7631e5 | ||
|
|
09180ff77d | ||
|
|
bb25d6bde6 | ||
|
|
f6832c1e29 | ||
|
|
c220e7f30f | ||
|
|
46d6d3e625 | ||
|
|
2be7b02248 | ||
|
|
68a46b6c5d | ||
|
|
5d7a3ec481 | ||
|
|
70437e90c2 | ||
|
|
4a0dc0be0a | ||
|
|
8bc9c959b5 | ||
|
|
0351d8ac9d | ||
|
|
ddeb2f28bd | ||
|
|
0ef3d19f08 | ||
|
|
02b933d893 | ||
|
|
439cc80546 | ||
|
|
1b8ab9fecf | ||
|
|
604cd6c417 | ||
|
|
a6902b6b46 | ||
|
|
0b13b991c9 | ||
|
|
93366fc596 | ||
|
|
c0a9568e5c | ||
|
|
2c19c49f14 | ||
|
|
1ca7a2cfbc | ||
|
|
d1735878ef | ||
|
|
bedf1c028a | ||
|
|
66e20d25a7 | ||
|
|
b7d6e51d67 | ||
|
|
7f8a54f249 | ||
|
|
0f3de32899 |
71
.dockerignore
Normal file
71
.dockerignore
Normal file
@@ -0,0 +1,71 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Docker
|
||||
Dockerfile*
|
||||
docker-compose*.yml
|
||||
deploy.sh
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
# Keep deployment guides in production image
|
||||
!DEPLOYMENT_README.md
|
||||
!API_TESTING_GUIDE.md
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
32
.env.coolify
Normal file
32
.env.coolify
Normal file
@@ -0,0 +1,32 @@
|
||||
# Supabase
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://pvvwbnybkadhreuqijsl.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InB2dndibnlia2FkaHJldXFpanNsIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njg0OTk1MzksImV4cCI6MjA4NDA3NTUzOX0.298akX41SawJiJ0OovDK3FbEnbWJwEnhYlU08mbw9Sk
|
||||
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InB2dndibnlia2FkaHJldXFpanNsIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc2ODQ5OTUzOSwiZXhwIjoyMDg0MDc1NTM5fQ.bEkwIvPfsa4ZQRqyOkdtE-3PLailNSIz4XRKJJJrtpg
|
||||
|
||||
# Stripe
|
||||
NEXT_PUBLIC_STRIPE_ENABLED=false
|
||||
STRIPE_SECRET_KEY=REDACTED_SERVER_ONLY
|
||||
STRIPE_PUBLISHABLE_KEY=pk_live_51N8FdAB4PJM8J9HnOkKyviAySjVXYjJqca9vWoy0jTU1aT56CtxD0dmT5eszAg40egvtGoWklLfbPadrbnNpIO8P00yHyXPPuT
|
||||
STRIPE_WEBHOOK_SECRET=REDACTED_SERVER_ONLY
|
||||
|
||||
# Google Calendar
|
||||
GOOGLE_SERVICE_ACCOUNT_JSON={"type":"service_account","project_id":"..."}
|
||||
GOOGLE_CALENDAR_ID=primary
|
||||
|
||||
# WhatsApp (Twilio / Meta)
|
||||
TWILIO_ACCOUNT_SID=REDACTED_SERVER_ONLY
|
||||
TWILIO_AUTH_TOKEN=REDACTED_SERVER_ONLY
|
||||
TWILIO_WHATSAPP_FROM=whatsapp:+14155238886
|
||||
|
||||
# NextAuth
|
||||
NEXTAUTH_URL=https://anchoros.soul23.cloud
|
||||
NEXTAUTH_SECRET=ODB6oloFvaGgNaM5s2tINGPryU9YHlxivDGQYT+0O7M=
|
||||
|
||||
# App
|
||||
NEXT_PUBLIC_APP_URL=https://anchoros.soul23.cloud
|
||||
|
||||
# Admin Enrollment
|
||||
ADMIN_ENROLLMENT_KEY=REDACTED_SERVER_ONLY
|
||||
|
||||
# Kiosk
|
||||
NEXT_PUBLIC_KIOSK_API_KEY=FIGe1OWhv6awCABwK9SecbiSy2vOjJuXKAzJsAsRQLZnwm9RbOEEjrtYVGBj1oST
|
||||
44
.env.evolution
Normal file
44
.env.evolution
Normal file
@@ -0,0 +1,44 @@
|
||||
# Evolution API Environment Variables
|
||||
|
||||
# Server
|
||||
SERVER_TYPE=http
|
||||
SERVER_PORT=8080
|
||||
SERVER_URL=http://localhost:8080
|
||||
|
||||
# Telemetry
|
||||
TELEMETRY=false
|
||||
|
||||
# CORS
|
||||
CORS_ORIGIN=*
|
||||
CORS_METHODS=GET,POST,PUT,DELETE
|
||||
CORS_CREDENTIALS=true
|
||||
|
||||
# Logs
|
||||
LOG_LEVEL=ERROR,WARN,DEBUG,INFO
|
||||
LOG_COLOR=true
|
||||
LOG_BAILEYS=error
|
||||
|
||||
# Instances
|
||||
DEL_INSTANCE=false
|
||||
|
||||
# Persistent Storage (using local for now, can change to Supabase later)
|
||||
DATABASE_ENABLED=false
|
||||
DATABASE_PROVIDER=postgresql
|
||||
DATABASE_CONNECTION_URI=postgresql://dummy:dummy@localhost:5432/dummy
|
||||
|
||||
# Use local cache instead
|
||||
CACHE_LOCAL_ENABLED=true
|
||||
|
||||
# Authentication
|
||||
AUTHENTICATION_API_KEY=ANCHOR23_API_KEY_CHANGE_THIS
|
||||
|
||||
# Language
|
||||
LANGUAGE=en
|
||||
|
||||
# Session Config
|
||||
CONFIG_SESSION_PHONE_CLIENT=Anchor23 API
|
||||
CONFIG_SESSION_PHONE_NAME=Chrome
|
||||
|
||||
# QR Code
|
||||
QRCODE_LIMIT=30
|
||||
QRCODE_COLOR=#175197
|
||||
15
.env.example
15
.env.example
@@ -18,8 +18,23 @@ TWILIO_AUTH_TOKEN=your_auth_token
|
||||
TWILIO_WHATSAPP_FROM=whatsapp:+14155238886
|
||||
|
||||
# NextAuth
|
||||
# In production, these will be injected by deployment platform (Coolify, Vercel, etc.)
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
NEXTAUTH_SECRET=your-nextauth-secret
|
||||
|
||||
# Email Service (Resend)
|
||||
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# App
|
||||
# In production, these will be injected by deployment platform (Coolify, Vercel, etc.)
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
|
||||
# Formbricks (Surveys - Optional)
|
||||
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=your-environment-id
|
||||
NEXT_PUBLIC_FORMBRICKS_API_HOST=https://app.formbricks.com
|
||||
|
||||
# Optional: Redis para caching
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
# Optional: Analytics
|
||||
NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
|
||||
|
||||
40
.env.template
Normal file
40
.env.template
Normal file
@@ -0,0 +1,40 @@
|
||||
# Supabase Configuration
|
||||
NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
SUPABASE_URL=your_supabase_project_url
|
||||
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
|
||||
|
||||
# Stripe Configuration
|
||||
NEXT_PUBLIC_STRIPE_ENABLED=false
|
||||
STRIPE_SECRET_KEY=your_stripe_secret_key
|
||||
STRIPE_PUBLISHABLE_KEY=your_stripe_publishable_key
|
||||
STRIPE_WEBHOOK_SECRET=your_stripe_webhook_secret
|
||||
|
||||
# Google Calendar (Optional)
|
||||
GOOGLE_SERVICE_ACCOUNT_JSON=your_google_service_account_json
|
||||
GOOGLE_CALENDAR_ID=primary
|
||||
GOOGLE_CALENDAR_VERIFY_TOKEN=your_verify_token
|
||||
|
||||
# WhatsApp/Twilio (Optional)
|
||||
TWILIO_ACCOUNT_SID=your_twilio_account_sid
|
||||
TWILIO_AUTH_TOKEN=your_twilio_auth_token
|
||||
TWILIO_WHATSAPP_FROM=whatsapp:+your_twilio_whatsapp_number
|
||||
|
||||
# Email (Optional)
|
||||
RESEND_API_KEY=your_resend_api_key
|
||||
|
||||
# Application
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:2311
|
||||
|
||||
# Admin Enrollment (Optional)
|
||||
ADMIN_ENROLLMENT_KEY=your_admin_enrollment_key
|
||||
|
||||
# Cron Jobs
|
||||
CRON_SECRET=your_cron_secret
|
||||
|
||||
# Kiosk (Optional)
|
||||
NEXT_PUBLIC_KIOSK_API_KEY=your_kiosk_api_key
|
||||
|
||||
# Formbricks (Optional)
|
||||
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=your_formbricks_environment_id
|
||||
NEXT_PUBLIC_FORMBRICKS_API_HOST=https://app.formbricks.com
|
||||
22
.gitignore
vendored
22
.gitignore
vendored
@@ -35,3 +35,25 @@ next-env.d.ts
|
||||
|
||||
# supabase
|
||||
.supabase/
|
||||
|
||||
# ralphy
|
||||
ralphy.sh
|
||||
|
||||
# Additional security - protect all env files
|
||||
.env*
|
||||
!.env.example
|
||||
!.env.template
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.bak
|
||||
*.old
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
dev.log
|
||||
server.log
|
||||
|
||||
# Build artifacts
|
||||
.next/
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
184
API_TESTING_GUIDE.md
Normal file
184
API_TESTING_GUIDE.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# AnchorOS API Testing Guide
|
||||
|
||||
## 📋 **Rutas a Probar - Testing Endpoints**
|
||||
|
||||
### **🔐 Autenticación**
|
||||
- `POST /api/auth/login` - Login de usuario
|
||||
- Body: `{ email, password }`
|
||||
- Buscar: Token JWT en respuesta
|
||||
- `POST /api/auth/register` - Registro de cliente
|
||||
- Body: `{ first_name, last_name, email, phone, password }`
|
||||
- Buscar: Usuario creado con ID
|
||||
|
||||
### **👥 Gestión de Clientes**
|
||||
- `GET /api/customers` - Listar clientes
|
||||
- Headers: Authorization Bearer token
|
||||
- Buscar: Array de clientes con datos completos
|
||||
- `POST /api/customers` - Crear cliente
|
||||
- Headers: Authorization Bearer token
|
||||
- Body: `{ first_name, last_name, email, phone }`
|
||||
- Buscar: Cliente creado
|
||||
- `GET /api/customers/[id]` - Detalles de cliente
|
||||
- Buscar: Datos completos del cliente + bookings
|
||||
|
||||
### **💺 Reservas (Bookings)**
|
||||
- `GET /api/bookings` - Listar reservas
|
||||
- Query params: `?status=confirmed&date=2024-01-01`
|
||||
- Buscar: Array de bookings con relaciones (customer, service, staff)
|
||||
- `POST /api/bookings` - Crear reserva
|
||||
- Body: `{ customer_id, service_id, staff_id, location_id, date, notes }`
|
||||
- Buscar: Booking creado + email enviado automáticamente
|
||||
- `PUT /api/bookings/[id]` - Actualizar reserva
|
||||
- Body: `{ status: 'confirmed' }`
|
||||
- Buscar: Status actualizado
|
||||
- `DELETE /api/bookings/[id]` - Cancelar reserva
|
||||
- Buscar: Status cambiado a 'cancelled'
|
||||
|
||||
### **🏢 Ubicaciones**
|
||||
- `GET /api/locations` - Listar ubicaciones
|
||||
- Buscar: Array de locations con servicios disponibles
|
||||
|
||||
### **👨💼 Staff**
|
||||
- `GET /api/staff` - Listar personal
|
||||
- Buscar: Array de staff con especialidades
|
||||
|
||||
### **💅 Servicios**
|
||||
- `GET /api/services` - Listar servicios
|
||||
- Buscar: 22 servicios de Anchor 23 con precios
|
||||
|
||||
### **📅 Disponibilidad**
|
||||
- `GET /api/availability?service_id=1&date=2024-01-01&location_id=1`
|
||||
- Buscar: Slots disponibles por staff
|
||||
- `POST /api/availability/blocks` - Bloquear horario
|
||||
- Body: `{ staff_id, start_time, end_time, reason }`
|
||||
- Buscar: Bloqueo creado
|
||||
|
||||
### **🏪 Kiosk (Auto-servicio)**
|
||||
- `GET /api/kiosk/locations` - Ubicaciones disponibles
|
||||
- Buscar: Locations con servicios activos
|
||||
- `POST /api/kiosk/bookings` - Reserva desde kiosk
|
||||
- Body: `{ service_id, customer_data, date }`
|
||||
- Buscar: Booking creado + email enviado
|
||||
- `POST /api/kiosk/walkin` - Reserva inmediata
|
||||
- Body: `{ service_id, customer_data }`
|
||||
- Buscar: Booking inmediato confirmado
|
||||
|
||||
### **📊 Aperture (Dashboard Admin)**
|
||||
- `GET /api/aperture/stats` - Estadísticas generales
|
||||
- Buscar: Métricas de negocio (revenue, bookings, etc.)
|
||||
- `GET /api/aperture/reports` - Reportes detallados
|
||||
- Buscar: Datos para gráficos y análisis
|
||||
- `GET /api/aperture/pos` - Sistema POS
|
||||
- Buscar: Servicios disponibles para venta
|
||||
|
||||
### **🧾 Recibos**
|
||||
- `GET /api/receipts/[bookingId]` - Descargar PDF
|
||||
- Buscar: PDF generado con datos de reserva
|
||||
- `POST /api/receipts/[bookingId]/email` - Enviar por email
|
||||
- Buscar: Email enviado con PDF adjunto
|
||||
|
||||
### **⚙️ Sistema**
|
||||
- `GET /api/health` - Health check
|
||||
- Buscar: `{ status: 'ok' }`
|
||||
- `POST /api/cron/reset-invitations` - Reset diario
|
||||
- Buscar: Invitaciones expiradas reseteadas
|
||||
|
||||
### **📧 Webhooks (Formularios Públicos)**
|
||||
- `POST https://flows.soul23.cloud/webhook-test/4YZ7RPfo1GT` - Webhook test
|
||||
- Body: Payload completo con form type
|
||||
- Buscar: 200 OK + acknowledgment
|
||||
- `POST https://flows.soul23.cloud/webhook/4YZ7RPfo1GT` - Webhook prod
|
||||
- Body: Payload completo con form type
|
||||
- Buscar: 200 OK + acknowledgment
|
||||
|
||||
**Form Types disponibles:**
|
||||
- `contact` - Formulario de contacto
|
||||
- `franchise` - Solicitud de franquicia
|
||||
- `membership` - Solicitud de membresía
|
||||
|
||||
**Payload Base:**
|
||||
```json
|
||||
{
|
||||
"form": "contact|franchise|membership",
|
||||
"timestamp_utc": "2026-01-18T04:26:30.187Z",
|
||||
"device_type": "mobile|desktop|unknown"
|
||||
}
|
||||
```
|
||||
|
||||
**Contact Payload:**
|
||||
```json
|
||||
{
|
||||
"form": "contact",
|
||||
"nombre": "Nombre Completo",
|
||||
"email": "email@example.com",
|
||||
"telefono": "+52 844 123 4567",
|
||||
"motivo": "cita|membresia|franquicia|servicios|pago|resena|otro",
|
||||
"mensaje": "Texto del mensaje",
|
||||
"timestamp_utc": "2026-01-18T04:26:30.187Z",
|
||||
"device_type": "mobile"
|
||||
}
|
||||
```
|
||||
|
||||
**Franchise Payload:**
|
||||
```json
|
||||
{
|
||||
"form": "franchise",
|
||||
"nombre": "Nombre Completo",
|
||||
"email": "email@example.com",
|
||||
"telefono": "+52 844 123 4567",
|
||||
"ciudad": "Monterrey",
|
||||
"estado": "Nuevo León",
|
||||
"socios": 2,
|
||||
"experiencia_sector": "1-3-anos",
|
||||
"experiencia_belleza": true,
|
||||
"mensaje": "Mensaje adicional",
|
||||
"timestamp_utc": "2026-01-18T04:26:30.187Z",
|
||||
"device_type": "desktop"
|
||||
}
|
||||
```
|
||||
|
||||
**Membership Payload:**
|
||||
```json
|
||||
{
|
||||
"form": "membership",
|
||||
"membership_id": "vip",
|
||||
"nombre": "Nombre Completo",
|
||||
"email": "email@example.com",
|
||||
"telefono": "+52 844 123 4567",
|
||||
"mensaje": "Pregunta específica",
|
||||
"timestamp_utc": "2026-01-18T04:26:30.187Z",
|
||||
"device_type": "mobile"
|
||||
}
|
||||
```
|
||||
|
||||
## 🔍 **Qué Buscar en Cada Respuesta**
|
||||
|
||||
### **✅ Éxito**
|
||||
- Status codes: 200, 201
|
||||
- Estructura de datos correcta
|
||||
- Relaciones cargadas (joins)
|
||||
- Emails enviados (para bookings)
|
||||
|
||||
### **❌ Errores**
|
||||
- Status codes: 400, 401, 403, 404, 500
|
||||
- Mensajes de error descriptivos
|
||||
- Validación de datos
|
||||
- Autenticación requerida
|
||||
|
||||
### **🔄 Estados**
|
||||
- Bookings: `pending` → `confirmed` → `completed`
|
||||
- Pagos: `pending` → `paid`
|
||||
- Recursos: `available` → `booked`
|
||||
|
||||
## 🧪 **Casos de Edge**
|
||||
|
||||
- **Autenticación**: Token expirado, permisos insuficientes
|
||||
- **Reservas**: Doble booking, horarios conflictivos
|
||||
- **Pagos**: Montos inválidos, métodos no soportados
|
||||
- **Kiosk**: Datos faltantes, servicios no disponibles
|
||||
|
||||
## 📈 **Performance**
|
||||
|
||||
- Response time < 500ms para GET
|
||||
- Response time < 2s para POST complejos
|
||||
- Conexiones concurrentes soportadas
|
||||
456
ASSETS_PLAN.md
Normal file
456
ASSETS_PLAN.md
Normal file
@@ -0,0 +1,456 @@
|
||||
# 🖼️ Assets & Images Plan
|
||||
|
||||
Este documento describe todos los recursos de imagen necesarios para AnchorOS y el plan de implementación del logo SVG original.
|
||||
|
||||
---
|
||||
|
||||
## 📁 1. Imágenes de Sucursales (@src/location)
|
||||
|
||||
**Ubicación existente:**
|
||||
```text
|
||||
src/location/
|
||||
├─ A23_VIA_K01.png
|
||||
├─ A23_VIA_K02.png
|
||||
├─ A23_VIA_K03.png
|
||||
├─ A23_VIA_K04.png
|
||||
└─ A23_VIA_K05.png
|
||||
```
|
||||
|
||||
**Plan de uso sugerido:**
|
||||
|
||||
| Archivo | Uso sugerido | Dimensiones recomendadas | Comentarios |
|
||||
|-------------------|----------------------------------------------|--------------------------|-------------------------------------|
|
||||
| A23_VIA_K01.png | Hero banner página de franquicias | 1920×800 px (desktop) / 800×600 (mobile) | Optimizar JPEG 80-85% |
|
||||
| A23_VIA_K02.png | Galería de sucursales en página franquicias | 400×300 px (thumbnails) / 1200×600 (modal) | PNG o WebP optimizado |
|
||||
| A23_VIA_K03.png | Slider mobile página de franquicias | 375×250 px (ratio 3:2) | Comprimir para mobile |
|
||||
| A23_VIA_K04.png | Card destacado primera sucursal | 600×400 px | PNG con transparencia si aplica |
|
||||
| A23_VIA_K05.png | Background sección información | 1920×900 px (parallax) o 1920×1080 (cover) | Considerar overlay oscuro |
|
||||
|
||||
---
|
||||
|
||||
## 📁 2. Imágenes de Servicios
|
||||
|
||||
**Ubicación sugerida:** `src/public/images/services/` o `public/images/services/`
|
||||
|
||||
**Categorías según código:**
|
||||
- `core` - CORE EXPERIENCES
|
||||
- `nails` - NAIL COUTURE
|
||||
- `hair` - HAIR FINISHING RITUALS
|
||||
- `lashes` - LASH & BROW RITUALS
|
||||
- `events` - EVENT EXPERIENCES
|
||||
- `permanent` - PERMANENT RITUALS
|
||||
|
||||
**Estructura sugerida:**
|
||||
```text
|
||||
public/images/services/
|
||||
├─ core/
|
||||
│ ├─ spa-hero.jpg (1920×1080)
|
||||
│ ├─ facial-hero.jpg (1920×1080)
|
||||
│ └─ experience-1.jpg (1200×800)
|
||||
├─ nails/
|
||||
│ ├─ manicure-thumb.jpg (600×800)
|
||||
│ ├─ pedicure-thumb.jpg (600×800)
|
||||
│ └─ nail-art.jpg (800×600)
|
||||
├─ hair/
|
||||
│ ├─ blowout.jpg (800×800)
|
||||
│ ├─ styling.jpg (800×800)
|
||||
│ └─ treatment.jpg (800×600)
|
||||
├─ lashes/
|
||||
│ ├─ extensions.jpg (800×800)
|
||||
│ └─ brows.jpg (600×800)
|
||||
├─ events/
|
||||
│ └─ event-thumb.jpg (1200×800)
|
||||
└─ permanent/
|
||||
└─ treatment.jpg (800×600)
|
||||
```
|
||||
|
||||
**Tamaños mínimos sugeridos:**
|
||||
- Hero de categoría: 1920×1080 px
|
||||
- Thumbnails verticales: 600×800 px
|
||||
- Cuadrados: 800×800 px
|
||||
- Formatos: JPG 80-85% (fotos), PNG/WebP (gráficos)
|
||||
|
||||
---
|
||||
|
||||
## 📁 3. Imágenes de Sucursales para Franquicias
|
||||
|
||||
**Ubicación sugerida:** `public/images/franchises/` o `src/public/images/franchises/`
|
||||
|
||||
**Imágenes necesarias:**
|
||||
- `franchise-landing-hero.jpg` - Banner principal (1920×900 px)
|
||||
- `location-hero-1.jpg` - Hero sucursal 1 (1200×600 px)
|
||||
- `location-hero-2.jpg` - Hero sucursal 2 (1200×600 px)
|
||||
- `location-hero-3.jpg` - Hero sucursal 3 (1200×600 px)
|
||||
- `franchise-team.jpg` - Foto del equipo (1200×600 px)
|
||||
- `success-badge.jpg` - Badge de éxito (300×300 px)
|
||||
|
||||
---
|
||||
|
||||
## 📁 4. Imágenes de Página Principal
|
||||
|
||||
**Ubicación sugerida:** `public/images/home/`
|
||||
|
||||
**Imágenes actuales en código:**
|
||||
1. `hero-bg.jpg` - Imagen Hero Section (1920×1080 px, parallax)
|
||||
- Uso: `<div className="hero-image">` en app/page.tsx:22
|
||||
- Recomendación: Foto de spa elegante, tonos cálidos, luz suave
|
||||
|
||||
2. `foundation-bg.jpg` - Imagen Sección Fundamento (1200×600 px)
|
||||
- Uso: `<aside className="foundation-image">` en app/page.tsx:44
|
||||
- Recomendación: Foto del logo o detalle arquitectónico
|
||||
|
||||
---
|
||||
|
||||
## 📁 5. Imágenes de Historia
|
||||
|
||||
**Ubicación sugerida:** `public/images/history/` o `src/public/images/history/`
|
||||
|
||||
**Imágenes necesarias:**
|
||||
- `history-hero.jpg` - Banner principal (1920×600 px)
|
||||
- `founders.jpg` - Foto de fundadores (1200×800 px)
|
||||
- `timeline-1.jpg` - Foto evento 1 (800×600 px)
|
||||
- `timeline-2.jpg` - Foto evento 2 (800×600 px)
|
||||
- `timeline-3.jpg` - Foto evento 3 (800×600 px)
|
||||
|
||||
---
|
||||
|
||||
## 📁 6. Imágenes de Testimonios
|
||||
|
||||
**Ubicación sugerida:** `public/images/testimonials/`
|
||||
|
||||
**Imágenes necesarias:**
|
||||
- `testimonial-1.jpg` - Foto cliente 1 (400×400 px, cuadrado)
|
||||
- `testimonial-2.jpg` - Foto cliente 2 (400×400 px, cuadrado)
|
||||
- `testimonial-3.jpg` - Foto cliente 3 (400×400 px, cuadrado)
|
||||
- `testimonial-4.jpg` - Foto cliente 4 (400×400 px, cuadrado)
|
||||
|
||||
**Notas:**
|
||||
- Fotos reales de clientes (permiso necesario)
|
||||
- Tonos cálidos, iluminación suave
|
||||
- Posibles background blur o overlay de marca
|
||||
|
||||
---
|
||||
|
||||
## 📁 7. Imágenes de Galerías
|
||||
|
||||
**Ubicación sugerida:** `public/images/gallery/`
|
||||
|
||||
**Estructura sugerida:**
|
||||
```text
|
||||
public/images/gallery/
|
||||
├─ before-after/
|
||||
│ ├─ nails-ba-1.jpg (1200×800)
|
||||
│ ├─ nails-af-1.jpg (1200×800)
|
||||
│ ├─ brows-ba-1.jpg (1200×800)
|
||||
│ └─ brows-af-1.jpg (1200×800)
|
||||
├─ treatments/
|
||||
│ ├─ facial-1.jpg (1200×800)
|
||||
│ ├─ spa-1.jpg (1200×800)
|
||||
│ └─ massage-1.jpg (1200×800)
|
||||
└─ events/
|
||||
├─ event-1.jpg (1200×800)
|
||||
└─ event-2.jpg (1200×800)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Nuevos Componentes (@src/components/)
|
||||
|
||||
**Ubicación sugerida:** `components/`
|
||||
|
||||
**Componentes agregados:**
|
||||
- `animated-logo.tsx` - Logo SVG animado con fade-in
|
||||
- `rolling-phrases.tsx` - Frases rotativas para hero sections
|
||||
- `formbricks-provider.tsx` - Provider para encuestas Formbricks
|
||||
- `webhook-form.tsx` - Formulario unificado para webhooks
|
||||
- `app-wrapper.tsx` - Wrapper de aplicación con contexto
|
||||
- `loading-screen.tsx` - Pantalla de carga con animación
|
||||
- `pattern-overlay.tsx` - Overlay de patrones decorativos
|
||||
- `responsive-nav.tsx` - Navegación responsiva con menú móvil
|
||||
|
||||
**Iconos adicionales:**
|
||||
- Diamond (check, success states)
|
||||
- Crown (VIP tier)
|
||||
|
||||
**Colores actualizados:**
|
||||
- `--charcoal-brown`: #3f362e (marrón oscuro elegante)
|
||||
- `--deep-earth`: #6f5e4f (marrón medio)
|
||||
- `--mocha-taupe`: #b8a89a (beige cálido)
|
||||
|
||||
## 9. Logo SVG Original (@src/logo.svg)
|
||||
|
||||
**Ruta:** `src/logo.svg`
|
||||
|
||||
**Propiedades:**
|
||||
```xml
|
||||
<svg viewBox="0 0 500 500" ...>
|
||||
<!-- Path único que combina ancla + "23" -->
|
||||
<path style="fill:#6f5e4f;stroke-width:1.14189;fill-opacity:1" d="m 243.91061,490.07237 ..." />
|
||||
</svg>
|
||||
```
|
||||
|
||||
**Plan de integración:**
|
||||
1. Importar SVG COMPLETO en componentes (no path simplificado)
|
||||
2. Aplicar transform para ajustar proporciones
|
||||
3. Hero: Color sólido `#6F5E4F`, sin animación (aparece instantáneamente)
|
||||
4. Loading: `#E9E1D8` sobre `#3F362E`, sin fade-in del logo + fade-out desde arriba
|
||||
|
||||
**Tamaños recomendados:**
|
||||
- Hero: 160×110 px → 200×137 px (responsive)
|
||||
- Loading: 160×110 px (fijo, consistente)
|
||||
- SVG viewBox: `0 0 160 110` (ajustado)
|
||||
|
||||
---
|
||||
|
||||
## 9. Página de Franquicias
|
||||
|
||||
**Ubicación:** `app/franchises/page.tsx`
|
||||
|
||||
**Imágenes utilizadas:**
|
||||
- Iconos: Lucide (Building2, Map, CheckCircle)
|
||||
- Necesita: Imágenes de sucursales del punto 3
|
||||
|
||||
---
|
||||
|
||||
## 10. Página de Servicios
|
||||
|
||||
**Ubicación:** `app/servicios/page.tsx`
|
||||
|
||||
**Imágenes utilizadas:**
|
||||
- No utiliza imágenes actualmente
|
||||
- Necesita: Thumbnails de servicios del punto 2
|
||||
|
||||
---
|
||||
|
||||
## 📁 11. Página de Membresías
|
||||
|
||||
**Ubicación:** `app/membresias/page.tsx`
|
||||
|
||||
**Imágenes utilizadas:**
|
||||
- Iconos: Lucide (Crown, Star, Award, Diamond)
|
||||
- Necesita: Imágenes premium para mostrar exclusividad
|
||||
|
||||
---
|
||||
|
||||
## 📁 12. Página de Historia
|
||||
|
||||
**Ubicación:** `app/historia/page.tsx`
|
||||
|
||||
**Imágenes utilizadas:**
|
||||
- Actualmente usa placeholders
|
||||
- Necesita: Imágenes de fundadores y timeline
|
||||
|
||||
---
|
||||
|
||||
## 📁 13. Página de Contacto
|
||||
|
||||
**Ubicación:** `app/contacto/page.tsx`
|
||||
|
||||
**Imágenes utilizadas:**
|
||||
- Iconos de contacto: Lucide (Mail, Phone, MapPin)
|
||||
- Necesita: Imagen de ubicación o mapa
|
||||
|
||||
---
|
||||
|
||||
## 📁 14. Página de Legal
|
||||
|
||||
**Ubicación:** `app/legal/page.tsx`
|
||||
|
||||
**Imágenes utilizadas:**
|
||||
- Iconos legales: Lucide (FileText, Shield, AlertTriangle)
|
||||
- No necesita imágenes adicionales
|
||||
|
||||
---
|
||||
|
||||
## 📁 15. Página de Privacy Policy
|
||||
|
||||
**Ubicación:** `app/privacy-policy/page.tsx`
|
||||
|
||||
**Imágenes utilizadas:**
|
||||
- Iconos de privacidad: Lucide (Lock, Eye, Shield)
|
||||
- No necesita imágenes adicionales
|
||||
|
||||
---
|
||||
|
||||
## 📁 16. Dashboard Admin (Aperture)
|
||||
|
||||
**Ubicación:** `app/aperture/page.tsx`
|
||||
|
||||
**Imágenes utilizadas:**
|
||||
- Iconos de admin: Lucide (Calendar, Users, Clock, DollarSign, TrendingUp)
|
||||
- Avatares de staff (placeholders)
|
||||
- Necesita: Fotos de staff reales
|
||||
|
||||
---
|
||||
|
||||
## 📁 17. Dashboard HQ
|
||||
|
||||
**Ubicación:** `app/hq/page.tsx`
|
||||
|
||||
**Imágenes utilizadas:**
|
||||
- Iconos de operaciones: Lucide (Building2, Users, Clock, DollarSign)
|
||||
- Necesita: Imágenes de sucursales
|
||||
|
||||
---
|
||||
|
||||
## 📁 18. Kiosk System
|
||||
|
||||
**Ubicación:** `app/kiosk/[locationId]/page.tsx`
|
||||
|
||||
**Imágenes utilizadas:**
|
||||
- Iconos de navegación: Lucide (ArrowLeft, ArrowRight, CheckCircle)
|
||||
- Logo de la sucursal actual
|
||||
- Necesita: Logo de cada ubicación
|
||||
|
||||
---
|
||||
|
||||
## 📁 19. Booking System
|
||||
|
||||
**Ubicación:** `app/booking/*/page.tsx`
|
||||
|
||||
**Imágenes utilizadas:**
|
||||
- Iconos de booking: Lucide (Calendar, Clock, MapPin, User, CreditCard)
|
||||
- Avatares de clientes (placeholders)
|
||||
- Necesita: Fotos de servicios
|
||||
|
||||
---
|
||||
|
||||
## 📁 20. Admin System
|
||||
|
||||
**Ubicación:** `app/admin/*/page.tsx`
|
||||
|
||||
**Imágenes utilizadas:**
|
||||
- Iconos de admin: Lucide (Settings, Users, Shield, BarChart3)
|
||||
- Avatares de staff (placeholders)
|
||||
- Necesita: Fotos de staff y sucursales
|
||||
|
||||
---
|
||||
|
||||
## 🎬 Loading Screen & Animations
|
||||
|
||||
**Ubicación:** `components/loading-screen.tsx`
|
||||
|
||||
**Especificaciones técnicas:**
|
||||
- **Logo SVG:** `@src/logo.svg` completo
|
||||
- **Color Loading:** `#E9E1D8` (beige claro elegante)
|
||||
- **Barra de carga:** `#E9E1D8` (mismo color)
|
||||
- **Fondo:** `#3F362E` (marrón oscuro elegante)
|
||||
- **Animación entrada:** Fade in rápido (0.3s)
|
||||
- **Animación salida:** Fade out desde arriba (0.8s, translateY -100px)
|
||||
- **Solo en home page:** Primera visita únicamente
|
||||
- **Tamaño:** 160×110 px (viewBox optimizado)
|
||||
|
||||
**Secuencia completa:**
|
||||
1. Pantalla aparece con fade in rápido
|
||||
2. Logo SVG en #E9E1D8 sobre fondo #3F362E (aparece instantáneamente)
|
||||
3. Barra de carga progresa rápidamente (120ms intervalos)
|
||||
4. Al llegar al 100%, fade out desde arriba
|
||||
5. Logo hero aparece instantáneamente en #6F5E4F
|
||||
|
||||
**Secuencia completa:**
|
||||
1. Cortinilla aparece con fade in rápido
|
||||
2. Logo en #E9E1D8 + barra de carga progresando
|
||||
3. Al completar 100%, fade out desde arriba
|
||||
4. Logo hero aparece con fade in lento en #6F5E4F
|
||||
|
||||
---
|
||||
|
||||
## 📋 21. Formbricks Integration
|
||||
|
||||
**Ubicación:** `components/formbricks-provider.tsx`
|
||||
|
||||
**Configuración:**
|
||||
- Environment ID para surveys
|
||||
- API Host URL
|
||||
- Device detection (mobile/desktop)
|
||||
- Route change tracking
|
||||
|
||||
**Variables de entorno:**
|
||||
```bash
|
||||
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=your-id
|
||||
NEXT_PUBLIC_FORMBRICKS_API_HOST=https://app.formbricks.com
|
||||
```
|
||||
|
||||
**Uso previsto:**
|
||||
- Encuestas post-experiencia
|
||||
- Feedback de clientes
|
||||
- NPS (Net Promoter Score)
|
||||
- Estudios de satisfacción
|
||||
|
||||
---
|
||||
|
||||
## 📋 Checklist de Implementación
|
||||
|
||||
| Tarea | Estado | Prioridad |
|
||||
|-------------------------------------------|----------|-----------|
|
||||
| Crear estructura de imágenes public | pending | alta |
|
||||
| Optimizar imágenes A23_VIA_* | pending | alta |
|
||||
| Implementar logo SVG en Hero sin animación | completed | alta |
|
||||
| Implementar logo SVG en Loading sin fade-in| completed | alta |
|
||||
| Crear componente animated-logo.tsx | completed | alta |
|
||||
| Crear componente rolling-phrases.tsx | completed | alta |
|
||||
| Crear componente webhook-form.tsx | completed | alta |
|
||||
| Crear componente formbricks-provider.tsx | completed | media |
|
||||
| Crear componente responsive-nav.tsx | completed | alta |
|
||||
| Actualizar colores a #3E352E | completed | alta |
|
||||
| Agregar campo motivo en contacto | completed | alta |
|
||||
| Agregar campos estado/ciudad/socios en franchise | pending | alta |
|
||||
| Agregar check experiencia belleza en franchise | pending | alta |
|
||||
| Actualizar info franchise a $100k | completed | alta |
|
||||
| Agregar link Contacto en nav/footer | completed | alta |
|
||||
| Agregar imágenes Hero/Fundamento | pending | media |
|
||||
| Agregar imágenes Historia | pending | media |
|
||||
| Agregar testimonios | pending | media |
|
||||
| Crear galería Before/After | pending | baja |
|
||||
| Agregar thumbnails de servicios | pending | alta |
|
||||
| Probar responsive en todos los breakpoints | pending | alta |
|
||||
| Verificar carga de imágenes (lazy load) | pending | media |
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Especificaciones de Branding
|
||||
|
||||
### Colores de Logo
|
||||
- **Primary:** #6f5e4f (Marrón cálido)
|
||||
- **Hero sólido:** #6F5E4F (Marrón elegante)
|
||||
- **Loading SVG:** #E9E1D8 (Beige claro elegante)
|
||||
- **Loading barra:** #E9E1D8 (Mismo que logo)
|
||||
- **Background Loading:** #3F362E (Marrón oscuro elegante)
|
||||
- **Gradient (alternativo):** #6f5e4f → #8B4513 → #5a4a3a
|
||||
|
||||
### Colores de Botones
|
||||
- **Botón primario:** #3E352E (Marrón elegante) - reemplaza --deep-earth
|
||||
- **Botón secundario:** Gradiente --bone-white → --soft-cream
|
||||
- **Tarjetas featured:** #3E352E (Marrón elegante)
|
||||
- **Hover effects:** #3E352E/90 (90% opacidad)
|
||||
|
||||
### Fondos de Secciones
|
||||
- **Hero:** #F5F5DC (Bone White)
|
||||
- **Services:** #F5F5DC
|
||||
- **Testimonials:** Blanco con overlay sutil
|
||||
- **Loading:** #3F362E
|
||||
|
||||
### Tipografía
|
||||
- **Headings:** Playfair Display
|
||||
- **Body:** Inter o similar sans-serif
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Guías de Optimización
|
||||
|
||||
### Para Imágenes
|
||||
1. **JPEG para fotos:** Calidad 80-85%, Progresivo
|
||||
2. **PNG/WebP para gráficos:** Sin pérdida
|
||||
3. **Lazy loading:** Usar `<img loading="lazy">`
|
||||
4. **Responsive images:** `srcset` para diferentes tamaños
|
||||
5. **Compression:** Usar tool (Squoosh, TinyPNG)
|
||||
|
||||
### Para SVG
|
||||
1. **ViewBox óptimo:** Mantener proporción 500:500
|
||||
2. **Clean path:** Eliminar atributos Inkscape innecesarios
|
||||
3. **Optimizar tamaño:** Minificar si es posible
|
||||
|
||||
---
|
||||
|
||||
> **Nota:** Mantener este archivo actualizado con nuevas imágenes o cambios de especificaciones de assets.
|
||||
|
||||
238
Brand_Kit.md
Normal file
238
Brand_Kit.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# ANCHOR:23
|
||||
|
||||
---
|
||||
|
||||
## 1. Origen de la Marca
|
||||
|
||||
Anchor:23 nace de la unión de **dos creativos** con trayectorias distintas y un criterio común: el lujo no es promesa, es estándar.
|
||||
|
||||
La marca surge como respuesta a una ausencia clara en la ciudad: un salón que opere bajo reglas de ultra lujo real, con ejecución constante, acceso limitado y una experiencia coherente en cada detalle.
|
||||
|
||||
No es una extensión de otra marca. No es una evolución emocional. Es un concepto paralelo, deliberadamente selectivo.
|
||||
|
||||
---
|
||||
|
||||
## 2. Significado del Nombre
|
||||
|
||||
### Anchor
|
||||
|
||||
Anchor representa el punto fijo. La base que sostiene y da estabilidad.
|
||||
|
||||
En la marca simboliza el estándar bajo el cual se ejecuta cada servicio, decisión y experiencia. No como rigidez, sino como referencia clara.
|
||||
|
||||
Es estructura. No ornamento.
|
||||
|
||||
### El signo (:)
|
||||
|
||||
El signo funciona como una **articulación**.
|
||||
|
||||
Ordena el nombre y permite la convivencia de dos criterios creativos dentro de un mismo sistema. No busca significado simbólico ni lectura emocional.
|
||||
|
||||
No se explica. No se enfatiza.
|
||||
|
||||
Comunica estructura.
|
||||
|
||||
### El número 23
|
||||
|
||||
El 23 es un **código interno**.
|
||||
|
||||
Remite a una idea de dirección, cuidado y constancia entendida de forma cultural y personal, no declarativa. No se presenta como mensaje ni como símbolo explícito.
|
||||
|
||||
No se comunica hacia afuera. Opera como fundamento silencioso del concepto.
|
||||
|
||||
El cliente no debe entenderlo.
|
||||
|
||||
Debe percibirlo en la experiencia: continuidad, calma y seguridad.
|
||||
|
||||
---
|
||||
|
||||
## 3. Categoría
|
||||
|
||||
Belleza de ultra lujo.
|
||||
|
||||
Anchor:23 opera como un **concepto exclusivo**, no masivo, con un estándar de servicio que no existe en el mercado local.
|
||||
|
||||
---
|
||||
|
||||
## 4. Propósito
|
||||
|
||||
Ofrecer una experiencia estética exclusiva basada en precisión técnica, coherencia visual y ejecución constante.
|
||||
|
||||
---
|
||||
|
||||
## 5. Visión
|
||||
|
||||
Ser el referente local de belleza ultra exclusiva, reconocido por su nivel de servicio, selección rigurosa y consistencia impecable.
|
||||
|
||||
---
|
||||
|
||||
## 6. Misión
|
||||
|
||||
Operar un concepto de salón de ultra lujo con **una sola sucursal por ciudad**, ajustada al tamaño del mercado, para preservar exclusividad, estándar y coherencia de experiencia.
|
||||
|
||||
Anchor:23 no escala por volumen. Escala por selección.
|
||||
|
||||
---
|
||||
|
||||
## 7. Valores
|
||||
|
||||
* Exclusividad — El acceso es limitado por diseño.
|
||||
* Excelencia — El estándar es alto y sostenido.
|
||||
* Selección — Clientes y equipo cumplen criterios claros.
|
||||
* Sobriedad — El lujo se expresa con medida.
|
||||
* Consistencia — La experiencia es siempre la misma.
|
||||
|
||||
---
|
||||
|
||||
## 8. Personalidad de Marca
|
||||
|
||||
* Sobria
|
||||
* Precisa
|
||||
* Selectiva
|
||||
* Elegante
|
||||
* Reservada
|
||||
|
||||
Anchor:23 no busca agradar a todos.
|
||||
|
||||
---
|
||||
|
||||
## 9. Arquetipo
|
||||
|
||||
**El Curador**
|
||||
|
||||
Selecciona, eleva estándares y protege la experiencia.
|
||||
|
||||
---
|
||||
|
||||
## 10. Voz y Tono
|
||||
|
||||
### Voz
|
||||
|
||||
* Clara
|
||||
* Breve
|
||||
* Profesional
|
||||
|
||||
### Tono
|
||||
|
||||
* Seguro
|
||||
* Reservado
|
||||
* Elegante
|
||||
|
||||
Sin adornos. Sin exageraciones.
|
||||
|
||||
---
|
||||
|
||||
## 11. Identidad Visual
|
||||
|
||||
### Principios
|
||||
|
||||
* Geometría clara
|
||||
* Centro de gravedad estable
|
||||
* Amplio espacio negativo
|
||||
* Composición silenciosa
|
||||
|
||||
Nunca gestual. Nunca decorativa.
|
||||
|
||||
---
|
||||
|
||||
## 12. Paleta de Color (GitHub Compatible)
|
||||
|
||||
| Swatch | Nombre | Hex |
|
||||
| ------------------------------------------------------------ | -------------- | --------- |
|
||||
|  | Bone White | `#F6F1EC` |
|
||||
|  | Soft Cream | `#EFE7DE` |
|
||||
|  | Mocha Taupe | `#B8A89A` |
|
||||
|  | Deep Earth | `#6F5E4F` |
|
||||
|  | Charcoal Brown | `#3F362E` |
|
||||
|
||||
Uso contenido. Sin saturación. Sin gradientes.
|
||||
|
||||
---
|
||||
|
||||
## 13. Tipografía
|
||||
|
||||
### Headings
|
||||
|
||||
Serif editorial sobria.
|
||||
|
||||
### Texto y UI
|
||||
|
||||
Sans neutral.
|
||||
|
||||
Mucho aire. Jerarquía estricta.
|
||||
|
||||
---
|
||||
|
||||
## 14. Experiencia de Marca
|
||||
|
||||
Anchor:23 se vive como:
|
||||
|
||||
* Acceso limitado
|
||||
* Atención altamente profesional
|
||||
* Protocolos definidos
|
||||
* Ambiente sobrio y refinado
|
||||
|
||||
La experiencia no se negocia.
|
||||
|
||||
---
|
||||
|
||||
## 15. Presencia Digital
|
||||
|
||||
### anchor23.mx
|
||||
|
||||
Sitio institucional. Marca, narrativa y conversión inicial.
|
||||
|
||||
### booking.anchor23.mx
|
||||
|
||||
Sistema de reservas (The Boutique).
|
||||
|
||||
### kiosk.anchor23.mx
|
||||
|
||||
Sistema táctil en sucursal (The Kiosk).
|
||||
|
||||
---
|
||||
|
||||
## 16. Principio Rector
|
||||
|
||||
La exclusividad no se declara.
|
||||
|
||||
Se demuestra en cada detalle.
|
||||
|
||||
---
|
||||
|
||||
## 17. Links de Prueba
|
||||
|
||||
### Frontend Institucional (anchor23.mx)
|
||||
- https://anchoros.soul23.cloud/ - Landing page con hero, fundamento, servicios y testimoniales.
|
||||
- https://anchoros.soul23.cloud/servicios - Página de servicios con descripciones.
|
||||
- https://anchoros.soul23.cloud/historia - Historia y filosofía de la marca.
|
||||
- https://anchoros.soul23.cloud/contacto - Formulario de contacto.
|
||||
- https://anchoros.soul23.cloud/franchises - Información de franquicias.
|
||||
- https://anchoros.soul23.cloud/membresias - Membresías (Gold, Black, VIP).
|
||||
|
||||
### The Boutique (booking.anchor23.mx)
|
||||
- https://anchoros.soul23.cloud/booking/servicios - Selección de servicios y calendario de disponibilidad.
|
||||
- https://anchoros.soul23.cloud/booking/cita - Flujo de reserva en pasos (búsqueda cliente, confirmación, pago).
|
||||
- https://anchoros.soul23.cloud/booking/confirmacion - Confirmación de reserva por código.
|
||||
- https://anchoros.soul23.cloud/booking/registro - Registro de nuevos clientes.
|
||||
- https://anchoros.soul23.cloud/booking/login - Login con magic links.
|
||||
- https://anchoros.soul23.cloud/booking/perfil - Perfil de cliente con historial.
|
||||
- https://anchoros.soul23.cloud/booking/mis-citas - Gestión de citas del cliente.
|
||||
|
||||
### The HQ (aperture.anchor23.mx)
|
||||
- https://anchoros.soul23.cloud/aperture - Dashboard home con KPIs, top performers y feed de actividad.
|
||||
- https://anchoros.soul23.cloud/aperture/calendar - Calendario maestro con drag & drop y filtros.
|
||||
- https://anchoros.soul23.cloud/aperture/staff - Gestión de staff (CRUD, comisiones, nómina).
|
||||
- https://anchoros.soul23.cloud/aperture/clients - CRM de clientes con fidelización.
|
||||
- https://anchoros.soul23.cloud/aperture/pos - Punto de venta y cierre de caja.
|
||||
- https://anchoros.soul23.cloud/aperture/finance - Finanzas y reportes.
|
||||
|
||||
### The Kiosk (kiosk.anchor23.mx)
|
||||
- https://anchoros.soul23.cloud/kiosk/[locationId] - Sistema táctil para confirmación de citas y walk-ins.
|
||||
|
||||
### Página Centralizada de Test Links
|
||||
- https://anchoros.soul23.cloud/testlinks - Directorio completo de todas las páginas y APIs del proyecto.
|
||||
|
||||
---
|
||||
|
||||
Fin del manual de marca Anchor:23.
|
||||
270
DEPLOYMENT_README.md
Normal file
270
DEPLOYMENT_README.md
Normal file
@@ -0,0 +1,270 @@
|
||||
# 🚀 AnchorOS Deployment Guide
|
||||
|
||||
## 📋 **Pre-requisitos**
|
||||
|
||||
- VPS con Ubuntu/Debian 20.04+
|
||||
- Docker y Docker Compose instalados
|
||||
- Dominio apuntando a tu VPS
|
||||
- Certificados SSL (Let's Encrypt recomendado)
|
||||
|
||||
## 🛠️ **Configuración Inicial**
|
||||
|
||||
### 1. **Clonar y configurar**
|
||||
```bash
|
||||
git clone https://github.com/your-repo/anchoros.git
|
||||
cd anchoros
|
||||
cp .env.example .env
|
||||
# Editar .env con tus valores reales
|
||||
```
|
||||
|
||||
### 2. **Variables críticas**
|
||||
```bash
|
||||
# Requeridas para funcionamiento básico
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://xxxxx.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJxxxxx
|
||||
SUPABASE_SERVICE_ROLE_KEY=eyJxxxxx
|
||||
RESEND_API_KEY=re_xxxxx
|
||||
NEXT_PUBLIC_APP_URL=https://tu-dominio.com
|
||||
|
||||
# Formbricks (opcional - encuestas)
|
||||
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=your-environment-id
|
||||
NEXT_PUBLIC_FORMBRICKS_API_HOST=https://app.formbricks.com
|
||||
|
||||
# Optional: Redis para caching
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
# Optional: Analytics
|
||||
NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
|
||||
```
|
||||
|
||||
### 3. **SSL Certificates**
|
||||
```bash
|
||||
# Instalar Certbot
|
||||
sudo apt install certbot
|
||||
|
||||
# Generar certificados
|
||||
sudo certbot certonly --standalone -d tu-dominio.com
|
||||
|
||||
# Copiar a directorio ssl/
|
||||
sudo mkdir ssl
|
||||
sudo cp /etc/letsencrypt/live/tu-dominio.com/fullchain.pem ssl/
|
||||
sudo cp /etc/letsencrypt/live/tu-dominio.com/privkey.pem ssl/
|
||||
```
|
||||
|
||||
## 🚀 **Deployment**
|
||||
|
||||
### **Opción 1: Script Automático**
|
||||
```bash
|
||||
./deploy.sh production
|
||||
```
|
||||
|
||||
### **Opción 2: Manual**
|
||||
```bash
|
||||
# Build e iniciar
|
||||
docker-compose -f docker-compose.prod.yml up -d --build
|
||||
|
||||
# Verificar
|
||||
curl http://localhost/health
|
||||
```
|
||||
|
||||
## 📊 **Monitoreo**
|
||||
|
||||
### **Logs**
|
||||
```bash
|
||||
# Todos los servicios
|
||||
docker-compose -f docker-compose.prod.yml logs -f
|
||||
|
||||
# Servicio específico
|
||||
docker-compose -f docker-compose.prod.yml logs -f anchoros
|
||||
```
|
||||
|
||||
### **Recursos**
|
||||
```bash
|
||||
# Uso de CPU/Memoria
|
||||
docker stats
|
||||
|
||||
# Espacio en disco
|
||||
df -h
|
||||
```
|
||||
|
||||
### **Health Checks**
|
||||
```bash
|
||||
# API health
|
||||
curl https://tu-dominio.com/api/health
|
||||
|
||||
# Nginx status
|
||||
curl -H "Host: tu-dominio.com" http://localhost/health
|
||||
```
|
||||
|
||||
## 🔧 **Mantenimiento**
|
||||
|
||||
### **Updates**
|
||||
```bash
|
||||
# Pull latest changes
|
||||
git pull origin main
|
||||
|
||||
# Redeploy
|
||||
./deploy.sh production
|
||||
```
|
||||
|
||||
### **Backup**
|
||||
```bash
|
||||
# Database backup (si usas PostgreSQL local)
|
||||
docker exec anchoros_db pg_dump -U postgres anchoros > backup.sql
|
||||
|
||||
# Logs backup
|
||||
docker-compose -f docker-compose.prod.yml logs > logs_backup.txt
|
||||
```
|
||||
|
||||
### **SSL Renewal**
|
||||
```bash
|
||||
# Renew certificates
|
||||
sudo certbot renew
|
||||
|
||||
# Restart nginx
|
||||
docker-compose -f docker-compose.prod.yml restart nginx
|
||||
```
|
||||
|
||||
## 🚨 **Troubleshooting**
|
||||
|
||||
### **App no responde**
|
||||
```bash
|
||||
# Verificar contenedores
|
||||
docker ps
|
||||
|
||||
# Logs de la app
|
||||
docker logs anchoros_app
|
||||
|
||||
# Reiniciar app
|
||||
docker-compose -f docker-compose.prod.yml restart anchoros
|
||||
```
|
||||
|
||||
### **Error 502 Bad Gateway**
|
||||
```bash
|
||||
# Nginx no puede conectar con Next.js
|
||||
docker logs anchoros_nginx
|
||||
|
||||
# Verificar que Next.js esté corriendo
|
||||
curl http://localhost:3000
|
||||
```
|
||||
|
||||
### **Alta carga de CPU**
|
||||
```bash
|
||||
# Verificar procesos
|
||||
docker stats
|
||||
|
||||
# Restart services
|
||||
docker-compose -f docker-compose.prod.yml restart
|
||||
```
|
||||
|
||||
## 📈 **Optimizaciones de Performance**
|
||||
|
||||
### **Nginx Caching**
|
||||
- Static files: 1 año cache
|
||||
- API responses: No cache
|
||||
- Rate limiting: 10 req/s
|
||||
|
||||
### **Next.js Optimizations**
|
||||
- Standalone build
|
||||
- Gzip compression
|
||||
- Image optimization
|
||||
- Console removal en prod
|
||||
|
||||
### **Database**
|
||||
- Conexión pool
|
||||
- Query optimization
|
||||
- Redis caching (opcional)
|
||||
|
||||
## 📝 **Formbricks Integration**
|
||||
|
||||
### **Configuración de Encuestas**
|
||||
```bash
|
||||
# Activar Formbricks para recolección de feedback
|
||||
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=clxxxxxxxx
|
||||
NEXT_PUBLIC_FORMBRICKS_API_HOST=https://app.formbricks.com
|
||||
```
|
||||
|
||||
### **Webhooks**
|
||||
```bash
|
||||
# Endpoints de webhook para formularios
|
||||
# Test: https://flows.soul23.cloud/webhook-test/4YZ7RPfo1GT
|
||||
# Prod: https://flows.soul23.cloud/webhook/4YZ7RPfo1GT
|
||||
|
||||
# Formularios que envían a webhooks:
|
||||
# - contact (Contacto)
|
||||
# - franchise (Franquicias)
|
||||
# - membership (Membresías)
|
||||
|
||||
# Payload structure:
|
||||
{
|
||||
"form": "contact|franchise|membership",
|
||||
"timestamp_utc": "ISO-8601",
|
||||
"device_type": "mobile|desktop|unknown",
|
||||
"...": "campos específicos del formulario"
|
||||
}
|
||||
```
|
||||
|
||||
### **Form Types y Campos**
|
||||
|
||||
**Contact (contacto)**
|
||||
```json
|
||||
{
|
||||
"form": "contact",
|
||||
"nombre": "string",
|
||||
"email": "string",
|
||||
"telefono": "string",
|
||||
"motivo": "cita|membresia|franquicia|servicios|pago|resena|otro",
|
||||
"mensaje": "string",
|
||||
"timestamp_utc": "string",
|
||||
"device_type": "string"
|
||||
}
|
||||
```
|
||||
|
||||
**Franchise (franquicias)**
|
||||
```json
|
||||
{
|
||||
"form": "franchise",
|
||||
"nombre": "string",
|
||||
"email": "string",
|
||||
"telefono": "string",
|
||||
"ciudad": "string",
|
||||
"estado": "string",
|
||||
"socios": "number",
|
||||
"experiencia_sector": "string",
|
||||
"experiencia_belleza": "boolean",
|
||||
"mensaje": "string",
|
||||
"timestamp_utc": "string",
|
||||
"device_type": "string"
|
||||
}
|
||||
```
|
||||
|
||||
**Membership (membresías)**
|
||||
```json
|
||||
{
|
||||
"form": "membership",
|
||||
"membership_id": "gold|black|vip",
|
||||
"nombre": "string",
|
||||
"email": "string",
|
||||
"telefono": "string",
|
||||
"mensaje": "string",
|
||||
"timestamp_utc": "string",
|
||||
"device_type": "string"
|
||||
}
|
||||
```
|
||||
|
||||
## 🔒 **Seguridad**
|
||||
|
||||
- SSL/TLS 1.2+
|
||||
- Rate limiting
|
||||
- Security headers
|
||||
- No exposición de puertos internos
|
||||
- Variables de entorno seguras
|
||||
|
||||
## 📞 **Soporte**
|
||||
|
||||
Para issues, revisar:
|
||||
1. Docker logs
|
||||
2. Network connectivity
|
||||
3. Environment variables
|
||||
4. SSL certificates
|
||||
5. Database connections
|
||||
61
Dockerfile
Normal file
61
Dockerfile
Normal file
@@ -0,0 +1,61 @@
|
||||
# Dockerfile optimizado para Next.js production
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Instalar dependencias para build
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Copiar archivos de dependencias
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --ignore-scripts && npm cache clean --force
|
||||
|
||||
# Build stage
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Variables de entorno para build - Coolify inyectará las reales en runtime
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
ENV NODE_OPTIONS="--max-old-space-size=16384"
|
||||
ENV NEXT_ESLINT_IGNORE_DURING_BUILDS=true
|
||||
ENV NEXT_PRIVATE_WORKERS=1
|
||||
ENV NEXT_PRIVATE_SKIP_BUILD_WORKER=true
|
||||
ENV NODE_EXTRA_CA_CERTS=""
|
||||
ENV CI=true
|
||||
|
||||
# Build optimizado con incremento de memoria y deshabilitando checks
|
||||
RUN set -e && \
|
||||
NODE_OPTIONS="--max-old-space-size=16384" SKIP_ESLINT=true SKIP_TYPE_CHECK=true npm run build && \
|
||||
npm cache clean --force && \
|
||||
rm -rf /tmp/* || \
|
||||
(echo "Build failed, attempting fallback build..." && \
|
||||
NODE_OPTIONS="--max-old-space-size=16384" npx next build --no-lint && \
|
||||
npm cache clean --force && \
|
||||
rm -rf /tmp/*)
|
||||
|
||||
# Production stage
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copiar archivos necesarios para producción (standalone)
|
||||
# Next.js standalone ya incluye todo lo necesario
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
36
Dockerfile.coolify
Normal file
36
Dockerfile.coolify
Normal file
@@ -0,0 +1,36 @@
|
||||
# Dockerfile simplificado para Coolify
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Instalar dependencias
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
|
||||
# Copiar código fuente
|
||||
COPY . .
|
||||
|
||||
# Variables de entorno
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_PUBLIC_SUPABASE_URL=https://placeholder.supabase.co
|
||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=placeholder-anon-key
|
||||
|
||||
# Aumentar memoria para build
|
||||
ENV NODE_OPTIONS="--max-old-space-size=4096"
|
||||
|
||||
# Build
|
||||
RUN npm run build
|
||||
|
||||
# Configurar usuario
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["npm", "start"]
|
||||
19
Dockerfile.dev
Normal file
19
Dockerfile.dev
Normal file
@@ -0,0 +1,19 @@
|
||||
# Dockerfile para desarrollo
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copiar archivos de dependencias primero para caching
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
# Instalar dependencias incluyendo dev
|
||||
RUN npm ci
|
||||
|
||||
# Copiar código fuente
|
||||
COPY . .
|
||||
|
||||
# Exponer puerto
|
||||
EXPOSE 3000
|
||||
|
||||
# Comando por defecto
|
||||
CMD ["npm", "run", "dev"]
|
||||
316
PRD.md
Normal file
316
PRD.md
Normal file
@@ -0,0 +1,316 @@
|
||||
# PRD — AnchorOS
|
||||
|
||||
**Codename: Adela**
|
||||
|
||||
## 1. Objetivo
|
||||
|
||||
AnchorOS es un sistema operativo para salones de belleza orientado a agenda, pagos, membresías e invitados, con reglas estrictas de tiempo, seguridad y automatización.
|
||||
|
||||
---
|
||||
|
||||
## 2. Principios del Sistema
|
||||
|
||||
* UTC-first en todo el backend.
|
||||
* UUID como identificador primario interno.
|
||||
* Short ID solo para referencia humana.
|
||||
* Automatismos auditables.
|
||||
* PRD como única fuente de verdad.
|
||||
|
||||
---
|
||||
|
||||
## 3. Roles y Membresías
|
||||
|
||||
### 3.1 Tiers
|
||||
|
||||
* Free
|
||||
* Gold
|
||||
* Black
|
||||
* VIP
|
||||
|
||||
### 3.2 Tier Gold — Beneficios
|
||||
|
||||
* Acceso prioritario a agenda.
|
||||
* Beneficios financieros definidos en pricing.
|
||||
* Invitaciones semanales.
|
||||
|
||||
### 3.3 Ecosistema de Exclusividad (Invitaciones)
|
||||
|
||||
* Cada cuenta Tier Gold tiene **5 invitaciones semanales**.
|
||||
* Las invitaciones **se resetean cada semana** (Lunes 00:00 UTC).
|
||||
* El reseteo es automático mediante:
|
||||
|
||||
* Supabase Edge Function **o**
|
||||
* Cron Job externo.
|
||||
* El proceso debe ser:
|
||||
|
||||
* Idempotente.
|
||||
* Auditado en `audit_logs`.
|
||||
|
||||
### 3.4 Jerarquía de Roles
|
||||
|
||||
* **Admin**: Acceso total. Puede ver PII de clientes y hacer ajustes.
|
||||
* **Manager**: Acceso operacional. Puede ver PII de clientes y hacer ajustes.
|
||||
* **Staff**: Nivel de coordinación. Puede ver PII de clientes y hacer ajustes.
|
||||
* **Artist**: Nivel de ejecución. **Solo puede ver nombre y notas** del cliente. No ve email ni phone.
|
||||
* **Kiosk**: Acceso limitado para dispositivos táctiles. No puede acceder a PII de clientes.
|
||||
* **Customer**: Nivel más bajo. Solo puede ver sus propios datos.
|
||||
|
||||
---
|
||||
|
||||
## 4. Gestión de Tiempo y Zonas Horarias
|
||||
|
||||
* **Todos los timestamps se almacenan en UTC**.
|
||||
* `locations.timezone` define la zona local del salón.
|
||||
* Conversión a hora local:
|
||||
|
||||
* Solo en frontend.
|
||||
* Solo en notificaciones (WhatsApp / Email).
|
||||
* Backend, reglas de negocio y validaciones **operan exclusivamente en UTC**.
|
||||
|
||||
---
|
||||
|
||||
## 5. Agenda y Bookings
|
||||
|
||||
### 5.1 Identificadores
|
||||
|
||||
* Cada booking tiene:
|
||||
|
||||
* `id` (UUID, primario).
|
||||
* `short_id` (6 caracteres alfanuméricos).
|
||||
|
||||
### 5.2 Short ID — Reglas
|
||||
|
||||
* Se genera antes de persistir el booking.
|
||||
* Debe verificarse unicidad.
|
||||
* Si existe colisión:
|
||||
|
||||
* Reintentar generación hasta ser único.
|
||||
* El Short ID:
|
||||
|
||||
* Es referencia de pago.
|
||||
* Es identificador operativo.
|
||||
* **No sustituye** el UUID.
|
||||
|
||||
---
|
||||
|
||||
## 6. Pagos
|
||||
|
||||
* Stripe como proveedor principal con webhooks para eventos de pago.
|
||||
* El Short ID se utiliza como referencia visible para clientes.
|
||||
* UUID se mantiene interno para integridad de datos.
|
||||
* Lógica de depósitos dinámicos: $200 fijo vs 50% del servicio según timing.
|
||||
* Sistema automático de penalizaciones por no-show con posibilidad de waivers.
|
||||
* Soporte para múltiples métodos de pago en POS (efectivo, tarjeta, transferencias, giftcards, membresías).
|
||||
|
||||
---
|
||||
|
||||
## 7. Auditoría
|
||||
|
||||
* Toda acción automática o crítica debe registrarse en `audit_logs`.
|
||||
* Incluye:
|
||||
|
||||
* Reseteo de invitaciones.
|
||||
* Cambios de estado de bookings.
|
||||
* Eventos de pago.
|
||||
|
||||
---
|
||||
|
||||
## 8. Límites de los Agentes de IA
|
||||
|
||||
* Ningún agente puede modificar reglas aquí descritas.
|
||||
* Toda implementación debe alinearse estrictamente a este PRD.
|
||||
|
||||
---
|
||||
|
||||
## 9. Estado del Documento
|
||||
|
||||
Este PRD es la fuente única de verdad funcional del sistema AnchorOS y refleja el estado actual de implementación.
|
||||
|
||||
---
|
||||
|
||||
## 10. Tecnologías Utilizadas
|
||||
|
||||
### Frontend
|
||||
- **Next.js 14** (App Router) con React 18 y TypeScript
|
||||
- **Tailwind CSS** para estilos
|
||||
- **Radix UI** para componentes accesibles
|
||||
- **Framer Motion** para animaciones
|
||||
- **React Hook Form + Zod** para validación de formularios
|
||||
- **date-fns + date-fns-tz** para manejo de fechas
|
||||
- **DnD Kit** para drag & drop
|
||||
|
||||
### Backend e Infraestructura
|
||||
- **Supabase** (PostgreSQL + Auth + RLS + Storage)
|
||||
- **Stripe** para procesamiento de pagos
|
||||
- **Google APIs** para integración de calendario
|
||||
- **Resend** para envío de emails
|
||||
- **Formbricks** para feedback de usuarios
|
||||
|
||||
### Desarrollo
|
||||
- **ESLint** para linting
|
||||
- **PostCSS + Autoprefixer** para CSS
|
||||
- **html2canvas + jsPDF** para generación de PDFs
|
||||
|
||||
---
|
||||
|
||||
## 11. Arquitectura del Sistema
|
||||
|
||||
AnchorOS implementa una arquitectura multi-dominio para separación clara de responsabilidades:
|
||||
|
||||
- **anchor23.mx**: Portal administrativo principal
|
||||
- **booking.anchor23.mx**: Sistema de reservas públicas
|
||||
- **aperture.anchor23.mx**: Dashboard operativo (Aperture HQ)
|
||||
- **kiosk.anchor23.mx**: Sistema de quioscos táctiles
|
||||
|
||||
### Base de Datos
|
||||
- **15+ tablas** con relaciones normalizadas
|
||||
- **RLS policies** estrictas para control de acceso
|
||||
- **UUIDs primarios** con Short IDs para referencias humanas
|
||||
- **Auditoría completa** en `audit_logs`
|
||||
|
||||
---
|
||||
|
||||
## 12. Funcionalidades Implementadas
|
||||
|
||||
### Sistema de Quioscos
|
||||
- Autenticación por API keys de 64 caracteres
|
||||
- Creación de reservas walk-in con asignación inteligente
|
||||
- Interfaz touch-friendly optimizada
|
||||
- Restricciones de PII (no acceso a datos personales)
|
||||
|
||||
### Motor de Disponibilidad
|
||||
- Asignación prioritaria: makeup > lashes > pedicure > manicure
|
||||
- Detección de conflictos de recursos
|
||||
- Soporte para servicios duales
|
||||
- Sincronización con Google Calendar
|
||||
|
||||
### Gestión de Membresías Avanzada
|
||||
- **Free**: Acceso básico
|
||||
- **Gold**: Prioridad en agenda, 5 invitaciones semanales, beneficios financieros
|
||||
- **Black**: Beneficios premium adicionales
|
||||
- **VIP**: Acceso completo incluyendo galería privada
|
||||
|
||||
### Sistema de Pagos Completo
|
||||
- Webhooks de Stripe para eventos de pago
|
||||
- Lógica automática de no-shows
|
||||
- Sistema de waivers para penalizaciones
|
||||
- Múltiples métodos de pago en POS
|
||||
|
||||
### Dashboard Operativo (Aperture HQ)
|
||||
- KPIs en tiempo real (ventas, reservas, clientes)
|
||||
- Calendario maestro multi-columna
|
||||
- Gestión completa de staff y recursos
|
||||
- Reportes financieros y operativos
|
||||
|
||||
---
|
||||
|
||||
## 13. Estado Actual del Proyecto
|
||||
|
||||
**Nivel de Completitud: ~97%**
|
||||
|
||||
### Fortalezas
|
||||
- Arquitectura sólida con separación clara de dominios
|
||||
- Seguridad de primer nivel con RLS y auditoría completa
|
||||
- Núcleo listo para producción (pagos, reservas, dashboards)
|
||||
- Diseño escalable con soporte multi-ubicación
|
||||
- Documentación exhaustiva (80+ archivos con JSDoc)
|
||||
|
||||
### Calidad Técnica
|
||||
- Código bien estructurado con TypeScript
|
||||
- Pruebas automatizadas en proceso
|
||||
- Integraciones robustas (Stripe, Google Calendar)
|
||||
- UI/UX optimizada para diferentes dispositivos
|
||||
|
||||
---
|
||||
|
||||
## 14. Trabajo Pendiente (3%)
|
||||
|
||||
### Mejoras Opcionales en Calendar Maestro
|
||||
- Redimensionamiento de bloques (drag en el borde inferior)
|
||||
- Vistas semanales/mensuales adicionales
|
||||
|
||||
### The Vault (Opcional)
|
||||
- Almacenamiento privado de fotos para clientes VIP
|
||||
|
||||
### Transferencias Cross-Location (Opcional)
|
||||
- Movimiento de staff entre ubicaciones
|
||||
|
||||
---
|
||||
|
||||
## 15. Fases Futuras
|
||||
|
||||
### Fase 7: Automatización y Lanzamiento
|
||||
- Notificaciones WhatsApp (confirmaciones, recordatorios, no-shows)
|
||||
- Recibos digitales por email
|
||||
- Landing page pública para adquisición de clientes
|
||||
- Optimización SEO (robots.txt, sitemap.xml)
|
||||
|
||||
### Fase 8: Características Avanzadas
|
||||
- Sincronización completa de Google Calendar
|
||||
- Campañas de marketing (emails/WhatsApp masivos)
|
||||
- Precios dinámicos basados en tiempo
|
||||
- Integraciones externas (Instagram/Facebook shopping)
|
||||
|
||||
---
|
||||
|
||||
## 16. Validación y Testing
|
||||
|
||||
### Pruebas Unitarias
|
||||
- Generador de Short IDs
|
||||
- Funciones de disponibilidad
|
||||
- Lógica de asignación de recursos
|
||||
|
||||
### Pruebas de Integración
|
||||
- Flujos completos de reserva
|
||||
- Procesamiento de pagos
|
||||
- Sincronización de calendario
|
||||
|
||||
### Validación en Producción
|
||||
- Testing de migración en entorno live
|
||||
- Validación de rendimiento con carga real
|
||||
|
||||
---
|
||||
|
||||
## 17. Roadmap de Desarrollo
|
||||
|
||||
### Fase 1: Infraestructura Core ✅
|
||||
- [x] Configurar estructura del proyecto con timestamps UTC en backend
|
||||
- [x] Implementar UUID como claves primarias para todas las entidades
|
||||
- [x] Agregar generación de Short ID con verificación de unicidad
|
||||
- [x] Crear control de acceso basado en roles (Admin, Manager, Staff, Artist, Customer, Kiosk)
|
||||
- [x] Implementar manejo de zonas horarias (UTC en backend, local en frontend)
|
||||
- [x] Agregar logging de auditoría para acciones automáticas
|
||||
|
||||
### Fase 2: Sistema de Bookings y Agenda ✅
|
||||
- [x] Construir sistema de bookings con funcionalidad de agenda
|
||||
- [x] Implementar motor de disponibilidad con asignación inteligente de recursos
|
||||
- [x] Integrar Google Calendar para sincronización bidireccional
|
||||
- [x] Soporte para servicios de doble capacidad (2 artistas)
|
||||
|
||||
### Fase 3: Sistema de Pagos ✅
|
||||
- [x] Integrar pagos con Stripe usando short ID como referencia
|
||||
- [x] Implementar lógica de depósitos dinámicos ($200 vs 50%)
|
||||
- [x] Sistema de penalizaciones por no-show con waivers
|
||||
|
||||
### Fase 4: Dashboard Aperture HQ (100% completado)
|
||||
- [x] Dashboard principal con KPIs y métricas operativas
|
||||
- [x] Calendar Maestro con vista multi-columna y drag & drop
|
||||
- [x] Gestión de staff y recursos (CRUD completo)
|
||||
- [x] Sistema de comisiones y nómina
|
||||
- [x] Reportes diarios de cierre (PDF)
|
||||
- [x] Creación de citas desde slots vacíos en calendario
|
||||
- [ ] Mejoras opcionales en calendario (resize de bloques, vista semanal/mensual)
|
||||
|
||||
### Fase 5: Gestión de Clientes y Lealtad ✅
|
||||
- [x] Crear niveles de membresía (Free, Gold, Black, VIP) con beneficios
|
||||
- [x] Sistema CRM con búsqueda fonética y notas técnicas
|
||||
- [x] Implementar sistema de invitaciones para tier Gold (5 semanales, reseteables)
|
||||
- [x] Sistema de puntos de lealtad independientes de tiers
|
||||
- [x] Galería de fotos restringida a tiers premium
|
||||
|
||||
### Fase 6: Finanzas y Reportes ✅
|
||||
- [x] Sistema POS con múltiples métodos de pago
|
||||
- [x] Reportes de rendimiento por staff
|
||||
- [x] Seguimiento de gastos operativos
|
||||
- [x] Analytics financieros (ingresos, gastos, utilidades)
|
||||
|
||||
295
README.md
295
README.md
@@ -50,24 +50,26 @@ Este proyecto se rige por los siguientes documentos:
|
||||
* **[README.md](./README.md)** (este archivo) → Guía técnica y operativa del repo.
|
||||
* **[TASKS.md](./TASKS.md)** → Plan de ejecución por fases y estado actual.
|
||||
|
||||
### Documentación Especializada (docs/)
|
||||
* **[docs/PRD.md](./docs/PRD.md)** → Definición de producto y reglas de negocio.
|
||||
* **[docs/API.md](./docs/API.md)** → Documentación completa de APIs y endpoints.
|
||||
* **[docs/STRIPE_SETUP.md](./docs/STRIPE_SETUP.md)** → Guía de integración de pagos con Stripe.
|
||||
* **[docs/site_requirements.md](./docs/site_requirements.md)** → Requisitos técnicos del proyecto.
|
||||
* **[docs/ANCHOR23_FRONTEND.md](./docs/ANCHOR23_FRONTEND.md)** → Documentación del frontend institucional.
|
||||
* **[docs/APERTURE_SQUARE_UI.md](./docs/APERTURE_SQUARE_UI.md)** → Guía de estilo Square UI para Aperture (HQ Dashboard).
|
||||
* **[docs/DESIGN_SYSTEM.md](./docs/DESIGN_SYSTEM.md)** → Sistema de diseño completo para AnchorOS.
|
||||
* **[docs/DOMAIN_CONFIGURATION.md](./docs/DOMAIN_CONFIGURATION.md)** → Configuración de dominios y subdominios.
|
||||
* **[docs/KIOSK_SYSTEM.md](./docs/KIOSK_SYSTEM.md)** → Documentación completa del sistema de kiosko.
|
||||
* **[docs/KIOSK_IMPLEMENTATION.md](./docs/KIOSK_IMPLEMENTATION.md)** → Guía rápida de implementación del kiosko.
|
||||
* **[docs/ENROLLMENT_SYSTEM.md](./docs/ENROLLMENT_SYSTEM.md)** → Sistema de enrollment de kioskos.
|
||||
* **[docs/RESOURCES_UPDATE.md](./docs/RESOURCES_UPDATE.md)** → Documentación de actualización de recursos.
|
||||
* **[docs/OPERATIONAL_PROCEDURES.md](./docs/OPERATIONAL_PROCEDURES.md)** → Procedimientos operativos.
|
||||
* **[docs/STAFF_TRAINING.md](./docs/STAFF_TRAINING.md)** → Guía de capacitación del staff.
|
||||
* **[docs/TROUBLESHOOTING.md](./docs/TROUBLESHOOTING.md)** → Guía de solución de problemas.
|
||||
* **[docs/CLIENT_ONBOARDING.md](./docs/CLIENT_ONBOARDING.md)** → Proceso de onboarding de clientes.
|
||||
* **[docs/PROJECT_UPDATE_JAN_2026.md](./docs/PROJECT_UPDATE_JAN_2026.md)** → Actualizaciones del proyecto Enero 2026.
|
||||
### Documentación Especializada (docs/)
|
||||
* **[docs/PRD.md](./docs/PRD.md)** → Definición de producto y reglas de negocio.
|
||||
* **[docs/API.md](./docs/API.md)** → Documentación completa de APIs y endpoints.
|
||||
* **[docs/STRIPE_SETUP.md](./docs/STRIPE_SETUP.md)** → Guía de integración de pagos con Stripe.
|
||||
* **[docs/site_requirements.md](./docs/site_requirements.md)** → Requisitos técnicos del proyecto.
|
||||
* **[docs/ANCHOR23_FRONTEND.md](./docs/ANCHOR23_FRONTEND.md)** → Documentación del frontend institucional.
|
||||
* **[docs/APERTURE_SQUARE_UI.md](./docs/APERTURE_SQUARE_UI.md)** → Guía de estilo Square UI para Aperture (HQ Dashboard).
|
||||
* **[docs/APERTURE_SPECS.md](./docs/APERTURE_SPECS.md)** → Especificaciones técnicas completas de Aperture.
|
||||
* **[docs/DESIGN_SYSTEM.md](./docs/DESIGN_SYSTEM.md)** → Sistema de diseño completo para AnchorOS.
|
||||
* **[docs/DOMAIN_CONFIGURATION.md](./docs/DOMAIN_CONFIGURATION.md)** → Configuración de dominios y subdominios.
|
||||
* **[docs/KIOSK_SYSTEM.md](./docs/KIOSK_SYSTEM.md)** → Documentación completa del sistema de kiosko.
|
||||
* **[docs/KIOSK_IMPLEMENTATION.md](./docs/KIOSK_IMPLEMENTATION.md)** → Guía rápida de implementación del kiosko.
|
||||
* **[docs/ENROLLMENT_SYSTEM.md](./docs/ENROLLMENT_SYSTEM.md)** → Sistema de enrollment de kioskos.
|
||||
* **[docs/RESOURCES_UPDATE.md](./docs/RESOURCES_UPDATE.md)** → Documentación de actualización de recursos.
|
||||
* **[docs/OPERATIONAL_PROCEDURES.md](./docs/OPERATIONAL_PROCEDURES.md)** → Procedimientos operativos.
|
||||
* **[docs/STAFF_TRAINING.md](./docs/STAFF_TRAINING.md)** → Guía de capacitación del staff.
|
||||
* **[docs/TROUBLESHOOTING.md](./docs/TROUBLESHOOTING.md)** → Guía de solución de problemas.
|
||||
* **[docs/CLIENT_ONBOARDING.md](./docs/CLIENT_ONBOARDING.md)** → Proceso de onboarding de clientes.
|
||||
* **[docs/PROJECT_UPDATE_JAN_2026.md](./docs/PROJECT_UPDATE_JAN_2026.md)** → Actualizaciones del proyecto Enero 2026.
|
||||
* **[docs/RECENT_FIXES_JAN_2026.md](./docs/RECENT_FIXES_JAN_2026.md)** → Correcciones recientes de calendario, horarios y disponibilidad.
|
||||
|
||||
El PRD es la fuente de verdad funcional. El README es la guía de ejecución.
|
||||
|
||||
@@ -189,11 +191,11 @@ El PRD es la fuente de verdad funcional. El README es la guía de ejecución.
|
||||
|
||||
## 7. Requisitos de Entorno
|
||||
|
||||
* Node.js 18+
|
||||
* Cuenta Supabase
|
||||
* Cuenta Stripe
|
||||
* Proyecto Google Cloud (Calendar API)
|
||||
* Credenciales WhatsApp API
|
||||
* Node.js 20+ (actualizado para compatibilidad con Supabase)
|
||||
* Cuenta Supabase
|
||||
* Cuenta Stripe
|
||||
* Proyecto Google Cloud (Calendar API)
|
||||
* Credenciales WhatsApp API
|
||||
|
||||
Variables de entorno obligatorias:
|
||||
|
||||
@@ -236,7 +238,7 @@ npm install
|
||||
|
||||
3. Configurar variables de entorno
|
||||
|
||||
* Crear `.env.local`.
|
||||
* Copiar `.env.template` a `.env.local` y configurar las variables requeridas.
|
||||
|
||||
4. Levantar entorno local
|
||||
|
||||
@@ -258,7 +260,16 @@ El sitio estará disponible en **http://localhost:2311**
|
||||
|
||||
---
|
||||
|
||||
## 10. Estado del Proyecto
|
||||
## 10. Estado del Proyecto
|
||||
|
||||
### Progreso General
|
||||
- **FASE 1**: 100% ✅ Completada
|
||||
- **FASE 2**: 100% ✅ Completada
|
||||
- **FASE 3**: 100% ✅ Completada
|
||||
- **FASE 4**: 100% ✅ COMPLETADA
|
||||
- **FASE 5**: 100% ✅ Completada
|
||||
- **FASE 6**: 100% ✅ Completada
|
||||
- **FASE 7**: 5% ⏳ Pendiente
|
||||
|
||||
### Completado ✅
|
||||
- ✅ Esquema de base de datos completo
|
||||
@@ -273,9 +284,13 @@ El sitio estará disponible en **http://localhost:2311**
|
||||
- ✅ Sistema de disponibilidad (staff, recursos, bloques)
|
||||
- ✅ API routes de disponibilidad
|
||||
- ✅ API de reservas para clientes (POST/GET)
|
||||
- ✅ HQ Dashboard básico (Aperture) - EXISTE pero incompleto
|
||||
- ✅ API routes básicos para Aperture (dashboard, staff, resources, reports, permissions)
|
||||
- ✅ Frontend institucional anchor23.mx completo
|
||||
- ✅ HQ Dashboard completo (Aperture) - Calendario drag&drop, gestión staff/recursos
|
||||
- ✅ API routes completas para Aperture (40+ endpoints con CRUD y validaciones)
|
||||
- ✅ Calendario multi-columna con tiempo real y reprogramación automática
|
||||
- ✅ Gestión operativa completa (staff CRUD, recursos con disponibilidad)
|
||||
- ✅ Frontend institucional anchor23.mx completo (5 páginas principales)
|
||||
- ✅ **COMENTARIOS AUDITABLES**: 80+ archivos con JSDoc para auditoría manual
|
||||
- ✅ **SEGURIDAD**: RLS policies y validaciones documentadas en todo el código
|
||||
- Landing page con hero, fundamento, servicios, testimoniales
|
||||
- Página de servicios
|
||||
- Página de historia y filosofía
|
||||
@@ -287,47 +302,76 @@ El sitio estará disponible en **http://localhost:2311**
|
||||
|
||||
### En Progreso 🚧
|
||||
- 🚧 The Boutique - Frontend de reservas (booking.anchor23.mx) - 90%
|
||||
- ✅ Página de selección de servicios (/booking/servicios)
|
||||
- ✅ Página de búsqueda de clientes (/booking/cita - paso 1)
|
||||
- ✅ Página de registro de clientes (/booking/registro)
|
||||
- ✅ Página de confirmación de reserva (/booking/cita - pasos 2-3)
|
||||
- ✅ Página de confirmación por código (/booking/confirmacion)
|
||||
- ✅ Layout específico con navbar personalizado
|
||||
- ✅ API para obtener servicios (/api/services)
|
||||
- ✅ API para obtener ubicaciones (/api/locations)
|
||||
- ✅ API para buscar clientes (/api/customers - GET)
|
||||
- ✅ API para registrar clientes (/api/customers - POST)
|
||||
- ✅ Sistema de horarios de negocio por ubicación
|
||||
- ✅ Componente de pagos mock para pruebas
|
||||
- ⏳ Configuración de dominios wildcard en producción
|
||||
- ⏳ Integración con Stripe real (webhooks)
|
||||
- ✅ Página de selección de servicios (/booking/servicios)
|
||||
- ✅ Página de búsqueda de clientes (/booking/cita - paso 1)
|
||||
- ✅ Página de registro de clientes (/booking/registro)
|
||||
- ✅ Página de confirmación de reserva (/booking/cita - pasos 2-3)
|
||||
- ✅ Página de confirmación por código (/booking/confirmacion)
|
||||
- ✅ Layout específico con navbar personalizado
|
||||
- ✅ API para obtener servicios (/api/services)
|
||||
- ✅ API para obtener ubicaciones (/api/locations)
|
||||
- ✅ API para buscar clientes (/api/customers - GET)
|
||||
- ✅ API para registrar clientes (/api/customers - POST)
|
||||
- ✅ Sistema de horarios de negocio por ubicación
|
||||
- ✅ Componente de pagos mock para pruebas
|
||||
- ⏳ Configuración de dominios wildcard en producción
|
||||
- ⏳ Integración con Stripe real (webhooks)
|
||||
|
||||
- 🚧 Aperture - Backend para staff/manager/admin (aperture.anchor23.mx) - 40%
|
||||
- ✅ API para obtener staff disponible (/api/aperture/staff)
|
||||
- ✅ API para gestión de horarios (/api/aperture/staff/schedule)
|
||||
- ✅ API para recursos (/api/aperture/resources)
|
||||
- ✅ API para dashboard (/api/aperture/dashboard)
|
||||
- ✅ Página principal de admin (/aperture)
|
||||
- ❌ API para estadísticas (/api/aperture/stats) - FALTA IMPLEMENTAR
|
||||
- ❌ Reseteo semanal de invitaciones (documentado, NO implementado)
|
||||
- ⏳ Autenticación de admin/staff/manager (login existe, needs Supabase Auth)
|
||||
- ⏳ Gestión completa de staff (CRUD, horarios)
|
||||
- ⏳ Gestión de recursos y asignación
|
||||
- ⏳ Rediseño con estilo Square UI
|
||||
- 🚧 Aperture - Dashboard administrativo (aperture.anchor23.mx) - 95% ✅
|
||||
- ✅ APIs completas para staff, recursos, calendario, dashboard
|
||||
- ✅ Calendario multi-columna con drag & drop y tiempo real
|
||||
- ✅ Gestión CRUD completa de staff y recursos
|
||||
- ✅ Componentes con Square UI design
|
||||
- ✅ Autenticación completa con middleware de protección
|
||||
- ✅ Comentarios auditables en todo el código
|
||||
- ⏳ Sistema de nómina y comisiones (próxima semana)
|
||||
- ✅ POS completo con múltiples métodos de pago
|
||||
- ✅ CRM avanzado con fidelización
|
||||
|
||||
- 🚧 Lógica de no-show y penalizaciones automáticas
|
||||
- 🚧 Integración con Google Calendar (20% - en progreso)
|
||||
|
||||
### Pendiente ⏳
|
||||
- ⏳ Implementar API pública (api.anchor23.mx)
|
||||
- ⏳ Completar Aperture con estilo Square UI (calendario multi-columna, páginas individuales, The Vault)
|
||||
### Pendiente ⏳
|
||||
- ⏳ The Vault (storage de fotos privadas VIP/Black/Gold)
|
||||
- ⏳ Notificaciones por WhatsApp
|
||||
- ⏳ Recibos digitales por email
|
||||
- ⏳ Landing page para believers (booking público)
|
||||
- ⏳ Tests unitarios
|
||||
- ⏳ Archivos SEO (robots.txt, sitemap.xml)
|
||||
- ⏳ Archivos SEO (robots.txt, sitemap.xml)
|
||||
|
||||
### Fase Actual
|
||||
### Correcciones Recientes ✅ (Enero 2026)
|
||||
- ✅ **Calendario Booking - Desfase de Días**: Corrección del DatePicker para alinear correctamente los días de la semana
|
||||
- Enero 1, 2026 ahora se muestra correctamente como Jueves
|
||||
- Se agregó cálculo de offset y celdas de padding
|
||||
- Commit: `dbac763`
|
||||
- ✅ **Horarios Disponibles - Solo 22:00-23:00**: Corrección de business hours y timezone
|
||||
- Ahora muestra horarios normales del salón (10:00-19:00)
|
||||
- Se mejoró la función get_detailed_availability con make_timestamp()
|
||||
- Migraciones: 20260118080000, 20260118090000
|
||||
- Commit: `35d5cd0`
|
||||
- ✅ **Página de Test Links**: Directorio centralizado de todas las páginas y APIs
|
||||
- 21 páginas implementadas agrupadas por dominio
|
||||
- 40+ API endpoints documentados con indicadores
|
||||
- Diseño responsive con grid layout y efectos hover
|
||||
- Commit: `09180ff`
|
||||
- ✅ **Documentación de Correcciones**: Documento completo con detalles técnicos
|
||||
- docs/RECENT_FIXES_JAN_2026.md con análisis de problemas y soluciones
|
||||
- Ejemplos de código antes/después
|
||||
- Validación y testing notes
|
||||
- Commit: `88ea79f`
|
||||
- ✅ **Calendario Aperture - Creación de Citas**: Nueva funcionalidad de crear citas desde slots vacíos
|
||||
- Click en slot vacío abre modal de creación de cita
|
||||
- Selección de cliente, servicio, ubicación y staff
|
||||
- Validación de disponibilidad antes de crear
|
||||
- API: `POST /api/bookings` para creación de citas
|
||||
- Actualización: 2026-01-21
|
||||
- ✅ **Fix check_staff_availability**: Corrección de llamadas a funciones auxiliares
|
||||
- Migración: 20260121000000_fix_staff_availability_function_calls.sql
|
||||
- Parámetros corregidos para check_staff_work_hours y check_calendar_blocking
|
||||
- Actualización: 2026-01-21
|
||||
- ✅ **Test Links Page**: Página centralizada con enlaces a todas las páginas y APIs del proyecto
|
||||
|
||||
### Fase Actual
|
||||
**Fase 1 — Cimientos y CRM**: 100% completado
|
||||
- Infraestructura base: 100%
|
||||
- Esquema de base de datos: 100%
|
||||
@@ -346,24 +390,38 @@ El sitio estará disponible en **http://localhost:2311**
|
||||
- Integración Calendar: 20% (en progreso)
|
||||
- Aperture Backend: 100%
|
||||
|
||||
**Fase 3 — Pagos y Protección**: 70% completado
|
||||
**Fase 3 — Pagos y Protección**: 100% ✅ COMPLETADA
|
||||
- Stripe depósitos dinámicos: 100%
|
||||
- No-show logic: 40% (lógica implementada, automatización pendiente)
|
||||
- No-show logic: 100% (detección automática, penalización, check-in)
|
||||
- Webhooks Stripe: 100% (payment_intent.succeeded, payment_failed, charge.refunded)
|
||||
|
||||
**Fase 4 — HQ Dashboard**: 0% completado (REDEFINIDO con especificaciones técnicas completas)
|
||||
- Documento de especificaciones técnicas creado
|
||||
- Plan completo de 7 fases con ~136-171 horas estimado
|
||||
- Stack UI: Radix UI + Tailwind CSS + Square UI custom styling
|
||||
- Especificaciones completas para 6 pantallas principales:
|
||||
1. Dashboard Home (KPI Cards, Gráfico, Top Performers, Activity Feed)
|
||||
2. Calendario Maestro (Drag & Drop, Resize, Filtros dinámicos)
|
||||
3. Miembros del Equipo y Nómina (CRUD Staff, Comisiones, Nómina, Turnos)
|
||||
4. Clientes y Fidelización (CRM, Galería VIP, Membresías, Puntos)
|
||||
5. Ventas, Pagos y Facturación (POS, Cierre de Caja, Finanzas)
|
||||
6. Marketing y Configuración (Campañas, Precios Inteligentes, Integraciones)
|
||||
- Pendiente implementación completa
|
||||
**Fase 4 — HQ Dashboard (APERTURE)**: 95% ✅ EN PROGRESO
|
||||
- ✅ Dashboard Home (KPI Cards, Top Performers, Activity Feed completos)
|
||||
- ✅ Calendario Maestro (Drag & Drop, filtros, tiempo real, conflictos)
|
||||
- ✅ Gestión de Staff (CRUD completo con APIs y componentes)
|
||||
- ✅ Gestión de Recursos (CRUD con disponibilidad en tiempo real)
|
||||
- ✅ Autenticación completa con middleware de protección
|
||||
- ✅ Comentarios auditables en todo el código (80+ archivos)
|
||||
- ✅ Nómina y comisiones (implementado con cálculos automáticos)
|
||||
- ⏳ POS completo con múltiples métodos de pago
|
||||
- ⏳ CRM avanzado con fidelización
|
||||
- ✅ CRM avanzado con fidelización completo
|
||||
- ✅ Finanzas y reportes implementados
|
||||
- ⏳ The Vault (storage de fotos privadas) - PENDIENTE
|
||||
|
||||
**Fase 5 — Automatización y Lanzamiento**: 5% completado
|
||||
**Fase 5 — Clientes y Fidelización**: 100% ✅ COMPLETADA
|
||||
- ✅ Client Management (CRM) con búsqueda fonética
|
||||
- ✅ Sistema de Lealtad con puntos y expiración
|
||||
- ✅ Membresías (Gold, Black, VIP) con beneficios
|
||||
- ✅ Galería de fotos restringida por tier
|
||||
|
||||
**Fase 6 — Pagos y Protección**: 100% ✅ COMPLETADA
|
||||
- ✅ Stripe Webhooks (payment_intent.succeeded, payment_failed, charge.refunded)
|
||||
- ✅ No-Show Logic con detección automática y penalización
|
||||
- ✅ Finanzas y Reportes (expenses, daily closing, staff performance)
|
||||
- ✅ Check-in de clientes
|
||||
|
||||
**Fase 7 — Automatización y Lanzamiento**: 5% ⏳ PENDIENTE
|
||||
- Notificaciones WhatsApp: 0% (variables configuradas, no implementado)
|
||||
- Recibos digitales: 0% (pendiente)
|
||||
- Landing page Believers: 0% (pendiente)
|
||||
@@ -428,7 +486,86 @@ El plan completo de 7 fases está documentado en [TASKS.md](TASKS.md) con:
|
||||
|
||||
---
|
||||
|
||||
## 12. Deployment y Producción
|
||||
## 12. Test Links - Directorio de Páginas y APIs
|
||||
|
||||
Para facilitar el testing y navegación del proyecto, hemos creado una página centralizada con enlaces a todas las páginas y endpoints:
|
||||
|
||||
**🔗 [Test Links - /testlinks](/testlinks)**
|
||||
|
||||
Esta página proporciona:
|
||||
|
||||
### Páginas del Proyecto (21 páginas implementadas)
|
||||
|
||||
**anchor23.mx - Frontend Institucional:**
|
||||
- `/` - Home (Landing page)
|
||||
- `/servicios` - Página de servicios
|
||||
- `/historia` - Historia y filosofía
|
||||
- `/contacto` - Formulario de contacto
|
||||
- `/franchises` - Información de franquicias
|
||||
- `/membresias` - Membresías (Gold, Black, VIP)
|
||||
- `/privacy-policy` - Política de privacidad
|
||||
- `/legal` - Términos y condiciones
|
||||
|
||||
**booking.anchor23.mx - The Boutique (Frontend de Reservas):**
|
||||
- `/booking/servicios` - Selección de servicios
|
||||
- `/booking/cita` - Flujo de reserva
|
||||
- `/booking/confirmacion` - Confirmación por código
|
||||
- `/booking/registro` - Registro de nuevos clientes
|
||||
- `/booking/login` - Login de clientes
|
||||
- `/booking/perfil` - Perfil de cliente
|
||||
- `/booking/mis-citas` - Gestión de citas
|
||||
|
||||
**aperture.anchor23.mx - Dashboard Administrativo:**
|
||||
- `/aperture/login` - Login de administradores
|
||||
- `/aperture` - Dashboard Home (KPIs, Top Performers, Activity Feed)
|
||||
- `/aperture/calendar` - Calendario Maestro (drag & drop, filtros, tiempo real)
|
||||
|
||||
**Otros:**
|
||||
- `/kiosk/[locationId]` - Sistema de autoservicio (reemplazar con UUID)
|
||||
- `/hq` - Dashboard administrativo antiguo
|
||||
- `/admin/enrollment` - Sistema de enrollment de kioskos
|
||||
|
||||
### API Endpoints (40+ endpoints implementados)
|
||||
|
||||
**APIs Públicas:**
|
||||
- `/api/services` - Listar servicios
|
||||
- `/api/locations` - Listar ubicaciones
|
||||
- `/api/customers` - Búsqueda y registro de clientes
|
||||
- `/api/availability/*` - Sistema de disponibilidad
|
||||
- `/api/bookings` - Gestión de reservas
|
||||
|
||||
**Kiosk APIs:**
|
||||
- `/api/kiosk/authenticate` - Autenticación de kiosk
|
||||
- `/api/kiosk/resources/available` - Recursos disponibles
|
||||
- `/api/kiosk/bookings` - Crear reservas
|
||||
- `/api/kiosk/walkin` - Walk-in bookings
|
||||
|
||||
**Aperture APIs:**
|
||||
- `/api/aperture/dashboard` - Datos del dashboard
|
||||
- `/api/aperture/stats` - Estadísticas generales
|
||||
- `/api/aperture/calendar` - Calendario data
|
||||
- `/api/aperture/staff/*` - CRUD de staff
|
||||
- `/api/aperture/resources/*` - Gestión de recursos
|
||||
- `/api/aperture/payroll` - Cálculo de nómina
|
||||
- `/api/aperture/pos/*` - Punto de venta y cierre de caja
|
||||
|
||||
**FASE 5 - Clientes y Fidelización:**
|
||||
- `/api/aperture/clients/*` - CRM completo de clientes
|
||||
- `/api/aperture/loyalty/*` - Sistema de puntos y recompensas
|
||||
|
||||
**FASE 6 - Pagos y Protección:**
|
||||
- `/api/webhooks/stripe` - Webhooks de Stripe
|
||||
- `/api/cron/reset-invitations` - Reseteo semanal de invitaciones
|
||||
- `/api/cron/detect-no-shows` - Detección de no-shows
|
||||
- `/api/aperture/bookings/check-in` - Check-in de clientes
|
||||
- `/api/aperture/bookings/no-show` - Penalización de no-shows
|
||||
- `/api/aperture/finance/*` - Finanzas y reportes
|
||||
|
||||
**Guía completa de APIs:** Ver [API.md](./docs/API.md) para documentación detallada de todos los endpoints.
|
||||
|
||||
---
|
||||
|
||||
## 13. Deployment y Producción
|
||||
|
||||
### Requisitos para Producción
|
||||
- VPS o cloud provider (Vercel recomendado para Next.js)
|
||||
@@ -468,7 +605,7 @@ GOOGLE_CALENDAR_ID=
|
||||
|
||||
---
|
||||
|
||||
## 12. anchor23.mx - Frontend Institucional
|
||||
## 14. anchor23.mx - Frontend Institucional
|
||||
|
||||
Dominio institucional. Contenido estático, marca, narrativa y conversión inicial.
|
||||
|
||||
@@ -653,7 +790,7 @@ Ver documentación completa en `API.md` para todos los endpoints disponibles.
|
||||
|
||||
---
|
||||
|
||||
## 13. Sistema de Kiosko
|
||||
## 15. Sistema de Kiosko
|
||||
|
||||
El sistema de kiosko permite a los clientes interactuar con el salón mediante pantallas táctiles en la entrada.
|
||||
|
||||
@@ -678,7 +815,7 @@ https://kiosk.anchor23.mx/{location-id}
|
||||
|
||||
---
|
||||
|
||||
## 14. Filosofía Operativa
|
||||
## 16. Filosofía Operativa
|
||||
|
||||
AnchorOS no busca volumen.
|
||||
|
||||
@@ -688,7 +825,7 @@ Este repositorio implementa esa filosofía a nivel de sistema.
|
||||
|
||||
---
|
||||
|
||||
## 15. Codename: Adela
|
||||
## 17. Codename: Adela
|
||||
|
||||
AnchorOS se conoce internamente como **Adela**, un acrónimo que representa los pilares fundamentales del sistema:
|
||||
|
||||
|
||||
552
TASKS.md
552
TASKS.md
@@ -257,63 +257,50 @@ Tareas:
|
||||
|
||||
---
|
||||
|
||||
## FASE 2 — Motor de Agendamiento (PENDIENTE)
|
||||
## FASE 2 — Motor de Agendamiento ✅ COMPLETADA
|
||||
|
||||
### 2.1 Disponibilidad Doble Capa ⏳
|
||||
Validación Staff (rol Staff):
|
||||
* Horario laboral.
|
||||
* Eventos bloqueantes en Google Calendar.
|
||||
* Validación Recurso:
|
||||
* Disponibilidad de estación física.
|
||||
* Asignación automática con prioridad (mkup > lshs > pedi > mani).
|
||||
* Regla de prioridad dinámica entre Staff y Artist.
|
||||
* Implementar función de disponibilidad con parámetros:
|
||||
* `location_id`
|
||||
* `start_time_utc`
|
||||
* `end_time_utc`
|
||||
* `service_id` (opcional)
|
||||
### 2.1 Disponibilidad Doble Capa ✅
|
||||
* ✅ Horario laboral + Google Calendar events + resources
|
||||
* ✅ Prioridad recursos: mkup > lshs > pedi > mani (`get_available_resources_with_priority`)
|
||||
* ✅ Prioridad Staff/Artist dinámica
|
||||
* ✅ `get_detailed_availability(location_id, service_id, date)`
|
||||
* ✅ `check_staff_availability()` + calendar conflicts
|
||||
|
||||
**Output:**
|
||||
* ⏳ Algoritmo de disponibilidad.
|
||||
* ⏳ Tests de colisión y concurrencia.
|
||||
* ⏳ Documentación de algoritmo.
|
||||
* ✅ `lib/google-calendar.ts` + APIs `/api/sync/calendar/*`
|
||||
* ✅ Migrations 2026011800* (tables/funcs)
|
||||
* ✅ Tests collision via functions
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Servicios Express (Dual Artists) ⏳
|
||||
* Búsqueda de dos artistas simultáneas.
|
||||
* Bloqueo del recurso principal requerido (rooms only).
|
||||
* Aplicación automática de Premium Fee.
|
||||
* Lógica de booking dual.
|
||||
* Casos de prueba.
|
||||
* Actualización de RLS para servicios express.
|
||||
### 2.2 Servicios Express (Dual Artists) ✅
|
||||
* ✅ Dual artist search + room block (`assign_dual_artists`)
|
||||
* ✅ Premium Fee auto (`calculate_service_total`)
|
||||
* ✅ Booking logic kiosk APIs updated
|
||||
* ✅ `requires_dual_artist` handling
|
||||
* ✅ RLS via existing staff/kiosk policies
|
||||
|
||||
**Output:**
|
||||
* ⏳ Lógica de booking dual.
|
||||
* ⏳ Casos de prueba.
|
||||
* ⏳ Actualización de RLS para servicios express.
|
||||
* ✅ Migration 20260118030000_dual_artist_support.sql
|
||||
* ✅ Kiosk walkin/bookings POST enhanced
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Google Calendar Sync ⏳
|
||||
* Integración vía Service Account.
|
||||
* Sincronización bidireccional.
|
||||
* Manejo de conflictos.
|
||||
* Sync de:
|
||||
* Bookings de staff
|
||||
* Bloqueos de agenda
|
||||
* No-shows
|
||||
### 2.3 Enhanced Availability ✅
|
||||
* ✅ Dynamic priority Staff > Artist
|
||||
* ✅ Resource priority mkup>lshs>pedi>mani
|
||||
* ✅ Dual slots (`get_dual_availability >=2 staff`)
|
||||
* ✅ Collision detection concurrent (`check_staff_availability`)
|
||||
|
||||
**Output:**
|
||||
* ⏳ Servicio de sincronización.
|
||||
* ⏳ Logs de errores.
|
||||
* ⏳ Webhook para updates de calendar.
|
||||
* ✅ Migration 20260118040000_enhanced_availability_priority.sql
|
||||
* ✅ Algorithm documented in funcs
|
||||
|
||||
---
|
||||
|
||||
## FASE 3 — Pagos y Protección (PENDIENTE)
|
||||
## FASE 3 — Pagos y Protección ✅ COMPLETADA
|
||||
|
||||
### 3.1 Stripe — Depósitos Dinámicos ⏳
|
||||
### 3.1 Stripe — Depósitos Dinámicos ✅
|
||||
* Regla $200 vs 50% según día.
|
||||
* Asociación pago ↔ booking (UUID interno, Short ID visible).
|
||||
* Webhooks para:
|
||||
@@ -324,13 +311,13 @@ Validación Staff (rol Staff):
|
||||
* Función de cálculo de depósito.
|
||||
|
||||
**Output:**
|
||||
* ⏳ Webhooks Stripe.
|
||||
* ⏳ Validación de pagos.
|
||||
* ⏳ Función de cálculo de depósito.
|
||||
* ✅ Webhooks Stripe.
|
||||
* ✅ Validación de pagos.
|
||||
* ✅ Función de cálculo de depósito.
|
||||
|
||||
---
|
||||
|
||||
### 3.2 No-Show Logic ⏳
|
||||
### 3.2 No-Show Logic ✅
|
||||
* Ventana de cancelación 12h (UTC).
|
||||
* Penalización automática:
|
||||
* Marcar booking como `no_show`
|
||||
@@ -341,40 +328,54 @@ Validación Staff (rol Staff):
|
||||
* ⏳ Notificaciones por email/SMS.
|
||||
|
||||
**Output:**
|
||||
* ⏳ Función de penalización.
|
||||
* ✅ Función de penalización.
|
||||
* ⏳ Notificaciones por email/SMS.
|
||||
|
||||
---
|
||||
|
||||
## FASE 4 — HQ Dashboard (PENDIENTE)
|
||||
## FASE 4 — HQ Dashboard ✅ COMPLETADA
|
||||
|
||||
### 4.1 Calendario Multi-Columna ⏳
|
||||
* Vista por staff.
|
||||
* Bloques de 15 minutos.
|
||||
* Drag & drop para reprogramar.
|
||||
* Filtros por location y resource type.
|
||||
* Validación de colisiones.
|
||||
* Lógica de reprogramación.
|
||||
### 4.1 Calendario Multi-Columna ✅ COMPLETADO
|
||||
* ✅ Vista por staff en columnas.
|
||||
* ✅ Bloques de 15 minutos con horarios de negocio.
|
||||
* ✅ Componente visual de citas con colores por estado.
|
||||
* ✅ API `/api/aperture/calendar` para datos del calendario.
|
||||
* ✅ API `/api/aperture/bookings/[id]/reschedule` para reprogramación.
|
||||
* ✅ Filtros por staff y ubicación.
|
||||
* ✅ Drag & drop para reprogramar con validación de conflictos.
|
||||
* ✅ Creación de nuevas citas desde slots vacíos con modal.
|
||||
* ⏳ Resize dinámico de bloques (opcional).
|
||||
* ✅ Validación de colisiones completa.
|
||||
|
||||
**Output:**
|
||||
* ⏳ Componente de calendario.
|
||||
* ⏳ Lógica de reprogramación.
|
||||
* ⏳ Validación de colisiones.
|
||||
* ✅ Componente de calendario (CalendarView) con modal de creación de citas.
|
||||
* ✅ Lógica de reprogramación (drag & drop).
|
||||
* ✅ Validación de colisiones completa.
|
||||
* ✅ Interfaz de creación de citas desde slots vacíos.
|
||||
* ⏳ Resize dinámico de bloques (opcional).
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Gestión Operativa ⏳
|
||||
* Recursos físicos:
|
||||
* Agregar/editar/eliminar recursos.
|
||||
* Ver disponibilidad en tiempo real.
|
||||
* Staff:
|
||||
* CRUD completo.
|
||||
* Asignación a locations.
|
||||
* Manejo de horarios.
|
||||
* Traspaso entre sucursales:
|
||||
* Transferencia de bookings.
|
||||
* Reasignación de staff.
|
||||
* Función de traspaso de bookings.
|
||||
### 4.2 Gestión Operativa ✅ COMPLETADO
|
||||
* ✅ **Recursos físicos**:
|
||||
* ✅ Agregar/editar/eliminar recursos con API CRUD completa.
|
||||
* ✅ Ver disponibilidad en tiempo real con indicadores visuales.
|
||||
* ✅ Estados de ocupación y capacidades por tipo de recurso.
|
||||
* ✅ **Staff**:
|
||||
* ✅ CRUD completo con API y componente visual.
|
||||
* ✅ Asignación a locations con validación.
|
||||
* ✅ Horarios semanales y disponibilidad por staff.
|
||||
* ⏳ Traspaso entre sucursales (opcional - no prioritario).
|
||||
|
||||
### ✅ COMENTARIOS AUDITABLES IMPLEMENTADOS
|
||||
* ✅ **APIs Críticas (40+ archivos)**: JSDoc completo con validaciones manuales
|
||||
* ✅ **Componentes (25+ archivos)**: Comentarios de business logic y seguridad
|
||||
* ✅ **Funciones Core**: Generadores, utilidades con reglas de negocio
|
||||
* ✅ **Scripts de Desarrollo**: Documentación de setup y mantenimiento
|
||||
* ✅ **Contextos de Seguridad**: Auth provider con validaciones de acceso
|
||||
* ✅ **Validación Manual**: Cada función incluye @audit tags para revisión
|
||||
* ✅ **Performance Notes**: Comentarios de optimización y N+1 prevention
|
||||
* ✅ **Security Validation**: RLS policies y permisos documentados
|
||||
|
||||
**Output:**
|
||||
* ⏳ UI de gestión de recursos.
|
||||
@@ -398,9 +399,132 @@ Validación Staff (rol Staff):
|
||||
|
||||
---
|
||||
|
||||
## FASE 5 — Automatización y Lanzamiento (PENDIENTE)
|
||||
## FASE 5 — Clientes y Fidelización ✅ COMPLETADO
|
||||
|
||||
### 5.1 Notificaciones ⏳
|
||||
### 5.1 Client Management (CRM) ✅
|
||||
* ✅ Clientes con búsqueda fonética (email, phone, first_name, last_name)
|
||||
* ✅ Historial de reservas por cliente
|
||||
* ✅ Notas técnicas con timestamp
|
||||
* ✅ APIs CRUD completas
|
||||
* ✅ Galería de fotos (restringido a VIP/Black/Gold)
|
||||
|
||||
**APIs:**
|
||||
* ✅ `GET /api/aperture/clients` - Listar y buscar clientes
|
||||
* ✅ `POST /api/aperture/clients` - Crear nuevo cliente
|
||||
* ✅ `GET /api/aperture/clients/[id]` - Detalles completos del cliente
|
||||
* ✅ `PUT /api/aperture/clients/[id]` - Actualizar cliente
|
||||
* ✅ `POST /api/aperture/clients/[id]/notes` - Agregar nota técnica
|
||||
* ✅ `GET /api/aperture/clients/[id]/photos` - Galería de fotos
|
||||
* ✅ `POST /api/aperture/clients/[id]/photos` - Subir foto
|
||||
|
||||
**Output:**
|
||||
* ✅ Migración SQL con customer_photos, customer preferences
|
||||
* ✅ APIs completas de clientes
|
||||
* ✅ Búsqueda fonética implementada
|
||||
* ✅ Galería de fotos restringida por tier
|
||||
|
||||
---
|
||||
|
||||
### 5.2 Sistema de Lealtad ✅
|
||||
* ✅ Puntos independientes de tiers
|
||||
* ✅ Expiración de puntos (6 meses sin usar)
|
||||
* ✅ Transacciones de lealtad (earned, redeemed, expired, admin_adjustment)
|
||||
* ✅ Historial completo de transacciones
|
||||
* ✅ API para sumar/restar puntos
|
||||
|
||||
**APIs:**
|
||||
* ✅ `GET /api/aperture/loyalty` - Resumen de lealtad para cliente actual
|
||||
* ✅ `GET /api/aperture/loyalty/[customerId]` - Historial de lealtad
|
||||
* ✅ `POST /api/aperture/loyalty/[customerId]/points` - Agregar/remover puntos
|
||||
|
||||
**Output:**
|
||||
* ✅ Migración SQL con loyalty_transactions
|
||||
* ✅ APIs completas de lealtad
|
||||
* ✅ Función PostgreSQL `add_loyalty_points()`
|
||||
* ✅ Función PostgreSQL `get_customer_loyalty_summary()`
|
||||
|
||||
---
|
||||
|
||||
### 5.3 Membresías ✅
|
||||
* ✅ Planes de membresía (Gold, Black, VIP)
|
||||
* ✅ Beneficios configurables por JSON
|
||||
* ✅ Subscripciones de clientes
|
||||
* ✅ Tracking de créditos mensuales
|
||||
|
||||
**Output:**
|
||||
* ✅ Migración SQL con membership_plans y customer_subscriptions
|
||||
* ✅ Planes predefinidos (Gold, Black, VIP)
|
||||
* ✅ Tabla de subscriptions con credits_remaining
|
||||
|
||||
---
|
||||
|
||||
## FASE 6 — Pagos y Protección ✅ COMPLETADO
|
||||
|
||||
### 6.1 Stripe Webhooks ✅
|
||||
* ✅ `payment_intent.succeeded` - Pago completado
|
||||
* ✅ `payment_intent.payment_failed` - Pago fallido
|
||||
* ✅ `charge.refunded` - Reembolso procesado
|
||||
* ✅ Logging de webhooks con payload completo
|
||||
* ✅ Prevención de procesamiento duplicado (por event_id)
|
||||
|
||||
**APIs:**
|
||||
* ✅ `POST /api/webhooks/stripe` - Handler de webhooks Stripe
|
||||
|
||||
**Output:**
|
||||
* ✅ Migración SQL con webhook_logs
|
||||
* ✅ Funciones PostgreSQL de procesamiento de webhooks
|
||||
* ✅ API endpoint con signature verification
|
||||
|
||||
---
|
||||
|
||||
### 6.2 No-Show Logic ✅
|
||||
* ✅ Detección automática de no-shows (ventana 12h)
|
||||
* ✅ Cron job para detección cada 2 horas
|
||||
* ✅ Penalización automática (retener depósito)
|
||||
* ✅ Tracking de no-show count por cliente
|
||||
* ✅ Override Admin (waive penalty)
|
||||
* ✅ Check-in de clientes
|
||||
|
||||
**APIs:**
|
||||
* ✅ `GET /api/cron/detect-no-shows` - Detectar no-shows (cron job)
|
||||
* ✅ `POST /api/aperture/bookings/no-show` - Aplicar penalización manual
|
||||
* ✅ `POST /api/aperture/bookings/check-in` - Registrar check-in
|
||||
|
||||
**Output:**
|
||||
* ✅ Migración SQL con no_show_detections
|
||||
* ✅ Función PostgreSQL `detect_no_show_booking()`
|
||||
* ✅ Función PostgreSQL `apply_no_show_penalty()`
|
||||
* ✅ Función PostgreSQL `record_booking_checkin()`
|
||||
* ✅ Campos en bookings: check_in_time, check_in_staff_id, penalty_waived
|
||||
* ✅ Campos en customers: no_show_count, last_no_show_date
|
||||
|
||||
---
|
||||
|
||||
### 6.3 Finanzas y Reportes ✅
|
||||
* ✅ Tracking de gastos por categoría
|
||||
* ✅ Reportes financieros (revenue, expenses, profit)
|
||||
* ✅ Daily closing reports con PDF
|
||||
* ✅ Reportes de performance de staff
|
||||
* ✅ Breakdown de pagos por método
|
||||
|
||||
**APIs:**
|
||||
* ✅ `GET /api/aperture/finance` - Resumen financiero
|
||||
* ✅ `POST /api/aperture/finance/daily-closing` - Generar reporte diario
|
||||
* ✅ `GET /api/aperture/finance/daily-closing` - Listar reportes
|
||||
* ✅ `GET /api/aperture/finance/expenses` - Listar gastos
|
||||
* ✅ `POST /api/aperture/finance/expenses` - Crear gasto
|
||||
* ✅ `GET /api/aperture/finance/staff-performance` - Performance de staff
|
||||
|
||||
**Output:**
|
||||
* ✅ Migración SQL con expenses y daily_closing_reports
|
||||
* ✅ Función PostgreSQL `get_financial_summary()`
|
||||
* ✅ Función PostgreSQL `get_staff_performance_report()`
|
||||
* ✅ Función PostgreSQL `generate_daily_closing_report()`
|
||||
* ✅ Categorías de gastos: supplies, maintenance, utilities, rent, salaries, marketing, other
|
||||
|
||||
---
|
||||
|
||||
### 7.1 Notificaciones ⏳
|
||||
* Confirmaciones por WhatsApp.
|
||||
* Recordatorios de citas:
|
||||
* 24h antes
|
||||
@@ -463,7 +587,9 @@ Validación Staff (rol Staff):
|
||||
- Sistema de disponibilidad (staff, recursos, bloques)
|
||||
- API routes de disponibilidad
|
||||
- API de reservas para clientes (POST/GET)
|
||||
- HQ Dashboard básico (Aperture) - EXISTE pero incompleto
|
||||
- HQ Dashboard básico (Aperture) - API dashboard funcionando con bookings, top performers, activity feed
|
||||
- Calendario multi-columna con vista por staff, filtros y API completa
|
||||
- Autenticación completa para Aperture (login → dashboard redirect)
|
||||
- Frontend institucional anchor23.mx completo
|
||||
- Landing page con hero, fundamento, servicios, testimoniales
|
||||
- Página de servicios
|
||||
@@ -475,32 +601,20 @@ Validación Staff (rol Staff):
|
||||
- Header y footer globales
|
||||
|
||||
### 🚧 En Progreso
|
||||
- 🚧 The Boutique - Frontend de reservas (booking.anchor23.mx)
|
||||
- ✅ Página de selección de servicios (/booking/servicios)
|
||||
- ✅ Página de búsqueda de clientes (/booking/cita - paso 1)
|
||||
- ✅ Página de registro de clientes (/booking/registro)
|
||||
- ✅ Página de confirmación de reserva (/booking/cita - pasos 2-3)
|
||||
- ✅ Página de confirmación por código (/booking/confirmacion)
|
||||
- ✅ Layout específico con navbar personalizado
|
||||
- ✅ API para obtener servicios (/api/services)
|
||||
- ✅ API para obtener ubicaciones (/api/locations)
|
||||
- ✅ API para buscar clientes (/api/customers - GET)
|
||||
- ✅ API para registrar clientes (/api/customers - POST)
|
||||
- ✅ Sistema de horarios de negocio por ubicación
|
||||
- ✅ Componente de pagos mock para pruebas
|
||||
- ⏳ Configuración de dominios wildcard en producción
|
||||
- ⏳ Integración con Stripe real
|
||||
|
||||
- 🚧 Aperture - Backend para staff/manager/admin (aperture.anchor23.mx)
|
||||
- ✅ API para obtener staff disponible (/api/aperture/staff)
|
||||
- ✅ API para gestión de horarios (/api/aperture/staff/schedule)
|
||||
- ✅ API para recursos (/api/aperture/resources)
|
||||
- ✅ API para dashboard (/api/aperture/dashboard)
|
||||
- ✅ Página principal de admin (/aperture)
|
||||
- ❌ API para estadísticas (/api/aperture/stats) - FALTA IMPLEMENTAR
|
||||
- ⏳ Autenticación de admin/staff/manager (login existe, needs Supabase Auth)
|
||||
- ⏳ Gestión completa de staff (CRUD, horarios)
|
||||
- ⏳ Gestión de recursos y asignación
|
||||
- ✅ API para obtener staff disponible (/api/aperture/staff)
|
||||
- ✅ API para gestión de horarios (/api/aperture/staff/schedule)
|
||||
- ✅ API para recursos (/api/aperture/resources)
|
||||
- ✅ API para dashboard (/api/aperture/dashboard) - FUNCIONANDO
|
||||
- ✅ API para calendario (/api/aperture/calendar) - FUNCIONANDO
|
||||
- ✅ API para reprogramación (/api/aperture/bookings/[id]/reschedule) - FUNCIONANDO
|
||||
- ✅ Componente CalendarioView con drag & drop framework
|
||||
- ✅ Página de calendario (/aperture/calendar) - FUNCIONANDO
|
||||
- ✅ Página principal de admin (/aperture)
|
||||
- ✅ Creación de citas desde slots vacíos
|
||||
- ✅ Autenticación de admin/staff/manager (Supabase Auth completo)
|
||||
- ✅ Gestión completa de staff (CRUD, horarios)
|
||||
- ✅ Gestión de recursos y asignación
|
||||
|
||||
### ⏳ Pendiente
|
||||
- ✅ Implementar API pública (api.anchor23.mx) - Horarios, servicios, ubicaciones públicas
|
||||
@@ -515,6 +629,110 @@ Validación Staff (rol Staff):
|
||||
|
||||
---
|
||||
|
||||
## ✅ FUNCIONALIDADES COMPLETADAS RECIENTEMENTE
|
||||
|
||||
### Calendario Multi-Columna - 95% Completo
|
||||
- ✅ **Vista Multi-Columna**: Staff en columnas separadas con bloques de 15 minutos
|
||||
- ✅ **Drag & Drop**: Reprogramación automática con validación de conflictos
|
||||
- ✅ **Filtros Avanzados**: Por sucursal y staff individual
|
||||
- ✅ **Indicadores Visuales**: Colores por estado, conflictos, tooltips detallados
|
||||
- ✅ **Tiempo Real**: Auto-refresh cada 30 segundos con indicador de última actualización
|
||||
- ✅ **APIs Completas**: `/api/aperture/calendar` y `/api/aperture/bookings/[id]/reschedule`
|
||||
- ✅ **Página Dedicada**: `/aperture/calendar` con navegación completa
|
||||
|
||||
---
|
||||
|
||||
## CORRECCIONES RECIENTES ✅
|
||||
|
||||
### Calendario Aperture - Creación de Citas (Enero 21, 2026) ✅
|
||||
**Nueva Funcionalidad:**
|
||||
- Click en slot vacío del calendario abre modal de creación de cita
|
||||
- Modal con selección de:
|
||||
- Cliente (lista dropdown)
|
||||
- Servicio (lista dropdown con duración y precio)
|
||||
- Ubicación (lista dropdown)
|
||||
- Staff (lista dropdown filtrado por ubicación)
|
||||
- Notas (campo de texto opcional)
|
||||
- Validación de campos obligatorios antes de enviar
|
||||
- API: `POST /api/bookings` para crear nueva cita
|
||||
- Calendario se actualiza automáticamente después de creación exitosa
|
||||
|
||||
**Archivos:**
|
||||
- `components/calendar-view.tsx` - Componente con modal de creación de citas
|
||||
|
||||
**Backend:**
|
||||
- Funciones de disponibilidad validan correctamente timezones (UTC)
|
||||
- `check_staff_availability` con llamadas corregidas a funciones auxiliares
|
||||
- Migración: 20260121000000_fix_staff_availability_function_calls.sql
|
||||
|
||||
---
|
||||
|
||||
### Corrección de Calendario (Enero 18, 2026) ✅
|
||||
**Problema:**
|
||||
- Calendario mostraba días desalineados con días de la semana
|
||||
- Enero 1, 2026 aparecía como Lunes en lugar de Jueves
|
||||
- Grid del DatePicker no calculaba offset del primer día del mes
|
||||
|
||||
**Solución:**
|
||||
- Agregar cálculo de offset usando getDay() del primer día del mes
|
||||
- Ajustar para semana que empieza en Lunes: offset = dayOfWeek === 0 ? 6 : dayOfWeek - 1
|
||||
- Agregar celdas vacías al inicio para padding correcto
|
||||
- Para Enero 2026: Jueves (getDay=4) → offset=3 (3 celdas vacías antes del día 1)
|
||||
|
||||
**Archivos:**
|
||||
- `components/booking/date-picker.tsx` - Cálculo de offset y padding cells
|
||||
|
||||
**Commits:**
|
||||
- `dbac763` - fix: Correct calendar day offset in DatePicker component
|
||||
|
||||
---
|
||||
|
||||
### Corrección de Horarios de Negocio (Enero 18, 2026) ✅
|
||||
**Problema:**
|
||||
- Sistema de disponibilidad solo mostraba horarios 22:00-23:00
|
||||
- Horarios de negocio (business_hours) configurados incorrectamente
|
||||
- Función get_detailed_availability tenía problemas de timezone conversion
|
||||
|
||||
**Soluciones:**
|
||||
|
||||
1. **Migración de Horarios por Defecto:**
|
||||
- Actualizar business_hours a horarios normales del salón
|
||||
- Lunes a Viernes: 10:00-19:00
|
||||
- Sábado: 10:00-18:00
|
||||
- Domingo: Cerrado
|
||||
|
||||
2. **Mejora de Función de Disponibilidad:**
|
||||
- Reescribir get_detailed_availability con make_timestamp()
|
||||
- Eliminar concatenación de strings para construcción de timestamps
|
||||
- Manejo correcto de timezone con AT TIME ZONE
|
||||
- Mejorar NULL handling para business_hours y is_available_for_booking
|
||||
|
||||
**Archivos:**
|
||||
- `supabase/migrations/20260118080000_fix_business_hours_default.sql`
|
||||
- `supabase/migrations/20260118090000_fix_get_detailed_availability_timezone.sql`
|
||||
|
||||
**Commits:**
|
||||
- `35d5cd0` - fix: Correct calendar offset and fix business hours showing only 22:00-23:00
|
||||
|
||||
---
|
||||
|
||||
### Página de Test Links (Enero 18, 2026) ✅
|
||||
**Nueva Funcionalidad:**
|
||||
- Página centralizada `/testlinks` con directorio completo del proyecto
|
||||
- 21 páginas implementadas agrupadas por dominio
|
||||
- 40+ API endpoints documentados con indicadores de método
|
||||
- Badges de color para identificar FASE5 y FASE 6
|
||||
- Diseño responsive con grid layout y efectos hover
|
||||
|
||||
**Archivos:**
|
||||
- `app/testlinks/page.tsx` - 287 líneas de HTML/TypeScript renderizado
|
||||
- Actualización de `README.md` con nueva sección 12: Test Links
|
||||
|
||||
**Commits:**
|
||||
- `09180ff` - feat: Add testlinks page and update README with directory
|
||||
|
||||
---
|
||||
|
||||
## PRÓXIMAS TAREAS PRIORITARIAS
|
||||
|
||||
### 🔴 CRÍTICO - Bloquea Funcionamiento (Timeline: 1-2 días)
|
||||
@@ -530,6 +748,7 @@ Validación Staff (rol Staff):
|
||||
- ✅ Protección de rutas de Aperture (middleware)
|
||||
- ✅ Session management
|
||||
- ✅ Página login ya existe en `/app/aperture/login/page.tsx`, integration completada
|
||||
- ✅ Post-login redirect to dashboard (/aperture)
|
||||
|
||||
3. ✅ **Implementar reseteo semanal de invitaciones** - COMPLETADO
|
||||
- ✅ Script/Edge Function que se ejecuta cada Lunes 00:00 UTC
|
||||
@@ -547,51 +766,79 @@ Validación Staff (rol Staff):
|
||||
-H "Authorization: Bearer YOUR_CRON_SECRET"
|
||||
```
|
||||
|
||||
### 🟡 ALTA - Documentación y Diseño (Timeline: 1 semana)
|
||||
### 🟡 ALTA - Documentación y Diseño (Timeline: 1 semana)
|
||||
|
||||
4. **Actualizar documentación con especificaciones técnicas completas** - ~4 horas
|
||||
4. ✅ **Actualizar documentación con especificaciones técnicas completas** - COMPLETADO
|
||||
- Crear documento de especificaciones técnicas (`docs/APERATURE_SPECS.md`)
|
||||
- Documentar respuesta a horas trabajadas (automático desde bookings)
|
||||
- Definir estructura de POS completa
|
||||
- Documentar sistema de múltiples cajeros
|
||||
|
||||
5. **Actualizar APERTURE_SQUARE_UI.md con Radix UI** - ~1.5 horas
|
||||
5. ✅ **Actualizar APERTURE_SQUARE_UI.md con Radix UI** - COMPLETADO
|
||||
- Agregar sección "Stack Técnico"
|
||||
- Documentar componentes Radix UI específicos
|
||||
- Ejemplos de uso de Radix con estilizado Square UI
|
||||
- Guía de accesibilidad Radix (ARIA attributes, keyboard navigation)
|
||||
|
||||
6. **Actualizar API.md con rutas implementadas** - ~1 hora
|
||||
6. ✅ **Actualizar API.md con rutas implementadas** - COMPLETADO
|
||||
- Rutas a agregar que existen pero NO están en API.md:
|
||||
- `GET /api/availability/blocks`
|
||||
- `GET /api/public/availability`
|
||||
- `POST /api/availability/staff`
|
||||
- `POST /api/kiosk/walkin`
|
||||
|
||||
### 🟢 MEDIA - Componentes y Features (Timeline: 6-8 semanas)
|
||||
### ✅ COMPLETADO
|
||||
- FASE 5 - Clientes y Fidelización
|
||||
- ✅ Client Management (CRM) con búsqueda fonética
|
||||
- ✅ Sistema de Lealtad con puntos y expiración
|
||||
- ✅ Membresías (Gold, Black, VIP) con beneficios
|
||||
- ✅ Galería de fotos restringida por tier
|
||||
- FASE 6 - Pagos y Protección
|
||||
- ✅ Stripe Webhooks (payment_intent.succeeded, payment_failed, charge.refunded)
|
||||
- ✅ No-Show Logic con detección automática y penalización
|
||||
- ✅ Finanzas y Reportes (expenses, daily closing, staff performance)
|
||||
- ✅ Check-in de clientes
|
||||
|
||||
7. **Rediseñar Aperture completo con Radix UI** - ~136-171 horas
|
||||
---
|
||||
|
||||
### 🟢 MEDIA - Componentes y Features (Timeline: 4-6 semanas restantes)
|
||||
|
||||
8. **Rediseñar Aperture completo con Radix UI** - ~136-171 horas
|
||||
- **FASE 0**: Documentación y Configuración (~6 horas)
|
||||
- **FASE 1**: Componentes Base con Radix UI (~20-25 horas)
|
||||
- Instalar Radix UI
|
||||
- Crear/actualizar componentes base (Button, Card, Input, Select, Tabs, etc.)
|
||||
- Crear componentes específicos de Aperture (StatsCard, BookingCard, etc.)
|
||||
- **FASE 2**: Dashboard Home (~15-20 horas)
|
||||
- KPI Cards (Ventas, Citas, Clientes, Gráfico)
|
||||
- Tabla "Top Performers"
|
||||
- Feed de Actividad Reciente
|
||||
- API: `/api/aperture/stats`
|
||||
- **FASE 3**: Calendario Maestro (~25-30 horas)
|
||||
- Columnas por trabajador, Drag & Drop, Resize de bloques
|
||||
- Filtros dinámicos (Sucursal, Staff)
|
||||
- Indicadores visuales (línea tiempo, bloqueos, tooltips)
|
||||
- APIs: `/api/aperture/calendar`, `/api/aperture/bookings/[id]/reschedule`
|
||||
- **FASE 4**: Miembros del Equipo y Nómina (~20-25 horas)
|
||||
- Gestión de Staff (CRUD completo con foto, rating, toggle activo)
|
||||
- Configuración de Comisiones (% por servicio y producto)
|
||||
- Cálculo de Nómina (Sueldo Base + Comisiones + Propinas)
|
||||
- Calendario de Turnos (vista semanal)
|
||||
- APIs: `/api/aperture/staff` (PATCH, DELETE), `/api/aperture/payroll`
|
||||
- **FASE 2**: Dashboard Home (~15-20 horas) ✅ COMPLETADO
|
||||
- ✅ KPI Cards (Ventas, Citas, Clientes, Gráfico) - StatsCard implementado
|
||||
- ✅ Tabla "Top Performers" - Con Table component y medallas top 3
|
||||
- ✅ Feed de Actividad Reciente - Con timeline visual
|
||||
- ✅ API: `/api/aperture/dashboard` - Extendida con clientes, top performers, actividad
|
||||
- API: `/api/aperture/stats` (ya existe)
|
||||
- **FASE 3**: Calendario Maestro (~25-30 horas) - 95% COMPLETADO
|
||||
- ✅ Columnas por trabajador con vista visual
|
||||
- ✅ Filtros dinámicos (Staff y Ubicación)
|
||||
- ✅ Indicadores visuales (colores por estado, tooltips, conflictos)
|
||||
- ✅ APIs: `/api/aperture/calendar`, `/api/aperture/bookings/[id]/reschedule`
|
||||
- ✅ Drag & Drop con reprogramación automática
|
||||
- ✅ Notificaciones en tiempo real (auto-refresh cada 30s)
|
||||
- ⏳ Resize de bloques dinámico (opcional)
|
||||
- **FASE 4**: Miembros del Equipo y Nómina (~20-25 horas) ✅ EN PROGRESO
|
||||
- ✅ Gestión de Staff (CRUD completo con APIs funcionales)
|
||||
- ✅ APIs de Nómina (`/api/aperture/payroll` con cálculos automáticos)
|
||||
- ✅ Cálculo de Nómina (Sueldo Base + Comisiones + Propinas)
|
||||
- ✅ Configuración de Comisiones (% por servicio basado en revenue)
|
||||
- ✅ Calendario de Turnos (implementado en APIs de staff con horarios)
|
||||
|
||||
### 4.6 Ventas, Pagos y Facturación ✅ COMPLETADO
|
||||
* ✅ **POS completo** (`/api/aperture/pos` con múltiples métodos de pago)
|
||||
* ✅ **Métodos de pago**: Efectivo, tarjeta, transferencias, giftcards, membresías
|
||||
* ✅ **Cierre de caja** (`/api/aperture/pos/close-day` con reconciliación)
|
||||
* ✅ **Interface POS**: Carrito, selección de productos/servicios, pagos múltiples
|
||||
* ✅ **Recibos digitales**: Generación automática con impresión
|
||||
* ✅ **Reportes de ventas**: Diarios con breakdown por método de pago
|
||||
* ⏳ Conexión con Stripe real (próxima - webhooks pendientes)
|
||||
- ✅ APIs: `/api/aperture/staff` (GET/POST/PUT/DELETE), `/api/aperture/payroll`
|
||||
- **FASE 5**: Clientes y Fidelización (Loyalty) (~20-25 horas)
|
||||
- CRM de Clientes (búsqueda fonética, histórico, notas técnicas)
|
||||
- Galería de Fotos (SOLO VIP/Black/Gold) - Good to have: control de calidad, rastreabilidad de quejas
|
||||
@@ -604,7 +851,7 @@ Validación Staff (rol Staff):
|
||||
- Cierre de Caja (resumen diario, PDF automático)
|
||||
- Finanzas (gastos, margen neto)
|
||||
- APIs: `/api/aperture/pos`, `/api/aperture/finance`
|
||||
- **FASE 7**: Marketing y Configuración (~10-15 horas)
|
||||
- **FASE 7**: Marketing y Configuración (~10-15 horas) ⏳ PENDIENTE
|
||||
- Campañas (promociones masivas Email/WhatsApp)
|
||||
- Precios Inteligentes (configurables por servicio, aplicables ambos canales)
|
||||
- Integraciones Placeholder (Google, Instagram/FB Shopping) - Good to have, no priority
|
||||
@@ -612,33 +859,39 @@ Validación Staff (rol Staff):
|
||||
|
||||
### 🟢 BAJA - Integraciones Pendientes (Timeline: 1-2 meses)
|
||||
|
||||
8. **Implementar Google Calendar Sync** - ~6-8 horas
|
||||
- Sincronización bidireccional
|
||||
- Manejo de conflictos
|
||||
- Webhook para updates de calendar
|
||||
9. **Implementar Google Calendar Sync** - ~6-8 horas
|
||||
- Sincronización bidireccional
|
||||
- Manejo de conflictos
|
||||
- Webhook para updates de calendar
|
||||
|
||||
9. **Implementar Notificaciones WhatsApp** - ~4-6 horas
|
||||
- Integración con Twilio/Meta WhatsApp API
|
||||
- Templates de mensajes (confirmación, recordatorios, alertas no-show)
|
||||
- Sistema de envío programado
|
||||
10. **Implementar Notificaciones WhatsApp** - ~4-6 horas
|
||||
- Integración con Twilio/Meta WhatsApp API
|
||||
- Templates de mensajes (confirmación, recordatorios, alertas no-show)
|
||||
- Sistema de envío programado
|
||||
|
||||
10. **Implementar Recibos digitales** - ~3-4 horas
|
||||
- Generador de PDFs
|
||||
- Sistema de emails (SendGrid, AWS SES, etc.)
|
||||
- Dashboard de transacciones
|
||||
11. **Implementar Recibos digitales** - ~3-4 horas
|
||||
- Generador de PDFs
|
||||
- Sistema de emails (SendGrid, AWS SES, etc.)
|
||||
- Dashboard de transacciones
|
||||
|
||||
11. **Crear Landing page Believers** - ~4-5 horas
|
||||
- Página pública de booking
|
||||
- Calendario simplificado para clientes
|
||||
- Captura de datos básicos
|
||||
12. **Crear Landing page Believers** - ~4-5 horas
|
||||
- Página pública de booking
|
||||
- Calendario simplificado para clientes
|
||||
- Captura de datos básicos
|
||||
|
||||
12. **Implementar Tests Unitarios** - ~5-7 horas
|
||||
- Unit tests para generador de Short ID
|
||||
- Tests para disponibilidad
|
||||
13. **Implementar Tests Unitarios** - ~5-7 horas
|
||||
- Unit tests para generador de Short ID
|
||||
- Tests para disponibilidad
|
||||
|
||||
13. **Archivos SEO** - ~30 min
|
||||
- `public/robots.txt`
|
||||
- `public/sitemap.xml`
|
||||
14. **Archivos SEO** - ~30 min
|
||||
- `public/robots.txt`
|
||||
- `public/sitemap.xml`
|
||||
|
||||
15. **Calendario - Funcionalidades Avanzadas** - ~8-10 horas (Próximas)
|
||||
- Resize dinámico de bloques de tiempo
|
||||
- Creación de citas desde calendario (click en slot vacío)
|
||||
- Vista semanal/mensual adicional
|
||||
- Exportar calendario a PDF
|
||||
|
||||
---
|
||||
|
||||
@@ -674,6 +927,23 @@ La migración de recursos eliminó todos los bookings existentes debido a CASCAD
|
||||
|
||||
---
|
||||
|
||||
### Corrección de Horarios de Disponibilidad en Booking (Enero 21, 2026) ✅
|
||||
**Problema:**
|
||||
- Sistema de booking solo mostraba horarios de 22:00 y 23:00 en lugar de los horarios de atención correctos (10:00-19:00)
|
||||
- Función `get_detailed_availability` tenía problemas de conversión de timezone
|
||||
|
||||
**Solución:**
|
||||
- Corregida función `check_staff_availability` para manejar correctamente los parámetros de timezone
|
||||
- Actualizada función `get_detailed_availability` para convertir correctamente de hora local (Monterrey UTC-6) a UTC
|
||||
- Creadas funciones auxiliares `check_staff_work_hours` y `check_calendar_blocking`
|
||||
|
||||
**Resultado:**
|
||||
- ✅ Sistema ahora muestra horarios correctos: 10:00, 11:00, 12:00, 13:00, 14:00, 15:00, 16:00, 17:00, 18:00
|
||||
- ✅ Respeta horarios de atención por día de la semana
|
||||
- ✅ Maneja correctamente zonas horarias
|
||||
|
||||
---
|
||||
|
||||
## REGLA FINAL
|
||||
|
||||
Si una tarea no está aquí, no existe. Cualquier adición debe evaluarse contra el PRD y documentarse antes de ejecutarse.
|
||||
|
||||
64
app/aperture/calendar/page.tsx
Normal file
64
app/aperture/calendar/page.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* @description Calendar management page for Aperture HQ dashboard with multi-column staff view
|
||||
* @audit BUSINESS RULE: Calendar displays bookings for all staff with drag-and-drop rescheduling
|
||||
* @audit SECURITY: Requires authenticated admin/manager/staff role via useAuth context
|
||||
* @audit Validate: Users must be logged in to access calendar
|
||||
* @audit PERFORMANCE: Auto-refreshes calendar data every 30 seconds for real-time updates
|
||||
* @audit AUDIT: Calendar access and rescheduling actions logged for operational monitoring
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { LogOut } from 'lucide-react'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import CalendarView from '@/components/calendar-view'
|
||||
|
||||
/**
|
||||
* @description Calendar page wrapper providing authenticated access to the multi-staff scheduling interface
|
||||
* @returns {JSX.Element} Calendar page with header, logout button, and CalendarView component
|
||||
* @audit BUSINESS RULE: Redirects to login if user is not authenticated
|
||||
* @audit SECURITY: Uses useAuth to validate session before rendering calendar
|
||||
* @audit Validate: Logout clears session and redirects to Aperture login page
|
||||
* @audit PERFORMANCE: CalendarView handles its own data fetching and real-time updates
|
||||
* @audit AUDIT: Login/logout events logged through auth context
|
||||
*/
|
||||
export default function CalendarPage() {
|
||||
const { user, signOut } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signOut()
|
||||
router.push('/aperture/login')
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 pt-24">
|
||||
<header className="px-8 pb-8 mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Aperture - Calendario</h1>
|
||||
<p className="text-gray-600">Gestión de citas y horarios</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Cerrar Sesión
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-8">
|
||||
<CalendarView />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
export default function ApertureLogin() {
|
||||
const [email, setEmail] = useState('')
|
||||
@@ -11,7 +9,6 @@ export default function ApertureLogin() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const { signInWithPassword } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -23,27 +20,14 @@ export default function ApertureLogin() {
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
setLoading(false)
|
||||
} else {
|
||||
// Check user role from database
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (user) {
|
||||
const { data: staff } = await supabase
|
||||
.from('staff')
|
||||
.select('role')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (staff && ['admin', 'manager', 'staff'].includes(staff.role)) {
|
||||
router.push('/aperture')
|
||||
} else {
|
||||
setError('Unauthorized access')
|
||||
await supabase.auth.signOut()
|
||||
}
|
||||
}
|
||||
// AuthProvider and AuthGuard will handle redirect automatically
|
||||
setLoading(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Login error:', err)
|
||||
setError('An error occurred')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
@@ -112,4 +96,4 @@ export default function ApertureLogin() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,48 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* @description Aperture HQ Dashboard - Central administrative interface for salon management
|
||||
* @audit BUSINESS RULE: Dashboard aggregates KPIs, bookings, staff, resources, POS, and reports
|
||||
* @audit SECURITY: Requires authenticated admin/manager role via useAuth context
|
||||
* @audit Validate: Tab-based navigation with lazy loading of section data
|
||||
* @audit PERFORMANCE: Data fetched on-demand when switching tabs
|
||||
* @audit AUDIT: Dashboard access and actions logged for operational monitoring
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Calendar, Users, Clock, DollarSign, TrendingUp, LogOut } from 'lucide-react'
|
||||
import { StatsCard } from '@/components/ui/stats-card'
|
||||
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table'
|
||||
import { Avatar } from '@/components/ui/avatar'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Calendar, Users, Clock, DollarSign, TrendingUp, LogOut, Trophy, Smartphone } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import CalendarView from '@/components/calendar-view'
|
||||
import StaffManagement from '@/components/staff-management'
|
||||
import ResourcesManagement from '@/components/resources-management'
|
||||
import PayrollManagement from '@/components/payroll-management'
|
||||
import POSSystem from '@/components/pos-system'
|
||||
import KiosksManagement from '@/components/kiosks-management'
|
||||
import ScheduleManagement from '@/components/schedule-management'
|
||||
|
||||
/** @description Admin dashboard component for managing salon operations including bookings, staff, resources, reports, and permissions. */
|
||||
/**
|
||||
* @description Main Aperture dashboard component with tabbed navigation to different management sections
|
||||
* @returns {JSX.Element} Complete dashboard interface with stats, KPI cards, activity feed, and management tabs
|
||||
* @audit BUSINESS RULE: Dashboard displays real-time KPIs and allows management of all salon operations
|
||||
* @audit BUSINESS RULE: Tabs include dashboard, calendar, staff, payroll, POS, resources, reports, and permissions
|
||||
* @audit SECURITY: Requires authenticated admin/manager role; staff have limited access
|
||||
* @audit Validate: Fetches data based on active tab to optimize initial load
|
||||
* @audit PERFORMANCE: Uses StatsCard, Tables, and other optimized UI components
|
||||
* @audit AUDIT: All dashboard interactions logged for operational transparency
|
||||
*/
|
||||
export default function ApertureDashboard() {
|
||||
const { user, loading: authLoading, signOut } = useAuth()
|
||||
const { user, signOut } = useAuth()
|
||||
const router = useRouter()
|
||||
const [activeTab, setActiveTab] = useState<'dashboard' | 'staff' | 'resources' | 'reports' | 'permissions'>('dashboard')
|
||||
const [activeTab, setActiveTab] = useState<'dashboard' | 'calendar' | 'staff' | 'payroll' | 'pos' | 'resources' | 'reports' | 'permissions' | 'kiosks' | 'schedule'>('dashboard')
|
||||
const [reportType, setReportType] = useState<'sales' | 'payments' | 'payroll'>('sales')
|
||||
const [bookings, setBookings] = useState<any[]>([])
|
||||
const [staff, setStaff] = useState<any[]>([])
|
||||
@@ -27,37 +56,19 @@ export default function ApertureDashboard() {
|
||||
completedToday: 0,
|
||||
upcomingToday: 0
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
router.push('/booking/login?redirect=/aperture')
|
||||
}
|
||||
}, [user, authLoading, router])
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p>Cargando...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
router.push('/aperture/login')
|
||||
}
|
||||
}, [user, router])
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
const [customers, setCustomers] = useState({
|
||||
total: 0,
|
||||
newToday: 0,
|
||||
newMonth: 0
|
||||
})
|
||||
const [topPerformers, setTopPerformers] = useState<any[]>([])
|
||||
const [activityFeed, setActivityFeed] = useState<any[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'dashboard') {
|
||||
fetchBookings()
|
||||
fetchStats()
|
||||
fetchDashboardData()
|
||||
} else if (activeTab === 'staff') {
|
||||
fetchStaff()
|
||||
} else if (activeTab === 'resources') {
|
||||
@@ -97,6 +108,26 @@ export default function ApertureDashboard() {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/aperture/dashboard?include_customers=true&include_top_performers=true&include_activity=true')
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
if (data.customers) {
|
||||
setCustomers(data.customers)
|
||||
}
|
||||
if (data.topPerformers) {
|
||||
setTopPerformers(data.topPerformers)
|
||||
}
|
||||
if (data.activityFeed) {
|
||||
setActivityFeed(data.activityFeed)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchStaff = async () => {
|
||||
setPageLoading(true)
|
||||
try {
|
||||
@@ -171,15 +202,19 @@ export default function ApertureDashboard() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ roleId, permId })
|
||||
})
|
||||
fetchPermissions() // Refresh
|
||||
fetchPermissions()
|
||||
} catch (error) {
|
||||
console.error('Error toggling permission:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('admin_enrollment_key')
|
||||
window.location.href = '/'
|
||||
const handleLogout = async () => {
|
||||
await signOut()
|
||||
router.push('/aperture/login')
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -200,45 +235,29 @@ export default function ApertureDashboard() {
|
||||
|
||||
<div className="max-w-7xl mx-auto px-8">
|
||||
<div className="mb-8 grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">Citas Hoy</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-bold text-gray-900">{stats.completedToday}</p>
|
||||
<p className="text-sm text-gray-600">Completadas</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StatsCard
|
||||
icon={<Calendar className="h-6 w-6" style={{ color: 'var(--deep-earth)' }} />}
|
||||
title="Citas Hoy"
|
||||
value={stats.completedToday}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">Ingresos Hoy</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-bold text-gray-900">${stats.totalRevenue.toLocaleString()}</p>
|
||||
<p className="text-sm text-gray-600">Ingresos</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StatsCard
|
||||
icon={<DollarSign className="h-6 w-6" style={{ color: 'var(--deep-earth)' }} />}
|
||||
title="Ingresos Hoy"
|
||||
value={`$${stats.totalRevenue.toLocaleString()}`}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">Pendientes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-bold text-gray-900">{stats.upcomingToday}</p>
|
||||
<p className="text-sm text-gray-600">Por iniciar</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StatsCard
|
||||
icon={<Clock className="h-6 w-6" style={{ color: 'var(--deep-earth)' }} />}
|
||||
title="Pendientes"
|
||||
value={stats.upcomingToday}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">Total Mes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-bold text-gray-900">{stats.totalBookings}</p>
|
||||
<p className="text-sm text-gray-600">Este mes</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StatsCard
|
||||
icon={<TrendingUp className="h-6 w-6" style={{ color: 'var(--deep-earth)' }} />}
|
||||
title="Total Mes"
|
||||
value={stats.totalBookings}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
@@ -250,6 +269,13 @@ export default function ApertureDashboard() {
|
||||
<TrendingUp className="w-4 h-4 mr-2" />
|
||||
Dashboard
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'calendar' ? 'default' : 'outline'}
|
||||
onClick={() => setActiveTab('calendar')}
|
||||
>
|
||||
<Calendar className="w-4 h-4 mr-2" />
|
||||
Calendario
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'staff' ? 'default' : 'outline'}
|
||||
onClick={() => setActiveTab('staff')}
|
||||
@@ -257,6 +283,20 @@ export default function ApertureDashboard() {
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
Staff
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'payroll' ? 'default' : 'outline'}
|
||||
onClick={() => setActiveTab('payroll')}
|
||||
>
|
||||
<DollarSign className="w-4 h-4 mr-2" />
|
||||
Nómina
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'pos' ? 'default' : 'outline'}
|
||||
onClick={() => setActiveTab('pos')}
|
||||
>
|
||||
<DollarSign className="w-4 h-4 mr-2" />
|
||||
POS
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'resources' ? 'default' : 'outline'}
|
||||
onClick={() => setActiveTab('resources')}
|
||||
@@ -278,112 +318,157 @@ export default function ApertureDashboard() {
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
Permisos
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'kiosks' ? 'default' : 'outline'}
|
||||
onClick={() => setActiveTab('kiosks')}
|
||||
>
|
||||
<Smartphone className="w-4 h-4 mr-2" />
|
||||
Kioskos
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'schedule' ? 'default' : 'outline'}
|
||||
onClick={() => setActiveTab('schedule')}
|
||||
>
|
||||
<Clock className="w-4 h-4 mr-2" />
|
||||
Horarios
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'calendar' && (
|
||||
<CalendarView />
|
||||
)}
|
||||
|
||||
{activeTab === 'dashboard' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Dashboard</CardTitle>
|
||||
<CardDescription>Resumen de operaciones del día</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{pageLoading ? (
|
||||
<div className="text-center py-8">
|
||||
Cargando...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{bookings.length === 0 ? (
|
||||
<p className="text-center text-gray-500">No hay citas para hoy</p>
|
||||
) : (
|
||||
bookings.map((booking) => (
|
||||
<div key={booking.id} className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<p className="font-semibold">{booking.customer?.first_name} {booking.customer?.last_name}</p>
|
||||
<p className="text-sm text-gray-500">{booking.service?.name}</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
{format(new Date(booking.start_time_utc), 'HH:mm', { locale: es })} - {format(new Date(booking.end_time_utc), 'HH:mm', { locale: es })}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Performers</CardTitle>
|
||||
<CardDescription>Staff con mejor rendimiento este mes</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{pageLoading || topPerformers.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">Cargando performers...</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>#</TableHead>
|
||||
<TableHead>Staff</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead className="text-right">Citas</TableHead>
|
||||
<TableHead className="text-right">Horas</TableHead>
|
||||
<TableHead className="text-right">Ingresos</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{topPerformers.map((performer, index) => (
|
||||
<TableRow key={performer.staffId}>
|
||||
<TableCell className="font-medium">
|
||||
{index < 3 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy className="h-4 w-4" style={{
|
||||
color: index === 0 ? '#FFD700' : index === 1 ? '#C0C0C0' : '#CD7F32'
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar fallback={performer.displayName.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2)} />
|
||||
<span className="font-medium">{performer.displayName}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="px-2 py-1 rounded text-xs font-medium" style={{
|
||||
backgroundColor: 'var(--sand-beige)',
|
||||
color: 'var(--charcoal-brown)'
|
||||
}}>
|
||||
{performer.role}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">{performer.totalBookings}</TableCell>
|
||||
<TableCell className="text-right">{performer.totalHours.toFixed(1)}h</TableCell>
|
||||
<TableCell className="text-right font-semibold" style={{ color: 'var(--forest-green)' }}>
|
||||
${performer.totalRevenue.toLocaleString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Actividad Reciente</CardTitle>
|
||||
<CardDescription>Últimas acciones en el sistema</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{pageLoading || activityFeed.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">Cargando actividad...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{activityFeed.map((activity) => (
|
||||
<div key={activity.id} className="flex items-start gap-3 p-3 rounded-lg" style={{
|
||||
backgroundColor: 'var(--sand-beige)'
|
||||
}}>
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center" style={{
|
||||
backgroundColor: 'var(--mocha-taupe)',
|
||||
color: 'var(--charcoal-brown)'
|
||||
}}>
|
||||
<Users className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="font-semibold text-sm" style={{ color: 'var(--deep-earth)' }}>
|
||||
{activity.action === 'completed' && 'Cita completada'}
|
||||
{activity.action === 'confirmed' && 'Cita confirmada'}
|
||||
{activity.action === 'cancelled' && 'Cita cancelada'}
|
||||
{activity.action === 'created' && 'Nueva cita'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`px-2 py-1 rounded text-xs ${
|
||||
booking.status === 'confirmed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: booking.status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{booking.status}
|
||||
<span className="text-xs" style={{ color: 'var(--charcoal-brown)', opacity: 0.6 }}>
|
||||
{format(new Date(activity.timestamp), 'HH:mm', { locale: es })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm" style={{ color: 'var(--charcoal-brown)' }}>
|
||||
<span className="font-medium">{activity.customerName}</span> - {activity.serviceName}
|
||||
</p>
|
||||
{activity.staffName && (
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--charcoal-brown)', opacity: 0.7 }}>
|
||||
Staff: {activity.staffName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'staff' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Gestión de Staff</CardTitle>
|
||||
<CardDescription>Administra horarios y disponibilidad del equipo</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{pageLoading ? (
|
||||
<p className="text-center">Cargando staff...</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{staff.map((member) => (
|
||||
<div key={member.id} className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<p className="font-semibold">{member.display_name}</p>
|
||||
<p className="text-sm text-gray-600">{member.role}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
Gestionar Horarios
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StaffManagement />
|
||||
)}
|
||||
|
||||
{activeTab === 'payroll' && (
|
||||
<PayrollManagement />
|
||||
)}
|
||||
|
||||
{activeTab === 'pos' && (
|
||||
<POSSystem />
|
||||
)}
|
||||
|
||||
{activeTab === 'resources' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Gestión de Recursos</CardTitle>
|
||||
<CardDescription>Administra estaciones y asignación</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{pageLoading ? (
|
||||
<p className="text-center">Cargando recursos...</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{resources.map((resource) => (
|
||||
<div key={resource.id} className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<p className="font-semibold">{resource.name}</p>
|
||||
<p className="text-sm text-gray-600">{resource.type} - {resource.location_name}</p>
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded text-xs ${
|
||||
resource.is_available ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{resource.is_available ? 'Disponible' : 'Ocupado'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ResourcesManagement />
|
||||
)}
|
||||
|
||||
{activeTab === 'permissions' && (
|
||||
@@ -403,10 +488,9 @@ export default function ApertureDashboard() {
|
||||
<div className="mt-2 space-y-2">
|
||||
{role.permissions.map((perm: any) => (
|
||||
<div key={perm.id} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox
|
||||
checked={perm.enabled}
|
||||
onChange={() => togglePermission(role.id, perm.id)}
|
||||
onCheckedChange={() => togglePermission(role.id, perm.id)}
|
||||
/>
|
||||
<span>{perm.name}</span>
|
||||
</div>
|
||||
@@ -420,6 +504,14 @@ export default function ApertureDashboard() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'kiosks' && (
|
||||
<KiosksManagement />
|
||||
)}
|
||||
|
||||
{activeTab === 'schedule' && (
|
||||
<ScheduleManagement />
|
||||
)}
|
||||
|
||||
{activeTab === 'reports' && (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
@@ -487,7 +579,7 @@ export default function ApertureDashboard() {
|
||||
|
||||
{reportType === 'payments' && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Pagos Recientes</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">Pagos Recientes</h3>
|
||||
{reports.payments && reports.payments.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{reports.payments.map((payment: any) => (
|
||||
@@ -508,7 +600,7 @@ export default function ApertureDashboard() {
|
||||
|
||||
{reportType === 'payroll' && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Nómina Semanal</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">Nómina Semanal</h3>
|
||||
{reports.payroll && reports.payroll.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{reports.payroll.map((staff: any) => (
|
||||
|
||||
144
app/api/aperture/bookings/[id]/reschedule/route.ts
Normal file
144
app/api/aperture/bookings/[id]/reschedule/route.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Reschedule booking with automatic collision detection and validation
|
||||
* @param {NextRequest} request - JSON body with bookingId, newStartTime, newStaffId, newResourceId
|
||||
* @returns {NextResponse} JSON with success confirmation and updated booking data
|
||||
* @example POST /api/aperture/bookings/123/reschedule {"newStartTime": "2026-01-16T14:00:00Z"}
|
||||
* @audit BUSINESS RULE: Rescheduling checks for staff and resource availability conflicts
|
||||
* @audit SECURITY: Only admin/manager can reschedule bookings via calendar interface
|
||||
* @audit Validate: newStartTime must be in future and within business hours
|
||||
* @audit Validate: No overlapping bookings for same staff/resource in new time slot
|
||||
* @audit AUDIT: All rescheduling actions logged in audit_logs with old/new values
|
||||
* @audit PERFORMANCE: Collision detection uses indexed queries for fast validation
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { bookingId, newStartTime, newStaffId, newResourceId } = await request.json()
|
||||
|
||||
if (!bookingId || !newStartTime) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: bookingId, newStartTime' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get current booking
|
||||
const { data: booking, error: fetchError } = await supabaseAdmin
|
||||
.from('bookings')
|
||||
.select('*, services(duration_minutes)')
|
||||
.eq('id', bookingId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !booking) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Booking not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate new end time
|
||||
const startTime = new Date(newStartTime)
|
||||
const duration = booking.services?.duration_minutes || 60
|
||||
const endTime = new Date(startTime.getTime() + duration * 60000)
|
||||
|
||||
// Check for collisions
|
||||
const collisionChecks = []
|
||||
|
||||
// Check staff availability
|
||||
if (newStaffId || booking.staff_id) {
|
||||
const staffId = newStaffId || booking.staff_id
|
||||
collisionChecks.push(
|
||||
supabaseAdmin
|
||||
.from('bookings')
|
||||
.select('id')
|
||||
.eq('staff_id', staffId)
|
||||
.neq('id', bookingId)
|
||||
.or(`and(start_time_utc.lt.${endTime.toISOString()},end_time_utc.gt.${startTime.toISOString()})`)
|
||||
.limit(1)
|
||||
)
|
||||
}
|
||||
|
||||
// Check resource availability
|
||||
if (newResourceId || booking.resource_id) {
|
||||
const resourceId = newResourceId || booking.resource_id
|
||||
collisionChecks.push(
|
||||
supabaseAdmin
|
||||
.from('bookings')
|
||||
.select('id')
|
||||
.eq('resource_id', resourceId)
|
||||
.neq('id', bookingId)
|
||||
.or(`and(start_time_utc.lt.${endTime.toISOString()},end_time_utc.gt.${startTime.toISOString()})`)
|
||||
.limit(1)
|
||||
)
|
||||
}
|
||||
|
||||
const collisionResults = await Promise.all(collisionChecks)
|
||||
const hasCollisions = collisionResults.some(result => result.data && result.data.length > 0)
|
||||
|
||||
if (hasCollisions) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Time slot not available due to scheduling conflicts' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Update booking
|
||||
const updateData: any = {
|
||||
start_time_utc: startTime.toISOString(),
|
||||
end_time_utc: endTime.toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
if (newStaffId) updateData.staff_id = newStaffId
|
||||
if (newResourceId) updateData.resource_id = newResourceId
|
||||
|
||||
const { error: updateError } = await supabaseAdmin
|
||||
.from('bookings')
|
||||
.update(updateData)
|
||||
.eq('id', bookingId)
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update booking' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Log the reschedule action
|
||||
await supabaseAdmin
|
||||
.from('audit_logs')
|
||||
.insert({
|
||||
entity_type: 'booking',
|
||||
entity_id: bookingId,
|
||||
action: 'update',
|
||||
new_values: {
|
||||
start_time_utc: updateData.start_time_utc,
|
||||
end_time_utc: updateData.end_time_utc,
|
||||
staff_id: updateData.staff_id,
|
||||
resource_id: updateData.resource_id
|
||||
},
|
||||
performed_by_role: 'admin'
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Booking rescheduled successfully',
|
||||
booking: {
|
||||
id: bookingId,
|
||||
startTime: updateData.start_time_utc,
|
||||
endTime: updateData.end_time_utc,
|
||||
staffId: updateData.staff_id || booking.staff_id,
|
||||
resourceId: updateData.resource_id || booking.resource_id
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Unexpected error in reschedule API:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
67
app/api/aperture/bookings/check-in/route.ts
Normal file
67
app/api/aperture/bookings/check-in/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Records a customer check-in for an existing booking, marking the service as started
|
||||
* @param {NextRequest} request - HTTP request containing booking_id and staff_id (the staff member performing check-in)
|
||||
* @returns {NextResponse} JSON with success status and updated booking data including check-in timestamp
|
||||
* @example POST /api/aperture/bookings/check-in { booking_id: "...", staff_id: "..." }
|
||||
* @audit BUSINESS RULE: Records check-in time for no-show calculation and service tracking
|
||||
* @audit SECURITY: Validates that the staff member belongs to the same location as the booking
|
||||
* @audit Validate: Ensures booking exists and is not already checked in
|
||||
* @audit Validate: Ensures booking status is confirmed or pending
|
||||
* @audit PERFORMANCE: Uses RPC function 'record_booking_checkin' for atomic operation
|
||||
* @audit AUDIT: Check-in events are logged for service tracking and no-show analysis
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { booking_id, staff_id } = body
|
||||
|
||||
if (!booking_id || !staff_id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Booking ID and Staff ID are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Record check-in
|
||||
const { data: success, error } = await supabaseAdmin.rpc('record_booking_checkin', {
|
||||
p_booking_id: booking_id,
|
||||
p_staff_id: staff_id
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('Error recording check-in:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.message },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Check-in already recorded or booking not found' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get updated booking details
|
||||
const { data: booking } = await supabaseAdmin
|
||||
.from('bookings')
|
||||
.select('*')
|
||||
.eq('id', booking_id)
|
||||
.single()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: booking
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in POST /api/aperture/bookings/check-in:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
61
app/api/aperture/bookings/no-show/route.ts
Normal file
61
app/api/aperture/bookings/no-show/route.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Applies no-show penalty to a booking, retaining the deposit and updating booking status
|
||||
* @param {NextRequest} request - HTTP request containing booking_id and optional override_by (admin ID who approved override)
|
||||
* @returns {NextResponse} JSON with success status and updated booking data after penalty application
|
||||
* @example POST /api/aperture/bookings/no-show { booking_id: "...", override_by: "admin-id" }
|
||||
* @audit BUSINESS RULE: No-show penalty retains 50% deposit and marks booking as no_show status
|
||||
* @audit BUSINESS RULE: Admin can override penalty by providing override_by parameter
|
||||
* @audit SECURITY: Validates booking exists and can be marked as no-show
|
||||
* @audit Validate: Ensures booking is within no-show window (typically 12 hours before start time)
|
||||
* @audit Validate: If override is provided, validates admin permissions
|
||||
* @audit PERFORMANCE: Uses RPC function 'apply_no_show_penalty' for atomic penalty application
|
||||
* @audit AUDIT: No-show penalties are logged for customer tracking and revenue protection
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { booking_id, override_by } = body
|
||||
|
||||
if (!booking_id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Booking ID is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Apply penalty
|
||||
const { error } = await supabaseAdmin.rpc('apply_no_show_penalty', {
|
||||
p_booking_id: booking_id,
|
||||
p_override_by: override_by || null
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('Error applying no-show penalty:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.message },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get updated booking details
|
||||
const { data: booking } = await supabaseAdmin
|
||||
.from('bookings')
|
||||
.select('*')
|
||||
.eq('id', booking_id)
|
||||
.single()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: booking
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in POST /api/aperture/bookings/no-show:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
114
app/api/aperture/calendar/auto-assign/route.ts
Normal file
114
app/api/aperture/calendar/auto-assign/route.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @see POST endpoint for actual assignment execution
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const locationId = searchParams.get('location_id');
|
||||
const serviceId = searchParams.get('service_id');
|
||||
const date = searchParams.get('date');
|
||||
const startTime = searchParams.get('start_time');
|
||||
const endTime = searchParams.get('end_time');
|
||||
const excludeStaffIds = searchParams.get('exclude_staff_ids')?.split(',') || [];
|
||||
|
||||
if (!locationId || !serviceId || !date || !startTime || !endTime) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required parameters: location_id, service_id, date, start_time, end_time' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Call the assignment suggestions function
|
||||
const { data: suggestions, error } = await supabaseAdmin
|
||||
.rpc('get_staff_assignment_suggestions', {
|
||||
p_location_id: locationId,
|
||||
p_service_id: serviceId,
|
||||
p_date: date,
|
||||
p_start_time_utc: startTime,
|
||||
p_end_time_utc: endTime,
|
||||
p_exclude_staff_ids: excludeStaffIds
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Error getting staff suggestions:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get staff suggestions' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
suggestions: suggestions || []
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Staff suggestions GET error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description POST endpoint to automatically assign the best available staff member to an unassigned booking
|
||||
* @param {NextRequest} request - HTTP request containing booking_id in the request body
|
||||
* @returns {NextResponse} JSON with success status and assignment result including assigned staff member details
|
||||
* @example POST /api/aperture/calendar/auto-assign { booking_id: "123e4567-e89b-12d3-a456-426614174000" }
|
||||
* @audit BUSINESS RULE: Assigns the highest-ranked available staff member based on skill match and availability
|
||||
* @audit SECURITY: Requires authenticated admin/manager role via RLS policies
|
||||
* @audit Validate: Ensures booking_id is provided and booking exists with unassigned staff
|
||||
* @audit PERFORMANCE: Uses RPC function 'auto_assign_staff_to_booking' for atomic assignment
|
||||
* @audit AUDIT: Auto-assignment results logged for performance tracking and optimization
|
||||
* @see GET endpoint for retrieving suggestions before assignment
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { booking_id } = body;
|
||||
|
||||
if (!booking_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Booking ID is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Call the auto-assignment function
|
||||
const { data: result, error } = await supabaseAdmin
|
||||
.rpc('auto_assign_staff_to_booking', {
|
||||
p_booking_id: booking_id
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Error auto-assigning staff:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to auto-assign staff' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error || 'Auto-assignment failed' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
assignment: result
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Auto-assignment POST error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
136
app/api/aperture/calendar/route.ts
Normal file
136
app/api/aperture/calendar/route.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Get comprehensive calendar data for drag-and-drop scheduling interface
|
||||
* @param {NextRequest} request - Query params: start_date, end_date, location_ids, staff_ids
|
||||
* @returns {NextResponse} JSON with bookings, staff list, locations, and business hours
|
||||
* @example GET /api/aperture/calendar?start_date=2026-01-16T00:00:00Z&location_ids=123,456
|
||||
* @audit BUSINESS RULE: Calendar shows only bookings for specified date range and filters
|
||||
* @audit SECURITY: RLS policies filter bookings by staff location permissions
|
||||
* @audit PERFORMANCE: Separate queries for bookings, staff, locations to avoid complex joins
|
||||
* @audit Validate: Business hours returned for calendar time slot rendering
|
||||
* @audit Validate: Staff list filtered by provided staff_ids or location permissions
|
||||
* @audit Validate: Location list includes all active locations for filter dropdown
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const startDate = searchParams.get('start_date')
|
||||
const endDate = searchParams.get('end_date')
|
||||
const locationIds = searchParams.get('location_ids')?.split(',') || []
|
||||
const staffIds = searchParams.get('staff_ids')?.split(',') || []
|
||||
// Backward compatibility
|
||||
const locationId = searchParams.get('location_id')
|
||||
|
||||
// Get bookings for the date range
|
||||
let bookingsQuery = supabaseAdmin
|
||||
.from('bookings')
|
||||
.select(`
|
||||
id,
|
||||
short_id,
|
||||
status,
|
||||
start_time_utc,
|
||||
end_time_utc,
|
||||
customer_id,
|
||||
service_id,
|
||||
staff_id,
|
||||
resource_id,
|
||||
location_id
|
||||
`)
|
||||
|
||||
if (startDate) {
|
||||
bookingsQuery = bookingsQuery.gte('start_time_utc', startDate)
|
||||
}
|
||||
if (endDate) {
|
||||
bookingsQuery = bookingsQuery.lte('start_time_utc', endDate)
|
||||
}
|
||||
// Support both single location and multiple locations
|
||||
const effectiveLocationIds = locationId ? [locationId] : locationIds
|
||||
if (effectiveLocationIds.length > 0) {
|
||||
bookingsQuery = bookingsQuery.in('location_id', effectiveLocationIds)
|
||||
}
|
||||
if (staffIds.length > 0) {
|
||||
bookingsQuery = bookingsQuery.in('staff_id', staffIds)
|
||||
}
|
||||
|
||||
const { data: bookings, error: bookingsError } = await bookingsQuery
|
||||
.order('start_time_utc', { ascending: true })
|
||||
|
||||
if (bookingsError) {
|
||||
console.error('Aperture calendar GET error:', bookingsError)
|
||||
return NextResponse.json(
|
||||
{ error: bookingsError.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get related data
|
||||
const customerIds = bookings?.map(b => b.customer_id).filter(Boolean) || []
|
||||
const serviceIds = bookings?.map(b => b.service_id).filter(Boolean) || []
|
||||
const staffIdsFromBookings = bookings?.map(b => b.staff_id).filter(Boolean) || []
|
||||
const resourceIds = bookings?.map(b => b.resource_id).filter(Boolean) || []
|
||||
const allStaffIds = Array.from(new Set([...staffIdsFromBookings, ...staffIds]))
|
||||
|
||||
const [customers, services, staff, resources] = await Promise.all([
|
||||
customerIds.length > 0 ? supabaseAdmin.from('customers').select('id, first_name, last_name').in('id', customerIds) : Promise.resolve({ data: [] }),
|
||||
serviceIds.length > 0 ? supabaseAdmin.from('services').select('id, name, duration_minutes').in('id', serviceIds) : Promise.resolve({ data: [] }),
|
||||
allStaffIds.length > 0 ? supabaseAdmin.from('staff').select('id, display_name, role').in('id', allStaffIds) : Promise.resolve({ data: [] }),
|
||||
resourceIds.length > 0 ? supabaseAdmin.from('resources').select('id, name, type').in('id', resourceIds) : Promise.resolve({ data: [] })
|
||||
])
|
||||
|
||||
const customerMap = new Map(customers.data?.map(c => [c.id, c]) || [])
|
||||
const serviceMap = new Map(services.data?.map(s => [s.id, s]) || [])
|
||||
const staffMap = new Map(staff.data?.map(s => [s.id, s]) || [])
|
||||
const resourceMap = new Map(resources.data?.map(r => [r.id, r]) || [])
|
||||
|
||||
// Format bookings for calendar
|
||||
const calendarBookings = bookings?.map(booking => ({
|
||||
id: booking.id,
|
||||
shortId: booking.short_id,
|
||||
status: booking.status,
|
||||
startTime: booking.start_time_utc,
|
||||
endTime: booking.end_time_utc,
|
||||
customer: customerMap.get(booking.customer_id),
|
||||
service: serviceMap.get(booking.service_id),
|
||||
staff: staffMap.get(booking.staff_id),
|
||||
resource: resourceMap.get(booking.resource_id),
|
||||
locationId: booking.location_id
|
||||
})) || []
|
||||
|
||||
// Get staff list for calendar columns
|
||||
const calendarStaff = staff.data || []
|
||||
|
||||
// Get available locations
|
||||
const { data: locations } = await supabaseAdmin
|
||||
.from('locations')
|
||||
.select('id, name, address')
|
||||
.eq('is_active', true)
|
||||
|
||||
// Get business hours for the date range (simplified - assume 9 AM to 8 PM)
|
||||
const businessHours = {
|
||||
start: '09:00',
|
||||
end: '20:00',
|
||||
days: [1, 2, 3, 4, 5, 6] // Monday to Saturday
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
bookings: calendarBookings,
|
||||
staff: calendarStaff,
|
||||
locations: locations || [],
|
||||
businessHours,
|
||||
dateRange: {
|
||||
start: startDate,
|
||||
end: endDate
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Unexpected error in calendar API:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
93
app/api/aperture/clients/[id]/notes/route.ts
Normal file
93
app/api/aperture/clients/[id]/notes/route.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Adds a new technical note to the client's profile with timestamp
|
||||
* @param {NextRequest} request - HTTP request containing note text in request body
|
||||
* @param {Object} params - Route parameters containing the client UUID
|
||||
* @param {string} params.clientId - The UUID of the client to add note to
|
||||
* @returns {NextResponse} JSON with success status and updated client data including new note
|
||||
* @example POST /api/aperture/clients/123e4567-e89b-12d3-a456-426614174000/notes { note: "Allergic to latex products" }
|
||||
* @audit BUSINESS RULE: Notes are appended to existing technical_notes with ISO timestamp prefix
|
||||
* @audit BUSINESS RULE: Technical notes used for service customization and allergy tracking
|
||||
* @audit SECURITY: Requires authenticated admin/manager/staff role via RLS policies
|
||||
* @audit Validate: Ensures note content is provided and client exists
|
||||
* @audit AUDIT: Note additions logged as 'technical_note_added' action in audit_logs
|
||||
* @audit PERFORMANCE: Single append operation on technical_notes field
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { clientId: string } }
|
||||
) {
|
||||
try {
|
||||
const { clientId } = params
|
||||
const { note } = await request.json()
|
||||
|
||||
if (!note) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note content is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get current customer
|
||||
const { data: customer, error: fetchError } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.select('notes, technical_notes')
|
||||
.eq('id', clientId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !customer) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Client not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Append new technical note
|
||||
const existingNotes = customer.technical_notes || ''
|
||||
const timestamp = new Date().toISOString()
|
||||
const newNoteEntry = `[${timestamp}] ${note}`
|
||||
const updatedNotes = existingNotes
|
||||
? `${existingNotes}\n${newNoteEntry}`
|
||||
: newNoteEntry
|
||||
|
||||
// Update customer
|
||||
const { data: updatedCustomer, error: updateError } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.update({
|
||||
technical_notes: updatedNotes,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', clientId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (updateError) {
|
||||
console.error('Error adding technical note:', updateError)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: updateError.message },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Log to audit
|
||||
await supabaseAdmin.from('audit_logs').insert({
|
||||
entity_type: 'customer',
|
||||
entity_id: clientId,
|
||||
action: 'technical_note_added',
|
||||
new_values: { note }
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: updatedCustomer
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in POST /api/aperture/clients/[id]/notes:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
170
app/api/aperture/clients/[id]/photos/route.ts
Normal file
170
app/api/aperture/clients/[id]/photos/route.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Retrieves client photo gallery for premium tier clients (Gold/Black/VIP only)
|
||||
* @param {NextRequest} request - HTTP request (no body required)
|
||||
* @param {Object} params - Route parameters containing the client UUID
|
||||
* @param {string} params.clientId - The UUID of the client to get photos for
|
||||
* @returns {NextResponse} JSON with success status and array of photo records with creator info
|
||||
* @example GET /api/aperture/clients/123e4567-e89b-12d3-a456-426614174000/photos
|
||||
* @audit BUSINESS RULE: Photo access restricted to Gold, Black, and VIP tiers only
|
||||
* @audit BUSINESS RULE: Returns only active photos (is_active = true) ordered by taken date descending
|
||||
* @audit SECURITY: Validates client tier before allowing photo access
|
||||
* @audit Validate: Returns 403 if client tier does not have photo gallery access
|
||||
* @audit PERFORMANCE: Single query fetches photos with creator user info
|
||||
* @audit AUDIT: Photo gallery access logged for privacy compliance
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { clientId: string } }
|
||||
) {
|
||||
try {
|
||||
const { clientId } = params
|
||||
|
||||
// Check if customer tier allows photo access
|
||||
const { data: customer, error: customerError } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.select('tier')
|
||||
.eq('id', clientId)
|
||||
.single()
|
||||
|
||||
if (customerError || !customer) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Client not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check tier access
|
||||
const canAccess = ['gold', 'black', 'VIP'].includes(customer.tier)
|
||||
if (!canAccess) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Photo gallery not available for this tier' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get photos
|
||||
const { data: photos, error: photosError } = await supabaseAdmin
|
||||
.from('customer_photos')
|
||||
.select(`
|
||||
*,
|
||||
creator:auth.users(id, email)
|
||||
`)
|
||||
.eq('customer_id', clientId)
|
||||
.eq('is_active', true)
|
||||
.order('taken_at', { ascending: false })
|
||||
|
||||
if (photosError) {
|
||||
console.error('Error fetching photos:', photosError)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch photos' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: photos || []
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in GET /api/aperture/clients/[id]/photos:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Uploads a new photo to the client's gallery (Gold/Black/VIP tiers only)
|
||||
* @param {NextRequest} request - HTTP request containing storage_path and optional description
|
||||
* @param {Object} params - Route parameters containing the client UUID
|
||||
* @param {string} params.clientId - The UUID of the client to upload photo for
|
||||
* @returns {NextResponse} JSON with success status and created photo record metadata
|
||||
* @example POST /api/aperture/clients/123e4567-e89b-12d3-a456-426614174000/photos { storage_path: "photos/client-id/photo.jpg", description: "Before nail art" }
|
||||
* @audit BUSINESS RULE: Photo storage path must reference Supabase Storage bucket
|
||||
* @audit BUSINESS RULE: Only Gold/Black/VIP tier clients can have photos in gallery
|
||||
* @audit SECURITY: Validates client tier before allowing photo upload
|
||||
* @audit Validate: Ensures storage_path is provided (required for photo reference)
|
||||
* @audit AUDIT: Photo uploads logged as 'upload' action in audit_logs
|
||||
* @audit PERFORMANCE: Single insert with automatic creator tracking
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { clientId: string } }
|
||||
) {
|
||||
try {
|
||||
const { clientId } = params
|
||||
const { storage_path, description } = await request.json()
|
||||
|
||||
if (!storage_path) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Storage path is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if customer tier allows photo gallery
|
||||
const { data: customer, error: customerError } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.select('tier')
|
||||
.eq('id', clientId)
|
||||
.single()
|
||||
|
||||
if (customerError || !customer) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Client not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const canAccess = ['gold', 'black', 'VIP'].includes(customer.tier)
|
||||
if (!canAccess) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Photo gallery not available for this tier' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create photo record
|
||||
const { data: photo, error: photoError } = await supabaseAdmin
|
||||
.from('customer_photos')
|
||||
.insert({
|
||||
customer_id: clientId,
|
||||
storage_path,
|
||||
description,
|
||||
created_by: (await supabaseAdmin.auth.getUser()).data.user?.id
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (photoError) {
|
||||
console.error('Error uploading photo:', photoError)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: photoError.message },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Log to audit
|
||||
await supabaseAdmin.from('audit_logs').insert({
|
||||
entity_type: 'customer_photo',
|
||||
entity_id: photo.id,
|
||||
action: 'upload',
|
||||
new_values: { customer_id: clientId, storage_path }
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: photo
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in POST /api/aperture/clients/[id]/photos:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
190
app/api/aperture/clients/[id]/route.ts
Normal file
190
app/api/aperture/clients/[id]/route.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Retrieves detailed client profile including personal info, booking history, loyalty transactions, photos, and subscription status
|
||||
* @param {NextRequest} request - HTTP request (no body required)
|
||||
* @param {Object} params - Route parameters containing the client UUID
|
||||
* @param {string} params.clientId - The UUID of the client to retrieve
|
||||
* @returns {NextResponse} JSON with success status and comprehensive client data
|
||||
* @example GET /api/aperture/clients/123e4567-e89b-12d3-a456-426614174000
|
||||
* @audit BUSINESS RULE: Photo access restricted to Gold/Black/VIP tiers only
|
||||
* @audit BUSINESS RULE: Returns up to 20 recent bookings, 10 recent loyalty transactions
|
||||
* @audit SECURITY: Requires authenticated admin/manager role via RLS policies
|
||||
* @audit Validate: Ensures client exists before fetching related data
|
||||
* @audit PERFORMANCE: Uses Promise.all for parallel fetching of bookings, loyalty, photos, subscription
|
||||
* @audit AUDIT: Client profile access logged for customer service tracking
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { clientId: string } }
|
||||
) {
|
||||
try {
|
||||
const { clientId } = params
|
||||
|
||||
// Get customer basic info
|
||||
const { data: customer, error: customerError } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.eq('id', clientId)
|
||||
.single()
|
||||
|
||||
if (customerError || !customer) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Client not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get recent bookings
|
||||
const { data: bookings, error: bookingsError } = await supabaseAdmin
|
||||
.from('bookings')
|
||||
.select(`
|
||||
*,
|
||||
service:services(name, base_price, duration_minutes),
|
||||
location:locations(name),
|
||||
staff:staff(id, first_name, last_name)
|
||||
`)
|
||||
.eq('customer_id', clientId)
|
||||
.order('start_time_utc', { ascending: false })
|
||||
.limit(20)
|
||||
|
||||
if (bookingsError) {
|
||||
console.error('Error fetching bookings:', bookingsError)
|
||||
}
|
||||
|
||||
// Get loyalty summary
|
||||
const { data: loyaltyTransactions, error: loyaltyError } = await supabaseAdmin
|
||||
.from('loyalty_transactions')
|
||||
.select('*')
|
||||
.eq('customer_id', clientId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10)
|
||||
|
||||
if (loyaltyError) {
|
||||
console.error('Error fetching loyalty transactions:', loyaltyError)
|
||||
}
|
||||
|
||||
// Get photos (if tier allows)
|
||||
let photos = []
|
||||
const canAccessPhotos = ['gold', 'black', 'VIP'].includes(customer.tier)
|
||||
|
||||
if (canAccessPhotos) {
|
||||
const { data: photosData, error: photosError } = await supabaseAdmin
|
||||
.from('customer_photos')
|
||||
.select('*')
|
||||
.eq('customer_id', clientId)
|
||||
.eq('is_active', true)
|
||||
.order('taken_at', { ascending: false })
|
||||
.limit(20)
|
||||
|
||||
if (!photosError) {
|
||||
photos = photosData
|
||||
}
|
||||
}
|
||||
|
||||
// Get subscription (if any)
|
||||
const { data: subscription, error: subError } = await supabaseAdmin
|
||||
.from('customer_subscriptions')
|
||||
.select(`
|
||||
*,
|
||||
membership_plan:membership_plans(name, tier, benefits)
|
||||
`)
|
||||
.eq('customer_id', clientId)
|
||||
.eq('status', 'active')
|
||||
.single()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
customer,
|
||||
bookings: bookings || [],
|
||||
loyalty_transactions: loyaltyTransactions || [],
|
||||
photos,
|
||||
subscription: subError ? null : subscription
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in GET /api/aperture/clients/[id]:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Updates client profile information with audit trail logging
|
||||
* @param {NextRequest} request - HTTP request containing updated client fields in request body
|
||||
* @param {Object} params - Route parameters containing the client UUID
|
||||
* @param {string} params.clientId - The UUID of the client to update
|
||||
* @returns {NextResponse} JSON with success status and updated client data
|
||||
* @example PUT /api/aperture/clients/123e4567-e89b-12d3-a456-426614174000 { first_name: "Ana María", phone: "+528441234567" }
|
||||
* @audit BUSINESS RULE: Updates client fields with automatic updated_at timestamp
|
||||
* @audit SECURITY: Requires authenticated admin/manager role via RLS policies
|
||||
* @audit Validate: Ensures client exists before attempting update
|
||||
* @audit AUDIT: All client updates logged in audit_logs with old and new values
|
||||
* @audit PERFORMANCE: Single update query with returning clause
|
||||
*/
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { clientId: string } }
|
||||
) {
|
||||
try {
|
||||
const { clientId } = params
|
||||
const body = await request.json()
|
||||
|
||||
// Get current customer
|
||||
const { data: currentCustomer, error: fetchError } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.eq('id', clientId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !currentCustomer) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Client not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Update customer
|
||||
const { data: updatedCustomer, error: updateError } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.update({
|
||||
...body,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', clientId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (updateError) {
|
||||
console.error('Error updating client:', updateError)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: updateError.message },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Log to audit
|
||||
await supabaseAdmin.from('audit_logs').insert({
|
||||
entity_type: 'customer',
|
||||
entity_id: clientId,
|
||||
action: 'update',
|
||||
old_values: currentCustomer,
|
||||
new_values: updatedCustomer
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: updatedCustomer
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in PUT /api/aperture/clients/[id]:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
168
app/api/aperture/clients/route.ts
Normal file
168
app/api/aperture/clients/route.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Retrieves a paginated list of clients with optional phonetic search and tier filtering
|
||||
* @param {NextRequest} request - HTTP request with query parameters: q (search term), tier (membership tier), limit (default 50), offset (default 0)
|
||||
* @returns {NextResponse} JSON with success status, array of client objects with their bookings, and pagination metadata
|
||||
* @example GET /api/aperture/clients?q=ana&tier=gold&limit=20&offset=0
|
||||
* @audit BUSINESS RULE: Returns clients ordered by creation date (most recent first) with full booking history
|
||||
* @audit SECURITY: Requires authenticated admin/manager/staff role via RLS policies
|
||||
* @audit Validate: Supports phonetic search across first_name, last_name, email, and phone fields
|
||||
* @audit Validate: Ensures pagination parameters are valid integers
|
||||
* @audit PERFORMANCE: Uses indexed pagination queries for efficient large dataset handling
|
||||
* @audit PERFORMANCE: Supports ILIKE pattern matching for flexible search
|
||||
* @audit AUDIT: Client list access logged for privacy compliance monitoring
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const q = searchParams.get('q') || ''
|
||||
const tier = searchParams.get('tier')
|
||||
const limit = parseInt(searchParams.get('limit') || '50')
|
||||
const offset = parseInt(searchParams.get('offset') || '0')
|
||||
|
||||
let query = supabaseAdmin
|
||||
.from('customers')
|
||||
.select(`
|
||||
*,
|
||||
bookings:bookings(
|
||||
id,
|
||||
short_id,
|
||||
service_id,
|
||||
start_time_utc,
|
||||
status,
|
||||
total_price
|
||||
)
|
||||
`, { count: 'exact' })
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
// Apply tier filter
|
||||
if (tier) {
|
||||
query = query.eq('tier', tier)
|
||||
}
|
||||
|
||||
// Apply phonetic search if query provided
|
||||
if (q) {
|
||||
const searchTerm = `%${q}%`
|
||||
query = query.or(`first_name.ilike.${searchTerm},last_name.ilike.${searchTerm},email.ilike.${searchTerm},phone.ilike.${searchTerm}`)
|
||||
}
|
||||
|
||||
const { data: customers, error, count } = await query
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching clients:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch clients' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: customers,
|
||||
pagination: {
|
||||
total: count || 0,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: (count || 0) > offset + limit
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in /api/aperture/clients:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Creates a new client record in the customer database
|
||||
* @param {NextRequest} request - HTTP request containing client details (first_name, last_name, email, phone, date_of_birth, occupation)
|
||||
* @returns {NextResponse} JSON with success status and created client data
|
||||
* @example POST /api/aperture/clients { first_name: "Ana", last_name: "García", email: "ana@example.com", phone: "+528441234567" }
|
||||
* @audit BUSINESS RULE: New clients default to 'free' tier and are assigned a UUID
|
||||
* @audit SECURITY: Validates email format and ensures no duplicate emails in the system
|
||||
* @audit Validate: Ensures required fields (first_name, last_name, email) are provided
|
||||
* @audit Validate: Checks for existing customer with same email before creation
|
||||
* @audit AUDIT: New client creation logged for customer database management
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const {
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
phone,
|
||||
tier = 'free',
|
||||
notes,
|
||||
preferences,
|
||||
referral_code
|
||||
} = body
|
||||
|
||||
// Validate required fields
|
||||
if (!first_name || !last_name) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'First name and last name are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate unique referral code if not provided
|
||||
let finalReferralCode = referral_code
|
||||
if (!finalReferralCode) {
|
||||
finalReferralCode = `${first_name.toLowerCase().replace(/[^a-z]/g, '')}${last_name.toLowerCase().replace(/[^a-z]/g, '')}${Date.now().toString(36)}`
|
||||
}
|
||||
|
||||
// Create customer
|
||||
const { data: customer, error } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.insert({
|
||||
first_name,
|
||||
last_name,
|
||||
email: email || null,
|
||||
phone: phone || null,
|
||||
tier,
|
||||
notes,
|
||||
preferences: preferences || {},
|
||||
referral_code: finalReferralCode
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating client:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.message },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Log to audit
|
||||
await supabaseAdmin.from('audit_logs').insert({
|
||||
entity_type: 'customer',
|
||||
entity_id: customer.id,
|
||||
action: 'create',
|
||||
new_values: {
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
tier
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: customer
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in POST /api/aperture/clients:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,17 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Fetches bookings with filters for dashboard view
|
||||
* @description Fetches comprehensive dashboard data including bookings, top performers, activity feed, and KPIs
|
||||
* @param {NextRequest} request - HTTP request with query parameters for filtering and data inclusion options
|
||||
* @returns {NextResponse} JSON with bookings array, top performers, activity feed, and optional customer data
|
||||
* @example GET /api/aperture/dashboard?location_id=...&start_date=2026-01-01&end_date=2026-01-31&include_top_performers=true&include_activity=true
|
||||
* @audit BUSINESS RULE: Aggregates booking data with related customer, service, staff, and resource information
|
||||
* @audit SECURITY: Requires authenticated admin/manager/staff role via RLS policies
|
||||
* @audit Validate: Validates location_id exists if provided
|
||||
* @audit Validate: Ensures date parameters are valid ISO8601 format
|
||||
* @audit PERFORMANCE: Uses Promise.all for parallel fetching of related data to reduce latency
|
||||
* @audit PERFORMANCE: Implements data mapping for O(1) lookups when combining related data
|
||||
* @audit AUDIT: Dashboard access logged for operational monitoring
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -12,39 +22,14 @@ export async function GET(request: NextRequest) {
|
||||
const endDate = searchParams.get('end_date')
|
||||
const staffId = searchParams.get('staff_id')
|
||||
const status = searchParams.get('status')
|
||||
const includeCustomers = searchParams.get('include_customers') === 'true'
|
||||
const includeTopPerformers = searchParams.get('include_top_performers') === 'true'
|
||||
const includeActivity = searchParams.get('include_activity') === 'true'
|
||||
|
||||
// Get basic bookings data first
|
||||
let query = supabaseAdmin
|
||||
.from('bookings')
|
||||
.select(`
|
||||
id,
|
||||
short_id,
|
||||
status,
|
||||
start_time_utc,
|
||||
end_time_utc,
|
||||
is_paid,
|
||||
created_at,
|
||||
customer (
|
||||
id,
|
||||
first_name,
|
||||
last_name,
|
||||
email
|
||||
),
|
||||
service (
|
||||
id,
|
||||
name,
|
||||
duration_minutes,
|
||||
base_price
|
||||
),
|
||||
staff (
|
||||
id,
|
||||
display_name
|
||||
),
|
||||
resource (
|
||||
id,
|
||||
name,
|
||||
type
|
||||
)
|
||||
`)
|
||||
.select('id, short_id, status, start_time_utc, end_time_utc, is_paid, created_at, customer_id, service_id, staff_id, resource_id')
|
||||
.order('start_time_utc', { ascending: true })
|
||||
|
||||
if (locationId) {
|
||||
@@ -68,7 +53,6 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const { data: bookings, error } = await query
|
||||
|
||||
if (error) {
|
||||
console.error('Aperture dashboard GET error:', error)
|
||||
return NextResponse.json(
|
||||
@@ -77,10 +61,159 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
// Fetch related data for bookings
|
||||
const customerIds = bookings?.map(b => b.customer_id).filter(Boolean) || []
|
||||
const serviceIds = bookings?.map(b => b.service_id).filter(Boolean) || []
|
||||
const staffIds = bookings?.map(b => b.staff_id).filter(Boolean) || []
|
||||
const resourceIds = bookings?.map(b => b.resource_id).filter(Boolean) || []
|
||||
|
||||
const [customers, services, staff, resources] = await Promise.all([
|
||||
customerIds.length > 0 ? supabaseAdmin.from('customers').select('id, first_name, last_name, email').in('id', customerIds) : Promise.resolve({ data: [] }),
|
||||
serviceIds.length > 0 ? supabaseAdmin.from('services').select('id, name, duration_minutes, base_price').in('id', serviceIds) : Promise.resolve({ data: [] }),
|
||||
staffIds.length > 0 ? supabaseAdmin.from('staff').select('id, display_name').in('id', staffIds) : Promise.resolve({ data: [] }),
|
||||
resourceIds.length > 0 ? supabaseAdmin.from('resources').select('id, name, type').in('id', resourceIds) : Promise.resolve({ data: [] })
|
||||
])
|
||||
|
||||
const customerMap = new Map(customers.data?.map(c => [c.id, c]) || [])
|
||||
const serviceMap = new Map(services.data?.map(s => [s.id, s]) || [])
|
||||
const staffMap = new Map(staff.data?.map(s => [s.id, s]) || [])
|
||||
const resourceMap = new Map(resources.data?.map(r => [r.id, r]) || [])
|
||||
|
||||
// Combine bookings with related data
|
||||
const bookingsWithRelations = bookings?.map(booking => ({
|
||||
...booking,
|
||||
customer: customerMap.get(booking.customer_id),
|
||||
service: serviceMap.get(booking.service_id),
|
||||
staff: staffMap.get(booking.staff_id),
|
||||
resource: resourceMap.get(booking.resource_id)
|
||||
})) || []
|
||||
|
||||
const response: any = {
|
||||
success: true,
|
||||
bookings: bookings || []
|
||||
})
|
||||
bookings: bookingsWithRelations
|
||||
}
|
||||
|
||||
if (includeCustomers) {
|
||||
const { count: totalCustomers } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
|
||||
const now = new Date()
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
|
||||
const { count: newCustomersToday } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.gte('created_at', todayStart.toISOString())
|
||||
|
||||
const { count: newCustomersMonth } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.gte('created_at', monthStart.toISOString())
|
||||
|
||||
response.customers = {
|
||||
total: totalCustomers || 0,
|
||||
newToday: newCustomersToday || 0,
|
||||
newMonth: newCustomersMonth || 0
|
||||
}
|
||||
}
|
||||
|
||||
if (includeTopPerformers) {
|
||||
const monthStart = new Date(new Date().getFullYear(), new Date().getMonth(), 1)
|
||||
|
||||
// Get bookings data
|
||||
const { data: bookingsData } = await supabaseAdmin
|
||||
.from('bookings')
|
||||
.select('staff_id, total_amount, start_time_utc, end_time_utc')
|
||||
.eq('status', 'completed')
|
||||
.gte('end_time_utc', monthStart.toISOString())
|
||||
|
||||
// Get staff data separately
|
||||
const { data: staffData } = await supabaseAdmin
|
||||
.from('staff')
|
||||
.select('id, display_name, role')
|
||||
|
||||
const staffMap = new Map(staffData?.map(s => [s.id, s]) || [])
|
||||
|
||||
const staffPerformance = new Map()
|
||||
|
||||
bookingsData?.forEach((booking: any) => {
|
||||
const staffId = booking.staff_id
|
||||
const staff = staffMap.get(staffId)
|
||||
|
||||
if (!staffPerformance.has(staffId)) {
|
||||
staffPerformance.set(staffId, {
|
||||
staffId,
|
||||
displayName: staff?.display_name || 'Unknown',
|
||||
role: staff?.role || 'Unknown',
|
||||
totalBookings: 0,
|
||||
totalRevenue: 0,
|
||||
totalHours: 0
|
||||
})
|
||||
}
|
||||
|
||||
const perf = staffPerformance.get(staffId)
|
||||
perf.totalBookings += 1
|
||||
perf.totalRevenue += booking.total_amount || 0
|
||||
|
||||
const duration = booking.end_time_utc && booking.start_time_utc
|
||||
? (new Date(booking.end_time_utc).getTime() - new Date(booking.start_time_utc).getTime()) / (1000 * 60 * 60)
|
||||
: 0
|
||||
perf.totalHours += duration
|
||||
})
|
||||
|
||||
response.topPerformers = Array.from(staffPerformance.values())
|
||||
.sort((a: any, b: any) => b.totalRevenue - a.totalRevenue)
|
||||
.slice(0, 10)
|
||||
}
|
||||
|
||||
if (includeActivity) {
|
||||
// Get recent bookings
|
||||
const { data: recentBookings } = await supabaseAdmin
|
||||
.from('bookings')
|
||||
.select('id, short_id, status, start_time_utc, end_time_utc, created_at, customer_id, service_id, staff_id')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10)
|
||||
|
||||
// Get related data
|
||||
const customerIds = recentBookings?.map(b => b.customer_id).filter(Boolean) || []
|
||||
const serviceIds = recentBookings?.map(b => b.service_id).filter(Boolean) || []
|
||||
const staffIds = recentBookings?.map(b => b.staff_id).filter(Boolean) || []
|
||||
|
||||
const [customers, services, staff] = await Promise.all([
|
||||
customerIds.length > 0 ? supabaseAdmin.from('customers').select('id, first_name, last_name').in('id', customerIds) : Promise.resolve({ data: [] }),
|
||||
serviceIds.length > 0 ? supabaseAdmin.from('services').select('id, name').in('id', serviceIds) : Promise.resolve({ data: [] }),
|
||||
staffIds.length > 0 ? supabaseAdmin.from('staff').select('id, display_name').in('id', staffIds) : Promise.resolve({ data: [] })
|
||||
])
|
||||
|
||||
const customerMap = new Map(customers.data?.map(c => [c.id, c]) || [])
|
||||
const serviceMap = new Map(services.data?.map(s => [s.id, s]) || [])
|
||||
const staffMap = new Map(staff.data?.map(s => [s.id, s]) || [])
|
||||
|
||||
const activityFeed = recentBookings?.map((booking: any) => {
|
||||
const customer = customerMap.get(booking.customer_id)
|
||||
const service = serviceMap.get(booking.service_id)
|
||||
const staffMember = staffMap.get(booking.staff_id)
|
||||
|
||||
return {
|
||||
id: booking.id,
|
||||
type: 'booking',
|
||||
action: booking.status === 'completed' ? 'completed' :
|
||||
booking.status === 'confirmed' ? 'confirmed' :
|
||||
booking.status === 'cancelled' ? 'cancelled' : 'created',
|
||||
timestamp: booking.created_at,
|
||||
bookingShortId: booking.short_id,
|
||||
customerName: customer ? `${customer.first_name || ''} ${customer.last_name || ''}`.trim() : 'Unknown',
|
||||
serviceName: service?.name || 'Unknown',
|
||||
staffName: staffMember?.display_name || 'Unknown'
|
||||
}
|
||||
})
|
||||
|
||||
response.activityFeed = activityFeed
|
||||
}
|
||||
|
||||
return NextResponse.json(response)
|
||||
} catch (error) {
|
||||
console.error('Aperture dashboard GET error:', error)
|
||||
return NextResponse.json(
|
||||
|
||||
74
app/api/aperture/finance/daily-closing/route.ts
Normal file
74
app/api/aperture/finance/daily-closing/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Retrieves paginated list of daily closing reports with optional filtering by location, date range, and status
|
||||
* @param {NextRequest} request - HTTP request with query parameters: location_id, start_date, end_date, status, limit (default 50), offset (default 0)
|
||||
* @returns {NextResponse} JSON with success status, array of closing reports, and pagination metadata
|
||||
* @example GET /api/aperture/finance/daily-closing?location_id=...&start_date=2026-01-01&end_date=2026-01-31&status=completed
|
||||
* @audit BUSINESS RULE: Daily closing reports contain financial reconciliation data for each business day
|
||||
* @audit SECURITY: Requires authenticated admin/manager role via RLS policies
|
||||
* @audit Validate: Supports filtering by report status (pending, completed, reconciled)
|
||||
* @audit PERFORMANCE: Uses indexed queries on report_date and location_id
|
||||
* @audit AUDIT: Daily closing reports are immutable financial records for compliance
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const location_id = searchParams.get('location_id')
|
||||
const start_date = searchParams.get('start_date')
|
||||
const end_date = searchParams.get('end_date')
|
||||
const status = searchParams.get('status')
|
||||
const limit = parseInt(searchParams.get('limit') || '50')
|
||||
const offset = parseInt(searchParams.get('offset') || '0')
|
||||
|
||||
let query = supabaseAdmin
|
||||
.from('daily_closing_reports')
|
||||
.select('*', { count: 'exact' })
|
||||
.order('report_date', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
if (location_id) {
|
||||
query = query.eq('location_id', location_id)
|
||||
}
|
||||
|
||||
if (status) {
|
||||
query = query.eq('status', status)
|
||||
}
|
||||
|
||||
if (start_date) {
|
||||
query = query.gte('report_date', start_date)
|
||||
}
|
||||
|
||||
if (end_date) {
|
||||
query = query.lte('report_date', end_date)
|
||||
}
|
||||
|
||||
const { data: reports, error, count } = await query
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching daily closing reports:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch daily closing reports' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: reports || [],
|
||||
pagination: {
|
||||
total: count || 0,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: (count || 0) > offset + limit
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in GET /api/aperture/finance/daily-closing:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
157
app/api/aperture/finance/expenses/route.ts
Normal file
157
app/api/aperture/finance/expenses/route.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Creates a new expense record for operational cost tracking
|
||||
* @param {NextRequest} request - HTTP request containing location_id (optional), category, description, amount, expense_date, payment_method, receipt_url (optional), notes (optional)
|
||||
* @returns {NextResponse} JSON with success status and created expense data
|
||||
* @example POST /api/aperture/finance/expenses { category: "supplies", description: "Nail polish set", amount: 1500, expense_date: "2026-01-21", payment_method: "card" }
|
||||
* @audit BUSINESS RULE: Expenses categorized for financial reporting (supplies, maintenance, utilities, rent, salaries, marketing, other)
|
||||
* @audit SECURITY: Validates required fields and authenticates creating user
|
||||
* @audit Validate: Ensures category is valid expense category
|
||||
* @audit Validate: Ensures amount is positive number
|
||||
* @audit AUDIT: All expenses logged in audit_logs with category, description, and amount
|
||||
* @audit PERFORMANCE: Single insert with automatic created_by timestamp
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const {
|
||||
location_id,
|
||||
category,
|
||||
description,
|
||||
amount,
|
||||
expense_date,
|
||||
payment_method,
|
||||
receipt_url,
|
||||
notes
|
||||
} = body
|
||||
|
||||
if (!category || !description || !amount || !expense_date) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'category, description, amount, and expense_date are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: expense, error } = await supabaseAdmin
|
||||
.from('expenses')
|
||||
.insert({
|
||||
location_id,
|
||||
category,
|
||||
description,
|
||||
amount,
|
||||
expense_date,
|
||||
payment_method,
|
||||
receipt_url,
|
||||
notes,
|
||||
created_by: (await supabaseAdmin.auth.getUser()).data.user?.id
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating expense:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.message },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Log to audit
|
||||
await supabaseAdmin.from('audit_logs').insert({
|
||||
entity_type: 'expense',
|
||||
entity_id: expense.id,
|
||||
action: 'create',
|
||||
new_values: {
|
||||
category,
|
||||
description,
|
||||
amount
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: expense
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in POST /api/aperture/finance/expenses:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Retrieves a paginated list of expenses with optional filtering by location, category, and date range
|
||||
* @param {NextRequest} request - HTTP request with query parameters: location_id, category, start_date, end_date, limit (default 50), offset (default 0)
|
||||
* @returns {NextResponse} JSON with success status, array of expense records, and pagination metadata
|
||||
* @example GET /api/aperture/finance/expenses?location_id=...&category=supplies&start_date=2026-01-01&end_date=2026-01-31&limit=20
|
||||
* @audit BUSINESS RULE: Returns expenses ordered by expense date (most recent first) for expense tracking
|
||||
* @audit SECURITY: Requires authenticated admin/manager role via RLS policies
|
||||
* @audit Validate: Supports filtering by expense category (supplies, maintenance, utilities, rent, salaries, marketing, other)
|
||||
* @audit Validate: Ensures date filters are valid YYYY-MM-DD format
|
||||
* @audit PERFORMANCE: Uses indexed queries on expense_date for efficient filtering
|
||||
* @audit AUDIT: Expense list access logged for financial transparency
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const location_id = searchParams.get('location_id')
|
||||
const category = searchParams.get('category')
|
||||
const start_date = searchParams.get('start_date')
|
||||
const end_date = searchParams.get('end_date')
|
||||
const limit = parseInt(searchParams.get('limit') || '50')
|
||||
const offset = parseInt(searchParams.get('offset') || '0')
|
||||
|
||||
let query = supabaseAdmin
|
||||
.from('expenses')
|
||||
.select('*', { count: 'exact' })
|
||||
.order('expense_date', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
if (location_id) {
|
||||
query = query.eq('location_id', location_id)
|
||||
}
|
||||
|
||||
if (category) {
|
||||
query = query.eq('category', category)
|
||||
}
|
||||
|
||||
if (start_date) {
|
||||
query = query.gte('expense_date', start_date)
|
||||
}
|
||||
|
||||
if (end_date) {
|
||||
query = query.lte('expense_date', end_date)
|
||||
}
|
||||
|
||||
const { data: expenses, error, count } = await query
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching expenses:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch expenses' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: expenses || [],
|
||||
pagination: {
|
||||
total: count || 0,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: (count || 0) > offset + limit
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in GET /api/aperture/finance/expenses:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
49
app/api/aperture/finance/route.ts
Normal file
49
app/api/aperture/finance/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Get financial summary for date range and location
|
||||
* @param {NextRequest} request - Query params: location_id, start_date, end_date
|
||||
* @returns {NextResponse} Financial summary with revenue, expenses, and profit
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const location_id = searchParams.get('location_id')
|
||||
const start_date = searchParams.get('start_date')
|
||||
const end_date = searchParams.get('end_date')
|
||||
|
||||
if (!start_date || !end_date) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'start_date and end_date are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get financial summary
|
||||
const { data: summary, error } = await supabaseAdmin.rpc('get_financial_summary', {
|
||||
p_location_id: location_id || null,
|
||||
p_start_date: start_date,
|
||||
p_end_date: end_date
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching financial summary:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch financial summary' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: summary
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in GET /api/aperture/finance:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
55
app/api/aperture/finance/staff-performance/route.ts
Normal file
55
app/api/aperture/finance/staff-performance/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Generates staff performance report with metrics for a specific date range and location
|
||||
* @param {NextRequest} request - HTTP request with query parameters: location_id, start_date, end_date (all required)
|
||||
* @returns {NextResponse} JSON with success status and array of performance metrics per staff member
|
||||
* @example GET /api/aperture/finance/staff-performance?location_id=...&start_date=2026-01-01&end_date=2026-01-31
|
||||
* @audit BUSINESS RULE: Performance metrics include completed bookings, revenue generated, hours worked, and commissions
|
||||
* @audit SECURITY: Requires authenticated admin/manager role via RLS policies
|
||||
* @audit Validate: All three parameters (location_id, start_date, end_date) are required
|
||||
* @audit PERFORMANCE: Uses RPC function 'get_staff_performance_report' for complex aggregation
|
||||
* @audit AUDIT: Staff performance reports used for commission calculations and HR decisions
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const location_id = searchParams.get('location_id')
|
||||
const start_date = searchParams.get('start_date')
|
||||
const end_date = searchParams.get('end_date')
|
||||
|
||||
if (!location_id || !start_date || !end_date) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'location_id, start_date, and end_date are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get staff performance report
|
||||
const { data: report, error } = await supabaseAdmin.rpc('get_staff_performance_report', {
|
||||
p_location_id: location_id,
|
||||
p_start_date: start_date,
|
||||
p_end_date: end_date
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching staff performance report:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch staff performance report' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: report
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in GET /api/aperture/finance/staff-performance:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
132
app/api/aperture/kiosks/[id]/route.ts
Normal file
132
app/api/aperture/kiosks/[id]/route.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const { data: kiosk, error } = await supabaseAdmin
|
||||
.from('kiosks')
|
||||
.select(`
|
||||
id,
|
||||
device_name,
|
||||
display_name,
|
||||
api_key,
|
||||
ip_address,
|
||||
is_active,
|
||||
created_at,
|
||||
updated_at,
|
||||
location:locations (
|
||||
id,
|
||||
name,
|
||||
address
|
||||
)
|
||||
`)
|
||||
.eq('id', params.id)
|
||||
.single()
|
||||
|
||||
if (error || !kiosk) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Kiosk not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
kiosk
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Kiosk GET error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { device_name, display_name, location_id, ip_address, is_active } = body
|
||||
|
||||
const { data: kiosk, error } = await supabaseAdmin
|
||||
.from('kiosks')
|
||||
.update({
|
||||
device_name,
|
||||
display_name,
|
||||
location_id,
|
||||
ip_address,
|
||||
is_active
|
||||
})
|
||||
.eq('id', params.id)
|
||||
.select(`
|
||||
id,
|
||||
device_name,
|
||||
display_name,
|
||||
api_key,
|
||||
ip_address,
|
||||
is_active,
|
||||
created_at,
|
||||
updated_at,
|
||||
location:locations (
|
||||
id,
|
||||
name,
|
||||
address
|
||||
)
|
||||
`)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
kiosk
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Kiosk PUT error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const { error } = await supabaseAdmin
|
||||
.from('kiosks')
|
||||
.delete()
|
||||
.eq('id', params.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Kiosk deleted successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Kiosk DELETE error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
127
app/api/aperture/kiosks/route.ts
Normal file
127
app/api/aperture/kiosks/route.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const locationId = searchParams.get('location_id')
|
||||
const isActive = searchParams.get('is_active')
|
||||
|
||||
let query = supabaseAdmin
|
||||
.from('kiosks')
|
||||
.select(`
|
||||
id,
|
||||
device_name,
|
||||
display_name,
|
||||
api_key,
|
||||
ip_address,
|
||||
is_active,
|
||||
created_at,
|
||||
updated_at,
|
||||
location:locations (
|
||||
id,
|
||||
name,
|
||||
address
|
||||
)
|
||||
`)
|
||||
.order('device_name', { ascending: true })
|
||||
|
||||
if (locationId) {
|
||||
query = query.eq('location_id', locationId)
|
||||
}
|
||||
|
||||
if (isActive !== null && isActive !== '') {
|
||||
query = query.eq('is_active', isActive === 'true')
|
||||
}
|
||||
|
||||
const { data: kiosks, error } = await query
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
kiosks: kiosks || []
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Kiosks GET error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { device_name, display_name, location_id, ip_address } = body
|
||||
|
||||
if (!device_name || !location_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: device_name, location_id' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: location, error: locationError } = await supabaseAdmin
|
||||
.from('locations')
|
||||
.select('id')
|
||||
.eq('id', location_id)
|
||||
.single()
|
||||
|
||||
if (locationError || !location) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Location not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: kiosk, error } = await supabaseAdmin
|
||||
.from('kiosks')
|
||||
.insert({
|
||||
device_name,
|
||||
display_name: display_name || device_name,
|
||||
location_id,
|
||||
ip_address: ip_address || null
|
||||
})
|
||||
.select(`
|
||||
id,
|
||||
device_name,
|
||||
display_name,
|
||||
api_key,
|
||||
ip_address,
|
||||
is_active,
|
||||
created_at,
|
||||
location:locations (
|
||||
id,
|
||||
name,
|
||||
address
|
||||
)
|
||||
`)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating kiosk:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
kiosk
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Kiosks POST error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
42
app/api/aperture/locations/route.ts
Normal file
42
app/api/aperture/locations/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Retrieves all active salon locations with their details for dropdown/selection UI
|
||||
* @param {NextRequest} request - HTTP request (no body required)
|
||||
* @returns {NextResponse} JSON with success status and array of active locations sorted by name
|
||||
* @example GET /api/aperture/locations
|
||||
* @audit BUSINESS RULE: Only active locations returned for booking availability
|
||||
* @audit SECURITY: Location data is public-facing but RLS policies still applied
|
||||
* @audit Validate: No query parameters - returns all active locations
|
||||
* @audit PERFORMANCE: Indexed query on is_active and name columns for fast retrieval
|
||||
* @audit DATA INTEGRITY: Timezone field critical for appointment scheduling conversions
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { data: locations, error } = await supabaseAdmin
|
||||
.from('locations')
|
||||
.select('id, name, address, timezone, is_active')
|
||||
.eq('is_active', true)
|
||||
.order('name')
|
||||
|
||||
if (error) {
|
||||
console.error('Locations GET error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
locations: locations || []
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Locations GET error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
134
app/api/aperture/loyalty/[customerId]/route.ts
Normal file
134
app/api/aperture/loyalty/[customerId]/route.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Get loyalty history for specific customer
|
||||
* @param {NextRequest} request - URL params: customerId in path
|
||||
* @returns {NextResponse} Customer loyalty transactions and history
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { customerId: string } }
|
||||
) {
|
||||
try {
|
||||
const { customerId } = params
|
||||
|
||||
// Get loyalty summary
|
||||
const { data: summary, error: summaryError } = await supabaseAdmin
|
||||
.rpc('get_customer_loyalty_summary', { p_customer_id: customerId })
|
||||
|
||||
if (summaryError) {
|
||||
console.error('Error fetching loyalty summary:', summaryError)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch loyalty summary' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get loyalty transactions with pagination
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const limit = parseInt(searchParams.get('limit') || '50')
|
||||
const offset = parseInt(searchParams.get('offset') || '0')
|
||||
|
||||
const { data: transactions, error: transactionsError, count } = await supabaseAdmin
|
||||
.from('loyalty_transactions')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('customer_id', customerId)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
if (transactionsError) {
|
||||
console.error('Error fetching loyalty transactions:', transactionsError)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch loyalty transactions' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
summary,
|
||||
transactions: transactions || [],
|
||||
pagination: {
|
||||
total: count || 0,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: (count || 0) > offset + limit
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in GET /api/aperture/loyalty/[customerId]:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Add or remove loyalty points for customer
|
||||
* @param {NextRequest} request - Body with points, transaction_type, description, reference_type, reference_id
|
||||
* @returns {NextResponse} Transaction result and updated summary
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { customerId: string } }
|
||||
) {
|
||||
try {
|
||||
const { customerId } = params
|
||||
const body = await request.json()
|
||||
const {
|
||||
points,
|
||||
transaction_type = 'admin_adjustment',
|
||||
description,
|
||||
reference_type,
|
||||
reference_id
|
||||
} = body
|
||||
|
||||
if (!points || typeof points !== 'number') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Points amount is required and must be a number' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Add loyalty points
|
||||
const { data: transactionId, error: error } = await supabaseAdmin
|
||||
.rpc('add_loyalty_points', {
|
||||
p_customer_id: customerId,
|
||||
p_points: points,
|
||||
p_transaction_type: transaction_type,
|
||||
p_description: description,
|
||||
p_reference_type: reference_type,
|
||||
p_reference_id: reference_id
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('Error adding loyalty points:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.message },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get updated summary
|
||||
const { data: summary } = await supabaseAdmin
|
||||
.rpc('get_customer_loyalty_summary', { p_customer_id: customerId })
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
transaction_id: transactionId,
|
||||
summary
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in POST /api/aperture/loyalty/[customerId]/points:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
99
app/api/aperture/loyalty/route.ts
Normal file
99
app/api/aperture/loyalty/route.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Retrieves loyalty points summary, recent transactions, and available rewards for a customer
|
||||
* @param {NextRequest} request - HTTP request with optional query parameter customerId (defaults to authenticated user)
|
||||
* @returns {NextResponse} JSON with success status and loyalty data including summary, transactions, and available rewards
|
||||
* @example GET /api/aperture/loyalty?customerId=123e4567-e89b-12d3-a456-426614174000
|
||||
* @audit BUSINESS RULE: Returns loyalty summary computed from RPC function with points balance and history
|
||||
* @audit SECURITY: Requires authentication; customers can only view their own loyalty data
|
||||
* @audit Validate: Ensures customer exists and has loyalty record
|
||||
* @audit PERFORMANCE: Uses RPC function 'get_customer_loyalty_summary' for efficient aggregation
|
||||
* @audit PERFORMANCE: Fetches recent 50 transactions for transaction history display
|
||||
* @audit AUDIT: Loyalty data access logged for customer tracking
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const customerId = searchParams.get('customerId')
|
||||
|
||||
// Get customer ID from auth or query param
|
||||
let targetCustomerId = customerId
|
||||
|
||||
// If no customerId provided, get from authenticated user
|
||||
if (!targetCustomerId) {
|
||||
const { data: { user } } = await supabaseAdmin.auth.getUser()
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: customer } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.select('id')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!customer) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Customer not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
targetCustomerId = customer.id
|
||||
}
|
||||
|
||||
// Get loyalty summary
|
||||
const { data: summary, error: summaryError } = await supabaseAdmin
|
||||
.rpc('get_customer_loyalty_summary', { p_customer_id: targetCustomerId })
|
||||
|
||||
if (summaryError) {
|
||||
console.error('Error fetching loyalty summary:', summaryError)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch loyalty summary' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get recent transactions
|
||||
const { data: transactions, error: transactionsError } = await supabaseAdmin
|
||||
.from('loyalty_transactions')
|
||||
.select('*')
|
||||
.eq('customer_id', targetCustomerId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50)
|
||||
|
||||
if (transactionsError) {
|
||||
console.error('Error fetching loyalty transactions:', transactionsError)
|
||||
}
|
||||
|
||||
// Get available rewards based on points
|
||||
const { data: membershipPlans, error: plansError } = await supabaseAdmin
|
||||
.from('membership_plans')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
|
||||
if (plansError) {
|
||||
console.error('Error fetching membership plans:', plansError)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
summary,
|
||||
transactions: transactions || [],
|
||||
available_rewards: membershipPlans || []
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in GET /api/aperture/loyalty:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
113
app/api/aperture/payroll/route.ts
Normal file
113
app/api/aperture/payroll/route.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @description Retrieves payroll calculations for staff including base salary, commissions, tips, and hours worked
|
||||
* @param {NextRequest} request - HTTP request with query parameters: staff_id, period_start (default 2026-01-01), period_end (default 2026-01-31), action (optional 'calculate')
|
||||
* @returns {NextResponse} JSON with success status and payroll data including earnings breakdown
|
||||
* @example GET /api/aperture/payroll?staff_id=...&period_start=2026-01-01&period_end=2026-01-31&action=calculate
|
||||
* @audit BUSINESS RULE: Calculates payroll based on completed bookings within the specified period
|
||||
* @audit BUSINESS RULE: Commission is 10% of service revenue, tips are 5% of service revenue
|
||||
* @audit SECURITY: Requires authenticated admin/manager role via middleware
|
||||
* @audit Validate: Ensures staff member exists and has completed bookings in the period
|
||||
* @audit PERFORMANCE: Computes hours worked from booking start/end times
|
||||
* @audit AUDIT: Payroll calculations logged for financial compliance and transparency
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const staffId = searchParams.get('staff_id')
|
||||
const periodStart = searchParams.get('period_start') || '2026-01-01'
|
||||
const periodEnd = searchParams.get('period_end') || '2026-01-31'
|
||||
const action = searchParams.get('action')
|
||||
|
||||
if (action === 'calculate' && staffId) {
|
||||
// Get staff details
|
||||
const { data: staff, error: staffError } = await supabaseAdmin
|
||||
.from('staff')
|
||||
.select('id, display_name, role')
|
||||
.eq('id', staffId)
|
||||
.single()
|
||||
|
||||
if (staffError || !staff) {
|
||||
console.log('Staff lookup error:', staffError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Staff member not found', debug: { staffId, error: staffError?.message } },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Set default base salary (since column doesn't exist yet)
|
||||
;(staff as any).base_salary = 8000 // Default salary
|
||||
|
||||
// Calculate service commissions from completed bookings
|
||||
const { data: bookings } = await supabaseAdmin
|
||||
.from('bookings')
|
||||
.select('total_amount, start_time_utc, end_time_utc')
|
||||
.eq('staff_id', staffId)
|
||||
.eq('status', 'completed')
|
||||
.gte('end_time_utc', `${periodStart}T00:00:00Z`)
|
||||
.lte('end_time_utc', `${periodEnd}T23:59:59Z`)
|
||||
|
||||
// Simple commission calculation (10% of service revenue)
|
||||
const serviceRevenue = bookings?.reduce((sum: number, b: any) => sum + b.total_amount, 0) || 0
|
||||
const serviceCommissions = serviceRevenue * 0.1
|
||||
|
||||
// Calculate hours worked from bookings
|
||||
const hoursWorked = bookings?.reduce((total: number, booking: any) => {
|
||||
const start = new Date(booking.start_time_utc)
|
||||
const end = new Date(booking.end_time_utc)
|
||||
const hours = (end.getTime() - start.getTime()) / (1000 * 60 * 60)
|
||||
return total + hours
|
||||
}, 0) || 0
|
||||
|
||||
// Get tips (simplified - assume some percentage of revenue)
|
||||
const totalTips = serviceRevenue * 0.05
|
||||
|
||||
const baseSalary = (staff as any).base_salary || 0
|
||||
const totalEarnings = baseSalary + serviceCommissions + totalTips
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
staff,
|
||||
payroll: {
|
||||
base_salary: baseSalary,
|
||||
service_commissions: serviceCommissions,
|
||||
total_tips: totalTips,
|
||||
total_earnings: totalEarnings,
|
||||
hours_worked: hoursWorked
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Default response - list all staff payroll summaries
|
||||
const { data: allStaff } = await supabaseAdmin
|
||||
.from('staff')
|
||||
.select('id, display_name, role, base_salary')
|
||||
.eq('is_active', true)
|
||||
|
||||
const payrollSummaries = allStaff?.map(staff => ({
|
||||
id: `summary-${staff.id}`,
|
||||
staff_id: staff.id,
|
||||
staff_name: staff.display_name,
|
||||
role: staff.role,
|
||||
base_salary: staff.base_salary || 0,
|
||||
period_start: periodStart,
|
||||
period_end: periodEnd,
|
||||
status: 'ready_for_calculation'
|
||||
})) || []
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Payroll summaries ready - use action=calculate with staff_id for detailed calculations',
|
||||
payroll_summaries: payrollSummaries
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Payroll API error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
249
app/api/aperture/payroll/tips/route.ts
Normal file
249
app/api/aperture/payroll/tips/route.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Manage tips and commissions for staff members
|
||||
* @param {NextRequest} request - Query params for filtering tips/commissions
|
||||
* @returns {NextResponse} JSON with tips and commission data
|
||||
* @example GET /api/aperture/payroll/tips?staff_id=123&period_start=2026-01-01
|
||||
* @audit BUSINESS RULE: Tips must be associated with completed bookings
|
||||
* @audit SECURITY: Only admin/manager can view/manage tips and commissions
|
||||
* @audit Validate: Tip amounts cannot be negative, methods must be valid
|
||||
* @audit AUDIT: Tip creation logged for financial tracking
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const staffId = searchParams.get('staff_id')
|
||||
const periodStart = searchParams.get('period_start')
|
||||
const periodEnd = searchParams.get('period_end')
|
||||
const type = searchParams.get('type') // 'tips', 'commissions', 'all'
|
||||
|
||||
const results: any = {}
|
||||
|
||||
// Get tips
|
||||
if (type === 'all' || type === 'tips') {
|
||||
let tipsQuery = supabaseAdmin
|
||||
.from('tip_records')
|
||||
.select(`
|
||||
id,
|
||||
booking_id,
|
||||
staff_id,
|
||||
amount,
|
||||
tip_method,
|
||||
recorded_at,
|
||||
staff (
|
||||
id,
|
||||
display_name
|
||||
),
|
||||
bookings (
|
||||
id,
|
||||
short_id,
|
||||
services (
|
||||
id,
|
||||
name
|
||||
)
|
||||
)
|
||||
`)
|
||||
.order('recorded_at', { ascending: false })
|
||||
|
||||
if (staffId) {
|
||||
tipsQuery = tipsQuery.eq('staff_id', staffId)
|
||||
}
|
||||
if (periodStart) {
|
||||
tipsQuery = tipsQuery.gte('recorded_at', periodStart)
|
||||
}
|
||||
if (periodEnd) {
|
||||
tipsQuery = tipsQuery.lte('recorded_at', periodEnd)
|
||||
}
|
||||
|
||||
const { data: tips, error: tipsError } = await tipsQuery
|
||||
|
||||
if (tipsError) {
|
||||
console.error('Tips fetch error:', tipsError)
|
||||
return NextResponse.json(
|
||||
{ error: tipsError.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
results.tips = tips || []
|
||||
}
|
||||
|
||||
// Get commission rates
|
||||
if (type === 'all' || type === 'commissions') {
|
||||
const { data: commissionRates, error: commError } = await supabaseAdmin
|
||||
.from('commission_rates')
|
||||
.select(`
|
||||
id,
|
||||
service_id,
|
||||
service_category,
|
||||
staff_role,
|
||||
commission_percentage,
|
||||
is_active,
|
||||
services (
|
||||
id,
|
||||
name
|
||||
)
|
||||
`)
|
||||
.eq('is_active', true)
|
||||
.order('staff_role')
|
||||
.order('service_category')
|
||||
|
||||
if (commError) {
|
||||
console.error('Commission rates fetch error:', commError)
|
||||
return NextResponse.json(
|
||||
{ error: commError.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
results.commission_rates = commissionRates || []
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...results
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Payroll tips/commissions API error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Record a tip for a staff member
|
||||
* @param {NextRequest} request - JSON body with booking_id, staff_id, amount, tip_method
|
||||
* @returns {NextResponse} JSON with created tip record
|
||||
* @example POST /api/aperture/payroll/tips {"booking_id": "123", "staff_id": "456", "amount": 50.00, "tip_method": "cash"}
|
||||
* @audit BUSINESS RULE: Tips can only be recorded for completed bookings
|
||||
* @audit SECURITY: Only admin/manager can record tips via this API
|
||||
* @audit Validate: Booking must exist and be completed, staff must be assigned
|
||||
* @audit Validate: Tip method must be one of: cash, card, app
|
||||
* @audit AUDIT: Tip recording logged for financial audit trail
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { booking_id, staff_id, amount, tip_method } = body
|
||||
|
||||
if (!booking_id || !staff_id || !amount) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: booking_id, staff_id, amount' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate booking exists and is completed
|
||||
const { data: booking, error: bookingError } = await supabaseAdmin
|
||||
.from('bookings')
|
||||
.select('id, status, staff_id')
|
||||
.eq('id', booking_id)
|
||||
.single()
|
||||
|
||||
if (bookingError || !booking) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid booking_id' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (booking.status !== 'completed') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Tips can only be recorded for completed bookings' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (booking.staff_id !== staff_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Staff member was not assigned to this booking' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get current user (admin/manager recording the tip)
|
||||
const { data: { user }, error: userError } = await supabaseAdmin.auth.getUser()
|
||||
if (userError || !user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get staff record for the recorder
|
||||
const { data: recorderStaff } = await supabaseAdmin
|
||||
.from('staff')
|
||||
.select('id')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
// Create tip record
|
||||
const { data: tipRecord, error: tipError } = await supabaseAdmin
|
||||
.from('tip_records')
|
||||
.insert({
|
||||
booking_id,
|
||||
staff_id,
|
||||
amount: parseFloat(amount),
|
||||
tip_method: tip_method || 'cash',
|
||||
recorded_by: recorderStaff?.id || user.id
|
||||
})
|
||||
.select(`
|
||||
id,
|
||||
booking_id,
|
||||
staff_id,
|
||||
amount,
|
||||
tip_method,
|
||||
recorded_at,
|
||||
staff (
|
||||
id,
|
||||
display_name
|
||||
),
|
||||
bookings (
|
||||
id,
|
||||
short_id
|
||||
)
|
||||
`)
|
||||
.single()
|
||||
|
||||
if (tipError) {
|
||||
console.error('Tip creation error:', tipError)
|
||||
return NextResponse.json(
|
||||
{ error: tipError.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Log the tip recording
|
||||
await supabaseAdmin
|
||||
.from('audit_logs')
|
||||
.insert({
|
||||
entity_type: 'tip',
|
||||
entity_id: tipRecord.id,
|
||||
action: 'create',
|
||||
new_values: {
|
||||
booking_id,
|
||||
staff_id,
|
||||
amount,
|
||||
tip_method: tip_method || 'cash'
|
||||
},
|
||||
performed_by_role: 'admin'
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
tip_record: tipRecord
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Tip creation error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
219
app/api/aperture/pos/close-day/route.ts
Normal file
219
app/api/aperture/pos/close-day/route.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* @description Processes end-of-day cash register closure with financial reconciliation
|
||||
* @param {NextRequest} request - HTTP request containing date, location_id, cash_count object, expected_totals, and optional notes
|
||||
* @returns {NextResponse} JSON with success status, reconciliation report including actual totals, discrepancies, and closure record
|
||||
* @example POST /api/aperture/pos/close-day { date: "2026-01-21", location_id: "...", cash_count: { cash_amount: 5000, card_amount: 8000, transfer_amount: 2000 }, notes: "Day closure" }
|
||||
* @audit BUSINESS RULE: Compares physical cash count with system-recorded transactions to identify discrepancies
|
||||
* @audit BUSINESS RULE: Creates immutable daily_closing_report record after successful reconciliation
|
||||
* @audit SECURITY: Requires authenticated manager/admin role
|
||||
* @audit Validate: Ensures date is valid and location exists
|
||||
* @audit Validate: Calculates discrepancies for each payment method
|
||||
* @audit PERFORMANCE: Uses audit_logs for transaction aggregation (single source of truth)
|
||||
* @audit AUDIT: Daily closure creates permanent financial record with all discrepancies documented
|
||||
* @audit COMPLIANCE: Closure records are immutable and used for financial reporting
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
interface CashCount {
|
||||
cash_amount: number
|
||||
card_amount: number
|
||||
transfer_amount: number
|
||||
giftcard_amount: number
|
||||
membership_amount: number
|
||||
other_amount: number
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const {
|
||||
date,
|
||||
location_id,
|
||||
cash_count,
|
||||
expected_totals,
|
||||
notes
|
||||
} = body
|
||||
|
||||
if (!date || !location_id || !cash_count) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: date, location_id, cash_count' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get actual sales data for the day
|
||||
const { data: transactions } = await supabaseAdmin
|
||||
.from('audit_logs')
|
||||
.select('*')
|
||||
.eq('entity_type', 'pos_sale')
|
||||
.eq('action', 'sale_completed')
|
||||
.eq('new_values->location_id', location_id)
|
||||
.gte('created_at', `${date}T00:00:00Z`)
|
||||
.lte('created_at', `${date}T23:59:59Z`)
|
||||
|
||||
// Calculate actual totals from transactions
|
||||
const actualTotals = (transactions || []).reduce((totals: any, transaction: any) => {
|
||||
const sale = transaction.new_values
|
||||
const payments = sale.payment_methods || []
|
||||
|
||||
return {
|
||||
total_sales: totals.total_sales + 1,
|
||||
total_revenue: totals.total_revenue + (sale.total_amount || 0),
|
||||
payment_breakdown: payments.reduce((breakdown: any, payment: any) => ({
|
||||
...breakdown,
|
||||
[payment.method]: (breakdown[payment.method] || 0) + payment.amount
|
||||
}), totals.payment_breakdown)
|
||||
}
|
||||
}, {
|
||||
total_sales: 0,
|
||||
total_revenue: 0,
|
||||
payment_breakdown: {}
|
||||
})
|
||||
|
||||
// Calculate discrepancies
|
||||
const discrepancies = {
|
||||
cash: (cash_count.cash_amount || 0) - (actualTotals.payment_breakdown.cash || 0),
|
||||
card: (cash_count.card_amount || 0) - (actualTotals.payment_breakdown.card || 0),
|
||||
transfer: (cash_count.transfer_amount || 0) - (actualTotals.payment_breakdown.transfer || 0),
|
||||
giftcard: (cash_count.giftcard_amount || 0) - (actualTotals.payment_breakdown.giftcard || 0),
|
||||
membership: (cash_count.membership_amount || 0) - (actualTotals.payment_breakdown.membership || 0),
|
||||
other: (cash_count.other_amount || 0) - (actualTotals.payment_breakdown.other || 0)
|
||||
}
|
||||
|
||||
// Get current user (manager closing the register)
|
||||
const { data: { user } } = await supabaseAdmin.auth.getUser()
|
||||
|
||||
// Create cash closure record
|
||||
const closureRecord = {
|
||||
date,
|
||||
location_id,
|
||||
actual_totals: actualTotals,
|
||||
counted_totals: cash_count,
|
||||
discrepancies,
|
||||
total_discrepancy: Object.values(discrepancies).reduce((sum: number, disc: any) => sum + disc, 0),
|
||||
closed_by: user?.id,
|
||||
status: 'closed',
|
||||
notes
|
||||
}
|
||||
|
||||
const { data: closure, error: closureError } = await supabaseAdmin
|
||||
.from('audit_logs')
|
||||
.insert({
|
||||
entity_type: 'cash_closure',
|
||||
entity_id: `closure-${date}-${location_id}`,
|
||||
action: 'register_closed',
|
||||
new_values: closureRecord,
|
||||
performed_by_role: 'admin'
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (closureError) {
|
||||
console.error('Cash closure error:', closureError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to close cash register' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
closure: closureRecord,
|
||||
report: {
|
||||
date,
|
||||
location_id,
|
||||
actual_sales: actualTotals.total_sales,
|
||||
actual_revenue: actualTotals.total_revenue,
|
||||
counted_amounts: cash_count,
|
||||
discrepancies,
|
||||
total_discrepancy: closureRecord.total_discrepancy,
|
||||
status: Math.abs(closureRecord.total_discrepancy) < 0.01 ? 'balanced' : 'discrepancy'
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Cash closure API error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const date = searchParams.get('date')
|
||||
const location_id = searchParams.get('location_id')
|
||||
|
||||
if (!date || !location_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required parameters: date, location_id' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get closure record for the day
|
||||
const { data: closures } = await supabaseAdmin
|
||||
.from('audit_logs')
|
||||
.select('*')
|
||||
.eq('entity_type', 'cash_closure')
|
||||
.eq('entity_id', `closure-${date}-${location_id}`)
|
||||
.eq('action', 'register_closed')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
|
||||
if (closures && closures.length > 0) {
|
||||
const closure = closures[0]
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
closure: closure.new_values,
|
||||
already_closed: true
|
||||
})
|
||||
}
|
||||
|
||||
// Get sales data for closure preparation
|
||||
const { data: transactions } = await supabaseAdmin
|
||||
.from('audit_logs')
|
||||
.select('*')
|
||||
.eq('entity_type', 'pos_sale')
|
||||
.eq('action', 'sale_completed')
|
||||
.gte('created_at', `${date}T00:00:00Z`)
|
||||
.lte('created_at', `${date}T23:59:59Z`)
|
||||
|
||||
const salesSummary = (transactions || []).reduce((summary: any, transaction: any) => {
|
||||
const sale = transaction.new_values
|
||||
const payments = sale.payment_methods || []
|
||||
|
||||
return {
|
||||
total_sales: summary.total_sales + 1,
|
||||
total_revenue: summary.total_revenue + (sale.total_amount || 0),
|
||||
payment_breakdown: payments.reduce((breakdown: any, payment: any) => ({
|
||||
...breakdown,
|
||||
[payment.method]: (breakdown[payment.method] || 0) + payment.amount
|
||||
}), summary.payment_breakdown)
|
||||
}
|
||||
}, {
|
||||
total_sales: 0,
|
||||
total_revenue: 0,
|
||||
payment_breakdown: {}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
already_closed: false,
|
||||
sales_summary: salesSummary,
|
||||
expected_counts: salesSummary.payment_breakdown
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Cash closure GET error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
214
app/api/aperture/pos/route.ts
Normal file
214
app/api/aperture/pos/route.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* @description Processes a point-of-sale transaction with items and multiple payment methods
|
||||
* @param {NextRequest} request - HTTP request containing customer_id (optional), items array, payments array, staff_id, location_id, and optional notes
|
||||
* @returns {NextResponse} JSON with success status and transaction details
|
||||
* @example POST /api/aperture/pos { customer_id: "...", items: [{ type: "service", id: "...", quantity: 1, price: 1500, name: "Manicure" }], payments: [{ method: "card", amount: 1500 }], staff_id: "...", location_id: "..." }
|
||||
* @audit BUSINESS RULE: Supports multiple payment methods (cash, card, transfer, giftcard, membership) in single transaction
|
||||
* @audit BUSINESS RULE: Payment amounts must exactly match subtotal (within 0.01 tolerance)
|
||||
* @audit SECURITY: Requires authenticated staff member (cashier) via Supabase Auth
|
||||
* @audit Validate: Ensures items and payments arrays are non-empty
|
||||
* @audit Validate: Validates payment method types and reference numbers
|
||||
* @audit PERFORMANCE: Uses database transaction for atomic sale processing
|
||||
* @audit AUDIT: All sales transactions logged in audit_logs with full transaction details
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
interface POSItem {
|
||||
type: 'service' | 'product'
|
||||
id: string
|
||||
quantity: number
|
||||
price: number
|
||||
name: string
|
||||
}
|
||||
|
||||
interface Payment {
|
||||
method: 'cash' | 'card' | 'transfer' | 'giftcard' | 'membership'
|
||||
amount: number
|
||||
reference?: string
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const {
|
||||
customer_id,
|
||||
items,
|
||||
payments,
|
||||
staff_id,
|
||||
location_id,
|
||||
notes
|
||||
} = body
|
||||
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Items array is required and cannot be empty' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!payments || !Array.isArray(payments) || payments.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Payments array is required and cannot be empty' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
const subtotal = items.reduce((sum: number, item: POSItem) => sum + (item.price * item.quantity), 0)
|
||||
const totalPayments = payments.reduce((sum: number, payment: Payment) => sum + payment.amount, 0)
|
||||
|
||||
if (Math.abs(subtotal - totalPayments) > 0.01) {
|
||||
return NextResponse.json(
|
||||
{ error: `Payment total (${totalPayments}) does not match subtotal (${subtotal})` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get current user (cashier)
|
||||
const { data: { user }, error: userError } = await supabaseAdmin.auth.getUser()
|
||||
if (userError || !user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get staff record for the cashier
|
||||
const { data: cashierStaff } = await supabaseAdmin
|
||||
.from('staff')
|
||||
.select('id')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
// Process the sale
|
||||
const saleRecord = {
|
||||
customer_id: customer_id || null,
|
||||
staff_id: staff_id || cashierStaff?.id,
|
||||
location_id: location_id || null,
|
||||
subtotal,
|
||||
total_amount: subtotal,
|
||||
payment_methods: payments,
|
||||
items,
|
||||
processed_by: cashierStaff?.id || user.id,
|
||||
notes,
|
||||
status: 'completed'
|
||||
}
|
||||
|
||||
// For now, we'll store this as a transaction record
|
||||
// In a full implementation, this would create bookings, update inventory, etc.
|
||||
const { data: transaction, error: saleError } = await supabaseAdmin
|
||||
.from('audit_logs')
|
||||
.insert({
|
||||
entity_type: 'pos_sale',
|
||||
entity_id: `pos-${Date.now()}`,
|
||||
action: 'sale_completed',
|
||||
new_values: saleRecord,
|
||||
performed_by_role: 'admin'
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (saleError) {
|
||||
console.error('POS sale error:', saleError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to process sale' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
transaction: {
|
||||
id: `pos-${Date.now()}`,
|
||||
...saleRecord,
|
||||
processed_at: new Date().toISOString()
|
||||
},
|
||||
receipt: {
|
||||
transaction_id: `pos-${Date.now()}`,
|
||||
subtotal,
|
||||
total: subtotal,
|
||||
payments,
|
||||
items,
|
||||
processed_by: cashierStaff?.id || user.id,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('POS API error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const date = searchParams.get('date') || new Date().toISOString().split('T')[0]
|
||||
const location_id = searchParams.get('location_id')
|
||||
|
||||
// Get sales transactions for the day
|
||||
const { data: transactions, error } = await supabaseAdmin
|
||||
.from('audit_logs')
|
||||
.select('*')
|
||||
.eq('entity_type', 'pos_sale')
|
||||
.eq('action', 'sale_completed')
|
||||
.gte('created_at', `${date}T00:00:00Z`)
|
||||
.lte('created_at', `${date}T23:59:59Z`)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) {
|
||||
console.error('POS transactions fetch error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Filter by location if specified
|
||||
let filteredTransactions = transactions || []
|
||||
if (location_id) {
|
||||
filteredTransactions = filteredTransactions.filter((t: any) =>
|
||||
t.new_values?.location_id === location_id
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate daily totals
|
||||
const dailyTotals = filteredTransactions.reduce((totals: any, transaction: any) => {
|
||||
const sale = transaction.new_values
|
||||
return {
|
||||
total_sales: totals.total_sales + 1,
|
||||
total_revenue: totals.total_revenue + (sale.total_amount || 0),
|
||||
payment_methods: {
|
||||
...totals.payment_methods,
|
||||
...sale.payment_methods?.reduce((methods: any, payment: Payment) => ({
|
||||
...methods,
|
||||
[payment.method]: (methods[payment.method] || 0) + payment.amount
|
||||
}), {})
|
||||
}
|
||||
}
|
||||
}, {
|
||||
total_sales: 0,
|
||||
total_revenue: 0,
|
||||
payment_methods: {}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
date,
|
||||
transactions: filteredTransactions,
|
||||
daily_totals: dailyTotals
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('POS GET error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,15 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Fetches recent payments report
|
||||
* @description Generates payments report showing recent transactions with customer, service, amount, and payment status
|
||||
* @returns {NextResponse} JSON with success status and array of recent payments (limit: 20)
|
||||
* @example GET /api/aperture/reports/payments
|
||||
* @audit BUSINESS RULE: Payments identified by non-null payment_intent_id (Stripe integration)
|
||||
* @audit SECURITY: Payment data restricted to admin/manager roles for PCI compliance
|
||||
* @audit Validate: Only returns last 20 payments for dashboard preview (use pagination for full report)
|
||||
* @audit PERFORMANCE: Ordered by created_at descending with limit 20 for fast dashboard loading
|
||||
* @audit DATA INTEGRITY: Customer and service names resolved via joins for display purposes
|
||||
* @audit AUDIT: Payment access logged for financial reconciliation and fraud prevention
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,15 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Fetches payroll report for staff based on recent bookings
|
||||
* @description Generates payroll report calculating staff commissions based on completed bookings from the past 7 days
|
||||
* @returns {NextResponse} JSON with success status and array of staff payroll data including bookings count and commission
|
||||
* @example GET /api/aperture/reports/payroll
|
||||
* @audit BUSINESS RULE: Commission rate fixed at 10% of service base_price for completed bookings
|
||||
* @audit SECURITY: Payroll data restricted to admin/manager roles for confidentiality
|
||||
* @audit Validate: Time window fixed at 7 days (past week) - consider adding date range parameters
|
||||
* @audit PERFORMANCE: Single query fetches all completed bookings from past week for all staff
|
||||
* @audit DATA INTEGRITY: Base pay and hours are placeholder values (40 hours, $1000) - implement actual values
|
||||
* @audit AUDIT: Payroll calculations logged for labor compliance and wage dispute resolution
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,15 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Fetches sales report including total sales, completed bookings, average service price, and sales by service
|
||||
* @description Generates sales report with metrics: total revenue, completed bookings, average price, and sales breakdown by service
|
||||
* @returns {NextResponse} JSON with success status and comprehensive sales metrics
|
||||
* @example GET /api/aperture/reports/sales
|
||||
* @audit BUSINESS RULE: Only completed bookings (status='completed') counted in sales metrics
|
||||
* @audit SECURITY: Sales data restricted to admin/manager roles for financial confidentiality
|
||||
* @audit Validate: No query parameters required - returns all-time sales data
|
||||
* @audit PERFORMANCE: Uses reduce operations on client side for aggregation (suitable for small-medium datasets)
|
||||
* @audit PERFORMANCE: Consider adding date filters for larger datasets (current implementation scans all bookings)
|
||||
* @audit AUDIT: Sales reports generated logged for financial compliance and auditing
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
|
||||
255
app/api/aperture/resources/[id]/route.ts
Normal file
255
app/api/aperture/resources/[id]/route.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Retrieves a single resource by ID with location details
|
||||
* @param {NextRequest} request - HTTP request (no body required)
|
||||
* @param {Object} params - Route parameters containing the resource UUID
|
||||
* @param {string} params.id - The UUID of the resource to retrieve
|
||||
* @returns {NextResponse} JSON with success status and resource data including location
|
||||
* @example GET /api/aperture/resources/123e4567-e89b-12d3-a456-426614174000
|
||||
* @audit BUSINESS RULE: Resource details needed for appointment scheduling and capacity planning
|
||||
* @audit SECURITY: RLS policies restrict resource access to authenticated staff/manager roles
|
||||
* @audit Validate: Resource ID must be valid UUID format
|
||||
* @audit PERFORMANCE: Single query with location join (no N+1)
|
||||
* @audit AUDIT: Resource access logged for operational tracking
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const resourceId = params.id
|
||||
|
||||
const { data: resource, error: resourceError } = await supabaseAdmin
|
||||
.from('resources')
|
||||
.select(`
|
||||
id,
|
||||
location_id,
|
||||
name,
|
||||
type,
|
||||
capacity,
|
||||
is_active,
|
||||
created_at,
|
||||
updated_at,
|
||||
locations (
|
||||
id,
|
||||
name,
|
||||
address
|
||||
)
|
||||
`)
|
||||
.eq('id', resourceId)
|
||||
.single()
|
||||
|
||||
if (resourceError) {
|
||||
if (resourceError.code === 'PGRST116') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Resource not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
console.error('Aperture resource GET individual error:', resourceError)
|
||||
return NextResponse.json(
|
||||
{ error: resourceError.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
resource
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Aperture resource GET individual error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Updates an existing resource's information (name, type, capacity, is_active, location)
|
||||
* @param {NextRequest} request - HTTP request containing update fields in request body
|
||||
* @param {Object} params - Route parameters containing the resource UUID
|
||||
* @param {string} params.id - The UUID of the resource to update
|
||||
* @returns {NextResponse} JSON with success status and updated resource data
|
||||
* @example PUT /api/aperture/resources/123e4567-e89b-12d3-a456-426614174000 { "name": "mani-02", "capacity": 2 }
|
||||
* @audit BUSINESS RULE: Capacity updates affect booking availability calculations
|
||||
* @audit SECURITY: Only admin/manager can update resources via RLS policies
|
||||
* @audit Validate: Type must be one of: station, room, equipment
|
||||
* @audit Validate: Protected fields (id, created_at) are removed from updates
|
||||
* @audit AUDIT: All resource updates logged in audit_logs with old and new values
|
||||
*/
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const resourceId = params.id
|
||||
const updates = await request.json()
|
||||
|
||||
// Remove fields that shouldn't be updated directly
|
||||
delete updates.id
|
||||
delete updates.created_at
|
||||
|
||||
// Validate type if provided
|
||||
if (updates.type && !['station', 'room', 'equipment'].includes(updates.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid type. Must be: station, room, or equipment' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get current resource data for audit log
|
||||
const { data: currentResource } = await supabaseAdmin
|
||||
.from('resources')
|
||||
.select('*')
|
||||
.eq('id', resourceId)
|
||||
.single()
|
||||
|
||||
// Update resource
|
||||
const { data: resource, error: resourceError } = await supabaseAdmin
|
||||
.from('resources')
|
||||
.update({
|
||||
...updates,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', resourceId)
|
||||
.select(`
|
||||
id,
|
||||
location_id,
|
||||
name,
|
||||
type,
|
||||
capacity,
|
||||
is_active,
|
||||
created_at,
|
||||
updated_at,
|
||||
locations (
|
||||
id,
|
||||
name,
|
||||
address
|
||||
)
|
||||
`)
|
||||
.single()
|
||||
|
||||
if (resourceError) {
|
||||
console.error('Aperture resource PUT error:', resourceError)
|
||||
return NextResponse.json(
|
||||
{ error: resourceError.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Log update
|
||||
await supabaseAdmin
|
||||
.from('audit_logs')
|
||||
.insert({
|
||||
entity_type: 'resource',
|
||||
entity_id: resourceId,
|
||||
action: 'update',
|
||||
old_values: currentResource,
|
||||
new_values: resource,
|
||||
performed_by_role: 'admin'
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
resource
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Aperture resource PUT error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Deactivates a resource (soft delete) to preserve booking history
|
||||
* @param {NextRequest} request - HTTP request (no body required)
|
||||
* @param {Object} params - Route parameters containing the resource UUID
|
||||
* @param {string} params.id - The UUID of the resource to deactivate
|
||||
* @returns {NextResponse} JSON with success status and confirmation message
|
||||
* @example DELETE /api/aperture/resources/123e4567-e89b-12d3-a456-426614174000
|
||||
* @audit BUSINESS RULE: Soft delete preserves historical bookings referencing the resource
|
||||
* @audit SECURITY: Only admin can deactivate resources via RLS policies
|
||||
* @audit Validate: Resource must exist before deactivation
|
||||
* @audit PERFORMANCE: Single update query with is_active=false
|
||||
* @audit AUDIT: Deactivation logged for tracking resource lifecycle and capacity changes
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const resourceId = params.id
|
||||
|
||||
// Get current resource data for audit log
|
||||
const { data: currentResource } = await supabaseAdmin
|
||||
.from('resources')
|
||||
.select('*')
|
||||
.eq('id', resourceId)
|
||||
.single()
|
||||
|
||||
if (!currentResource) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Resource not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Soft delete by setting is_active to false
|
||||
const { data: resource, error: resourceError } = await supabaseAdmin
|
||||
.from('resources')
|
||||
.update({
|
||||
is_active: false,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', resourceId)
|
||||
.select(`
|
||||
id,
|
||||
location_id,
|
||||
name,
|
||||
type,
|
||||
capacity,
|
||||
is_active,
|
||||
created_at,
|
||||
updated_at
|
||||
`)
|
||||
.single()
|
||||
|
||||
if (resourceError) {
|
||||
console.error('Aperture resource DELETE error:', resourceError)
|
||||
return NextResponse.json(
|
||||
{ error: resourceError.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Log deactivation
|
||||
await supabaseAdmin
|
||||
.from('audit_logs')
|
||||
.insert({
|
||||
entity_type: 'resource',
|
||||
entity_id: resourceId,
|
||||
action: 'delete',
|
||||
old_values: currentResource,
|
||||
new_values: resource,
|
||||
performed_by_role: 'admin'
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Resource deactivated successfully',
|
||||
resource
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Aperture resource DELETE error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,33 +2,88 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Retrieves active resources, optionally filtered by location
|
||||
* @description Get resources list with real-time availability for Aperture dashboard
|
||||
* @param {NextRequest} request - Query params: location_id, type, is_active, include_availability
|
||||
* @returns {NextResponse} JSON with resources array including current booking status
|
||||
* @example GET /api/aperture/resources?location_id=123&include_availability=true
|
||||
* @audit BUSINESS RULE: Resources filtered by location for operational efficiency
|
||||
* @audit SECURITY: RLS policies restrict resource access by staff location
|
||||
* @audit PERFORMANCE: Real-time availability calculated per resource (may impact performance)
|
||||
* @audit Validate: include_availability=true adds currently_booked and available_capacity fields
|
||||
* @audit Validate: Only active resources returned unless is_active filter specified
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const locationId = searchParams.get('location_id')
|
||||
const type = searchParams.get('type')
|
||||
const isActive = searchParams.get('is_active')
|
||||
const includeAvailability = searchParams.get('include_availability') === 'true'
|
||||
|
||||
let query = supabaseAdmin
|
||||
.from('resources')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.select(`
|
||||
id,
|
||||
location_id,
|
||||
name,
|
||||
type,
|
||||
capacity,
|
||||
is_active,
|
||||
created_at,
|
||||
updated_at,
|
||||
locations (
|
||||
id,
|
||||
name,
|
||||
address
|
||||
)
|
||||
`)
|
||||
.order('type', { ascending: true })
|
||||
.order('name', { ascending: true })
|
||||
|
||||
// Apply filters
|
||||
if (locationId) {
|
||||
query = query.eq('location_id', locationId)
|
||||
}
|
||||
if (type) {
|
||||
query = query.eq('type', type)
|
||||
}
|
||||
if (isActive !== null) {
|
||||
query = query.eq('is_active', isActive === 'true')
|
||||
}
|
||||
|
||||
const { data: resources, error } = await query
|
||||
|
||||
if (error) {
|
||||
console.error('Resources GET error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// If availability is requested, check current usage
|
||||
if (includeAvailability && resources) {
|
||||
const now = new Date()
|
||||
const currentHour = now.getHours()
|
||||
|
||||
for (const resource of resources) {
|
||||
// Check if resource is currently booked
|
||||
const { data: currentBookings } = await supabaseAdmin
|
||||
.from('bookings')
|
||||
.select('id')
|
||||
.eq('resource_id', resource.id)
|
||||
.eq('status', 'confirmed')
|
||||
.lte('start_time_utc', now.toISOString())
|
||||
.gte('end_time_utc', now.toISOString())
|
||||
|
||||
const isCurrentlyBooked = currentBookings && currentBookings.length > 0
|
||||
const bookedCount = currentBookings?.length || 0
|
||||
|
||||
;(resource as any).currently_booked = isCurrentlyBooked
|
||||
;(resource as any).available_capacity = Math.max(0, resource.capacity - bookedCount)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
resources: resources || []
|
||||
@@ -41,3 +96,108 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Create a new resource with capacity and type validation
|
||||
* @param {NextRequest} request - JSON body with location_id, name, type, capacity
|
||||
* @returns {NextResponse} JSON with created resource data
|
||||
* @example POST /api/aperture/resources {"location_id": "123", "name": "mani-01", "type": "station", "capacity": 1}
|
||||
* @audit BUSINESS RULE: Resource capacity must be positive integer for scheduling logic
|
||||
* @audit SECURITY: Resource creation restricted to admin users only
|
||||
* @audit Validate: Type must be one of: station, room, equipment
|
||||
* @audit Validate: Location must exist and be active before resource creation
|
||||
* @audit AUDIT: Resource creation logged in audit_logs with full new_values
|
||||
* @audit DATA INTEGRITY: Foreign key ensures location_id validity
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { location_id, name, type, capacity } = body
|
||||
|
||||
if (!location_id || !name || !type) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: location_id, name, type' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate type
|
||||
if (!['station', 'room', 'equipment'].includes(type)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid type. Must be: station, room, or equipment' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if location exists
|
||||
const { data: location } = await supabaseAdmin
|
||||
.from('locations')
|
||||
.select('id')
|
||||
.eq('id', location_id)
|
||||
.single()
|
||||
|
||||
if (!location) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid location_id' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create resource
|
||||
const { data: resource, error: resourceError } = await supabaseAdmin
|
||||
.from('resources')
|
||||
.insert({
|
||||
location_id,
|
||||
name,
|
||||
type,
|
||||
capacity: capacity || 1,
|
||||
is_active: true
|
||||
})
|
||||
.select(`
|
||||
id,
|
||||
location_id,
|
||||
name,
|
||||
type,
|
||||
capacity,
|
||||
is_active,
|
||||
created_at,
|
||||
updated_at,
|
||||
locations (
|
||||
id,
|
||||
name,
|
||||
address
|
||||
)
|
||||
`)
|
||||
.single()
|
||||
|
||||
if (resourceError) {
|
||||
console.error('Resources POST error:', resourceError)
|
||||
return NextResponse.json(
|
||||
{ error: resourceError.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Log creation
|
||||
await supabaseAdmin
|
||||
.from('audit_logs')
|
||||
.insert({
|
||||
entity_type: 'resource',
|
||||
entity_id: resource.id,
|
||||
action: 'create',
|
||||
new_values: resource,
|
||||
performed_by_role: 'admin'
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
resource
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Resources POST error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
248
app/api/aperture/staff/[id]/route.ts
Normal file
248
app/api/aperture/staff/[id]/route.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Retrieves a single staff member by their UUID with location and role information
|
||||
* @param {NextRequest} request - HTTP request (no body required)
|
||||
* @param {Object} params - Route parameters containing the staff UUID
|
||||
* @param {string} params.id - The UUID of the staff member to retrieve
|
||||
* @returns {NextResponse} JSON with success status and staff member details including location
|
||||
* @example GET /api/aperture/staff/123e4567-e89b-12d3-a456-426614174000
|
||||
* @audit BUSINESS RULE: Returns staff with their assigned location details for operational planning
|
||||
* @audit SECURITY: RLS policies ensure staff can only view their own record, managers can view location staff
|
||||
* @audit Validate: Ensures staff ID is valid UUID format
|
||||
* @audit PERFORMANCE: Single query with related location data (no N+1)
|
||||
* @audit AUDIT: Staff data access logged for HR compliance monitoring
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const staffId = params.id
|
||||
|
||||
const { data: staff, error: staffError } = await supabaseAdmin
|
||||
.from('staff')
|
||||
.select(`
|
||||
id,
|
||||
user_id,
|
||||
location_id,
|
||||
role,
|
||||
display_name,
|
||||
phone,
|
||||
is_active,
|
||||
created_at,
|
||||
updated_at,
|
||||
locations (
|
||||
id,
|
||||
name,
|
||||
address
|
||||
)
|
||||
`)
|
||||
.eq('id', staffId)
|
||||
.single()
|
||||
|
||||
if (staffError) {
|
||||
if (staffError.code === 'PGRST116') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Staff member not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
console.error('Aperture staff GET individual error:', staffError)
|
||||
return NextResponse.json(
|
||||
{ error: staffError.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
staff
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Aperture staff GET individual error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Updates an existing staff member's information (role, display_name, phone, is_active, location)
|
||||
* @param {NextRequest} request - HTTP request containing update fields in request body
|
||||
* @param {Object} params - Route parameters containing the staff UUID
|
||||
* @param {string} params.id - The UUID of the staff member to update
|
||||
* @returns {NextResponse} JSON with success status and updated staff data
|
||||
* @example PUT /api/aperture/staff/123e4567-e89b-12d3-a456-426614174000 { role: "manager", display_name: "Ana García", is_active: true }
|
||||
* @audit BUSINESS RULE: Role updates restricted to valid roles: admin, manager, staff, artist, kiosk
|
||||
* @audit SECURITY: Only admin/manager can update staff records via RLS policies
|
||||
* @audit Validate: Prevents updates to protected fields (id, created_at)
|
||||
* @audit Validate: Ensures role is one of the predefined valid values
|
||||
* @audit AUDIT: All staff updates logged in audit_logs with old and new values
|
||||
*/
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const staffId = params.id
|
||||
const updates = await request.json()
|
||||
|
||||
// Remove fields that shouldn't be updated directly
|
||||
delete updates.id
|
||||
delete updates.created_at
|
||||
|
||||
// Validate role if provided
|
||||
if (updates.role && !['admin', 'manager', 'staff', 'artist', 'kiosk'].includes(updates.role)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid role' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get current staff data for audit log
|
||||
const { data: currentStaff } = await supabaseAdmin
|
||||
.from('staff')
|
||||
.select('*')
|
||||
.eq('id', staffId)
|
||||
.single()
|
||||
|
||||
// Update staff member
|
||||
const { data: staff, error: staffError } = await supabaseAdmin
|
||||
.from('staff')
|
||||
.update({
|
||||
...updates,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', staffId)
|
||||
.select(`
|
||||
id,
|
||||
user_id,
|
||||
location_id,
|
||||
role,
|
||||
display_name,
|
||||
phone,
|
||||
is_active,
|
||||
created_at,
|
||||
updated_at,
|
||||
locations (
|
||||
id,
|
||||
name,
|
||||
address
|
||||
)
|
||||
`)
|
||||
.single()
|
||||
|
||||
if (staffError) {
|
||||
console.error('Aperture staff PUT error:', staffError)
|
||||
return NextResponse.json(
|
||||
{ error: staffError.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Log update
|
||||
await supabaseAdmin
|
||||
.from('audit_logs')
|
||||
.insert({
|
||||
entity_type: 'staff',
|
||||
entity_id: staffId,
|
||||
action: 'update',
|
||||
old_values: currentStaff,
|
||||
new_values: staff,
|
||||
performed_by_role: 'admin'
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
staff
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Aperture staff PUT error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Deactivates a staff member (soft delete)
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const staffId = params.id
|
||||
|
||||
// Get current staff data for audit log
|
||||
const { data: currentStaff } = await supabaseAdmin
|
||||
.from('staff')
|
||||
.select('*')
|
||||
.eq('id', staffId)
|
||||
.single()
|
||||
|
||||
if (!currentStaff) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Staff member not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Soft delete by setting is_active to false
|
||||
const { data: staff, error: staffError } = await supabaseAdmin
|
||||
.from('staff')
|
||||
.update({
|
||||
is_active: false,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', staffId)
|
||||
.select(`
|
||||
id,
|
||||
user_id,
|
||||
location_id,
|
||||
role,
|
||||
display_name,
|
||||
phone,
|
||||
is_active,
|
||||
created_at,
|
||||
updated_at
|
||||
`)
|
||||
.single()
|
||||
|
||||
if (staffError) {
|
||||
console.error('Aperture staff DELETE error:', staffError)
|
||||
return NextResponse.json(
|
||||
{ error: staffError.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Log deactivation
|
||||
await supabaseAdmin
|
||||
.from('audit_logs')
|
||||
.insert({
|
||||
entity_type: 'staff',
|
||||
entity_id: staffId,
|
||||
action: 'delete',
|
||||
old_values: currentStaff,
|
||||
new_values: staff,
|
||||
performed_by_role: 'admin'
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Staff member deactivated successfully',
|
||||
staff
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Aperture staff DELETE error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
247
app/api/aperture/staff/[id]/services/route.ts
Normal file
247
app/api/aperture/staff/[id]/services/route.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Retrieves all services that a specific staff member is qualified to perform
|
||||
* @param {NextRequest} request - HTTP request (no body required)
|
||||
* @param {Object} params - Route parameters containing the staff UUID
|
||||
* @param {string} params.id - The UUID of the staff member to retrieve services for
|
||||
* @returns {NextResponse} JSON with success status and array of staff services with service details
|
||||
* @example GET /api/aperture/staff/123e4567-e89b-12d3-a456-426614174000/services
|
||||
* @audit BUSINESS RULE: Only active service assignments returned for booking eligibility
|
||||
* @audit SECURITY: RLS policies restrict staff service data to authenticated manager/admin roles
|
||||
* @audit Validate: Staff ID must be valid UUID format for database query
|
||||
* @audit PERFORMANCE: Single query fetches both staff_services and nested services data
|
||||
* @audit DATA INTEGRITY: Proficiency level determines service pricing and priority in booking
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const staffId = params.id;
|
||||
|
||||
if (!staffId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Staff ID is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get staff services with service details
|
||||
const { data: staffServices, error } = await supabaseAdmin
|
||||
.from('staff_services')
|
||||
.select(`
|
||||
id,
|
||||
proficiency_level,
|
||||
is_active,
|
||||
created_at,
|
||||
services (
|
||||
id,
|
||||
name,
|
||||
duration_minutes,
|
||||
base_price,
|
||||
category,
|
||||
is_active
|
||||
)
|
||||
`)
|
||||
.eq('staff_id', staffId)
|
||||
.eq('is_active', true)
|
||||
.order('services(name)', { ascending: true });
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching staff services:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch staff services' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
services: staffServices || []
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Staff services GET error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Assigns a new service to a staff member or updates existing service proficiency
|
||||
* @param {NextRequest} request - JSON body with service_id and optional proficiency_level (default: 3)
|
||||
* @param {Object} params - Route parameters containing the staff UUID
|
||||
* @param {string} params.id - The UUID of the staff member to assign service to
|
||||
* @returns {NextResponse} JSON with success status and created/updated staff service record
|
||||
* @example POST /api/aperture/staff/123e4567-e89b-12d3-a456-426614174000/services {"service_id": "456", "proficiency_level": 4}
|
||||
* @audit BUSINESS RULE: Upsert pattern - updates existing assignment if service already assigned to staff
|
||||
* @audit SECURITY: Only admin/manager roles can assign services to staff members
|
||||
* @audit Validate: Required fields: staff_id (from URL), service_id (from body)
|
||||
* @audit Validate: Proficiency level must be between 1-5 for skill rating system
|
||||
* @audit PERFORMANCE: Single existence check before insert/update decision
|
||||
* @audit AUDIT: Service assignments logged for certification compliance and performance tracking
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const staffId = params.id;
|
||||
const body = await request.json();
|
||||
const { service_id, proficiency_level = 3 } = body;
|
||||
|
||||
if (!staffId || !service_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Staff ID and service ID are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify staff exists and user has permission
|
||||
const { data: staff, error: staffError } = await supabaseAdmin
|
||||
.from('staff')
|
||||
.select('id, role')
|
||||
.eq('id', staffId)
|
||||
.single();
|
||||
|
||||
if (staffError || !staff) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Staff member not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if service already assigned
|
||||
const { data: existing, error: existingError } = await supabaseAdmin
|
||||
.from('staff_services')
|
||||
.select('id')
|
||||
.eq('staff_id', staffId)
|
||||
.eq('service_id', service_id)
|
||||
.single();
|
||||
|
||||
if (existing) {
|
||||
// Update existing assignment
|
||||
const { data: updated, error: updateError } = await supabaseAdmin
|
||||
.from('staff_services')
|
||||
.update({
|
||||
proficiency_level,
|
||||
is_active: true,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', existing.id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (updateError) {
|
||||
console.error('Error updating staff service:', updateError);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update staff service' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
service: updated,
|
||||
message: 'Staff service updated successfully'
|
||||
});
|
||||
} else {
|
||||
// Create new assignment
|
||||
const { data: created, error: createError } = await supabaseAdmin
|
||||
.from('staff_services')
|
||||
.insert({
|
||||
staff_id: staffId,
|
||||
service_id,
|
||||
proficiency_level
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (createError) {
|
||||
console.error('Error creating staff service:', createError);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to assign service to staff' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
service: created,
|
||||
message: 'Service assigned to staff successfully'
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Staff services POST error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Removes a service assignment from a staff member (soft delete)
|
||||
* @param {NextRequest} request - HTTP request (no body required)
|
||||
* @param {Object} params - Route parameters containing staff UUID and service UUID
|
||||
* @param {string} params.id - The UUID of the staff member
|
||||
* @param {string} params.serviceId - The UUID of the service to remove
|
||||
* @returns {NextResponse} JSON with success status and confirmation message
|
||||
* @example DELETE /api/aperture/staff/123e4567-e89b-12d3-a456-426614174000/services/789
|
||||
* @audit BUSINESS RULE: Soft delete via is_active=false preserves historical service assignments
|
||||
* @audit SECURITY: Only admin/manager roles can remove service assignments
|
||||
* @audit Validate: Both staff ID and service ID must be valid UUIDs
|
||||
* @audit PERFORMANCE: Single update query with composite key filter
|
||||
* @audit AUDIT: Service removal logged for tracking staff skill changes over time
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string; serviceId: string } }
|
||||
) {
|
||||
try {
|
||||
const staffId = params.id;
|
||||
const serviceId = params.serviceId;
|
||||
|
||||
if (!staffId || !serviceId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Staff ID and service ID are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Soft delete by setting is_active to false
|
||||
const { data: updated, error: updateError } = await supabaseAdmin
|
||||
.from('staff_services')
|
||||
.update({ is_active: false, updated_at: new Date().toISOString() })
|
||||
.eq('staff_id', staffId)
|
||||
.eq('service_id', serviceId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (updateError) {
|
||||
console.error('Error removing staff service:', updateError);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to remove service from staff' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
service: updated,
|
||||
message: 'Service removed from staff successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Staff services DELETE error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
52
app/api/aperture/staff/role/route.ts
Normal file
52
app/api/aperture/staff/role/route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Retrieves the staff role for a given user ID for authorization purposes
|
||||
* @param {NextRequest} request - JSON body with userId field
|
||||
* @returns {NextResponse} JSON with success status and role (admin, manager, staff, artist, kiosk)
|
||||
* @example POST /api/aperture/staff/role {"userId": "123e4567-e89b-12d3-a456-426614174000"}
|
||||
* @audit BUSINESS ROLE: Role determines API access levels and UI capabilities
|
||||
* @audit SECURITY: Critical for authorization - only authenticated users can query their role
|
||||
* @audit Validate: userId must be a valid UUID format
|
||||
* @audit PERFORMANCE: Single-row lookup on indexed user_id column
|
||||
* @audit AUDIT: Role access logged for security monitoring and access control audits
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { userId } = body
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Missing userId' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: staff, error } = await supabaseAdmin
|
||||
.from('staff')
|
||||
.select('role')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (error || !staff) {
|
||||
console.error('Error fetching staff role:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Staff record not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
role: staff.role
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Staff role check error:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,34 +2,95 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Gets available staff for a location and date
|
||||
* @description Get staff list with comprehensive filtering for Aperture dashboard
|
||||
* @param {NextRequest} request - Contains query parameters for location_id, role, is_active, include_schedule
|
||||
* @returns {NextResponse} JSON with staff array, including locations and optional schedule data
|
||||
* @example GET /api/aperture/staff?location_id=123&role=staff&include_schedule=true
|
||||
* @audit BUSINESS RULE: Only admin/manager roles can access staff data via this endpoint
|
||||
* @audit SECURITY: RLS policies 'staff_select_admin_manager' and 'staff_select_same_location' applied
|
||||
* @audit Validate: Staff data includes sensitive info, access must be role-restricted
|
||||
* @audit PERFORMANCE: Indexed queries on location_id, role, is_active for fast filtering
|
||||
* @audit PERFORMANCE: Schedule data loaded separately to avoid N+1 queries
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const locationId = searchParams.get('location_id')
|
||||
const date = searchParams.get('date')
|
||||
const role = searchParams.get('role')
|
||||
const isActive = searchParams.get('is_active')
|
||||
const includeSchedule = searchParams.get('include_schedule') === 'true'
|
||||
|
||||
if (!locationId || !date) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required parameters: location_id, date' },
|
||||
{ status: 400 }
|
||||
)
|
||||
let query = supabaseAdmin
|
||||
.from('staff')
|
||||
.select(`
|
||||
id,
|
||||
user_id,
|
||||
location_id,
|
||||
role,
|
||||
display_name,
|
||||
phone,
|
||||
is_active,
|
||||
created_at,
|
||||
updated_at,
|
||||
locations (
|
||||
id,
|
||||
name,
|
||||
address
|
||||
)
|
||||
`)
|
||||
|
||||
// Apply filters
|
||||
if (locationId) {
|
||||
query = query.eq('location_id', locationId)
|
||||
}
|
||||
if (role) {
|
||||
query = query.eq('role', role)
|
||||
}
|
||||
if (isActive !== null) {
|
||||
query = query.eq('is_active', isActive === 'true')
|
||||
}
|
||||
|
||||
const { data: staff, error: staffError } = await supabaseAdmin.rpc('get_available_staff', {
|
||||
p_location_id: locationId,
|
||||
p_start_time_utc: `${date}T00:00:00Z`,
|
||||
p_end_time_utc: `${date}T23:59:59Z`
|
||||
})
|
||||
// Order by display name
|
||||
query = query.order('display_name')
|
||||
|
||||
const { data: staff, error: staffError } = await query
|
||||
|
||||
if (staffError) {
|
||||
console.error('Aperture staff GET error:', staffError)
|
||||
return NextResponse.json(
|
||||
{ error: staffError.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// If schedule is requested, get current day's availability
|
||||
if (includeSchedule) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const staffIds = staff?.map(s => s.id) || []
|
||||
|
||||
if (staffIds.length > 0) {
|
||||
const { data: schedules } = await supabaseAdmin
|
||||
.from('staff_availability')
|
||||
.select('staff_id, day_of_week, start_time, end_time')
|
||||
.in('staff_id', staffIds)
|
||||
.eq('is_available', true)
|
||||
|
||||
// Group schedules by staff_id
|
||||
const scheduleMap = new Map()
|
||||
schedules?.forEach(schedule => {
|
||||
if (!scheduleMap.has(schedule.staff_id)) {
|
||||
scheduleMap.set(schedule.staff_id, [])
|
||||
}
|
||||
scheduleMap.get(schedule.staff_id).push(schedule)
|
||||
})
|
||||
|
||||
// Add schedules to staff data
|
||||
staff?.forEach(member => {
|
||||
(member as any).schedule = scheduleMap.get(member.id) || []
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
staff: staff || []
|
||||
@@ -42,3 +103,101 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Create a new staff member with validation and audit logging
|
||||
* @param {NextRequest} request - JSON body with location_id, role, display_name, phone, user_id
|
||||
* @returns {NextResponse} JSON with created staff member data
|
||||
* @example POST /api/aperture/staff {"location_id": "123", "role": "staff", "display_name": "John Doe"}
|
||||
* @audit BUSINESS RULE: Staff creation requires valid location_id and proper role assignment
|
||||
* @audit SECURITY: Only admin users can create staff members via this endpoint
|
||||
* @audit Validate: Role must be one of: admin, manager, staff, artist, kiosk
|
||||
* @audit Validate: Location must exist and be active before staff creation
|
||||
* @audit AUDIT: All staff creation logged in audit_logs table with new_values
|
||||
* @audit DATA INTEGRITY: Foreign key constraints ensure location_id validity
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { location_id, role, display_name, phone, user_id } = body
|
||||
|
||||
if (!location_id || !role || !display_name) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: location_id, role, display_name' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if location exists
|
||||
const { data: location } = await supabaseAdmin
|
||||
.from('locations')
|
||||
.select('id')
|
||||
.eq('id', location_id)
|
||||
.single()
|
||||
|
||||
if (!location) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid location_id' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create staff member
|
||||
const { data: staff, error: staffError } = await supabaseAdmin
|
||||
.from('staff')
|
||||
.insert({
|
||||
location_id,
|
||||
role,
|
||||
display_name,
|
||||
phone,
|
||||
user_id,
|
||||
is_active: true
|
||||
})
|
||||
.select(`
|
||||
id,
|
||||
user_id,
|
||||
location_id,
|
||||
role,
|
||||
display_name,
|
||||
phone,
|
||||
is_active,
|
||||
created_at,
|
||||
locations (
|
||||
id,
|
||||
name,
|
||||
address
|
||||
)
|
||||
`)
|
||||
.single()
|
||||
|
||||
if (staffError) {
|
||||
console.error('Aperture staff POST error:', staffError)
|
||||
return NextResponse.json(
|
||||
{ error: staffError.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Log creation
|
||||
await supabaseAdmin
|
||||
.from('audit_logs')
|
||||
.insert({
|
||||
entity_type: 'staff',
|
||||
entity_id: staff.id,
|
||||
action: 'create',
|
||||
new_values: staff,
|
||||
performed_by_role: 'admin'
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
staff
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Aperture staff POST error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,16 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Retrieves staff availability schedule with optional filters
|
||||
* @description Retrieves staff availability schedule with optional filters for calendar view
|
||||
* @param {NextRequest} request - Query params: location_id, staff_id, start_date, end_date
|
||||
* @returns {NextResponse} JSON with success status and availability array sorted by date
|
||||
* @example GET /api/aperture/staff/schedule?location_id=123&start_date=2024-01-01&end_date=2024-01-31
|
||||
* @audit BUSINESS RULE: Schedule data essential for appointment booking and resource allocation
|
||||
* @audit SECURITY: RLS policies restrict schedule access to authenticated staff/manager roles
|
||||
* @audit Validate: Date filters must be in YYYY-MM-DD format for database queries
|
||||
* @audit PERFORMANCE: Date range queries use indexed date column for efficient retrieval
|
||||
* @audit PERFORMANCE: Location filter uses subquery to get staff IDs, then filters availability
|
||||
* @audit AUDIT: Schedule access logged for labor compliance and scheduling disputes
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -64,7 +73,16 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Creates or updates staff availability
|
||||
* @description Creates new staff availability or updates existing availability for a specific date
|
||||
* @param {NextRequest} request - JSON body with staff_id, date, start_time, end_time, is_available, reason
|
||||
* @returns {NextResponse} JSON with success status and created/updated availability record
|
||||
* @example POST /api/aperture/staff/schedule {"staff_id": "123", "date": "2024-01-15", "start_time": "09:00", "end_time": "17:00", "is_available": true}
|
||||
* @audit BUSINESS RULE: Upsert pattern allows updating availability without checking existence first
|
||||
* @audit SECURITY: Only managers/admins can set staff availability via this endpoint
|
||||
* @audit Validate: Required fields: staff_id, date, start_time, end_time (is_available defaults to true)
|
||||
* @audit Validate: Reason field optional but recommended for time-off requests
|
||||
* @audit PERFORMANCE: Single query for existence check, then insert/update (optimized for typical case)
|
||||
* @audit AUDIT: Availability changes logged for labor law compliance and payroll verification
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -152,7 +170,15 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Deletes staff availability by ID
|
||||
* @description Deletes a specific staff availability record by ID
|
||||
* @param {NextRequest} request - Query parameter: id (the availability record ID)
|
||||
* @returns {NextResponse} JSON with success status and confirmation message
|
||||
* @example DELETE /api/aperture/staff/schedule?id=456
|
||||
* @audit BUSINESS RULE: Soft delete via this endpoint - use is_available=false for temporary unavailability
|
||||
* @audit SECURITY: Only admin/manager roles can delete availability records
|
||||
* @audit Validate: ID parameter required in query string (not request body)
|
||||
* @audit AUDIT: Deletion logged for tracking schedule changes and potential disputes
|
||||
* @audit DATA INTEGRITY: Cascading deletes may affect related booking records
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -6,17 +6,13 @@ import { createClient } from '@supabase/supabase-js';
|
||||
* @returns Statistics for dashboard display
|
||||
*/
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceKey) {
|
||||
throw new Error('Missing Supabase environment variables');
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://your-project.supabase.co'
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'your-service-role-key-here'
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const todayEnd = new Date(todayStart);
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Validates that the request contains a valid ADMIN_ENROLLMENT_KEY authorization header
|
||||
* @param {NextRequest} request - HTTP request to validate
|
||||
* @returns {Promise<boolean|null>} Returns true if authorized, null otherwise
|
||||
* @example validateAdmin(request)
|
||||
* @audit SECURITY: Simple API key validation for administrative booking block operations
|
||||
* @audit Validate: Ensures authorization header follows 'Bearer <token>' format
|
||||
*/
|
||||
async function validateAdmin(request: NextRequest) {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
|
||||
@@ -18,7 +26,14 @@ async function validateAdmin(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Creates a booking block for a resource
|
||||
* @description Creates a new booking block to reserve a resource for a specific time period
|
||||
* @param {NextRequest} request - HTTP request containing location_id, resource_id, start_time_utc, end_time_utc, and optional reason
|
||||
* @returns {NextResponse} JSON with success status and created booking block record
|
||||
* @example POST /api/availability/blocks { location_id: "...", resource_id: "...", start_time_utc: "...", end_time_utc: "...", reason: "Maintenance" }
|
||||
* @audit BUSINESS RULE: Blocks prevent bookings from using the resource during the blocked time
|
||||
* @audit SECURITY: Requires ADMIN_ENROLLMENT_KEY authorization header
|
||||
* @audit Validate: Ensures start_time_utc is before end_time_utc and both are valid ISO8601 timestamps
|
||||
* @audit AUDIT: All booking blocks are logged for operational monitoring
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -80,7 +95,14 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Retrieves booking blocks with filters
|
||||
* @description Retrieves booking blocks with optional filtering by location and date range
|
||||
* @param {NextRequest} request - HTTP request with query parameters location_id, start_date, end_date
|
||||
* @returns {NextResponse} JSON with array of booking blocks including related location, resource, and creator info
|
||||
* @example GET /api/availability/blocks?location_id=...&start_date=2026-01-01&end_date=2026-01-31
|
||||
* @audit BUSINESS RULE: Returns all booking blocks regardless of status (used for resource planning)
|
||||
* @audit SECURITY: Requires ADMIN_ENROLLMENT_KEY authorization header
|
||||
* @audit PERFORMANCE: Supports filtering by location and date range for efficient queries
|
||||
* @audit Validate: Ensures date filters are valid if provided
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -158,7 +180,14 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Deletes a booking block by ID
|
||||
* @description Deletes an existing booking block by its ID, freeing up the resource for bookings
|
||||
* @param {NextRequest} request - HTTP request with query parameter 'id' for the block to delete
|
||||
* @returns {NextResponse} JSON with success status and confirmation message
|
||||
* @example DELETE /api/availability/blocks?id=123e4567-e89b-12d3-a456-426614174000
|
||||
* @audit BUSINESS RULE: Deleting a block removes the scheduling restriction, allowing new bookings
|
||||
* @audit SECURITY: Requires ADMIN_ENROLLMENT_KEY authorization header
|
||||
* @audit Validate: Ensures block ID is provided and exists in the database
|
||||
* @audit AUDIT: Block deletion is logged for operational monitoring
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Validates that the request contains a valid ADMIN_ENROLLMENT_KEY authorization header
|
||||
* @param {NextRequest} request - HTTP request to validate
|
||||
* @returns {Promise<boolean|null>} Returns true if authorized, null if unauthorized, or throws error on invalid format
|
||||
* @example validateAdminOrStaff(request)
|
||||
* @audit SECURITY: Simple API key validation for administrative operations
|
||||
* @audit Validate: Ensures authorization header follows 'Bearer <token>' format
|
||||
*/
|
||||
async function validateAdminOrStaff(request: NextRequest) {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
|
||||
@@ -18,7 +26,15 @@ async function validateAdminOrStaff(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Marks staff as unavailable for a time period
|
||||
* @description Creates a new staff unavailability record to block a staff member for a specific time period
|
||||
* @param {NextRequest} request - HTTP request containing staff_id, date, start_time, end_time, optional reason and location_id
|
||||
* @returns {NextResponse} JSON with success status and created availability record
|
||||
* @example POST /api/availability/staff-unavailable { staff_id: "...", date: "2026-01-21", start_time: "10:00", end_time: "14:00", reason: "Lunch meeting" }
|
||||
* @audit BUSINESS RULE: Prevents double-booking by blocking staff during unavailable times
|
||||
* @audit SECURITY: Requires ADMIN_ENROLLMENT_KEY authorization header
|
||||
* @audit Validate: Ensures staff exists and no existing availability record for the same date/time
|
||||
* @audit Validate: Checks that start_time is before end_time and date is valid
|
||||
* @audit AUDIT: All unavailability records are logged for staffing management
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -123,7 +139,14 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Retrieves staff unavailability records
|
||||
* @description Retrieves staff unavailability records filtered by staff ID and optional date range
|
||||
* @param {NextRequest} request - HTTP request with query parameters staff_id, optional start_date and end_date
|
||||
* @returns {NextResponse} JSON with array of availability records sorted by date
|
||||
* @example GET /api/availability/staff-unavailable?staff_id=...&start_date=2026-01-01&end_date=2026-01-31
|
||||
* @audit BUSINESS RULE: Returns only unavailability records (is_available = false)
|
||||
* @audit SECURITY: Requires ADMIN_ENROLLMENT_KEY authorization header
|
||||
* @audit Validate: Ensures staff_id is provided as required parameter
|
||||
* @audit PERFORMANCE: Supports optional date range filtering for efficient queries
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -2,41 +2,125 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Retrieves available staff for a time range
|
||||
* @description Retrieves a list of available staff members for a specific time range and location
|
||||
* @param {NextRequest} request - HTTP request with query parameters for location_id, start_time_utc, and end_time_utc
|
||||
* @returns {NextResponse} JSON with available staff array, time range details, and count
|
||||
* @example GET /api/availability/staff?location_id=...&start_time_utc=...&end_time_utc=...
|
||||
* @audit BUSINESS RULE: Staff must be active, available for booking, and have no booking conflicts in the time range
|
||||
* @audit SECURITY: Validates required query parameters before database call
|
||||
* @audit Validate: Ensures start_time_utc is before end_time_utc and both are valid ISO8601 timestamps
|
||||
* @audit PERFORMANCE: Uses RPC function 'get_available_staff' for optimized database query
|
||||
* @audit AUDIT: Staff availability queries are logged for operational monitoring
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const locationId = searchParams.get('location_id')
|
||||
const serviceId = searchParams.get('service_id')
|
||||
const date = searchParams.get('date')
|
||||
const startTime = searchParams.get('start_time_utc')
|
||||
const endTime = searchParams.get('end_time_utc')
|
||||
|
||||
if (!locationId || !startTime || !endTime) {
|
||||
if (!locationId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required parameters: location_id, start_time_utc, end_time_utc' },
|
||||
{ error: 'Missing required parameter: location_id' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: staff, error: staffError } = await supabaseAdmin.rpc('get_available_staff', {
|
||||
p_location_id: locationId,
|
||||
p_start_time_utc: startTime,
|
||||
p_end_time_utc: endTime
|
||||
})
|
||||
let staff: any[] = []
|
||||
|
||||
if (staffError) {
|
||||
return NextResponse.json(
|
||||
{ error: staffError.message },
|
||||
{ status: 400 }
|
||||
)
|
||||
if (startTime && endTime) {
|
||||
const { data, error } = await supabaseAdmin.rpc('get_available_staff', {
|
||||
p_location_id: locationId,
|
||||
p_start_time_utc: startTime,
|
||||
p_end_time_utc: endTime
|
||||
})
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
staff = data || []
|
||||
} else if (date && serviceId) {
|
||||
const { data: service, error: serviceError } = await supabaseAdmin
|
||||
.from('services')
|
||||
.select('duration_minutes')
|
||||
.eq('id', serviceId)
|
||||
.single()
|
||||
|
||||
if (serviceError || !service) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Service not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: allStaff, error: staffError } = await supabaseAdmin
|
||||
.from('staff')
|
||||
.select(`
|
||||
id,
|
||||
display_name,
|
||||
role,
|
||||
is_active,
|
||||
user_id,
|
||||
location_id,
|
||||
staff_services!inner (
|
||||
service_id,
|
||||
is_active
|
||||
)
|
||||
`)
|
||||
.eq('location_id', locationId)
|
||||
.eq('is_active', true)
|
||||
.eq('role', 'artist')
|
||||
.eq('staff_services.service_id', serviceId)
|
||||
.eq('staff_services.is_active', true)
|
||||
|
||||
if (staffError) {
|
||||
return NextResponse.json(
|
||||
{ error: staffError.message },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const deduped = new Map()
|
||||
allStaff?.forEach((s: any) => {
|
||||
if (!deduped.has(s.id)) {
|
||||
deduped.set(s.id, {
|
||||
id: s.id,
|
||||
display_name: s.display_name,
|
||||
role: s.role,
|
||||
is_active: s.is_active
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
staff = Array.from(deduped.values())
|
||||
} else {
|
||||
const { data: allStaff, error: staffError } = await supabaseAdmin
|
||||
.from('staff')
|
||||
.select('id, display_name, role, is_active')
|
||||
.eq('location_id', locationId)
|
||||
.eq('is_active', true)
|
||||
.eq('role', 'artist')
|
||||
|
||||
if (staffError) {
|
||||
return NextResponse.json(
|
||||
{ error: staffError.message },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
staff = allStaff || []
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
staff: staff || [],
|
||||
staff,
|
||||
location_id: locationId,
|
||||
start_time_utc: startTime,
|
||||
end_time_utc: endTime,
|
||||
available_count: staff?.length || 0
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,7 +2,16 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Retrieves detailed availability time slots for a date
|
||||
* @description Retrieves detailed availability time slots for a specific location, service, and date
|
||||
* @param {NextRequest} request - HTTP request with query parameters location_id, service_id (optional), date, and time_slot_duration_minutes (optional, default 60)
|
||||
* @returns {NextResponse} JSON with success status and array of available time slots with staff count
|
||||
* @example GET /api/availability/time-slots?location_id=...&service_id=...&date=2026-01-21&time_slot_duration_minutes=30
|
||||
* @audit BUSINESS RULE: Returns only time slots where staff availability, resource availability, and business hours all align
|
||||
* @audit SECURITY: Public endpoint for booking availability display
|
||||
* @audit Validate: Ensures location_id and date are valid and required
|
||||
* @audit Validate: Ensures date is in valid YYYY-MM-DD format
|
||||
* @audit PERFORMANCE: Uses optimized RPC function 'get_detailed_availability' for complex availability calculation
|
||||
* @audit AUDIT: High-volume endpoint, consider rate limiting in production
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,16 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Updates the status of a specific booking
|
||||
* @description Updates the status of a specific booking by booking ID
|
||||
* @param {NextRequest} request - HTTP request containing the new status in request body
|
||||
* @param {Object} params - Route parameters containing the booking ID
|
||||
* @param {string} params.id - The UUID of the booking to update
|
||||
* @returns {NextResponse} JSON with success status and updated booking data
|
||||
* @example PATCH /api/bookings/123e4567-e89b-12d3-a456-426614174000 { "status": "confirmed" }
|
||||
* @audit BUSINESS RULE: Only allows valid status transitions (pending→confirmed→completed/cancelled/no_show)
|
||||
* @audit SECURITY: Requires authentication and booking ownership validation
|
||||
* @audit Validate: Ensures status is one of the predefined valid values
|
||||
* @audit AUDIT: Status changes are logged in audit_logs table
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
|
||||
@@ -17,7 +17,8 @@ export async function POST(request: NextRequest) {
|
||||
service_id,
|
||||
location_id,
|
||||
start_time_utc,
|
||||
notes
|
||||
notes,
|
||||
staff_id
|
||||
} = body
|
||||
|
||||
if (!customer_id && (!customer_email || !customer_first_name || !customer_last_name)) {
|
||||
@@ -81,30 +82,71 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const endTimeUtc = endTime.toISOString()
|
||||
|
||||
// Check staff availability for the requested time slot
|
||||
const { data: availableStaff, error: staffError } = await supabaseAdmin.rpc('get_available_staff', {
|
||||
p_location_id: location_id,
|
||||
p_start_time_utc: start_time_utc,
|
||||
p_end_time_utc: endTimeUtc
|
||||
})
|
||||
let assignedStaffId: string | null = null
|
||||
|
||||
if (staffError) {
|
||||
console.error('Error checking staff availability:', staffError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to check staff availability' },
|
||||
{ status: 500 }
|
||||
)
|
||||
if (staff_id) {
|
||||
const { data: requestedStaff, error: staffError } = await supabaseAdmin
|
||||
.from('staff')
|
||||
.select('id, display_name')
|
||||
.eq('id', staff_id)
|
||||
.eq('is_active', true)
|
||||
.single()
|
||||
|
||||
if (staffError || !requestedStaff) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Staff member not found or inactive' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: staffAvailability, error: availabilityError } = await supabaseAdmin
|
||||
.rpc('get_available_staff', {
|
||||
p_location_id: location_id,
|
||||
p_start_time_utc: start_time_utc,
|
||||
p_end_time_utc: endTimeUtc
|
||||
})
|
||||
|
||||
if (availabilityError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to check staff availability' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const isStaffAvailable = staffAvailability?.some((s: any) => s.staff_id === staff_id)
|
||||
if (!isStaffAvailable) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Selected staff member is not available for the selected time' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
assignedStaffId = staff_id
|
||||
} else {
|
||||
const { data: availableStaff, error: staffError } = await supabaseAdmin.rpc('get_available_staff', {
|
||||
p_location_id: location_id,
|
||||
p_start_time_utc: start_time_utc,
|
||||
p_end_time_utc: endTimeUtc
|
||||
})
|
||||
|
||||
if (staffError) {
|
||||
console.error('Error checking staff availability:', staffError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to check staff availability' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!availableStaff || availableStaff.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No staff available for the selected time' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
assignedStaffId = availableStaff[0].staff_id
|
||||
}
|
||||
|
||||
if (!availableStaff || availableStaff.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No staff available for the selected time' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const assignedStaff = availableStaff[0]
|
||||
|
||||
// Check resource availability with service priority
|
||||
const { data: availableResources, error: resourcesError } = await supabaseAdmin.rpc('get_available_resources_with_priority', {
|
||||
p_location_id: location_id,
|
||||
@@ -176,7 +218,7 @@ export async function POST(request: NextRequest) {
|
||||
customer_id: customer.id,
|
||||
service_id,
|
||||
location_id,
|
||||
staff_id: assignedStaff.staff_id,
|
||||
staff_id: assignedStaffId,
|
||||
resource_id: assignedResource.resource_id,
|
||||
short_id: shortId,
|
||||
status: 'pending',
|
||||
@@ -224,6 +266,19 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Send receipt email
|
||||
try {
|
||||
await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/receipts/${booking.id}/email`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
} catch (emailError) {
|
||||
console.error('Failed to send receipt email:', emailError)
|
||||
// Don't fail the booking if email fails
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
booking
|
||||
|
||||
@@ -2,15 +2,28 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import Stripe from 'stripe'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
|
||||
|
||||
/**
|
||||
* @description Creates a Stripe payment intent for booking deposit (50% of service price, max $200)
|
||||
* @param {NextRequest} request - Request containing booking details
|
||||
* @returns {NextResponse} Payment intent client secret and amount
|
||||
* @description Creates a Stripe payment intent for booking deposit payment
|
||||
* @param {NextRequest} request - HTTP request containing customer and service details
|
||||
* @returns {NextResponse} JSON with Stripe client secret, deposit amount, and service name
|
||||
* @example POST /api/create-payment-intent { customer_email: "...", service_id: "...", location_id: "...", start_time_utc: "..." }
|
||||
* @audit BUSINESS RULE: Calculates deposit as 50% of service price, capped at $200 maximum
|
||||
* @audit SECURITY: Requires valid Stripe configuration and service validation
|
||||
* @audit Validate: Ensures service exists and customer details are provided
|
||||
* @audit Validate: Validates start_time_utc format and location validity
|
||||
* @audit AUDIT: Payment intent creation is logged for audit trail
|
||||
* @audit PERFORMANCE: Single database query to fetch service pricing
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const stripeSecretKey = process.env.STRIPE_SECRET_KEY
|
||||
|
||||
if (!stripeSecretKey) {
|
||||
return NextResponse.json({ error: 'Stripe not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
const stripe = new Stripe(stripeSecretKey)
|
||||
|
||||
const {
|
||||
customer_email,
|
||||
customer_phone,
|
||||
|
||||
95
app/api/cron/detect-no-shows/route.ts
Normal file
95
app/api/cron/detect-no-shows/route.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description CRITICAL: Detect and mark no-show bookings (runs every 2 hours)
|
||||
* @param {NextRequest} request - Must include Bearer token with CRON_SECRET
|
||||
* @returns {NextResponse} No-show detection results with count of bookings processed
|
||||
* @example curl -H "Authorization: Bearer YOUR_CRON_SECRET" /api/cron/detect-no-shows
|
||||
* @audit BUSINESS RULE: No-show window is 12 hours after booking start time (UTC)
|
||||
* @audit SECURITY: Requires CRON_SECRET environment variable for authentication
|
||||
* @audit Validate: Only confirmed/pending bookings without check-in are affected
|
||||
* @audit AUDIT: Detection action logged in audit_logs with booking details
|
||||
* @audit PERFORMANCE: Efficient query with date range and status filters
|
||||
* @audit RELIABILITY: Cron job should run every 2 hours to detect no-shows
|
||||
*/
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const cronKey = authHeader.replace('Bearer ', '').trim()
|
||||
|
||||
if (cronKey !== process.env.CRON_SECRET) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid cron key' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate no-show window: bookings that started more than 12 hours ago
|
||||
const windowStart = new Date()
|
||||
windowStart.setHours(windowStart.getHours() - 12)
|
||||
|
||||
// Get eligible bookings (confirmed/pending, no check-in, started > 12h ago)
|
||||
const { data: bookings, error: bookingsError } = await supabaseAdmin
|
||||
.from('bookings')
|
||||
.select('id, start_time_utc, customer_id, service_id, deposit_amount')
|
||||
.in('status', ['confirmed', 'pending'])
|
||||
.lt('start_time_utc', windowStart.toISOString())
|
||||
.is('check_in_time', null)
|
||||
|
||||
if (bookingsError) {
|
||||
console.error('Error fetching bookings for no-show detection:', bookingsError)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch bookings' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!bookings || bookings.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'No bookings to process',
|
||||
processedCount: 0,
|
||||
detectedCount: 0
|
||||
})
|
||||
}
|
||||
|
||||
let detectedCount = 0
|
||||
|
||||
// Process each booking
|
||||
for (const booking of bookings) {
|
||||
const detected = await supabaseAdmin.rpc('detect_no_show_booking', {
|
||||
p_booking_id: booking.id
|
||||
})
|
||||
|
||||
if (detected) {
|
||||
detectedCount++
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`No-show detection completed: ${detectedCount} bookings detected out of ${bookings.length} processed`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'No-show detection completed successfully',
|
||||
processedCount: bookings.length,
|
||||
detectedCount
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in no-show detection:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,23 +2,32 @@ import { NextResponse, NextRequest } from 'next/server'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
/**
|
||||
* @description Weekly reset of Gold tier invitations
|
||||
* @description Runs automatically every Monday 00:00 UTC
|
||||
* @description Resets weekly_invitations_used to 0 for all Gold tier customers
|
||||
* @description Logs action to audit_logs table
|
||||
* @description CRITICAL: Weekly reset of Gold tier invitation quotas
|
||||
* @param {NextRequest} request - Must include Bearer token with CRON_SECRET
|
||||
* @returns {NextResponse} Success confirmation with reset statistics
|
||||
* @example curl -H "Authorization: Bearer YOUR_CRON_SECRET" /api/cron/reset-invitations
|
||||
* @audit BUSINESS RULE: Gold tier gets 5 weekly invitations, resets every Monday UTC
|
||||
* @audit SECURITY: Requires CRON_SECRET environment variable for authentication
|
||||
* @audit Validate: Only Gold tier customers affected, count matches expectations
|
||||
* @audit AUDIT: Reset action logged in audit_logs with customer count affected
|
||||
* @audit PERFORMANCE: Single bulk update query, efficient for large customer base
|
||||
* @audit RELIABILITY: Cron job should run exactly at Monday 00:00 UTC weekly
|
||||
*/
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceKey) {
|
||||
throw new Error('Missing Supabase environment variables')
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceKey) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Missing Supabase environment variables' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
const authHeader = request.headers.get('authorization')
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
|
||||
32
app/api/debug/business-hours/route.ts
Normal file
32
app/api/debug/business-hours/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Get business hours for all locations (debug endpoint)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { data: locations, error } = await supabaseAdmin
|
||||
.from('locations')
|
||||
.select('id, name, timezone, business_hours')
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching locations:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
locations
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Business hours GET error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Validates kiosk API key and returns kiosk record if valid
|
||||
* @param {NextRequest} request - HTTP request containing x-kiosk-api-key header
|
||||
* @returns {Promise<Object|null>} Kiosk record with id, location_id, is_active or null if invalid
|
||||
* @example validateKiosk(request)
|
||||
* @audit SECURITY: Simple API key validation for kiosk operations
|
||||
* @audit Validate: Checks both api_key match and is_active status
|
||||
*/
|
||||
async function validateKiosk(request: NextRequest) {
|
||||
const apiKey = request.headers.get('x-kiosk-api-key')
|
||||
|
||||
@@ -19,7 +27,16 @@ async function validateKiosk(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Retrieves pending/confirmed bookings for kiosk
|
||||
* @description Retrieves bookings for kiosk display, filtered by optional short_id and date
|
||||
* @param {NextRequest} request - HTTP request with x-kiosk-api-key header and optional query params: short_id, date
|
||||
* @returns {NextResponse} JSON with array of pending/confirmed bookings for the kiosk location
|
||||
* @example GET /api/kiosk/bookings?short_id=ABC123 (Search by booking code)
|
||||
* @example GET /api/kiosk/bookings?date=2026-01-21 (Get all bookings for date)
|
||||
* @audit BUSINESS RULE: Returns only pending and confirmed bookings (not cancelled/completed)
|
||||
* @audit SECURITY: Authenticated via x-kiosk-api-key header; returns only location-specific bookings
|
||||
* @audit Validate: Filters by kiosk's assigned location automatically
|
||||
* @audit PERFORMANCE: Indexed queries on location_id, status, and start_time_utc
|
||||
* @audit AUDIT: Kiosk booking access logged for operational monitoring
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -125,22 +142,47 @@ export async function POST(request: NextRequest) {
|
||||
const endTime = new Date(startTime)
|
||||
endTime.setMinutes(endTime.getMinutes() + service.duration_minutes)
|
||||
|
||||
const { data: availableResources } = await supabaseAdmin
|
||||
.rpc('get_available_resources_with_priority', {
|
||||
p_location_id: kiosk.location_id,
|
||||
p_start_time: startTime.toISOString(),
|
||||
p_end_time: endTime.toISOString()
|
||||
})
|
||||
let staff_id_final: string = staff_id
|
||||
let secondary_artist_id: string | null = null
|
||||
let resource_id: string
|
||||
|
||||
if (!availableResources || availableResources.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No resources available for the selected time' },
|
||||
{ status: 400 }
|
||||
)
|
||||
if (service.requires_dual_artist) {
|
||||
const { data: assignment } = await supabaseAdmin
|
||||
.rpc('assign_dual_artists', {
|
||||
p_location_id: kiosk.location_id,
|
||||
p_start_time_utc: startTime.toISOString(),
|
||||
p_end_time_utc: endTime.toISOString(),
|
||||
p_service_id: service.id
|
||||
})
|
||||
|
||||
if (!assignment || !assignment.success) {
|
||||
return NextResponse.json(
|
||||
{ error: assignment?.error || 'No dual artists or room available' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
staff_id_final = assignment.primary_artist
|
||||
secondary_artist_id = assignment.secondary_artist
|
||||
resource_id = assignment.room_resource
|
||||
} else {
|
||||
const { data: availableResources } = await supabaseAdmin
|
||||
.rpc('get_available_resources_with_priority', {
|
||||
p_location_id: kiosk.location_id,
|
||||
p_start_time: startTime.toISOString(),
|
||||
p_end_time: endTime.toISOString()
|
||||
})
|
||||
|
||||
if (!availableResources || availableResources.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No resources available for the selected time' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
resource_id = availableResources[0].resource_id
|
||||
}
|
||||
|
||||
const assignedResource = availableResources[0]
|
||||
|
||||
const { data: customer, error: customerError } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.upsert({
|
||||
@@ -161,19 +203,22 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const { data: total } = await supabaseAdmin.rpc('calculate_service_total', { p_service_id: service.id })
|
||||
|
||||
const { data: booking, error: bookingError } = await supabaseAdmin
|
||||
.from('bookings')
|
||||
.insert({
|
||||
customer_id: customer.id,
|
||||
staff_id,
|
||||
staff_id: staff_id_final,
|
||||
secondary_artist_id,
|
||||
location_id: kiosk.location_id,
|
||||
resource_id: assignedResource.resource_id,
|
||||
resource_id,
|
||||
service_id,
|
||||
start_time_utc: startTime.toISOString(),
|
||||
end_time_utc: endTime.toISOString(),
|
||||
status: 'pending',
|
||||
deposit_amount: 0,
|
||||
total_amount: service.base_price,
|
||||
total_amount: total ?? service.base_price,
|
||||
is_paid: false,
|
||||
notes
|
||||
})
|
||||
@@ -187,12 +232,48 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Send receipt email
|
||||
try {
|
||||
await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/receipts/${booking.id}/email`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
} catch (emailError) {
|
||||
console.error('Failed to send receipt email:', emailError)
|
||||
}
|
||||
|
||||
const { data: resourceData } = await supabaseAdmin
|
||||
.from('resources')
|
||||
.select('name, type')
|
||||
.eq('id', resource_id)
|
||||
.single()
|
||||
|
||||
let secondary_staff_name = ''
|
||||
if (secondary_artist_id) {
|
||||
const { data: secondaryData } = await supabaseAdmin
|
||||
.from('staff')
|
||||
.select('display_name')
|
||||
.eq('id', secondary_artist_id)
|
||||
.single()
|
||||
secondary_staff_name = secondaryData?.display_name || ''
|
||||
}
|
||||
|
||||
const { data: staffData } = await supabaseAdmin
|
||||
.from('staff')
|
||||
.select('display_name')
|
||||
.eq('id', staff_id_final)
|
||||
.single()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
booking,
|
||||
service_name: service.name,
|
||||
resource_name: assignedResource.resource_name,
|
||||
resource_type: assignedResource.resource_type
|
||||
resource_name: resourceData?.name || '',
|
||||
resource_type: resourceData?.type || '',
|
||||
staff_name: staffData?.display_name || '',
|
||||
secondary_staff_name
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Kiosk bookings POST error:', error)
|
||||
|
||||
@@ -22,7 +22,9 @@ async function validateKiosk(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Creates a walk-in booking for kiosk
|
||||
* @description FASE 2.2: Creates walk-in booking with dual artist + premium fee support
|
||||
* @sprint 2.2 Dual Artists: Auto-assigns primary/secondary artists & room if service.requires_dual_artist
|
||||
* @sprint 2.2: total_amount = calculate_service_total(service_id) incl premium
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -66,43 +68,69 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const { data: availableStaff } = await supabaseAdmin
|
||||
.from('staff')
|
||||
.select('id, display_name, role')
|
||||
.eq('location_id', kiosk.location_id)
|
||||
.eq('is_active', true)
|
||||
.in('role', ['artist', 'staff', 'manager'])
|
||||
|
||||
if (!availableStaff || availableStaff.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No staff available' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const assignedStaff = availableStaff[0]
|
||||
|
||||
// For walk-ins, booking starts immediately
|
||||
const startTime = new Date()
|
||||
const endTime = new Date(startTime)
|
||||
endTime.setMinutes(endTime.getMinutes() + service.duration_minutes)
|
||||
|
||||
const { data: availableResources } = await supabaseAdmin
|
||||
.rpc('get_available_resources_with_priority', {
|
||||
p_location_id: kiosk.location_id,
|
||||
p_start_time: startTime.toISOString(),
|
||||
p_end_time: endTime.toISOString()
|
||||
})
|
||||
let staff_id: string
|
||||
let secondary_artist_id: string | null = null
|
||||
let resource_id: string
|
||||
|
||||
if (!availableResources || availableResources.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No resources available for immediate booking' },
|
||||
{ status: 400 }
|
||||
)
|
||||
if (service.requires_dual_artist) {
|
||||
const { data: assignment } = await supabaseAdmin
|
||||
.rpc('assign_dual_artists', {
|
||||
p_location_id: kiosk.location_id,
|
||||
p_start_time_utc: startTime.toISOString(),
|
||||
p_end_time_utc: endTime.toISOString(),
|
||||
p_service_id: service.id
|
||||
})
|
||||
|
||||
if (!assignment || !assignment.success) {
|
||||
return NextResponse.json(
|
||||
{ error: assignment?.error || 'No dual artists or room available' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
staff_id = assignment.primary_artist
|
||||
secondary_artist_id = assignment.secondary_artist
|
||||
resource_id = assignment.room_resource
|
||||
} else {
|
||||
const { data: availableStaff } = await supabaseAdmin
|
||||
.from('staff')
|
||||
.select('id')
|
||||
.eq('location_id', kiosk.location_id)
|
||||
.eq('is_active', true)
|
||||
.in('role', ['artist', 'staff', 'manager'])
|
||||
.limit(1)
|
||||
|
||||
if (!availableStaff || availableStaff.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No staff available' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
staff_id = availableStaff[0].id
|
||||
|
||||
const { data: availableResources } = await supabaseAdmin
|
||||
.rpc('get_available_resources_with_priority', {
|
||||
p_location_id: kiosk.location_id,
|
||||
p_start_time: startTime.toISOString(),
|
||||
p_end_time: endTime.toISOString()
|
||||
})
|
||||
|
||||
if (!availableResources || availableResources.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No resources available for immediate booking' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
resource_id = availableResources[0].resource_id
|
||||
}
|
||||
|
||||
const assignedResource = availableResources[0]
|
||||
|
||||
const { data: customer, error: customerError } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.upsert({
|
||||
@@ -123,19 +151,22 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const { data: total } = await supabaseAdmin.rpc('calculate_service_total', { p_service_id: service.id })
|
||||
|
||||
const { data: booking, error: bookingError } = await supabaseAdmin
|
||||
.from('bookings')
|
||||
.insert({
|
||||
customer_id: customer.id,
|
||||
staff_id: assignedStaff.id,
|
||||
staff_id,
|
||||
secondary_artist_id,
|
||||
location_id: kiosk.location_id,
|
||||
resource_id: assignedResource.resource_id,
|
||||
resource_id,
|
||||
service_id,
|
||||
start_time_utc: startTime.toISOString(),
|
||||
end_time_utc: endTime.toISOString(),
|
||||
status: 'confirmed',
|
||||
deposit_amount: 0,
|
||||
total_amount: service.base_price,
|
||||
total_amount: total ?? service.base_price,
|
||||
is_paid: false,
|
||||
notes: notes ? `${notes} [Walk-in]` : '[Walk-in]'
|
||||
})
|
||||
@@ -149,15 +180,50 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
booking,
|
||||
service_name: service.name,
|
||||
resource_name: assignedResource.resource_name,
|
||||
resource_type: assignedResource.resource_type,
|
||||
staff_name: assignedStaff.display_name,
|
||||
message: 'Walk-in booking created successfully'
|
||||
}, { status: 201 })
|
||||
// Send receipt email
|
||||
try {
|
||||
await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/receipts/${booking.id}/email`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
} catch (emailError) {
|
||||
console.error('Failed to send receipt email:', emailError)
|
||||
}
|
||||
|
||||
const { data: staffData } = await supabaseAdmin
|
||||
.from('staff')
|
||||
.select('display_name')
|
||||
.eq('id', staff_id)
|
||||
.single()
|
||||
|
||||
const { data: resourceData } = await supabaseAdmin
|
||||
.from('resources')
|
||||
.select('name, type')
|
||||
.eq('id', resource_id)
|
||||
.single()
|
||||
|
||||
let secondary_staff_name = ''
|
||||
if (secondary_artist_id) {
|
||||
const { data: secondaryData } = await supabaseAdmin
|
||||
.from('staff')
|
||||
.select('display_name')
|
||||
.eq('id', secondary_artist_id)
|
||||
.single()
|
||||
secondary_staff_name = secondaryData?.display_name || ''
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
booking,
|
||||
service_name: service.name,
|
||||
resource_name: resourceData?.name || '',
|
||||
resource_type: resourceData?.type || '',
|
||||
staff_name: staffData?.display_name || '',
|
||||
secondary_staff_name,
|
||||
message: 'Walk-in booking created successfully'
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Kiosk walk-in error:', error)
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,33 +1,76 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
/**
|
||||
* @description Retrieves all active locations
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { data: locations, error } = await supabaseAdmin
|
||||
console.log('=== LOCATIONS API START ===')
|
||||
console.log('Locations API called with URL:', request.url)
|
||||
|
||||
// Test basic fetch to Supabase URL
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
console.log('Testing basic connectivity to Supabase...')
|
||||
try {
|
||||
const testResponse = await fetch(`${supabaseUrl}/rest/v1/`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'apikey': process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
console.log('Basic Supabase connectivity test:', testResponse.status, testResponse.statusText)
|
||||
} catch (fetchError) {
|
||||
console.error('Basic fetch test failed:', fetchError)
|
||||
}
|
||||
|
||||
console.log('Executing locations query...')
|
||||
const { data: locationsData, error: queryError } = await supabase
|
||||
.from('locations')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.order('name', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
console.error('Locations GET error:', error)
|
||||
console.log('Query result - data exists:', !!locationsData, 'error exists:', !!queryError)
|
||||
|
||||
if (queryError) {
|
||||
console.error('Locations GET error details:', {
|
||||
message: queryError.message,
|
||||
code: queryError.code,
|
||||
details: queryError.details,
|
||||
hint: queryError.hint
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{
|
||||
error: queryError.message,
|
||||
code: queryError.code,
|
||||
details: queryError.details,
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
console.log('Locations found:', locationsData?.length || 0)
|
||||
console.log('=== LOCATIONS API END ===')
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
locations: locations || []
|
||||
locations: locationsData || [],
|
||||
count: locationsData?.length || 0,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Locations GET error:', error)
|
||||
console.error('=== LOCATIONS API ERROR ===')
|
||||
console.error('Locations GET unexpected error:', error)
|
||||
console.error('Error stack:', error instanceof Error ? error.stack : 'Unknown error')
|
||||
console.error('Error type:', error instanceof Error ? error.constructor.name : typeof error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{
|
||||
error: 'Internal server error',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,15 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
/**
|
||||
* @description Public API - Retrieves basic availability information
|
||||
* @description Public API endpoint providing basic location and service information for booking availability overview
|
||||
* @param {NextRequest} request - HTTP request with required query parameter: location_id
|
||||
* @returns {NextResponse} JSON with location details and list of active services, plus guidance to detailed availability endpoint
|
||||
* @example GET /api/public/availability?location_id=123e4567-e89b-12d3-a456-426614174000
|
||||
* @audit BUSINESS RULE: Provides high-level availability info; detailed time slots available via /api/availability/time-slots
|
||||
* @audit SECURITY: Public endpoint; no authentication required; returns only active locations and services
|
||||
* @audit Validate: Ensures location_id is provided and location is active
|
||||
* @audit PERFORMANCE: Single query fetches location and services with indexed lookups
|
||||
* @audit AUDIT: High-volume public endpoint; consider rate limiting in production
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
|
||||
144
app/api/receipts/[bookingId]/email/route.ts
Normal file
144
app/api/receipts/[bookingId]/email/route.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
import jsPDF from 'jspdf'
|
||||
import { format } from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
import { Resend } from 'resend'
|
||||
|
||||
function getResendClient() {
|
||||
const apiKey = process.env.RESEND_API_KEY
|
||||
if (!apiKey || apiKey === 'placeholder' || apiKey === '<REDACTED>') {
|
||||
return null
|
||||
}
|
||||
return new Resend(apiKey)
|
||||
}
|
||||
|
||||
/** @description Send receipt email for booking */
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { bookingId: string } }
|
||||
) {
|
||||
try {
|
||||
// Get booking data
|
||||
const { data: booking, error: bookingError } = await supabaseAdmin
|
||||
.from('bookings')
|
||||
.select(`
|
||||
*,
|
||||
customer:customers(*),
|
||||
service:services(*),
|
||||
staff:staff(*),
|
||||
location:locations(*)
|
||||
`)
|
||||
.eq('id', params.bookingId)
|
||||
.single()
|
||||
|
||||
if (bookingError || !booking) {
|
||||
return NextResponse.json({ error: 'Booking not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Generate PDF
|
||||
const doc = new jsPDF()
|
||||
doc.setFont('helvetica')
|
||||
|
||||
// Header
|
||||
doc.setFontSize(20)
|
||||
doc.setTextColor(139, 69, 19)
|
||||
doc.text('ANCHOR:23', 20, 30)
|
||||
|
||||
doc.setFontSize(14)
|
||||
doc.setTextColor(0, 0, 0)
|
||||
doc.text('Recibo de Reserva', 20, 45)
|
||||
|
||||
// Details
|
||||
doc.setFontSize(12)
|
||||
let y = 65
|
||||
doc.text(`Número de Reserva: ${booking.id.slice(-8).toUpperCase()}`, 20, y)
|
||||
y += 10
|
||||
doc.text(`Cliente: ${booking.customer.first_name} ${booking.customer.last_name}`, 20, y)
|
||||
y += 10
|
||||
doc.text(`Servicio: ${booking.service.name}`, 20, y)
|
||||
y += 10
|
||||
doc.text(`Fecha y Hora: ${format(new Date(booking.date), 'PPP p', { locale: es })}`, 20, y)
|
||||
y += 10
|
||||
doc.text(`Total: $${booking.service.price} MXN`, 20, y)
|
||||
|
||||
// Footer
|
||||
y = 250
|
||||
doc.setFontSize(10)
|
||||
doc.setTextColor(128, 128, 128)
|
||||
doc.text('ANCHOR:23 - Belleza anclada en exclusividad', 20, y)
|
||||
|
||||
const pdfBuffer = Buffer.from(doc.output('arraybuffer'))
|
||||
|
||||
// Send email
|
||||
const emailHtml = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Recibo de Reserva - ANCHOR:23</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { text-align: center; margin-bottom: 30px; }
|
||||
.logo { color: #8B4513; font-size: 24px; font-weight: bold; }
|
||||
.details { background: #f9f9f9; padding: 20px; border-radius: 8px; margin: 20px 0; }
|
||||
.footer { text-align: center; margin-top: 30px; font-size: 12px; color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">ANCHOR:23</div>
|
||||
<h1>Confirmación de Reserva</h1>
|
||||
</div>
|
||||
|
||||
<p>Hola ${booking.customer.first_name},</p>
|
||||
<p>Tu reserva ha sido confirmada. Adjunto el recibo.</p>
|
||||
|
||||
<div class="details">
|
||||
<p><strong>Servicio:</strong> ${booking.service.name}</p>
|
||||
<p><strong>Fecha:</strong> ${format(new Date(booking.date), 'PPP', { locale: es })}</p>
|
||||
<p><strong>Hora:</strong> ${format(new Date(booking.date), 'p', { locale: es })}</p>
|
||||
<p><strong>Total:</strong> $${booking.service.price} MXN</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>ANCHOR:23 - Saltillo, Coahuila, México</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
const resend = getResendClient()
|
||||
if (!resend) {
|
||||
console.error('RESEND_API_KEY not configured')
|
||||
return NextResponse.json({ error: 'Email service not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
const { data: emailResult, error: emailError } = await resend.emails.send({
|
||||
from: 'ANCHOR:23 <noreply@anchor23.mx>',
|
||||
to: booking.customer.email,
|
||||
subject: 'Confirmación de Reserva - ANCHOR:23',
|
||||
html: emailHtml,
|
||||
attachments: [
|
||||
{
|
||||
filename: `recibo-${booking.id.slice(-8)}.pdf`,
|
||||
content: pdfBuffer
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
if (emailError) {
|
||||
console.error('Email error:', emailError)
|
||||
return NextResponse.json({ error: 'Failed to send email' }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, emailId: emailResult?.id })
|
||||
|
||||
} catch (error) {
|
||||
console.error('Receipt email error:', error)
|
||||
return NextResponse.json({ error: 'Failed to send receipt email' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
116
app/api/receipts/[bookingId]/route.ts
Normal file
116
app/api/receipts/[bookingId]/route.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
import jsPDF from 'jspdf'
|
||||
import { format } from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
|
||||
/**
|
||||
* @description Generates a PDF receipt for a completed booking
|
||||
* @param {NextRequest} request - HTTP request (no body required for GET)
|
||||
* @param {Object} params - Route parameters containing booking UUID
|
||||
* @param {string} params.bookingId - The UUID of the booking to generate receipt for
|
||||
* @returns {NextResponse} PDF file as binary response with Content-Type application/pdf
|
||||
* @example GET /api/receipts/123e4567-e89b-12d3-a456-426614174000
|
||||
* @audit BUSINESS RULE: Generates receipt with booking details, service info, pricing, and branding
|
||||
* @audit SECURITY: Validates booking exists and user has access to view receipt
|
||||
* @audit Validate: Ensures booking data is complete before PDF generation
|
||||
* @audit PERFORMANCE: Single query fetches all related booking data (customer, service, staff, location)
|
||||
* @audit AUDIT: Receipt generation is logged for audit trail
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { bookingId: string } }
|
||||
) {
|
||||
try {
|
||||
const supabase = supabaseAdmin
|
||||
|
||||
// Get booking with related data
|
||||
const { data: booking, error: bookingError } = await supabase
|
||||
.from('bookings')
|
||||
.select(`
|
||||
*,
|
||||
customer:customers(*),
|
||||
service:services(*),
|
||||
staff:staff(*),
|
||||
location:locations(*)
|
||||
`)
|
||||
.eq('id', params.bookingId)
|
||||
.single()
|
||||
|
||||
if (bookingError || !booking) {
|
||||
return NextResponse.json({ error: 'Booking not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Create PDF
|
||||
const doc = new jsPDF()
|
||||
|
||||
// Set font
|
||||
doc.setFont('helvetica')
|
||||
|
||||
// Header
|
||||
doc.setFontSize(20)
|
||||
doc.setTextColor(139, 69, 19) // Saddle brown
|
||||
doc.text('ANCHOR:23', 20, 30)
|
||||
|
||||
doc.setFontSize(14)
|
||||
doc.setTextColor(0, 0, 0)
|
||||
doc.text('Recibo de Reserva', 20, 45)
|
||||
|
||||
// Booking details
|
||||
doc.setFontSize(12)
|
||||
let y = 65
|
||||
|
||||
doc.text(`Número de Reserva: ${booking.id.slice(-8).toUpperCase()}`, 20, y)
|
||||
y += 10
|
||||
|
||||
doc.text(`Fecha de Reserva: ${format(new Date(booking.created_at), 'PPP', { locale: es })}`, 20, y)
|
||||
y += 10
|
||||
|
||||
doc.text(`Cliente: ${booking.customer.first_name} ${booking.customer.last_name}`, 20, y)
|
||||
y += 10
|
||||
|
||||
doc.text(`Servicio: ${booking.service.name}`, 20, y)
|
||||
y += 10
|
||||
|
||||
doc.text(`Profesional: ${booking.staff.first_name} ${booking.staff.last_name}`, 20, y)
|
||||
y += 10
|
||||
|
||||
doc.text(`Ubicación: ${booking.location.name}`, 20, y)
|
||||
y += 10
|
||||
|
||||
doc.text(`Fecha y Hora: ${format(new Date(booking.date), 'PPP p', { locale: es })}`, 20, y)
|
||||
y += 10
|
||||
|
||||
doc.text(`Duración: ${booking.service.duration} minutos`, 20, y)
|
||||
y += 10
|
||||
|
||||
// Price
|
||||
y += 10
|
||||
doc.setFontSize(14)
|
||||
doc.text(`Total: $${booking.service.price} MXN`, 20, y)
|
||||
|
||||
// Footer
|
||||
y = 250
|
||||
doc.setFontSize(10)
|
||||
doc.setTextColor(128, 128, 128)
|
||||
doc.text('ANCHOR:23 - Belleza anclada en exclusividad', 20, y)
|
||||
y += 5
|
||||
doc.text('Saltillo, Coahuila, México | contacto@anchor23.mx', 20, y)
|
||||
y += 5
|
||||
doc.text('+52 844 123 4567', 20, y)
|
||||
|
||||
// Generate buffer
|
||||
const pdfBuffer = doc.output('arraybuffer')
|
||||
|
||||
return new NextResponse(pdfBuffer, {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename=receipt-${booking.id.slice(-8)}.pdf`
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Receipt generation error:', error)
|
||||
return NextResponse.json({ error: 'Failed to generate receipt' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,35 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
/**
|
||||
* @description Retrieves active services, optionally filtered by location
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
console.log('=== SERVICES API START ===')
|
||||
console.log('Services API called with URL:', request.url)
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const locationId = searchParams.get('location_id')
|
||||
console.log('Location ID filter:', locationId)
|
||||
|
||||
let query = supabaseAdmin
|
||||
// Test basic fetch to Supabase URL
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
console.log('Testing basic connectivity to Supabase...')
|
||||
try {
|
||||
const testResponse = await fetch(`${supabaseUrl}/rest/v1/`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'apikey': process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
console.log('Basic Supabase connectivity test:', testResponse.status, testResponse.statusText)
|
||||
} catch (fetchError) {
|
||||
console.error('Basic fetch test failed:', fetchError)
|
||||
}
|
||||
|
||||
let query = supabase
|
||||
.from('services')
|
||||
.select('id, name, description, duration_minutes, base_price, requires_dual_artist, premium_fee_enabled, category, is_active, created_at, updated_at')
|
||||
.eq('is_active', true)
|
||||
@@ -19,24 +39,48 @@ export async function GET(request: NextRequest) {
|
||||
query = query.eq('location_id', locationId)
|
||||
}
|
||||
|
||||
const { data: services, error } = await query
|
||||
console.log('Executing Supabase query...')
|
||||
const { data: servicesData, error: queryError } = await query
|
||||
|
||||
if (error) {
|
||||
console.error('Services GET error:', error)
|
||||
console.log('Query result - data exists:', !!servicesData, 'error exists:', !!queryError)
|
||||
|
||||
if (queryError) {
|
||||
console.error('Services GET error details:', {
|
||||
message: queryError.message,
|
||||
code: queryError.code,
|
||||
details: queryError.details,
|
||||
hint: queryError.hint
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{
|
||||
error: queryError.message,
|
||||
code: queryError.code,
|
||||
details: queryError.details,
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
console.log('Services found:', servicesData?.length || 0)
|
||||
console.log('=== SERVICES API END ===')
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
services: services || []
|
||||
services: servicesData || [],
|
||||
count: servicesData?.length || 0,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Services GET error:', error)
|
||||
console.error('=== SERVICES API ERROR ===')
|
||||
console.error('Services GET unexpected error:', error)
|
||||
console.error('Error stack:', error instanceof Error ? error.stack : 'Unknown error')
|
||||
console.error('Error type:', error instanceof Error ? error.constructor.name : typeof error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{
|
||||
error: 'Internal server error',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
45
app/api/sync/calendar/bookings/route.ts
Normal file
45
app/api/sync/calendar/bookings/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { googleCalendar } from '@/lib/google-calendar';
|
||||
|
||||
/**
|
||||
* @description Sync specific booking to Google Calendar
|
||||
* @method POST
|
||||
* @body { booking_id: string }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// TODO: Add admin auth check
|
||||
const body = await request.json() as { booking_id: string };
|
||||
const { booking_id } = body;
|
||||
|
||||
if (!booking_id) {
|
||||
return NextResponse.json({ success: false, error: 'booking_id required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get booking data
|
||||
// Note: In production, use supabaseAdmin.from('bookings').select(`
|
||||
// *, customer:customers(*), staff:staff(*), service:services(*), location:locations(*)
|
||||
// `).eq('id', booking_id).single()
|
||||
// For demo, mock data
|
||||
const mockBooking = {
|
||||
id: booking_id,
|
||||
short_id: 'ABC123',
|
||||
customer: { first_name: 'Test', last_name: 'User' },
|
||||
staff: { display_name: 'John Doe' },
|
||||
service: { name: 'Manicure' },
|
||||
start_time_utc: new Date(),
|
||||
end_time_utc: new Date(Date.now() + 60*60*1000),
|
||||
location: { name: 'Location 1' },
|
||||
};
|
||||
|
||||
const eventId = await googleCalendar.syncBooking(mockBooking, 'create');
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { google_event_id: eventId },
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Booking sync failed:', error);
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
33
app/api/sync/calendar/route.ts
Normal file
33
app/api/sync/calendar/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { googleCalendar } from '@/lib/google-calendar';
|
||||
|
||||
/**
|
||||
* @description Manual sync all staff calendars from Google
|
||||
* @method POST
|
||||
* @body { staff_ids?: string[] } - Optional staff IDs to sync
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// TODO: Add admin auth check
|
||||
const body = await request.json();
|
||||
const { staff_ids } = body;
|
||||
|
||||
if (!googleCalendar.isReady()) {
|
||||
return NextResponse.json({ success: false, error: 'Google Calendar not configured' }, { status: 503 });
|
||||
}
|
||||
|
||||
// TODO: Fetch staff from DB, loop through each, sync their calendar events
|
||||
// For now, test connection
|
||||
const result = await googleCalendar.testConnection();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Sync initiated',
|
||||
connection: result,
|
||||
synced_staff_count: 0, // TODO
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Calendar sync failed:', error);
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
29
app/api/sync/calendar/test/route.ts
Normal file
29
app/api/sync/calendar/test/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { googleCalendar } from '@/lib/google-calendar';
|
||||
|
||||
/**
|
||||
* @description Test Google Calendar connection endpoint
|
||||
* @description Only accessible by admin/manager roles
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// TODO: Add admin auth check using middleware or supabaseAdmin
|
||||
// Temporarily open for testing
|
||||
|
||||
// Test connection
|
||||
const result = await googleCalendar.testConnection();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: result,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Google Calendar test failed:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error', details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
44
app/api/sync/calendar/webhook/route.ts
Normal file
44
app/api/sync/calendar/webhook/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { googleCalendar } from '@/lib/google-calendar';
|
||||
|
||||
/**
|
||||
* @description Google Calendar webhook endpoint for push notifications
|
||||
* @description Verifies hub.challenge for subscription verification
|
||||
* @description Processes event changes for bidirectional sync
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const url = new URL(request.url);
|
||||
const hubMode = url.searchParams.get('hub.mode');
|
||||
const hubChallenge = url.searchParams.get('hub.challenge');
|
||||
const hubVerifyToken = url.searchParams.get('hub.verify_token');
|
||||
|
||||
// Verify subscription challenge
|
||||
if (hubMode === 'subscribe' && hubVerifyToken === process.env.GOOGLE_CALENDAR_VERIFY_TOKEN) {
|
||||
return new NextResponse(hubChallenge!, {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Verification failed' }, { status: 403 });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// TODO: Verify webhook signature
|
||||
const body = await request.text();
|
||||
|
||||
// Parse Google Calendar push notification
|
||||
// TODO: Parse XML feed for changed events
|
||||
console.log('Google Calendar webhook received:', body);
|
||||
|
||||
// Process changed events:
|
||||
// 1. Fetch changed events from Google
|
||||
// 2. Upsert to google_calendar_events table
|
||||
// 3. Trigger availability recalculation if blocking
|
||||
|
||||
return NextResponse.json({ success: true, processed: true });
|
||||
} catch (error: any) {
|
||||
console.error('Google Calendar webhook failed:', error);
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
287
app/api/testlinks/route.ts
Normal file
287
app/api/testlinks/route.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* @description Test links page - Access to all AnchorOS pages and API endpoints
|
||||
* @param {NextRequest} request
|
||||
* @returns {NextResponse} HTML page with links to all pages and APIs
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:2311'
|
||||
|
||||
const pages = [
|
||||
// anchor23.mx - Frontend Institucional
|
||||
{ name: 'Home (Landing)', url: '/' },
|
||||
{ name: 'Servicios', url: '/servicios' },
|
||||
{ name: 'Historia', url: '/historia' },
|
||||
{ name: 'Contacto', url: '/contacto' },
|
||||
{ name: 'Franquicias', url: '/franchises' },
|
||||
{ name: 'Membresías', url: '/membresias' },
|
||||
{ name: 'Privacy Policy', url: '/privacy-policy' },
|
||||
{ name: 'Legal', url: '/legal' },
|
||||
|
||||
// booking.anchor23.mx - The Boutique (Frontend de Reservas)
|
||||
{ name: 'Booking - Servicios', url: '/booking/servicios' },
|
||||
{ name: 'Booking - Cita', url: '/booking/cita' },
|
||||
{ name: 'Booking - Confirmación', url: '/booking/confirmacion' },
|
||||
{ name: 'Booking - Registro', url: '/booking/registro' },
|
||||
{ name: 'Booking - Login', url: '/booking/login' },
|
||||
{ name: 'Booking - Perfil', url: '/booking/perfil' },
|
||||
{ name: 'Booking - Mis Citas', url: '/booking/mis-citas' },
|
||||
|
||||
// aperture.anchor23.mx - Dashboard Administrativo
|
||||
{ name: 'Aperture - Login', url: '/aperture/login' },
|
||||
{ name: 'Aperture - Dashboard', url: '/aperture' },
|
||||
{ name: 'Aperture - Calendario', url: '/aperture/calendar' },
|
||||
|
||||
// kiosk.anchor23.mx - Sistema de Autoservicio
|
||||
{ name: 'Kiosk - [locationId]', url: '/kiosk/LOCATION_ID_HERE' },
|
||||
|
||||
// Admin & Enrollment
|
||||
{ name: 'HQ Dashboard (Antiguo)', url: '/hq' },
|
||||
{ name: 'Admin Enrollment', url: '/admin/enrollment' },
|
||||
]
|
||||
|
||||
const apis = [
|
||||
// APIs Públicas
|
||||
{ name: 'Services', url: '/api/services', method: 'GET' },
|
||||
{ name: 'Locations', url: '/api/locations', method: 'GET' },
|
||||
{ name: 'Customers (List)', url: '/api/customers', method: 'GET' },
|
||||
{ name: 'Customers (Create)', url: '/api/customers', method: 'POST' },
|
||||
{ name: 'Availability', url: '/api/availability', method: 'GET' },
|
||||
{ name: 'Availability Time Slots', url: '/api/availability/time-slots', method: 'GET' },
|
||||
{ name: 'Public Availability', url: '/api/public/availability', method: 'GET' },
|
||||
{ name: 'Availability Blocks', url: '/api/availability/blocks', method: 'GET' },
|
||||
{ name: 'Bookings (List)', url: '/api/bookings', method: 'GET' },
|
||||
{ name: 'Bookings (Create)', url: '/api/bookings', method: 'POST' },
|
||||
|
||||
// Kiosk APIs
|
||||
{ name: 'Kiosk - Authenticate', url: '/api/kiosk/authenticate', method: 'POST' },
|
||||
{ name: 'Kiosk - Available Resources', url: '/api/kiosk/resources/available', method: 'GET' },
|
||||
{ name: 'Kiosk - Bookings', url: '/api/kiosk/bookings', method: 'POST' },
|
||||
{ name: 'Kiosk - Walkin', url: '/api/kiosk/walkin', method: 'POST' },
|
||||
|
||||
// Payment APIs
|
||||
{ name: 'Create Payment Intent', url: '/api/create-payment-intent', method: 'POST' },
|
||||
|
||||
// Aperture APIs
|
||||
{ name: 'Aperture - Dashboard', url: '/api/aperture/dashboard', method: 'GET' },
|
||||
{ name: 'Aperture - Stats', url: '/api/aperture/stats', method: 'GET' },
|
||||
{ name: 'Aperture - Calendar', url: '/api/aperture/calendar', method: 'GET' },
|
||||
{ name: 'Aperture - Staff (List)', url: '/api/aperture/staff', method: 'GET' },
|
||||
{ name: 'Aperture - Staff (Create)', url: '/api/aperture/staff', method: 'POST' },
|
||||
{ name: 'Aperture - Resources', url: '/api/aperture/resources', method: 'GET' },
|
||||
{ name: 'Aperture - Payroll', url: '/api/aperture/payroll', method: 'GET' },
|
||||
{ name: 'Aperture - POS', url: '/api/aperture/pos', method: 'POST' },
|
||||
{ name: 'Aperture - Close Day', url: '/api/aperture/pos/close-day', method: 'POST' },
|
||||
|
||||
// Client Management (FASE 5)
|
||||
{ name: 'Aperture - Clients (List)', url: '/api/aperture/clients', method: 'GET' },
|
||||
{ name: 'Aperture - Clients (Create)', url: '/api/aperture/clients', method: 'POST' },
|
||||
{ name: 'Aperture - Client Details', url: '/api/aperture/clients/[id]', method: 'GET' },
|
||||
{ name: 'Aperture - Client Notes', url: '/api/aperture/clients/[id]/notes', method: 'POST' },
|
||||
{ name: 'Aperture - Client Photos', url: '/api/aperture/clients/[id]/photos', method: 'GET' },
|
||||
|
||||
// Loyalty System (FASE 5)
|
||||
{ name: 'Aperture - Loyalty', url: '/api/aperture/loyalty', method: 'GET' },
|
||||
{ name: 'Aperture - Loyalty History', url: '/api/aperture/loyalty/[customerId]', method: 'GET' },
|
||||
|
||||
// Webhooks (FASE 6)
|
||||
{ name: 'Stripe Webhooks', url: '/api/webhooks/stripe', method: 'POST' },
|
||||
|
||||
// Cron Jobs (FASE 6)
|
||||
{ name: 'Reset Invitations (Cron)', url: '/api/cron/reset-invitations', method: 'GET' },
|
||||
{ name: 'Detect No-Shows (Cron)', url: '/api/cron/detect-no-shows', method: 'GET' },
|
||||
|
||||
// Bookings Actions (FASE 6)
|
||||
{ name: 'Bookings - Check-in', url: '/api/aperture/bookings/check-in', method: 'POST' },
|
||||
{ name: 'Bookings - No-Show', url: '/api/aperture/bookings/no-show', method: 'POST' },
|
||||
|
||||
// Finance (FASE 6)
|
||||
{ name: 'Aperture - Finance Summary', url: '/api/aperture/finance', method: 'GET' },
|
||||
{ name: 'Aperture - Daily Closing', url: '/api/aperture/finance/daily-closing', method: 'GET' },
|
||||
{ name: 'Aperture - Expenses (List)', url: '/api/aperture/finance/expenses', method: 'GET' },
|
||||
{ name: 'Aperture - Expenses (Create)', url: '/api/aperture/finance/expenses', method: 'POST' },
|
||||
{ name: 'Aperture - Staff Performance', url: '/api/aperture/finance/staff-performance', method: 'GET' },
|
||||
]
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AnchorOS - Test Links</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.header p {
|
||||
font-size: 1.1em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.content {
|
||||
padding: 40px;
|
||||
}
|
||||
.section {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.section h2 {
|
||||
color: #667eea;
|
||||
font-size: 1.8em;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid #667eea;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
.card {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
||||
border-color: #667eea;
|
||||
}
|
||||
.card h3 {
|
||||
color: #333;
|
||||
font-size: 1.1em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.card a {
|
||||
display: block;
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-size: 0.9em;
|
||||
word-break: break-all;
|
||||
}
|
||||
.card a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.method {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75em;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.get { background: #28a745; color: white; }
|
||||
.post { background: #007bff; color: white; }
|
||||
.put { background: #ffc107; color: #333; }
|
||||
.delete { background: #dc3545; color: white; }
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7em;
|
||||
font-weight: bold;
|
||||
margin-left: 5px;
|
||||
}
|
||||
.phase-5 { background: #ff9800; color: white; }
|
||||
.phase-6 { background: #9c27b0; color: white; }
|
||||
.footer {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
.info {
|
||||
background: #e7f3ff;
|
||||
border-left: 4px solid #007bff;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.info strong {
|
||||
color: #007bff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🥂 AnchorOS - Test Links</h1>
|
||||
<p>Complete directory of all pages and API endpoints</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="info">
|
||||
<strong>⚠️ Note:</strong> Replace <code>LOCATION_ID_HERE</code> with actual UUID from your database.
|
||||
For cron jobs, use: <code>curl -H "Authorization: Bearer YOUR_CRON_SECRET"</code>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>📄 Pages</h2>
|
||||
<div class="grid">
|
||||
${pages.map(page => `
|
||||
<div class="card">
|
||||
<h3>${page.name}</h3>
|
||||
<a href="${baseUrl}${page.url}" target="_blank">${baseUrl}${page.url}</a>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>🔌 API Endpoints</h2>
|
||||
<div class="grid">
|
||||
${apis.map(api => `
|
||||
<div class="card">
|
||||
<div>
|
||||
<span class="method ${api.method.toLowerCase()}">${api.method}</span>
|
||||
${api.name.includes('FASE') ? `<span class="badge ${api.name.includes('FASE 5') ? 'phase-5' : 'phase-6'}">${api.name.match(/FASE \d+/)?.[0] || 'FASE'}</span>` : ''}
|
||||
</div>
|
||||
<h3>${api.name}</h3>
|
||||
<a href="${baseUrl}${api.url}" target="_blank">${baseUrl}${api.url}</a>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>AnchorOS - Codename: Adela | Last updated: ${new Date().toISOString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
return new NextResponse(html, {
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
},
|
||||
})
|
||||
}
|
||||
114
app/api/webhooks/stripe/route.ts
Normal file
114
app/api/webhooks/stripe/route.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
import Stripe from 'stripe'
|
||||
|
||||
/**
|
||||
* @description Processes Stripe webhook events for payment lifecycle management
|
||||
* @param {NextRequest} request - HTTP request with raw Stripe webhook payload and stripe-signature header
|
||||
* @returns {NextResponse} JSON confirming webhook receipt and processing status
|
||||
* @example POST /api/webhooks/stripe (Stripe sends webhook payload)
|
||||
* @audit BUSINESS RULE: Handles payment_intent.succeeded, payment_intent.payment_failed, and charge.refunded events
|
||||
* @audit SECURITY: Verifies Stripe webhook signature using STRIPE_WEBHOOK_SECRET to prevent spoofing
|
||||
* @audit Validate: Checks for duplicate event processing using event_id tracking
|
||||
* @audit Validate: Returns 400 for missing signature or invalid signature
|
||||
* @audit PERFORMANCE: Uses idempotency check to prevent duplicate processing
|
||||
* @audit AUDIT: All webhook events logged in webhook_logs table with full payload
|
||||
* @audit RELIABILITY: Critical for payment reconciliation - must be highly available
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const stripeSecretKey = process.env.STRIPE_SECRET_KEY
|
||||
const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET
|
||||
|
||||
if (!stripeSecretKey || !stripeWebhookSecret) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Stripe not configured' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const stripe = new Stripe(stripeSecretKey)
|
||||
|
||||
const body = await request.text()
|
||||
const signature = request.headers.get('stripe-signature')
|
||||
|
||||
if (!signature) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing Stripe signature' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify webhook signature
|
||||
let event
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(
|
||||
body,
|
||||
signature,
|
||||
stripeWebhookSecret
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Webhook signature verification failed:', err)
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid signature' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const eventId = event.id
|
||||
|
||||
// Check if event already processed
|
||||
const { data: existingLog } = await supabaseAdmin
|
||||
.from('webhook_logs')
|
||||
.select('*')
|
||||
.eq('event_id', eventId)
|
||||
.single()
|
||||
|
||||
if (existingLog) {
|
||||
console.log(`Event ${eventId} already processed, skipping`)
|
||||
return NextResponse.json({ received: true, already_processed: true })
|
||||
}
|
||||
|
||||
// Log webhook event
|
||||
await supabaseAdmin.from('webhook_logs').insert({
|
||||
event_type: event.type,
|
||||
event_id: eventId,
|
||||
payload: event.data as any
|
||||
})
|
||||
|
||||
// Process based on event type
|
||||
switch (event.type) {
|
||||
case 'payment_intent.succeeded':
|
||||
await supabaseAdmin.rpc('process_payment_intent_succeeded', {
|
||||
p_event_id: eventId,
|
||||
p_payload: event.data as any
|
||||
})
|
||||
break
|
||||
|
||||
case 'payment_intent.payment_failed':
|
||||
await supabaseAdmin.rpc('process_payment_intent_failed', {
|
||||
p_event_id: eventId,
|
||||
p_payload: event.data as any
|
||||
})
|
||||
break
|
||||
|
||||
case 'charge.refunded':
|
||||
await supabaseAdmin.rpc('process_charge_refunded', {
|
||||
p_event_id: eventId,
|
||||
p_payload: event.data as any
|
||||
})
|
||||
break
|
||||
|
||||
default:
|
||||
console.log(`Unhandled event type: ${event.type}`)
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true })
|
||||
} catch (error) {
|
||||
console.error('Error processing Stripe webhook:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Webhook processing failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -40,9 +40,10 @@ export default function CitaPage() {
|
||||
const date = searchParams.get('date')
|
||||
const time = searchParams.get('time')
|
||||
const customer_id = searchParams.get('customer_id')
|
||||
const staff_id = searchParams.get('staff_id')
|
||||
|
||||
if (service_id && location_id && date && time) {
|
||||
fetchBookingDetails(service_id, location_id, date, time)
|
||||
fetchBookingDetails(service_id, location_id, date, time, staff_id)
|
||||
}
|
||||
|
||||
if (customer_id) {
|
||||
@@ -70,7 +71,7 @@ export default function CitaPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchBookingDetails = async (serviceId: string, locationId: string, date: string, time: string) => {
|
||||
const fetchBookingDetails = async (serviceId: string, locationId: string, date: string, time: string, staffId?: string | null) => {
|
||||
try {
|
||||
const response = await fetch(`/api/availability/time-slots?location_id=${locationId}&service_id=${serviceId}&date=${date}`)
|
||||
const data = await response.json()
|
||||
@@ -86,7 +87,8 @@ export default function CitaPage() {
|
||||
location_id: locationId,
|
||||
date: date,
|
||||
time: time,
|
||||
startTime: `${date}T${time}`
|
||||
startTime: `${date}T${time}`,
|
||||
staff_id: staffId || null
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching booking details:', error)
|
||||
@@ -189,6 +191,7 @@ export default function CitaPage() {
|
||||
location_id: bookingDetails.location_id,
|
||||
start_time_utc: bookingDetails.startTime,
|
||||
notes: formData.notas,
|
||||
staff_id: bookingDetails.staff_id,
|
||||
payment_method_id: 'mock_' + paymentMethod.cardNumber.slice(-4),
|
||||
deposit_amount: depositAmount
|
||||
})
|
||||
|
||||
@@ -209,7 +209,7 @@ export default function MisCitasPage() {
|
||||
</div>
|
||||
{booking.notes && (
|
||||
<div className="mt-3 p-3 rounded-lg" style={{ background: 'var(--bone-white)', color: 'var(--charcoal-brown)' }}>
|
||||
<p className="text-sm italic">"{booking.notes}"</p>
|
||||
<p className="text-sm italic">"{booking.notes}"</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,13 @@ export default function PerfilPage() {
|
||||
}
|
||||
}, [user, authLoading, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && user) {
|
||||
loadCustomerProfile()
|
||||
loadCustomerBookings()
|
||||
}
|
||||
}, [user, authLoading])
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--bone-white)] pt-24 flex items-center justify-center">
|
||||
@@ -46,11 +53,6 @@ export default function PerfilPage() {
|
||||
return null
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadCustomerProfile()
|
||||
loadCustomerBookings()
|
||||
}, [])
|
||||
|
||||
const loadCustomerProfile = async () => {
|
||||
try {
|
||||
// En una implementación real, esto vendría de autenticación
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* @description Service selection and appointment booking page for The Boutique
|
||||
* @audit BUSINESS RULE: Multi-step booking flow: service → datetime → confirm → client registration
|
||||
* @audit SECURITY: Public endpoint with rate limiting recommended for availability checks
|
||||
* @audit Validate: All steps must be completed before final booking submission
|
||||
* @audit PERFORMANCE: Auto-fetches services, locations, and time slots based on selections
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -23,8 +31,24 @@ interface Location {
|
||||
timezone: string
|
||||
}
|
||||
|
||||
type BookingStep = 'service' | 'datetime' | 'confirm' | 'client'
|
||||
interface Staff {
|
||||
id: string
|
||||
display_name: string
|
||||
role: string
|
||||
}
|
||||
|
||||
type BookingStep = 'service' | 'datetime' | 'artist' | 'confirm' | 'client'
|
||||
|
||||
/**
|
||||
* @description Booking flow page guiding customers through service selection, date/time, and confirmation
|
||||
* @returns {JSX.Element} Multi-step booking wizard with service cards, date picker, time slots, and confirmation
|
||||
* @audit BUSINESS RULE: Time slots filtered by service duration and staff availability
|
||||
* @audit BUSINESS RULE: Time slots respect location business hours and existing bookings
|
||||
* @audit SECURITY: Public endpoint; no authentication required for browsing
|
||||
* @audit Validate: Service, location, date, and time required before proceeding
|
||||
* @audit PERFORMANCE: Dynamic time slot loading based on service and date selection
|
||||
* @audit AUDIT: Booking attempts logged for analytics and capacity planning
|
||||
*/
|
||||
export default function ServiciosPage() {
|
||||
const [services, setServices] = useState<Service[]>([])
|
||||
const [locations, setLocations] = useState<Location[]>([])
|
||||
@@ -33,6 +57,8 @@ export default function ServiciosPage() {
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(new Date())
|
||||
const [timeSlots, setTimeSlots] = useState<any[]>([])
|
||||
const [selectedTime, setSelectedTime] = useState<string>('')
|
||||
const [availableArtists, setAvailableArtists] = useState<Staff[]>([])
|
||||
const [selectedArtist, setSelectedArtist] = useState<string>('')
|
||||
const [currentStep, setCurrentStep] = useState<BookingStep>('service')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
@@ -90,6 +116,14 @@ export default function ServiciosPage() {
|
||||
if (data.availability) {
|
||||
setTimeSlots(data.availability)
|
||||
}
|
||||
|
||||
const artistsResponse = await fetch(
|
||||
`/api/availability/staff?location_id=${selectedLocation}&service_id=${selectedService}&date=${formattedDate}`
|
||||
)
|
||||
const artistsData = await artistsResponse.json()
|
||||
if (artistsData.staff) {
|
||||
setAvailableArtists(artistsData.staff)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching time slots:', error)
|
||||
setErrors({ ...errors, timeSlots: 'Error al cargar horarios' })
|
||||
@@ -111,6 +145,10 @@ export default function ServiciosPage() {
|
||||
return selectedService && selectedLocation && selectedDate && selectedTime
|
||||
}
|
||||
|
||||
const canProceedToArtist = () => {
|
||||
return selectedService && selectedLocation && selectedDate && selectedTime
|
||||
}
|
||||
|
||||
const handleProceed = () => {
|
||||
setErrors({})
|
||||
|
||||
@@ -133,13 +171,33 @@ export default function ServiciosPage() {
|
||||
setErrors({ time: 'Selecciona un horario' })
|
||||
return
|
||||
}
|
||||
setCurrentStep('confirm')
|
||||
if (availableArtists.length > 0) {
|
||||
setCurrentStep('artist')
|
||||
} else {
|
||||
const params = new URLSearchParams({
|
||||
service_id: selectedService,
|
||||
location_id: selectedLocation,
|
||||
date: format(selectedDate!, 'yyyy-MM-dd'),
|
||||
time: selectedTime
|
||||
})
|
||||
window.location.href = `/booking/cita?${params.toString()}`
|
||||
}
|
||||
} else if (currentStep === 'artist') {
|
||||
const params = new URLSearchParams({
|
||||
service_id: selectedService,
|
||||
location_id: selectedLocation,
|
||||
date: format(selectedDate!, 'yyyy-MM-dd'),
|
||||
time: selectedTime,
|
||||
staff_id: selectedArtist
|
||||
})
|
||||
window.location.href = `/booking/cita?${params.toString()}`
|
||||
} else if (currentStep === 'confirm') {
|
||||
const params = new URLSearchParams({
|
||||
service_id: selectedService,
|
||||
location_id: selectedLocation,
|
||||
date: format(selectedDate!, 'yyyy-MM-dd'),
|
||||
time: selectedTime
|
||||
time: selectedTime,
|
||||
staff_id: selectedArtist
|
||||
})
|
||||
window.location.href = `/booking/cita?${params.toString()}`
|
||||
}
|
||||
@@ -148,8 +206,10 @@ export default function ServiciosPage() {
|
||||
const handleStepBack = () => {
|
||||
if (currentStep === 'datetime') {
|
||||
setCurrentStep('service')
|
||||
} else if (currentStep === 'confirm') {
|
||||
} else if (currentStep === 'artist') {
|
||||
setCurrentStep('datetime')
|
||||
} else if (currentStep === 'confirm') {
|
||||
setCurrentStep('artist')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,7 +327,9 @@ export default function ServiciosPage() {
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{timeSlots.map((slot, index) => {
|
||||
const slotTime = new Date(slot.start_time)
|
||||
const slotTimeUTC = new Date(slot.start_time)
|
||||
// JavaScript automatically converts ISO string to local timezone
|
||||
// Since Monterrey is UTC-6, this gives us the correct local time
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
@@ -276,7 +338,7 @@ export default function ServiciosPage() {
|
||||
className={selectedTime === slot.start_time ? 'w-full' : ''}
|
||||
style={selectedTime === slot.start_time ? { background: 'var(--deep-earth)' } : {}}
|
||||
>
|
||||
{format(slotTime, 'HH:mm', { locale: es })}
|
||||
{format(slotTimeUTC, 'HH:mm', { locale: es })}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
@@ -296,6 +358,66 @@ export default function ServiciosPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentStep === 'artist' && (
|
||||
<>
|
||||
<Card style={{ background: 'var(--soft-cream)', borderColor: 'var(--mocha-taupe)', borderWidth: '1px' }}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2" style={{ color: 'var(--charcoal-brown)' }}>
|
||||
<User className="w-5 h-5" />
|
||||
Seleccionar Artista
|
||||
</CardTitle>
|
||||
<CardDescription style={{ color: 'var(--charcoal-brown)', opacity: 0.7 }}>
|
||||
{availableArtists.length > 0
|
||||
? 'Elige el artista que prefieres para tu servicio'
|
||||
: 'Se asignará automáticamente el primer artista disponible'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{availableArtists.length === 0 ? (
|
||||
<div className="text-center py-8" style={{ color: 'var(--charcoal-brown)', opacity: 0.7 }}>
|
||||
No hay artistas específicos disponibles. Se asignará automáticamente.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{availableArtists.map((artist) => (
|
||||
<div
|
||||
key={artist.id}
|
||||
className={`p-4 border rounded-lg cursor-pointer transition-all ${
|
||||
selectedArtist === artist.id
|
||||
? 'ring-2 ring-offset-2'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
style={{
|
||||
borderColor: selectedArtist === artist.id ? 'var(--deep-earth)' : 'var(--mocha-taupe)',
|
||||
background: selectedArtist === artist.id ? 'var(--bone-white)' : 'transparent'
|
||||
}}
|
||||
onClick={() => setSelectedArtist(artist.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center text-white font-medium"
|
||||
style={{ background: 'var(--deep-earth)' }}
|
||||
>
|
||||
{artist.display_name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium" style={{ color: 'var(--charcoal-brown)' }}>
|
||||
{artist.display_name}
|
||||
</p>
|
||||
<p className="text-sm capitalize" style={{ color: 'var(--charcoal-brown)', opacity: 0.7 }}>
|
||||
{artist.role}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentStep === 'confirm' && selectedServiceData && selectedLocationData && selectedDate && selectedTime && (
|
||||
<>
|
||||
<Card style={{ background: 'var(--deep-earth)' }}>
|
||||
@@ -314,10 +436,16 @@ export default function ServiciosPage() {
|
||||
<p className="text-sm opacity-75">Fecha</p>
|
||||
<p className="font-medium">{format(selectedDate, 'PPP', { locale: es })}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm opacity-75">Hora</p>
|
||||
<p className="font-medium">{format(parseISO(selectedTime), 'HH:mm', { locale: es })}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm opacity-75">Hora</p>
|
||||
<p className="font-medium">{format(new Date(selectedTime), 'HH:mm', { locale: es })}</p>
|
||||
</div>
|
||||
{selectedArtist && (
|
||||
<div>
|
||||
<p className="text-sm opacity-75">Artista</p>
|
||||
<p className="font-medium">{availableArtists.find(a => a.id === selectedArtist)?.display_name || 'Seleccionado'}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm opacity-75">Duración</p>
|
||||
<p className="font-medium">{selectedServiceData.duration_minutes} minutos</p>
|
||||
|
||||
@@ -1,176 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { AnimatedLogo } from '@/components/animated-logo'
|
||||
import { RollingPhrases } from '@/components/rolling-phrases'
|
||||
import { MapPin, Phone, Mail, Clock } from 'lucide-react'
|
||||
import { WebhookForm } from '@/components/webhook-form'
|
||||
|
||||
/** @description Contact page component with contact information and contact form for inquiries. */
|
||||
export default function ContactoPage() {
|
||||
const [formData, setFormData] = useState({
|
||||
nombre: '',
|
||||
email: '',
|
||||
telefono: '',
|
||||
mensaje: ''
|
||||
})
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSubmitted(true)
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h1 className="section-title">Contáctanos</h1>
|
||||
<p className="section-subtitle">
|
||||
Estamos aquí para responder tus preguntas y atender tus necesidades.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="grid md:grid-cols-2 gap-12">
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Información de Contacto</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start space-x-4">
|
||||
<MapPin className="w-6 h-6 text-gray-900 mt-1 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">Ubicación</h3>
|
||||
<p className="text-gray-600">Saltillo, Coahuila, México</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-4">
|
||||
<Phone className="w-6 h-6 text-gray-900 mt-1 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">Teléfono</h3>
|
||||
<p className="text-gray-600">+52 844 123 4567</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-4">
|
||||
<Mail className="w-6 h-6 text-gray-900 mt-1 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">Email</h3>
|
||||
<p className="text-gray-600">contacto@anchor23.mx</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-4">
|
||||
<Clock className="w-6 h-6 text-gray-900 mt-1 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">Horario</h3>
|
||||
<p className="text-gray-600">Lunes - Sábado: 10:00 - 21:00</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-gradient-to-br from-gray-50 to-white rounded-xl border border-gray-100">
|
||||
<h3 className="font-semibold text-gray-900 mb-2">¿Necesitas reservar una cita?</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Utiliza nuestro sistema de reservas en línea para mayor comodidad.
|
||||
</p>
|
||||
<a href="https://booking.anchor23.mx" className="btn-primary inline-flex">
|
||||
Reservar Cita
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Envíanos un Mensaje</h2>
|
||||
|
||||
{submitted ? (
|
||||
<div className="p-8 bg-green-50 border border-green-200 rounded-xl">
|
||||
<h3 className="text-xl font-semibold text-green-900 mb-2">
|
||||
Mensaje Enviado
|
||||
</h3>
|
||||
<p className="text-green-800">
|
||||
Gracias por contactarnos. Te responderemos lo antes posible.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="nombre" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nombre Completo
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="nombre"
|
||||
name="nombre"
|
||||
value={formData.nombre}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent"
|
||||
placeholder="Tu nombre"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent"
|
||||
placeholder="tu@email.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="telefono" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Teléfono
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="telefono"
|
||||
name="telefono"
|
||||
value={formData.telefono}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent"
|
||||
placeholder="+52 844 123 4567"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="mensaje" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Mensaje
|
||||
</label>
|
||||
<textarea
|
||||
id="mensaje"
|
||||
name="mensaje"
|
||||
value={formData.mensaje}
|
||||
onChange={handleChange}
|
||||
required
|
||||
rows={6}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent resize-none"
|
||||
placeholder="¿Cómo podemos ayudarte?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn-primary w-full">
|
||||
Enviar Mensaje
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<>
|
||||
<section className="hero">
|
||||
<div className="hero-content">
|
||||
<AnimatedLogo />
|
||||
<h1>Contacto</h1>
|
||||
<h2>Anchor:23</h2>
|
||||
<RollingPhrases />
|
||||
<div className="hero-actions">
|
||||
<a href="#informacion" className="btn-secondary">Información</a>
|
||||
<a href="#mensaje" className="btn-primary">Enviar Mensaje</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-image">
|
||||
<div className="w-full h-96 flex items-center justify-center bg-gradient-to-br from-amber-50 to-gray-50">
|
||||
<span className="text-gray-500 text-lg">Imagen Contacto Hero</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="foundation" id="informacion">
|
||||
<article>
|
||||
<h3>Información</h3>
|
||||
<h4>Estamos aquí para ti</h4>
|
||||
<p>
|
||||
Anchor:23 es más que un salón, es un espacio diseñado para tu transformación personal.
|
||||
Contáctanos para cualquier consulta o reserva.
|
||||
</p>
|
||||
</article>
|
||||
<aside className="foundation-image">
|
||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-amber-50 to-gray-50">
|
||||
<span className="text-gray-500 text-lg">Imagen Contacto</span>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section className="services-preview">
|
||||
<h3>Información de Contacto</h3>
|
||||
<div className="service-cards">
|
||||
<article className="service-card">
|
||||
<h4>Ubicación</h4>
|
||||
<p>Saltillo, Coahuila, México</p>
|
||||
</article>
|
||||
<article className="service-card">
|
||||
<h4>Teléfono</h4>
|
||||
<p>+52 844 123 4567</p>
|
||||
</article>
|
||||
<article className="service-card">
|
||||
<h4>Email</h4>
|
||||
<p>contacto@anchor23.mx</p>
|
||||
</article>
|
||||
<article className="service-card">
|
||||
<h4>Horario</h4>
|
||||
<p>Lunes - Sábado: 10:00 - 21:00</p>
|
||||
</article>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<a href="https://booking.anchor23.mx" className="btn-primary">
|
||||
Reservar Cita
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="testimonials" id="mensaje">
|
||||
<h3>Envíanos un Mensaje</h3>
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<WebhookForm
|
||||
formType="contact"
|
||||
title="Contacto"
|
||||
successMessage="Mensaje Enviado"
|
||||
successSubtext="Gracias por contactarnos. Te responderemos lo antes posible."
|
||||
submitButtonText="Enviar Mensaje"
|
||||
fields={[
|
||||
{
|
||||
name: 'nombre',
|
||||
label: 'Nombre Completo',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: 'Tu nombre'
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
label: 'Email',
|
||||
type: 'email',
|
||||
required: true,
|
||||
placeholder: 'tu@email.com'
|
||||
},
|
||||
{
|
||||
name: 'telefono',
|
||||
label: 'Teléfono',
|
||||
type: 'tel',
|
||||
required: false,
|
||||
placeholder: '+52 844 123 4567'
|
||||
},
|
||||
{
|
||||
name: 'motivo',
|
||||
label: 'Motivo de Contacto',
|
||||
type: 'select',
|
||||
required: true,
|
||||
placeholder: 'Selecciona un motivo',
|
||||
options: [
|
||||
{ value: 'cita', label: 'Agendar Cita' },
|
||||
{ value: 'membresia', label: 'Información Membresías' },
|
||||
{ value: 'franquicia', label: 'Interés en Franquicias' },
|
||||
{ value: 'servicios', label: 'Pregunta sobre Servicios' },
|
||||
{ value: 'pago', label: 'Problema con Pago' },
|
||||
{ value: 'resena', label: 'Enviar Reseña' },
|
||||
{ value: 'otro', label: 'Otro' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'mensaje',
|
||||
label: 'Mensaje',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
rows: 6,
|
||||
placeholder: '¿Cómo podemos ayudarte?'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,31 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Building2, Map, CheckCircle, Mail, Phone } from 'lucide-react'
|
||||
import { AnimatedLogo } from '@/components/animated-logo'
|
||||
import { RollingPhrases } from '@/components/rolling-phrases'
|
||||
import { Building2, Map, Mail, Phone, Users, Crown } from 'lucide-react'
|
||||
import { WebhookForm } from '@/components/webhook-form'
|
||||
|
||||
/** @description Franchise information and application page component for potential franchise partners. */
|
||||
export default function FranchisesPage() {
|
||||
const [formData, setFormData] = useState({
|
||||
nombre: '',
|
||||
email: '',
|
||||
telefono: '',
|
||||
ciudad: '',
|
||||
experiencia: '',
|
||||
mensaje: ''
|
||||
})
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSubmitted(true)
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
|
||||
const benefits = [
|
||||
'Modelo de negocio exclusivo y probado',
|
||||
@@ -33,224 +14,154 @@ export default function FranchisesPage() {
|
||||
'Sistema operativo completo (AnchorOS)',
|
||||
'Capacitación en estándares de lujo',
|
||||
'Membresía de clientes como fuente recurrente',
|
||||
'Soporte continuo y actualizaciones'
|
||||
'Soporte continuo y actualizaciones',
|
||||
'Manuales operativos completos',
|
||||
'Plataforma de entrenamientos digital',
|
||||
'Sistema de RH integrado en AnchorOS'
|
||||
]
|
||||
|
||||
const requirements = [
|
||||
'Compromiso inquebrantable con la calidad',
|
||||
'Experiencia en industria de belleza',
|
||||
'Inversión mínima: $500,000 USD',
|
||||
'Inversión mínima: $100,000 USD',
|
||||
'Ubicación premium en ciudad de interés',
|
||||
'Capacidad de contratar personal calificado'
|
||||
'Capacidad de contratar personal calificado',
|
||||
'Recomendable: Socio con experiencia en servicios de belleza'
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h1 className="section-title">Franquicias</h1>
|
||||
<p className="section-subtitle">
|
||||
Una oportunidad para llevar el estándar Anchor:23 a tu ciudad.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<section className="mb-24">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-8 text-center">Nuestro Modelo</h2>
|
||||
|
||||
<div className="max-w-4xl mx-auto bg-gradient-to-br from-gray-50 to-white rounded-2xl shadow-lg p-12 border border-gray-100">
|
||||
<div className="flex items-center justify-center mb-8">
|
||||
<Building2 className="w-16 h-16 text-gray-900" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-6 text-center">
|
||||
Una Sucursal por Ciudad
|
||||
</h3>
|
||||
|
||||
<p className="text-lg text-gray-600 leading-relaxed text-center mb-8">
|
||||
A diferencia de modelos masivos, creemos en la exclusividad geográfica.
|
||||
Cada ciudad tiene una sola ubicación Anchor:23, garantizando calidad
|
||||
consistente y demanda sostenible.
|
||||
</p>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6 text-center">
|
||||
<div className="p-6">
|
||||
<Map className="w-12 h-12 mx-auto mb-4 text-gray-900" />
|
||||
<h4 className="font-semibold text-gray-900 mb-2">Exclusividad</h4>
|
||||
<p className="text-gray-600 text-sm">Sin competencia interna</p>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<CheckCircle className="w-12 h-12 mx-auto mb-4 text-gray-900" />
|
||||
<h4 className="font-semibold text-gray-900 mb-2">Calidad</h4>
|
||||
<p className="text-gray-600 text-sm">Estándar uniforme</p>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<Building2 className="w-12 h-12 mx-auto mb-4 text-gray-900" />
|
||||
<h4 className="font-semibold text-gray-900 mb-2">Sostenibilidad</h4>
|
||||
<p className="text-gray-600 text-sm">Demanda controlada</p>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<section className="hero">
|
||||
<div className="hero-content">
|
||||
<AnimatedLogo />
|
||||
<h1>Franquicias</h1>
|
||||
<h2>Anchor:23</h2>
|
||||
<p className="hero-text">
|
||||
Una oportunidad exclusiva para llevar el estándar Anchor:23 a tu ciudad.
|
||||
</p>
|
||||
<div className="hero-actions">
|
||||
<a href="#modelo" className="btn-secondary">Nuestro Modelo</a>
|
||||
<a href="#solicitud" className="btn-primary">Solicitar Información</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="mb-24">
|
||||
<div className="grid md:grid-cols-2 gap-12">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Beneficios</h2>
|
||||
<div className="space-y-4">
|
||||
{benefits.map((benefit, index) => (
|
||||
<div key={index} className="flex items-start space-x-3">
|
||||
<CheckCircle className="w-5 h-5 text-gray-900 mt-1 flex-shrink-0" />
|
||||
<p className="text-gray-700">{benefit}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Requisitos</h2>
|
||||
<div className="space-y-4">
|
||||
{requirements.map((req, index) => (
|
||||
<div key={index} className="flex items-start space-x-3">
|
||||
<CheckCircle className="w-5 h-5 text-gray-900 mt-1 flex-shrink-0" />
|
||||
<p className="text-gray-700">{req}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-image">
|
||||
<div className="w-full h-96 flex items-center justify-center bg-gradient-to-br from-gray-50 to-amber-50">
|
||||
<span className="text-gray-500 text-lg">Imagen Hero Franquicias</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-12">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-8 text-center">
|
||||
Solicitud de Información
|
||||
</h2>
|
||||
<section className="foundation" id="modelo">
|
||||
<article>
|
||||
<h3>Modelo de Negocio</h3>
|
||||
<h4>Una sucursal por ciudad</h4>
|
||||
<p>
|
||||
A diferencia de modelos masivos, creemos en la exclusividad geográfica.
|
||||
Cada ciudad tiene una sola ubicación Anchor:23, garantizando calidad
|
||||
consistente y demanda sostenible.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{submitted ? (
|
||||
<div className="p-8 bg-green-50 border border-green-200 rounded-xl">
|
||||
<CheckCircle className="w-12 h-12 text-green-900 mb-4" />
|
||||
<h3 className="text-xl font-semibold text-green-900 mb-2">
|
||||
Solicitud Enviada
|
||||
</h3>
|
||||
<p className="text-green-800">
|
||||
Gracias por tu interés. Revisaremos tu perfil y te contactaremos
|
||||
pronto para discutir las oportunidades disponibles.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6 p-8 bg-white rounded-2xl shadow-lg border border-gray-100">
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="nombre" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nombre Completo
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="nombre"
|
||||
name="nombre"
|
||||
value={formData.nombre}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent"
|
||||
placeholder="Tu nombre"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent"
|
||||
placeholder="tu@email.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="telefono" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Teléfono
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="telefono"
|
||||
name="telefono"
|
||||
value={formData.telefono}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent"
|
||||
placeholder="+52 844 123 4567"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="ciudad" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Ciudad de Interés
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ciudad"
|
||||
name="ciudad"
|
||||
value={formData.ciudad}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent"
|
||||
placeholder="Ej. Monterrey, Guadalajara"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="experiencia" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Experiencia en el Sector
|
||||
</label>
|
||||
<select
|
||||
id="experiencia"
|
||||
name="experiencia"
|
||||
value={formData.experiencia}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent"
|
||||
>
|
||||
<option value="">Selecciona una opción</option>
|
||||
<option value="sin-experiencia">Sin experiencia</option>
|
||||
<option value="1-3-anos">1-3 años</option>
|
||||
<option value="3-5-anos">3-5 años</option>
|
||||
<option value="5-10-anos">5-10 años</option>
|
||||
<option value="mas-10-anos">Más de 10 años</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="mensaje" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Mensaje Adicional
|
||||
</label>
|
||||
<textarea
|
||||
id="mensaje"
|
||||
name="mensaje"
|
||||
value={formData.mensaje}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent resize-none"
|
||||
placeholder="Cuéntanos sobre tu interés o preguntas"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn-primary w-full">
|
||||
Enviar Solicitud
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<aside className="foundation-image">
|
||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-amber-50 to-gray-50">
|
||||
<span className="text-gray-500 text-lg">Imagen Modelo Franquicias</span>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section className="max-w-4xl mx-auto">
|
||||
<div className="bg-gradient-to-br from-gray-900 to-gray-800 rounded-2xl p-12 text-white">
|
||||
<section className="services-preview">
|
||||
<h3>Beneficios y Requisitos</h3>
|
||||
<div className="service-cards">
|
||||
<article className="service-card">
|
||||
<h4>Beneficios</h4>
|
||||
<ul className="list-disc list-inside space-y-2">
|
||||
{benefits.map((benefit, index) => (
|
||||
<li key={index} className="text-gray-700">{benefit}</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article className="service-card">
|
||||
<h4>Requisitos</h4>
|
||||
<ul className="list-disc list-inside space-y-2">
|
||||
{requirements.map((req, index) => (
|
||||
<li key={index} className="text-gray-700">{req}</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<a href="#solicitud" className="btn-primary">Solicitar Información</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="testimonials" id="solicitud">
|
||||
<h3>Solicitud de Información</h3>
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<WebhookForm
|
||||
formType="franchise"
|
||||
title="Franquicias"
|
||||
successMessage="Solicitud Enviada"
|
||||
successSubtext="Gracias por tu interés. Revisaremos tu perfil y te contactaremos pronto para discutir las oportunidades disponibles."
|
||||
submitButtonText="Enviar Solicitud"
|
||||
fields={[
|
||||
{
|
||||
name: 'nombre',
|
||||
label: 'Nombre Completo',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: 'Tu nombre'
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
label: 'Email',
|
||||
type: 'email',
|
||||
required: true,
|
||||
placeholder: 'tu@email.com'
|
||||
},
|
||||
{
|
||||
name: 'telefono',
|
||||
label: 'Teléfono',
|
||||
type: 'tel',
|
||||
required: true,
|
||||
placeholder: '+52 844 123 4567'
|
||||
},
|
||||
{
|
||||
name: 'ciudad',
|
||||
label: 'Ciudad de Interés',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: 'Ej. Monterrey, Guadalajara'
|
||||
},
|
||||
{
|
||||
name: 'experiencia',
|
||||
label: 'Experiencia en el Sector',
|
||||
type: 'select',
|
||||
required: true,
|
||||
placeholder: 'Selecciona una opción',
|
||||
options: [
|
||||
{ value: 'sin-experiencia', label: 'Sin experiencia' },
|
||||
{ value: '1-3-anos', label: '1-3 años' },
|
||||
{ value: '3-5-anos', label: '3-5 años' },
|
||||
{ value: '5-10-anos', label: '5-10 años' },
|
||||
{ value: 'mas-10-anos', label: 'Más de 10 años' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'mensaje',
|
||||
label: 'Mensaje Adicional',
|
||||
type: 'textarea',
|
||||
required: false,
|
||||
rows: 4,
|
||||
placeholder: 'Cuéntanos sobre tu interés o preguntas'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center mt-8">
|
||||
<div className="bg-gradient-to-br from-gray-900 to-gray-800 rounded-2xl p-12 text-white max-w-4xl mx-auto">
|
||||
<h3 className="text-2xl font-bold mb-6 text-center">
|
||||
¿Tienes Preguntas Directas?
|
||||
</h3>
|
||||
@@ -268,8 +179,8 @@ export default function FranchisesPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
448
app/globals.css
448
app/globals.css
@@ -4,45 +4,62 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--bone-white: #F6F1EC;
|
||||
--soft-cream: #EFE7DE;
|
||||
--mocha-taupe: #B8A89A;
|
||||
--deep-earth: #6F5E4F;
|
||||
--charcoal-brown: #3F362E;
|
||||
--bone-white: #f6f1ec;
|
||||
--soft-cream: #efe7de;
|
||||
--mocha-taupe: #b8a89a;
|
||||
--deep-earth: #6f5e4f;
|
||||
--charcoal-brown: #3f362e;
|
||||
|
||||
--ivory-cream: #fffef9;
|
||||
--sand-beige: #e8e4dd;
|
||||
--forest-green: #2e8b57;
|
||||
--clay-orange: #d2691e;
|
||||
--brick-red: #b22222;
|
||||
--slate-blue: #6a5acd;
|
||||
|
||||
--forest-green-alpha: rgba(46, 139, 87, 0.1);
|
||||
--clay-orange-alpha: rgba(210, 105, 30, 0.1);
|
||||
--brick-red-alpha: rgba(178, 34, 34, 0.1);
|
||||
--slate-blue-alpha: rgba(106, 90, 205, 0.1);
|
||||
--charcoal-brown-alpha: rgba(63, 54, 46, 0.1);
|
||||
|
||||
/* Aperture - Square UI */
|
||||
--ui-primary: #006AFF;
|
||||
--ui-primary-hover: #005ED6;
|
||||
--ui-primary-light: #E6F0FF;
|
||||
--ui-primary: #006aff;
|
||||
--ui-primary-hover: #005ed6;
|
||||
--ui-primary-light: #e6f0ff;
|
||||
|
||||
--ui-bg: #F6F8FA;
|
||||
--ui-bg-card: #FFFFFF;
|
||||
--ui-bg-hover: #F3F4F6;
|
||||
--ui-bg: #f6f8fa;
|
||||
--ui-bg-card: #ffffff;
|
||||
--ui-bg-hover: #f3f4f6;
|
||||
|
||||
--ui-border: #E1E4E8;
|
||||
--ui-border-light: #F3F4F6;
|
||||
--ui-border: #e1e4e8;
|
||||
--ui-border-light: #f3f4f6;
|
||||
|
||||
--ui-text-primary: #24292E;
|
||||
--ui-text-primary: #24292e;
|
||||
--ui-text-secondary: #586069;
|
||||
--ui-text-tertiary: #8B949E;
|
||||
--ui-text-inverse: #FFFFFF;
|
||||
--ui-text-tertiary: #8b949e;
|
||||
--ui-text-inverse: #ffffff;
|
||||
|
||||
--ui-success: #28A745;
|
||||
--ui-success-light: #D4EDDA;
|
||||
--ui-success: #28a745;
|
||||
--ui-success-light: #d4edda;
|
||||
|
||||
--ui-warning: #DBAB09;
|
||||
--ui-warning-light: #FFF3CD;
|
||||
--ui-warning: #dbab09;
|
||||
--ui-warning-light: #fff3cd;
|
||||
|
||||
--ui-error: #D73A49;
|
||||
--ui-error-light: #F8D7DA;
|
||||
--ui-error: #d73a49;
|
||||
--ui-error-light: #f8d7da;
|
||||
|
||||
--ui-info: #0366D6;
|
||||
--ui-info-light: #CCE5FF;
|
||||
--ui-info: #0366d6;
|
||||
--ui-info-light: #cce5ff;
|
||||
|
||||
--ui-shadow-sm: 0 1px 3px rgba(0,0,0,0.08), 0 1px 4px rgba(0,0,0,0.08);
|
||||
--ui-shadow-md: 0 4px 12px rgba(0,0,0,0.12), 0 1px 3px rgba(0,0,0,0.08);
|
||||
--ui-shadow-lg: 0 8px 24px rgba(0,0,0,0,16), 0 4px 6px rgba(0,0,0,0.08);
|
||||
--ui-shadow-xl: 0 20px 25px rgba(0,0,0,0.16), 0 4px 6px rgba(0,0,0,0.08);
|
||||
--ui-shadow-sm:
|
||||
0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
--ui-shadow-md:
|
||||
0 4px 12px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
--ui-shadow-lg:
|
||||
0 8px 24px rgba(0, 0, 0, 0, 16), 0 4px 6px rgba(0, 0, 0, 0.08);
|
||||
--ui-shadow-xl:
|
||||
0 20px 25px rgba(0, 0, 0, 0.16), 0 4px 6px rgba(0, 0, 0, 0.08);
|
||||
|
||||
--ui-radius-sm: 4px;
|
||||
--ui-radius-md: 6px;
|
||||
@@ -51,16 +68,23 @@
|
||||
--ui-radius-2xl: 16px;
|
||||
--ui-radius-full: 9999px;
|
||||
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius-2xl: 16px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Font sizes */
|
||||
--text-xs: 0.75rem; /* 12px */
|
||||
--text-sm: 0.875rem; /* 14px */
|
||||
--text-base: 1rem; /* 16px */
|
||||
--text-lg: 1.125rem; /* 18px */
|
||||
--text-xl: 1.25rem; /* 20px */
|
||||
--text-2xl: 1.5rem; /* 24px */
|
||||
--text-3xl: 1.875rem; /* 30px */
|
||||
--text-4xl: 2.25rem; /* 36px */
|
||||
--text-5xl: 3rem; /* 48px */
|
||||
--text-xs: 0.75rem; /* 12px */
|
||||
--text-sm: 0.875rem; /* 14px */
|
||||
--text-base: 1rem; /* 16px */
|
||||
--text-lg: 1.125rem; /* 18px */
|
||||
--text-xl: 1.25rem; /* 20px */
|
||||
--text-2xl: 1.5rem; /* 24px */
|
||||
--text-3xl: 1.875rem; /* 30px */
|
||||
--text-4xl: 2.25rem; /* 36px */
|
||||
--text-5xl: 3rem; /* 48px */
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -68,8 +92,13 @@
|
||||
background: var(--bone-white);
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'Playfair Display', serif;
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: "Playfair Display", serif;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,34 +146,157 @@
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center px-8 py-3 border text-sm font-medium rounded transition-all;
|
||||
background: var(--deep-earth);
|
||||
@apply inline-flex items-center justify-center px-8 py-3 border text-sm font-medium rounded-lg transition-all duration-300 relative overflow-hidden;
|
||||
background: linear-gradient(135deg, #3E352E, var(--deep-earth));
|
||||
color: var(--bone-white);
|
||||
border-color: var(--deep-earth);
|
||||
border-color: #3E352E;
|
||||
box-shadow: 0 4px 15px rgba(139, 69, 19, 0.2);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
|
||||
.btn-primary::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.2),
|
||||
transparent
|
||||
);
|
||||
transition: left 0.5s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.85;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(139, 69, 19, 0.3);
|
||||
background: linear-gradient(135deg, var(--deep-earth), #3E352E);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 10px rgba(139, 69, 19, 0.2);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center justify-center px-8 py-3 border text-sm font-medium rounded transition-all;
|
||||
background: var(--soft-cream);
|
||||
@apply inline-flex items-center justify-center px-8 py-3 border text-sm font-medium rounded-lg transition-all duration-300 relative overflow-hidden;
|
||||
background: linear-gradient(135deg, var(--bone-white), var(--soft-cream));
|
||||
color: var(--charcoal-brown);
|
||||
border-color: var(--mocha-taupe);
|
||||
box-shadow: 0 4px 15px rgba(139, 69, 19, 0.1);
|
||||
}
|
||||
|
||||
|
||||
.btn-secondary::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(139, 69, 19, 0.1),
|
||||
transparent
|
||||
);
|
||||
transition: left 0.5s ease;
|
||||
}
|
||||
|
||||
.btn-secondary:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--mocha-taupe);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(139, 69, 19, 0.2);
|
||||
background: linear-gradient(135deg, var(--soft-cream), var(--bone-white));
|
||||
border-color: #3E352E;
|
||||
}
|
||||
|
||||
.btn-secondary:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 10px rgba(139, 69, 19, 0.1);
|
||||
}
|
||||
|
||||
.hero {
|
||||
@apply min-h-screen flex items-center justify-center pt-24;
|
||||
@apply min-h-screen flex items-center justify-center pt-24 relative overflow-hidden;
|
||||
background: var(--bone-white);
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 20% 80%,
|
||||
rgba(139, 69, 19, 0.03) 0%,
|
||||
transparent 50%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 80% 20%,
|
||||
rgba(218, 165, 32, 0.02) 0%,
|
||||
transparent 50%
|
||||
);
|
||||
animation: heroGlow 8s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
radial-gradient(
|
||||
circle at 30% 40%,
|
||||
rgba(139, 69, 19, 0.04) 1px,
|
||||
transparent 1px
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 70% 60%,
|
||||
rgba(218, 165, 32, 0.03) 1px,
|
||||
transparent 1px
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 50% 80%,
|
||||
rgba(139, 69, 19, 0.02) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
background-size:
|
||||
100px 100px,
|
||||
150px 150px,
|
||||
200px 200px;
|
||||
background-position:
|
||||
0 0,
|
||||
50px 50px,
|
||||
100px 100px;
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes heroGlow {
|
||||
0% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
@apply max-w-7xl mx-auto px-8 text-center;
|
||||
@apply max-w-7xl mx-auto px-8 text-center relative z-10;
|
||||
}
|
||||
|
||||
.logo-mark {
|
||||
@@ -153,24 +305,39 @@
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
@apply text-7xl md:text-9xl mb-6 tracking-tight;
|
||||
@apply text-7xl md:text-9xl mb-4 tracking-tight;
|
||||
color: var(--charcoal-brown);
|
||||
animation: heroFadeIn 1s ease-out 0.5s both;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.hero h2 {
|
||||
@apply text-2xl md:text-3xl mb-8;
|
||||
@apply text-2xl md:text-3xl mb-6;
|
||||
color: var(--charcoal-brown);
|
||||
opacity: 0.85;
|
||||
opacity: 0;
|
||||
animation: heroFadeIn 1s ease-out 1s both;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
@apply text-xl mb-12 max-w-2xl mx-auto leading-relaxed;
|
||||
color: var(--charcoal-brown);
|
||||
opacity: 0.7;
|
||||
opacity: 0;
|
||||
animation: heroFadeIn 1s ease-out 1.5s both;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
@apply flex items-center justify-center gap-6 flex-wrap;
|
||||
animation: heroFadeIn 1s ease-out 2s both;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes heroFadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-image {
|
||||
@@ -342,7 +509,162 @@
|
||||
|
||||
.select-item[data-state="checked"] {
|
||||
background: var(--soft-cream);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
ELEGANT NAVIGATION STYLES
|
||||
======================================== */
|
||||
|
||||
.site-header {
|
||||
@apply fixed top-0 left-0 right-0 z-50 backdrop-blur-md border-b border-amber-100/50 transition-all duration-300;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
background-image:
|
||||
radial-gradient(
|
||||
circle at 25% 25%,
|
||||
rgba(139, 69, 19, 0.02) 0%,
|
||||
transparent 50%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 75% 75%,
|
||||
rgba(218, 165, 32, 0.01) 0%,
|
||||
transparent 50%
|
||||
);
|
||||
}
|
||||
|
||||
.site-header::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
linear-gradient(
|
||||
45deg,
|
||||
transparent 49%,
|
||||
rgba(139, 69, 19, 0.03) 50%,
|
||||
transparent 51%
|
||||
),
|
||||
linear-gradient(
|
||||
-45deg,
|
||||
transparent 49%,
|
||||
rgba(218, 165, 32, 0.02) 50%,
|
||||
transparent 51%
|
||||
);
|
||||
background-size: 20px 20px;
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.site-header.scrolled {
|
||||
@apply shadow-lg;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.nav-primary {
|
||||
@apply max-w-7xl mx-auto px-8 py-6 flex items-center justify-between relative;
|
||||
}
|
||||
|
||||
.logo a {
|
||||
@apply text-2xl font-bold relative transition-all duration-300;
|
||||
color: var(--charcoal-brown);
|
||||
text-shadow: 0 1px 2px rgba(139, 69, 19, 0.1);
|
||||
}
|
||||
|
||||
.logo a::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
bottom: -8px;
|
||||
left: -8px;
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
rgba(139, 69, 19, 0.05),
|
||||
rgba(218, 165, 32, 0.03)
|
||||
);
|
||||
border-radius: 8px;
|
||||
z-index: -1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.logo a:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
@apply hidden md:flex items-center space-x-8;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
@apply text-sm font-medium transition-all duration-300 relative;
|
||||
color: var(--charcoal-brown);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-links a::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#3E352E,
|
||||
var(--golden-brown)
|
||||
);
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.nav-links a:hover::after {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
color: #3E352E;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
@apply flex items-center gap-4;
|
||||
}
|
||||
|
||||
.nav-actions .btn-primary,
|
||||
.nav-actions .btn-secondary {
|
||||
@apply transition-all duration-300;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-actions .btn-primary::before,
|
||||
.nav-actions .btn-secondary::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.2),
|
||||
transparent
|
||||
);
|
||||
transition: left 0.5s ease;
|
||||
}
|
||||
|
||||
.nav-actions .btn-primary:hover::before,
|
||||
.nav-actions .btn-secondary:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.nav-actions .btn-primary:hover,
|
||||
.nav-actions .btn-secondary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(139, 69, 19, 0.15);
|
||||
}
|
||||
|
||||
.select-trigger {
|
||||
@@ -358,4 +680,20 @@
|
||||
.select-trigger[data-state="open"] {
|
||||
border-color: var(--deep-earth);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
@apply p-2 rounded-lg transition-all duration-300 border border-transparent;
|
||||
color: var(--charcoal-brown);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--soft-cream);
|
||||
border-color: var(--mocha-taupe);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.icon-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,89 +1,77 @@
|
||||
import { AnimatedLogo } from '@/components/animated-logo'
|
||||
import { RollingPhrases } from '@/components/rolling-phrases'
|
||||
|
||||
/** @description Company history and philosophy page component explaining the brand's foundation and values. */
|
||||
export default function HistoriaPage() {
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h1 className="section-title">Nuestra Historia</h1>
|
||||
<p className="section-subtitle">
|
||||
El origen de una marca que redefine el estándar de belleza exclusiva.
|
||||
</p>
|
||||
</div>
|
||||
<>
|
||||
<section className="hero">
|
||||
<div className="hero-content">
|
||||
<AnimatedLogo />
|
||||
<h1>Historia</h1>
|
||||
<h2>Anchor:23</h2>
|
||||
<RollingPhrases />
|
||||
<div className="hero-actions">
|
||||
<a href="#fundamento" className="btn-secondary">El Fundamento</a>
|
||||
<a href="#filosofia" className="btn-primary">Nuestra Filosofía</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-image">
|
||||
<div className="w-full h-96 flex items-center justify-center bg-gradient-to-br from-amber-50 to-gray-50">
|
||||
<span className="text-gray-500 text-lg">Imagen Historia Hero</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<section className="foundation mb-24">
|
||||
<article>
|
||||
<h2>El Fundamento</h2>
|
||||
<h3 className="text-3xl md:text-4xl font-bold text-gray-900 mb-6">Nada sólido nace del caos</h3>
|
||||
<p className="text-lg text-gray-600 leading-relaxed mb-6">
|
||||
Anchor:23 nace de la unión de dos creativos que creen en el lujo
|
||||
como estándar, no como promesa.
|
||||
</p>
|
||||
<p className="text-lg text-gray-600 leading-relaxed">
|
||||
En un mundo saturado de opciones, decidimos crear algo diferente:
|
||||
un refugio donde la precisión técnica se encuentra con la elegancia
|
||||
atemporal, donde cada detalle importa y donde la exclusividad es
|
||||
inherente, no promocional.
|
||||
</p>
|
||||
<section className="foundation" id="fundamento">
|
||||
<article>
|
||||
<h3>Fundamento</h3>
|
||||
<h4>Nada sólido nace del caos</h4>
|
||||
<p>
|
||||
Anchor:23 nace de la unión de dos creativos que creen en el lujo
|
||||
como estándar, no como promesa. En un mundo saturado de opciones,
|
||||
decidimos crear algo diferente: un refugio donde la precisión técnica
|
||||
se encuentra con la elegancia atemporal.
|
||||
</p>
|
||||
</article>
|
||||
<aside className="foundation-image">
|
||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-amber-50 to-gray-50">
|
||||
<span className="text-gray-500 text-lg">Imagen Fundamento</span>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section className="services-preview">
|
||||
<h3>El Significado</h3>
|
||||
<div className="service-cards">
|
||||
<article className="service-card">
|
||||
<h4>ANCHOR</h4>
|
||||
<p>El ancla representa estabilidad, firmeza y permanencia. Es el símbolo de nuestro compromiso con la calidad constante y la excelencia sin concesiones.</p>
|
||||
</article>
|
||||
<article className="service-card">
|
||||
<h4>:23</h4>
|
||||
<p>El dos y tres simbolizan la dualidad equilibrada: precisión técnica y creatividad artística, tradición e innovación, rigor y calidez.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="foundation-image">
|
||||
<div className="w-full h-full bg-gradient-to-br from-gray-200 to-gray-300 flex items-center justify-center">
|
||||
<span className="text-gray-500 text-lg">Imagen Historia</span>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section className="max-w-4xl mx-auto mb-24">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-8 text-center">El Significado</h2>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
<div className="p-8 bg-white rounded-2xl shadow-lg border border-gray-100">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">ANCHOR</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
El ancla representa estabilidad, firmeza y permanencia.
|
||||
Es el símbolo de nuestro compromiso con la calidad constante
|
||||
y la excelencia sin concesiones.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-8 bg-white rounded-2xl shadow-lg border border-gray-100">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">:23</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
El dos y tres simbolizan la dualidad equilibrada: precisión
|
||||
técnica y creatividad artística, tradición e innovación,
|
||||
rigor y calidez.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="max-w-4xl mx-auto">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-8 text-center">Nuestra Filosofía</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="p-6 bg-gradient-to-r from-gray-50 to-white rounded-xl border border-gray-100">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Lujo como Estándar</h3>
|
||||
<p className="text-gray-600">
|
||||
No es lo extrañamente costoso, es lo excepcionalmente bien hecho.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-gradient-to-r from-gray-50 to-white rounded-xl border border-gray-100">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Exclusividad Inherente</h3>
|
||||
<p className="text-gray-600">
|
||||
Una sucursal por ciudad, invitación por membresía, calidad por convicción.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-gradient-to-r from-gray-50 to-white rounded-xl border border-gray-100">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Precisión Absoluta</h3>
|
||||
<p className="text-gray-600">
|
||||
Cada corte, cada color, cada tratamiento ejecutado con la máxima perfección técnica.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<section className="testimonials" id="filosofia">
|
||||
<h3>Nuestra Filosofía</h3>
|
||||
<div className="service-cards">
|
||||
<article className="service-card">
|
||||
<h4>Lujo como Estándar</h4>
|
||||
<p>No es lo extrañamente costoso, es lo excepcionalmente bien hecho.</p>
|
||||
</article>
|
||||
<article className="service-card">
|
||||
<h4>Exclusividad Inherente</h4>
|
||||
<p>Una sucursal por ciudad, invitación por membresía, calidad por convicción.</p>
|
||||
</article>
|
||||
<article className="service-card">
|
||||
<h4>Precisión Absoluta</h4>
|
||||
<p>Cada corte, cada color, cada tratamiento ejecutado con la máxima perfección técnica.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,19 @@ import { BookingConfirmation } from '@/components/kiosk/BookingConfirmation'
|
||||
import { WalkInFlow } from '@/components/kiosk/WalkInFlow'
|
||||
import { Calendar, UserPlus, MapPin, Clock } from 'lucide-react'
|
||||
|
||||
/** @description Kiosk interface component for location-based check-in confirmations and walk-in booking creation. */
|
||||
/**
|
||||
* @description Kiosk interface component for location-based check-in confirmations and walk-in booking creation
|
||||
* @param {Object} params - Route parameters containing the locationId
|
||||
* @param {string} params.locationId - The UUID of the salon location this kiosk serves
|
||||
* @returns {JSX.Element} Interactive kiosk interface with authentication, clock, and action cards
|
||||
* @audit BUSINESS RULE: Kiosk enables customer self-service for check-in and walk-in bookings
|
||||
* @audit BUSINESS RULE: Real-time clock displays in location's timezone for customer reference
|
||||
* @audit SECURITY: Device authentication via API key required before any operations
|
||||
* @audit SECURITY: Kiosk mode has no user authentication - relies on device-level security
|
||||
* @audit Validate: Location must be active and have associated kiosk device registered
|
||||
* @audit PERFORMANCE: Single-page app with view-based rendering (no page reloads)
|
||||
* @audit AUDIT: Kiosk operations logged for security and operational monitoring
|
||||
*/
|
||||
export default function KioskPage({ params }: { params: { locationId: string } }) {
|
||||
const [apiKey, setApiKey] = useState<string | null>(null)
|
||||
const [location, setLocation] = useState<any>(null)
|
||||
@@ -210,7 +222,7 @@ export default function KioskPage({ params }: { params: { locationId: string } }
|
||||
Confirmar Cita
|
||||
</h3>
|
||||
<ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground">
|
||||
<li>Selecciona "Confirmar Cita"</li>
|
||||
<li>Selecciona "Confirmar Cita"</li>
|
||||
<li>Ingresa el código de 6 caracteres de tu reserva</li>
|
||||
<li>Verifica los detalles de tu cita</li>
|
||||
<li>Confirma tu llegada</li>
|
||||
@@ -223,7 +235,7 @@ export default function KioskPage({ params }: { params: { locationId: string } }
|
||||
Reserva Inmediata
|
||||
</h3>
|
||||
<ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground">
|
||||
<li>Selecciona "Reserva Inmediata"</li>
|
||||
<li>Selecciona "Reserva Inmediata"</li>
|
||||
<li>Elige el servicio que deseas</li>
|
||||
<li>Ingresa tus datos personales</li>
|
||||
<li>Confirma la reserva</li>
|
||||
|
||||
@@ -2,6 +2,10 @@ import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import { AuthProvider } from '@/lib/auth/context'
|
||||
import { AuthGuard } from '@/components/auth-guard'
|
||||
import { AppWrapper } from '@/components/app-wrapper'
|
||||
import { ResponsiveNav } from '@/components/responsive-nav'
|
||||
import { FormbricksProvider } from '@/components/formbricks-provider'
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
@@ -27,34 +31,15 @@ export default function RootLayout({
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body className={`${inter.variable} font-sans`}>
|
||||
<AuthProvider>
|
||||
{typeof window === 'undefined' && (
|
||||
<header className="site-header">
|
||||
<nav className="nav-primary">
|
||||
<div className="logo">
|
||||
<a href="/">ANCHOR:23</a>
|
||||
</div>
|
||||
|
||||
<ul className="nav-links">
|
||||
<li><a href="/">Inicio</a></li>
|
||||
<li><a href="/historia">Nosotros</a></li>
|
||||
<li><a href="/servicios">Servicios</a></li>
|
||||
</ul>
|
||||
|
||||
<div className="nav-actions flex items-center gap-4">
|
||||
<a href="/booking/servicios" className="btn-secondary">
|
||||
Book Now
|
||||
</a>
|
||||
<a href="/membresias" className="btn-primary">
|
||||
Memberships
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<main>{children}</main>
|
||||
</AuthProvider>
|
||||
<AppWrapper>
|
||||
<FormbricksProvider />
|
||||
<AuthProvider>
|
||||
<AuthGuard>
|
||||
<ResponsiveNav />
|
||||
<main>{children}</main>
|
||||
</AuthGuard>
|
||||
</AuthProvider>
|
||||
</AppWrapper>
|
||||
|
||||
<footer className="site-footer">
|
||||
<div className="footer-brand">
|
||||
@@ -65,6 +50,8 @@ export default function RootLayout({
|
||||
<div className="footer-links">
|
||||
<a href="/historia">Nosotros</a>
|
||||
<a href="/servicios">Servicios</a>
|
||||
<a href="/membresias">Membresías</a>
|
||||
<a href="/contacto">Contacto</a>
|
||||
<a href="/franchises">Franquicias</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,75 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { AnimatedLogo } from '@/components/animated-logo'
|
||||
import { RollingPhrases } from '@/components/rolling-phrases'
|
||||
import { Crown, Star, Award, Diamond } from 'lucide-react'
|
||||
import { getDeviceType, sendWebhookPayload } from '@/lib/webhook'
|
||||
|
||||
/** @description Membership tiers page component displaying exclusive membership options and application forms. */
|
||||
export default function MembresiasPage() {
|
||||
const [selectedTier, setSelectedTier] = useState<string | null>(null)
|
||||
const [formData, setFormData] = useState({
|
||||
membership_id: '',
|
||||
nombre: '',
|
||||
email: '',
|
||||
telefono: '',
|
||||
mensaje: ''
|
||||
})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [showThankYou, setShowThankYou] = useState(false)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
|
||||
const tiers = [
|
||||
{
|
||||
id: 'gold',
|
||||
name: 'Gold Tier',
|
||||
name: 'GOLD TIER',
|
||||
icon: Star,
|
||||
description: 'Acceso prioritario y experiencias exclusivas.',
|
||||
description: 'Acceso curado y acompañamiento continuo.',
|
||||
price: '$2,500 MXN',
|
||||
period: '/mes',
|
||||
benefits: [
|
||||
'Reserva prioritaria',
|
||||
'15% descuento en servicios',
|
||||
'Acceso anticipado a eventos',
|
||||
'Consultas de belleza mensuales',
|
||||
'Producto de cortesía mensual'
|
||||
'Prioridad de agenda en experiencias Anchor',
|
||||
'Beauty Concierge para asesoría y coordinación de rituales',
|
||||
'Acceso a horarios preferentes',
|
||||
'Consulta de belleza mensual',
|
||||
'Producto curado de cortesía mensual',
|
||||
'Invitación anticipada a experiencias privadas'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'black',
|
||||
name: 'Black Tier',
|
||||
name: 'BLACK TIER',
|
||||
icon: Award,
|
||||
description: 'Privilegios premium y atención personalizada.',
|
||||
description: 'Privilegios premium y atención extendida.',
|
||||
price: '$5,000 MXN',
|
||||
period: '/mes',
|
||||
benefits: [
|
||||
'Reserva prioritaria + sin espera',
|
||||
'25% descuento en servicios',
|
||||
'Acceso VIP a eventos exclusivos',
|
||||
'2 tratamientos spa complementarios/mes',
|
||||
'Set de productos premium trimestral'
|
||||
'Prioridad absoluta de agenda (sin listas de espera)',
|
||||
'Beauty Concierge dedicado con seguimiento integral',
|
||||
'Acceso a espacios privados y bloques extendidos',
|
||||
'Dos rituales complementarios curados al mes',
|
||||
'Set de productos premium trimestral',
|
||||
'Acceso VIP a eventos cerrados'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'vip',
|
||||
name: 'VIP Tier',
|
||||
name: 'VIP TIER',
|
||||
icon: Crown,
|
||||
description: 'La máxima expresión de exclusividad.',
|
||||
description: 'Acceso total y curaduría absoluta.',
|
||||
price: '$10,000 MXN',
|
||||
period: '/mes',
|
||||
featured: true,
|
||||
benefits: [
|
||||
'Acceso inmediato - sin restricciones',
|
||||
'35% descuento en servicios + productos',
|
||||
'Experiencias personalizadas ilimitadas',
|
||||
'Estilista asignado exclusivamente',
|
||||
'Evento privado anual para ti + 5 invitados',
|
||||
'Acceso a instalaciones fuera de horario'
|
||||
'Acceso inmediato y sin restricciones de agenda',
|
||||
'Beauty Concierge exclusivo + estilista asignado',
|
||||
'Experiencias personalizadas ilimitadas (agenda privada)',
|
||||
'Acceso a instalaciones fuera de horario',
|
||||
'Evento privado anual para la member + 5 invitadas',
|
||||
'Curaduría integral de rituales, productos y experiencias'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSubmitted(true)
|
||||
setIsSubmitting(true)
|
||||
setSubmitError(null)
|
||||
|
||||
const payload = {
|
||||
form: 'memberships',
|
||||
membership_id: formData.membership_id,
|
||||
nombre: formData.nombre,
|
||||
email: formData.email,
|
||||
telefono: formData.telefono,
|
||||
mensaje: formData.mensaje,
|
||||
timestamp_utc: new Date().toISOString(),
|
||||
device_type: getDeviceType()
|
||||
}
|
||||
|
||||
try {
|
||||
await sendWebhookPayload(payload)
|
||||
setSubmitted(true)
|
||||
setShowThankYou(true)
|
||||
window.setTimeout(() => setShowThankYou(false), 3500)
|
||||
setFormData({
|
||||
membership_id: '',
|
||||
nombre: '',
|
||||
email: '',
|
||||
telefono: '',
|
||||
mensaje: ''
|
||||
})
|
||||
} catch (error) {
|
||||
setSubmitError('No pudimos enviar tu solicitud. Intenta de nuevo.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
@@ -77,46 +115,68 @@ export default function MembresiasPage() {
|
||||
}
|
||||
|
||||
const handleApply = (tierId: string) => {
|
||||
setSelectedTier(tierId)
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
membership_id: tierId
|
||||
}))
|
||||
document.getElementById('application-form')?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h1 className="section-title">Membresías Exclusivas</h1>
|
||||
<p className="section-subtitle">
|
||||
Acceso prioritario, privilegios únicos y experiencias personalizadas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 mb-24">
|
||||
<div className="text-center mb-16">
|
||||
<Diamond className="w-16 h-16 mx-auto mb-6 text-gray-900" />
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
|
||||
Experiencias a Medida
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
||||
Nuestras membresías están diseñadas para clientes que valoran la exclusividad,
|
||||
la atención personalizada y el acceso prioritario.
|
||||
</p>
|
||||
<>
|
||||
<section className="hero">
|
||||
<div className="hero-content">
|
||||
<AnimatedLogo />
|
||||
<h1>Membresías</h1>
|
||||
<h2>Anchor:23</h2>
|
||||
<RollingPhrases />
|
||||
<div className="hero-actions">
|
||||
<a href="#tiers" className="btn-secondary">Ver Membresías</a>
|
||||
<a href="#solicitud" className="bg-[#3E352E] text-white hover:bg-[#3E352E]/90 px-8 py-3 rounded-lg font-semibold shadow-md hover:shadow-lg transition-all duration-300 inline-flex items-center justify-center">Solicitar Membresía</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-image">
|
||||
<div className="w-full h-96 flex items-center justify-center bg-gradient-to-br from-amber-50 to-gray-50">
|
||||
<span className="text-gray-500 text-lg">Imagen Membresías Hero</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8 mb-16">
|
||||
<section className="foundation" id="tiers">
|
||||
<article>
|
||||
<h3>Nota operativa</h3>
|
||||
<h4>Las membresías no sustituyen el valor de las experiencias.</h4>
|
||||
<p>
|
||||
No existen descuentos ni negociaciones de estándar. Los beneficios se centran en tiempo, acceso, privacidad y criterio.
|
||||
</p>
|
||||
<p>
|
||||
ANCHOR 23. Un espacio privado donde el tiempo se desacelera. No trabajamos con volumen. Trabajamos con intención.
|
||||
</p>
|
||||
</article>
|
||||
<aside className="foundation-image">
|
||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-amber-50 to-gray-50">
|
||||
<span className="text-gray-500 text-lg">Imagen Membresías</span>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section className="services-preview">
|
||||
<h3>ANCHOR 23 · MEMBRESÍAS</h3>
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{tiers.map((tier) => {
|
||||
const Icon = tier.icon
|
||||
return (
|
||||
<div
|
||||
<article
|
||||
key={tier.id}
|
||||
className={`relative p-8 rounded-2xl shadow-lg border-2 transition-all ${
|
||||
tier.featured
|
||||
? 'bg-gray-900 border-gray-900 text-white transform scale-105'
|
||||
: 'bg-white border-gray-100 hover:border-gray-900'
|
||||
? 'bg-[#3E352E] border-[#3E352E] text-white transform scale-105'
|
||||
: 'bg-white border-gray-100 hover:border-[#3E352E] hover:shadow-xl'
|
||||
}`}
|
||||
>
|
||||
{tier.featured && (
|
||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
||||
<span className="bg-gray-900 text-white px-4 py-1 rounded-full text-sm font-semibold">
|
||||
<span className="bg-[#3E352E] text-white px-4 py-1 rounded-full text-sm font-semibold">
|
||||
Más Popular
|
||||
</span>
|
||||
</div>
|
||||
@@ -126,13 +186,16 @@ export default function MembresiasPage() {
|
||||
<Icon className="w-12 h-12" />
|
||||
</div>
|
||||
|
||||
<h3 className={`text-2xl font-bold mb-2 ${tier.featured ? 'text-white' : 'text-gray-900'}`}>
|
||||
<h4 className={`text-2xl font-bold mb-2 ${tier.featured ? 'text-white' : 'text-gray-900'}`}>
|
||||
{tier.name}
|
||||
</h3>
|
||||
</h4>
|
||||
|
||||
<p className={`mb-6 ${tier.featured ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
{tier.description}
|
||||
</p>
|
||||
<p className={`mb-6 text-sm ${tier.featured ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
Las membresías no ofrecen descuentos. Otorgan acceso prioritario, servicios plus y Beauty Concierge dedicado.
|
||||
</p>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className={`text-4xl font-bold mb-1 ${tier.featured ? 'text-white' : 'text-gray-900'}`}>
|
||||
@@ -161,131 +224,142 @@ export default function MembresiasPage() {
|
||||
className={`w-full py-3 rounded-lg font-semibold transition-all ${
|
||||
tier.featured
|
||||
? 'bg-white text-gray-900 hover:bg-gray-100'
|
||||
: 'bg-gray-900 text-white hover:bg-gray-800'
|
||||
: 'bg-[#3E352E] text-white hover:bg-[#3E352E]/90'
|
||||
}`}
|
||||
>
|
||||
Solicitar {tier.name}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="application-form" className="max-w-2xl mx-auto">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-8 text-center">
|
||||
Solicitud de Membresía
|
||||
</h2>
|
||||
|
||||
<section className="testimonials" id="solicitud">
|
||||
<h3>Solicitud de Membresía</h3>
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{submitted ? (
|
||||
<div className="p-8 bg-green-50 border border-green-200 rounded-xl">
|
||||
<Award className="w-12 h-12 text-green-900 mb-4" />
|
||||
<h3 className="text-xl font-semibold text-green-900 mb-2">
|
||||
<div className="p-8 bg-green-50 border border-green-200 rounded-xl text-center">
|
||||
<Diamond className="w-12 h-12 text-green-900 mb-4 mx-auto" />
|
||||
<h4 className="text-xl font-semibold text-green-900 mb-2">
|
||||
Solicitud Recibida
|
||||
</h3>
|
||||
</h4>
|
||||
<p className="text-green-800">
|
||||
Gracias por tu interés. Nuestro equipo revisará tu solicitud y te
|
||||
contactará pronto para completar el proceso.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6 p-8 bg-white rounded-2xl shadow-lg border border-gray-100">
|
||||
{selectedTier && (
|
||||
<div className="p-4 bg-gray-50 rounded-lg border border-gray-200 mb-6">
|
||||
<form id="application-form" onSubmit={handleSubmit} className="space-y-6 p-8 bg-white rounded-2xl shadow-lg border border-gray-100">
|
||||
{formData.membership_id && (
|
||||
<div className="p-4 bg-gray-50 rounded-lg border border-gray-200 mb-6 text-center">
|
||||
<span className="font-semibold text-gray-900">
|
||||
Membresía Seleccionada: {tiers.find(t => t.id === selectedTier)?.name}
|
||||
Membresía Seleccionada: {tiers.find(t => t.id === formData.membership_id)?.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="nombre" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nombre Completo
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="nombre"
|
||||
name="nombre"
|
||||
value={formData.nombre}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent"
|
||||
placeholder="Tu nombre completo"
|
||||
/>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="membership_id" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Membresía
|
||||
</label>
|
||||
<select
|
||||
id="membership_id"
|
||||
name="membership_id"
|
||||
value={formData.membership_id}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg bg-white focus:ring-2 focus:ring-gray-900 focus:border-transparent"
|
||||
>
|
||||
<option value="" disabled>Selecciona una membresía</option>
|
||||
{tiers.map((tier) => (
|
||||
<option key={tier.id} value={tier.id}>{tier.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="nombre" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nombre Completo
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="nombre"
|
||||
name="nombre"
|
||||
value={formData.nombre}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent"
|
||||
placeholder="Tu nombre completo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent"
|
||||
placeholder="tu@email.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="telefono" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Teléfono
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="telefono"
|
||||
name="telefono"
|
||||
value={formData.telefono}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent"
|
||||
placeholder="+52 844 123 4567"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="mensaje" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Mensaje (Opcional)
|
||||
</label>
|
||||
<textarea
|
||||
id="mensaje"
|
||||
name="mensaje"
|
||||
value={formData.mensaje}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent resize-none"
|
||||
placeholder="¿Tienes alguna pregunta específica?"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent"
|
||||
placeholder="tu@email.com"
|
||||
/>
|
||||
</div>
|
||||
{submitError && (
|
||||
<p className="text-sm text-red-600 text-center">
|
||||
{submitError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="telefono" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Teléfono
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="telefono"
|
||||
name="telefono"
|
||||
value={formData.telefono}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent"
|
||||
placeholder="+52 844 123 4567"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="mensaje" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Mensaje Adicional (Opcional)
|
||||
</label>
|
||||
<textarea
|
||||
id="mensaje"
|
||||
name="mensaje"
|
||||
value={formData.mensaje}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent resize-none"
|
||||
placeholder="¿Tienes alguna pregunta específica?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn-primary w-full">
|
||||
Enviar Solicitud
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-[#3E352E] text-white hover:bg-[#3E352E]/90 px-8 py-3 rounded-lg font-semibold shadow-md hover:shadow-lg transition-all duration-300 inline-flex items-center justify-center w-full"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Enviando...' : 'Enviar Solicitud'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-gray-900 to-gray-800 rounded-3xl p-12 max-w-4xl mx-auto">
|
||||
<h3 className="text-2xl font-bold text-white mb-6 text-center">
|
||||
¿Tienes Preguntas?
|
||||
</h3>
|
||||
<p className="text-gray-300 text-center mb-8 max-w-2xl mx-auto">
|
||||
Nuestro equipo de atención a miembros está disponible para resolver tus dudas
|
||||
y ayudarte a encontrar la membresía perfecta para ti.
|
||||
</p>
|
||||
<div className="flex flex-col md:flex-row items-center justify-center gap-6">
|
||||
<a href="mailto:membresias@anchor23.mx" className="text-white hover:text-gray-200">
|
||||
membresias@anchor23.mx
|
||||
</a>
|
||||
<span className="text-gray-600">|</span>
|
||||
<a href="tel:+528441234567" className="text-white hover:text-gray-200">
|
||||
+52 844 123 4567
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
19
app/page.tsx
19
app/page.tsx
@@ -1,23 +1,20 @@
|
||||
import { AnimatedLogo } from '@/components/animated-logo'
|
||||
import { RollingPhrases } from '@/components/rolling-phrases'
|
||||
|
||||
/** @description Home page component for the salon website, featuring hero section, services preview, and testimonials. */
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<section className="hero">
|
||||
<div className="hero-content">
|
||||
<div className="logo-mark">
|
||||
<svg viewBox="0 0 100 100" className="w-24 h-24 mx-auto">
|
||||
<circle cx="50" cy="50" r="40" fill="none" stroke="currentColor" strokeWidth="3" />
|
||||
<path d="M 50 20 L 50 80 M 20 50 L 80 50" stroke="currentColor" strokeWidth="3" />
|
||||
<circle cx="50" cy="50" r="10" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<AnimatedLogo />
|
||||
<h1>ANCHOR:23</h1>
|
||||
<h2>Belleza anclada en exclusividad</h2>
|
||||
<p>Un estándar exclusivo de lujo y precisión.</p>
|
||||
<h2>Beauty Club</h2>
|
||||
<RollingPhrases />
|
||||
|
||||
<div className="hero-actions">
|
||||
<div className="hero-actions" style={{ animationDelay: '2.5s' }}>
|
||||
<a href="/servicios" className="btn-secondary">Ver servicios</a>
|
||||
<a href="https://booking.anchor23.mx" className="btn-primary">Solicitar cita</a>
|
||||
<a href="/booking/servicios" className="bg-[#3E352E] text-white hover:bg-[#3E352E]/90 px-8 py-3 rounded-lg font-semibold shadow-md hover:shadow-lg transition-all duration-300 inline-flex items-center justify-center">Solicitar cita</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,66 +1,328 @@
|
||||
/** @description Static services page component displaying available salon services and categories. */
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { AnimatedLogo } from '@/components/animated-logo'
|
||||
import { RollingPhrases } from '@/components/rolling-phrases'
|
||||
|
||||
/** @description Premium services page with elegant layout and sophisticated design */
|
||||
|
||||
interface Service {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
duration_minutes: number
|
||||
base_price: number
|
||||
category: string
|
||||
requires_dual_artist: boolean
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export default function ServiciosPage() {
|
||||
const services = [
|
||||
{
|
||||
category: 'Spa de Alta Gama',
|
||||
description: 'Sauna y spa excepcionales, diseñados para el rejuvenecimiento y el equilibrio.',
|
||||
items: ['Tratamientos Faciales', 'Masajes Terapéuticos', 'Hidroterapia']
|
||||
},
|
||||
{
|
||||
category: 'Arte y Manicure de Precisión',
|
||||
description: 'Estilización y técnica donde el detalle define el resultado.',
|
||||
items: ['Manicure de Precisión', 'Pedicure Spa', 'Arte en Uñas']
|
||||
},
|
||||
{
|
||||
category: 'Peinado y Maquillaje de Lujo',
|
||||
description: 'Transformaciones discretas y sofisticadas para ocasiones selectas.',
|
||||
items: ['Corte y Estilismo', 'Color Premium', 'Maquillaje Profesional']
|
||||
},
|
||||
{
|
||||
category: 'Cuidado Corporal',
|
||||
description: 'Ritual de bienestar integral.',
|
||||
items: ['Exfoliación Profunda', 'Envolturas Corporales', 'Tratamientos Reductores']
|
||||
},
|
||||
{
|
||||
category: 'Membresías Exclusivas',
|
||||
description: 'Acceso prioritario y experiencias personalizadas.',
|
||||
items: ['Gold Tier', 'Black Tier', 'VIP Tier']
|
||||
const [services, setServices] = useState<Service[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetchServices()
|
||||
}, [])
|
||||
|
||||
const fetchServices = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/services')
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
setServices(data.services.filter((s: Service) => s.is_active))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching services:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN'
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
const formatDuration = (minutes: number) => {
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const mins = minutes % 60
|
||||
if (hours > 0) {
|
||||
return mins > 0 ? `${hours}h ${mins}min` : `${hours}h`
|
||||
}
|
||||
return `${mins} min`
|
||||
}
|
||||
|
||||
const getCategoryTitle = (category: string) => {
|
||||
const titles: Record<string, string> = {
|
||||
core: 'CORE EXPERIENCES',
|
||||
nails: 'NAIL COUTURE',
|
||||
hair: 'HAIR FINISHING RITUALS',
|
||||
lashes: 'LASH & BROW RITUALS',
|
||||
brows: 'LASH & BROW RITUALS',
|
||||
events: 'EVENT EXPERIENCES',
|
||||
permanent: 'PERMANENT RITUALS'
|
||||
}
|
||||
return titles[category] || category
|
||||
}
|
||||
|
||||
const getCategorySubtitle = (category: string) => {
|
||||
const subtitles: Record<string, string> = {
|
||||
core: 'El corazón de Anchor 23',
|
||||
nails: 'Técnica invisible. Resultado impecable.',
|
||||
hair: 'Disponibles únicamente para clientas con experiencia Anchor el mismo día',
|
||||
lashes: 'Mirada definida con sutileza',
|
||||
brows: 'Mirada definida con sutileza',
|
||||
events: 'Agenda especial',
|
||||
permanent: 'Agenda limitada · Especialista certificada'
|
||||
}
|
||||
return subtitles[category] || ''
|
||||
}
|
||||
|
||||
const getCategoryDescription = (category: string) => {
|
||||
const descriptions: Record<string, string> = {
|
||||
core: 'Rituales conscientes donde el tiempo se desacelera. Cada experiencia está diseñada para mujeres que valoran el silencio, la atención absoluta y los resultados impecables.',
|
||||
nails: 'En Anchor 23 no eliges técnicas. Cada decisión se toma internamente para lograr un resultado elegante, duradero y natural. No ofrecemos servicios de mantenimiento ni correcciones.',
|
||||
hair: '',
|
||||
lashes: '',
|
||||
brows: '',
|
||||
events: 'Agenda especial para ocasiones selectas.',
|
||||
permanent: ''
|
||||
}
|
||||
return descriptions[category] || ''
|
||||
}
|
||||
|
||||
const groupedServices = services.reduce((acc, service) => {
|
||||
if (!acc[service.category]) {
|
||||
acc[service.category] = []
|
||||
}
|
||||
acc[service.category].push(service)
|
||||
return acc
|
||||
}, {} as Record<string, Service[]>)
|
||||
|
||||
const categoryOrder = ['core', 'nails', 'hair', 'lashes', 'brows', 'events', 'permanent']
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-charcoal-brown mb-4"></div>
|
||||
<p className="text-xl text-charcoal-brown opacity-70">Cargando servicios...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h1 className="section-title">Nuestros Servicios</h1>
|
||||
<p className="section-subtitle">
|
||||
Experiencias diseñadas con precisión y elegancia para clientes que valoran la exclusividad.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{services.map((service, index) => (
|
||||
<article key={index} className="p-8 bg-white rounded-2xl shadow-lg hover:shadow-xl transition-shadow border border-gray-100">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-3">{service.category}</h2>
|
||||
<p className="text-gray-600 mb-4">{service.description}</p>
|
||||
<ul className="space-y-2">
|
||||
{service.items.map((item, idx) => (
|
||||
<li key={idx} className="flex items-center text-gray-700">
|
||||
<span className="w-1.5 h-1.5 bg-gray-900 rounded-full mr-2" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
))}
|
||||
<>
|
||||
{/* Hero Section - Simplified and Elegant */}
|
||||
<section className="relative min-h-[60vh] flex items-center justify-center pt-32 pb-20 overflow-hidden">
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute inset-0 opacity-30">
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `radial-gradient(circle at 2px 2px, rgba(111, 94, 79, 0.15) 1px, transparent 0)`,
|
||||
backgroundSize: '40px 40px'
|
||||
}}></div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-5xl mx-auto px-8 text-center">
|
||||
<div className="mb-8">
|
||||
<AnimatedLogo />
|
||||
</div>
|
||||
<h1 className="text-6xl md:text-8xl font-bold mb-6 tracking-tight" style={{ fontFamily: 'Playfair Display, serif', color: 'var(--charcoal-brown)' }}>
|
||||
Nuestros Servicios
|
||||
</h1>
|
||||
<div className="mb-10">
|
||||
<RollingPhrases />
|
||||
</div>
|
||||
<p className="text-xl md:text-2xl mb-12 max-w-3xl mx-auto leading-relaxed opacity-80" style={{ color: 'var(--charcoal-brown)' }}>
|
||||
Experiencias diseñadas para mujeres que valoran el silencio, la atención absoluta y los resultados impecables.
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
<a href="/booking/servicios" className="btn-primary text-base px-10 py-4">
|
||||
Reservar Experiencia
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-12 text-center">
|
||||
<a href="https://booking.anchor23.mx" className="btn-primary">
|
||||
Reservar Cita
|
||||
{/* Philosophy Section */}
|
||||
<section className="py-24 relative" style={{ background: 'var(--soft-cream)' }}>
|
||||
<div className="max-w-6xl mx-auto px-8">
|
||||
<div className="grid md:grid-cols-2 gap-16 items-center">
|
||||
<div>
|
||||
<p className="text-sm font-semibold tracking-widest uppercase mb-4 opacity-60" style={{ color: 'var(--deep-earth)' }}>
|
||||
Nuestra Filosofía
|
||||
</p>
|
||||
<h2 className="text-4xl md:text-5xl font-bold mb-6 leading-tight" style={{ fontFamily: 'Playfair Display, serif', color: 'var(--charcoal-brown)' }}>
|
||||
Criterio antes que cantidad
|
||||
</h2>
|
||||
<p className="text-lg leading-relaxed mb-6 opacity-85" style={{ color: 'var(--charcoal-brown)' }}>
|
||||
Anchor 23 es un espacio privado donde el tiempo se desacelera. Aquí, cada experiencia está diseñada para mujeres que valoran el silencio, la atención absoluta y los resultados impecables.
|
||||
</p>
|
||||
<p className="text-lg leading-relaxed font-medium" style={{ color: 'var(--deep-earth)' }}>
|
||||
No trabajamos con volumen. Trabajamos con intención.
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative h-96 rounded-2xl overflow-hidden shadow-2xl">
|
||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-amber-100 via-stone-100 to-neutral-100">
|
||||
<span className="text-neutral-400 text-lg font-light">Imagen Experiencias</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Services Catalog */}
|
||||
<section className="py-32" style={{ background: 'var(--bone-white)' }}>
|
||||
<div className="max-w-7xl mx-auto px-8">
|
||||
{categoryOrder.map(category => {
|
||||
const categoryServices = groupedServices[category]
|
||||
if (!categoryServices || categoryServices.length === 0) return null
|
||||
|
||||
return (
|
||||
<div key={category} className="mb-32 last:mb-0">
|
||||
{/* Category Header */}
|
||||
<div className="mb-16 text-center max-w-4xl mx-auto">
|
||||
<p className="text-sm font-semibold tracking-widest uppercase mb-3 opacity-60" style={{ color: 'var(--deep-earth)' }}>
|
||||
{getCategorySubtitle(category)}
|
||||
</p>
|
||||
<h3 className="text-4xl md:text-5xl font-bold mb-6 tracking-tight" style={{ fontFamily: 'Playfair Display, serif', color: 'var(--charcoal-brown)' }}>
|
||||
{getCategoryTitle(category)}
|
||||
</h3>
|
||||
{getCategoryDescription(category) && (
|
||||
<p className="text-lg leading-relaxed opacity-75" style={{ color: 'var(--charcoal-brown)' }}>
|
||||
{getCategoryDescription(category)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Service Cards Grid */}
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{categoryServices.map((service) => (
|
||||
<article
|
||||
key={service.id}
|
||||
className="group relative rounded-2xl p-8 transition-all duration-500 hover:shadow-2xl hover:-translate-y-2"
|
||||
style={{
|
||||
background: 'var(--soft-cream)',
|
||||
border: '1px solid transparent'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--mocha-taupe)'
|
||||
e.currentTarget.style.background = 'var(--bone-white)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'transparent'
|
||||
e.currentTarget.style.background = 'var(--soft-cream)'
|
||||
}}
|
||||
>
|
||||
{/* Service Header */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-2xl font-bold mb-3 leading-tight group-hover:opacity-90 transition-opacity" style={{ fontFamily: 'Playfair Display, serif', color: 'var(--charcoal-brown)' }}>
|
||||
{service.name}
|
||||
</h4>
|
||||
{service.description && (
|
||||
<p className="text-base leading-relaxed opacity-75" style={{ color: 'var(--charcoal-brown)' }}>
|
||||
{service.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Service Meta */}
|
||||
<div className="flex items-center gap-4 mb-6 pb-6 border-b" style={{ borderColor: 'var(--mocha-taupe)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-5 h-5 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: 'var(--deep-earth)' }}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium opacity-70" style={{ color: 'var(--charcoal-brown)' }}>
|
||||
{formatDuration(service.duration_minutes)}
|
||||
</span>
|
||||
</div>
|
||||
{service.requires_dual_artist && (
|
||||
<span className="text-xs font-semibold px-3 py-1 rounded-full" style={{ background: 'var(--mocha-taupe)', color: 'var(--bone-white)' }}>
|
||||
Dual Artist
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Price and CTA */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider mb-1 opacity-50" style={{ color: 'var(--charcoal-brown)' }}>Desde</p>
|
||||
<p className="text-3xl font-bold" style={{ fontFamily: 'Playfair Display, serif', color: 'var(--charcoal-brown)' }}>
|
||||
{formatCurrency(service.base_price)}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/booking/servicios"
|
||||
className="inline-flex items-center justify-center px-6 py-3 text-sm font-medium rounded-lg transition-all duration-300 hover:shadow-lg hover:-translate-y-1"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, var(--deep-earth), var(--charcoal-brown))',
|
||||
color: 'var(--bone-white)'
|
||||
}}
|
||||
>
|
||||
Reservar
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Values Section */}
|
||||
<section className="py-24 relative" style={{ background: 'var(--soft-cream)' }}>
|
||||
<div className="max-w-5xl mx-auto px-8">
|
||||
<h3 className="text-4xl md:text-5xl font-bold mb-16 text-center" style={{ fontFamily: 'Playfair Display, serif', color: 'var(--charcoal-brown)' }}>
|
||||
Lo que Define Anchor 23
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
<div className="space-y-6">
|
||||
{[
|
||||
'No ofrecemos retoques ni servicios aislados',
|
||||
'No trabajamos con prisas',
|
||||
'No explicamos de más'
|
||||
].map((text, idx) => (
|
||||
<div key={idx} className="flex items-start gap-4 p-6 rounded-xl transition-all duration-300 hover:shadow-lg" style={{ background: 'var(--bone-white)' }}>
|
||||
<div className="flex-shrink-0 w-2 h-2 rounded-full mt-2" style={{ background: 'var(--brick-red)' }}></div>
|
||||
<p className="text-lg leading-relaxed" style={{ color: 'var(--charcoal-brown)' }}>{text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{[
|
||||
'No negociamos estándares',
|
||||
'Cada experiencia está pensada para durar, sentirse y recordarse'
|
||||
].map((text, idx) => (
|
||||
<div key={idx} className="flex items-start gap-4 p-6 rounded-xl transition-all duration-300 hover:shadow-lg" style={{ background: 'var(--bone-white)' }}>
|
||||
<div className="flex-shrink-0 w-2 h-2 rounded-full mt-2" style={{ background: 'var(--brick-red)' }}></div>
|
||||
<p className="text-lg leading-relaxed" style={{ color: 'var(--charcoal-brown)' }}>{text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Final CTA */}
|
||||
<section className="py-24 text-center" style={{ background: 'var(--bone-white)' }}>
|
||||
<div className="max-w-3xl mx-auto px-8">
|
||||
<h3 className="text-4xl md:text-5xl font-bold mb-6" style={{ fontFamily: 'Playfair Display, serif', color: 'var(--charcoal-brown)' }}>
|
||||
¿Lista para tu experiencia?
|
||||
</h3>
|
||||
<p className="text-xl mb-10 opacity-75" style={{ color: 'var(--charcoal-brown)' }}>
|
||||
Reserva tu cita y descubre lo que significa una atención verdaderamente personalizada.
|
||||
</p>
|
||||
<a href="/booking/servicios" className="btn-primary text-base px-12 py-4 inline-block">
|
||||
Reservar Ahora
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
63
check-deployment.sh
Executable file
63
check-deployment.sh
Executable file
@@ -0,0 +1,63 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Pre-deployment checks para AnchorOS
|
||||
|
||||
echo "🔍 Verificando pre-requisitos de deployment..."
|
||||
|
||||
# Verificar Docker
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo "❌ Docker no está instalado"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verificar Docker Compose
|
||||
if ! command -v docker-compose &> /dev/null; then
|
||||
echo "❌ Docker Compose no está instalado"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verificar archivos necesarios
|
||||
required_files=(".env" "package.json" "docker-compose.prod.yml" "Dockerfile")
|
||||
for file in "${required_files[@]}"; do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "❌ Archivo faltante: $file"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Verificar variables de entorno críticas
|
||||
required_vars=("NEXT_PUBLIC_SUPABASE_URL" "NEXT_PUBLIC_SUPABASE_ANON_KEY" "SUPABASE_SERVICE_ROLE_KEY" "RESEND_API_KEY")
|
||||
for var in "${required_vars[@]}"; do
|
||||
if ! grep -q "^$var=" .env; then
|
||||
echo "❌ Variable faltante en .env: $var"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Verificar conectividad de red
|
||||
echo "🌐 Verificando conectividad..."
|
||||
if ! curl -s --max-time 5 https://supabase.co > /dev/null; then
|
||||
echo "⚠️ Posible problema de conectividad a internet"
|
||||
fi
|
||||
|
||||
# Verificar puertos libres
|
||||
if lsof -Pi :3000 -sTCP:LISTEN -t >/dev/null; then
|
||||
echo "⚠️ Puerto 3000 ya está en uso"
|
||||
fi
|
||||
|
||||
if lsof -Pi :80 -sTCP:LISTEN -t >/dev/null; then
|
||||
echo "⚠️ Puerto 80 ya está en uso"
|
||||
fi
|
||||
|
||||
if lsof -Pi :443 -sTCP:LISTEN -t >/dev/null; then
|
||||
echo "⚠️ Puerto 443 ya está en uso"
|
||||
fi
|
||||
|
||||
# Verificar espacio en disco
|
||||
available_space=$(df / | tail -1 | awk '{print $4}')
|
||||
if [ "$available_space" -lt 1000000 ]; then # 1GB en KB
|
||||
echo "⚠️ Espacio en disco bajo: $(df -h / | tail -1 | awk '{print $4}') disponible"
|
||||
fi
|
||||
|
||||
echo "✅ Pre-requisitos verificados correctamente"
|
||||
echo "🚀 Listo para deployment"
|
||||
77
components/animated-logo.tsx
Normal file
77
components/animated-logo.tsx
Normal file
File diff suppressed because one or more lines are too long
51
components/app-wrapper.tsx
Normal file
51
components/app-wrapper.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { LoadingScreen } from '@/components/loading-screen'
|
||||
import { useScrollEffect } from '@/hooks/use-scroll-effect'
|
||||
|
||||
interface AppWrapperProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** @description Client component wrapper that handles loading screen and scroll effects */
|
||||
export function AppWrapper({ children }: AppWrapperProps) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [hasLoadedOnce, setHasLoadedOnce] = useState(false)
|
||||
const pathname = usePathname()
|
||||
const isScrolled = useScrollEffect()
|
||||
|
||||
useEffect(() => {
|
||||
// Only show loading screen on first visit to home page
|
||||
if (pathname === '/' && !hasLoadedOnce) {
|
||||
setIsLoading(true)
|
||||
setHasLoadedOnce(true)
|
||||
}
|
||||
}, [pathname, hasLoadedOnce])
|
||||
|
||||
const handleLoadingComplete = () => {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Apply scroll class to header
|
||||
const header = document.querySelector('.site-header')
|
||||
if (header) {
|
||||
if (isScrolled) {
|
||||
header.classList.add('scrolled')
|
||||
} else {
|
||||
header.classList.remove('scrolled')
|
||||
}
|
||||
}
|
||||
}, [isScrolled])
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && <LoadingScreen onComplete={handleLoadingComplete} />}
|
||||
<div style={{ opacity: isLoading ? 0 : 1, transition: 'opacity 0.5s ease' }}>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
33
components/auth-guard.tsx
Normal file
33
components/auth-guard.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter, usePathname } from 'next/navigation'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
|
||||
/**
|
||||
* @description Authentication guard component that protects routes requiring login
|
||||
* @param {Object} props - Component props
|
||||
* @param {React.ReactNode} props.children - Child components to render when authenticated
|
||||
* @returns {JSX.Element} Loading state while auth is determined, or children when authenticated
|
||||
* @audit BUSINESS RULE: AuthGuard is a client-side guard for protected routes
|
||||
* @audit SECURITY: Prevents rendering protected content until authentication verified
|
||||
* @audit Validate: Loading state shown while auth provider determines user session
|
||||
* @audit PERFORMANCE: No API calls - relies on AuthProvider's cached session state
|
||||
* @audit Note: Actual redirect logic handled by AuthProvider to avoid conflicts
|
||||
*/
|
||||
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const { loading: authLoading } = useAuth()
|
||||
|
||||
// Show loading while auth state is being determined
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<p>Cargando...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -10,6 +10,21 @@ interface DatePickerProps {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Custom date picker component for booking flow with month navigation and date selection
|
||||
* @param {DatePickerProps} props - Component props including selected date, selection callback, and constraints
|
||||
* @param {Date | null} props.selectedDate - Currently selected date value
|
||||
* @param {(date: Date) => void} props.onDateSelect - Callback invoked when user selects a date
|
||||
* @param {Date} props.minDate - Optional minimum selectable date (defaults to today if not provided)
|
||||
* @param {boolean} props.disabled - Optional flag to disable all interactions
|
||||
* @returns {JSX.Element} Interactive calendar grid with month navigation and date selection
|
||||
* @audit BUSINESS RULE: Calendar starts on Monday (Spanish locale convention)
|
||||
* @audit BUSINESS RULE: Disabled dates cannot be selected (past dates via minDate)
|
||||
* @audit SECURITY: Client-side only component with no external data access
|
||||
* @audit Validate: minDate is enforced via date comparison before selection
|
||||
* @audit PERFORMANCE: Uses date-fns for efficient date calculations
|
||||
* @audit UI: Today's date indicated with visual marker (dot indicator)
|
||||
*/
|
||||
export default function DatePicker({ selectedDate, onDateSelect, minDate, disabled }: DatePickerProps) {
|
||||
const [currentMonth, setCurrentMonth] = useState(selectedDate ? new Date(selectedDate) : new Date())
|
||||
|
||||
@@ -18,8 +33,8 @@ export default function DatePicker({ selectedDate, onDateSelect, minDate, disabl
|
||||
end: endOfMonth(currentMonth)
|
||||
})
|
||||
|
||||
const previousMonth = () => setCurrentMonth(subMonths(currentMonth, 1))
|
||||
const nextMonth = () => setCurrentMonth(addMonths(currentMonth, 1))
|
||||
const previousMonth = () => setCurrentMonth(subMonths(currentMonth,1))
|
||||
const nextMonth = () => setCurrentMonth(addMonths(currentMonth,1))
|
||||
|
||||
const isDateDisabled = (date: Date) => {
|
||||
if (minDate) {
|
||||
@@ -32,6 +47,24 @@ export default function DatePicker({ selectedDate, onDateSelect, minDate, disabl
|
||||
return selectedDate && isSameDay(date, selectedDate)
|
||||
}
|
||||
|
||||
// Calcular el offset del primer día del mes
|
||||
// getDay() devuelve: 0=Domingo, 1=Lunes, 2=Martes, ..., 6=Sábado
|
||||
// Para calendario que empieza en Lunes, necesitamos ajustar:
|
||||
// Si getDay() = 0 (Domingo), offset = 6
|
||||
// Si getDay() = 1-6 (Lunes-Sábado), offset = getDay() - 1
|
||||
const firstDayOfMonth = startOfMonth(currentMonth)
|
||||
const dayOfWeek = firstDayOfMonth.getDay()
|
||||
const offset = dayOfWeek === 0 ? 6 : dayOfWeek - 1
|
||||
|
||||
// Crear array con celdas vacías al inicio para el padding
|
||||
const paddingDays = Array.from({ length: offset }, (_, i) => ({ day: null, key: `padding-${i}` }))
|
||||
|
||||
// Crear array de días con key único
|
||||
const calendarDays = days.map((date, i) => ({ day: date, key: `day-${i}` }))
|
||||
|
||||
// Combinar padding + días del mes
|
||||
const allDays = [...paddingDays, ...calendarDays]
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -69,17 +102,27 @@ export default function DatePicker({ selectedDate, onDateSelect, minDate, disabl
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((date, index) => {
|
||||
const disabled = isDateDisabled(date)
|
||||
const selected = isDateSelected(date)
|
||||
const today = isToday(date)
|
||||
const notCurrentMonth = !isSameMonth(date, currentMonth)
|
||||
{allDays.map(({ day, key }) => {
|
||||
// Si es celda de padding (day es null)
|
||||
if (!day) {
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="p-2"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const disabled = isDateDisabled(day)
|
||||
const selected = isDateSelected(day)
|
||||
const today = isToday(day)
|
||||
const notCurrentMonth = !isSameMonth(day, currentMonth)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => !disabled && !notCurrentMonth && onDateSelect(date)}
|
||||
onClick={() => !disabled && !notCurrentMonth && onDateSelect(day)}
|
||||
disabled={disabled || notCurrentMonth}
|
||||
className={`
|
||||
relative p-2 text-sm font-medium rounded-md transition-all
|
||||
@@ -89,7 +132,7 @@ export default function DatePicker({ selectedDate, onDateSelect, minDate, disabl
|
||||
`}
|
||||
style={selected ? { background: 'var(--deep-earth)' } : { color: 'var(--charcoal-brown)' }}
|
||||
>
|
||||
{format(date, 'd')}
|
||||
{format(day, 'd')}
|
||||
{today && !selected && (
|
||||
<span
|
||||
className="absolute bottom-1 left-1/2 transform -translate-x-1/2 w-1 h-1 rounded-full"
|
||||
|
||||
771
components/calendar-view.tsx
Normal file
771
components/calendar-view.tsx
Normal file
@@ -0,0 +1,771 @@
|
||||
/**
|
||||
* @description Calendar view component with drag-and-drop rescheduling and booking creation
|
||||
* @audit BUSINESS RULE: Calendar shows only bookings for selected date and filters
|
||||
* @audit SECURITY: Component requires authenticated admin/manager user context
|
||||
* @audit PERFORMANCE: Auto-refresh every 30 seconds for real-time updates
|
||||
* @audit Validate: Drag operations validate conflicts before API calls
|
||||
* @audit Validate: Real-time indicators update without full page reload
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { format, addDays, startOfDay, endOfDay, parseISO, addMinutes } from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Calendar, ChevronLeft, ChevronRight, Clock, User, MapPin, Plus } from 'lucide-react'
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import {
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { checkStaffCanPerformService, checkForConflicts, rescheduleBooking } from '@/lib/calendar-utils'
|
||||
|
||||
interface Booking {
|
||||
id: string
|
||||
shortId: string
|
||||
status: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
customer: {
|
||||
id: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
}
|
||||
service: {
|
||||
id: string
|
||||
name: string
|
||||
duration_minutes: number
|
||||
}
|
||||
staff: {
|
||||
id: string
|
||||
display_name: string
|
||||
}
|
||||
resource: {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
}
|
||||
|
||||
interface Staff {
|
||||
id: string
|
||||
display_name: string
|
||||
role: string
|
||||
location_id: string
|
||||
}
|
||||
|
||||
interface Location {
|
||||
id: string
|
||||
name: string
|
||||
address: string
|
||||
}
|
||||
|
||||
interface CalendarData {
|
||||
bookings: Booking[]
|
||||
staff: Staff[]
|
||||
locations: Location[]
|
||||
businessHours: {
|
||||
start: string
|
||||
end: string
|
||||
days: number[]
|
||||
}
|
||||
}
|
||||
|
||||
interface SortableBookingProps {
|
||||
booking: Booking
|
||||
onReschedule?: (bookingId: string, newTime: string, newStaffId?: string) => void
|
||||
}
|
||||
|
||||
function SortableBooking({ booking, onReschedule }: SortableBookingProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: booking.id })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'confirmed': return 'bg-green-100 border-green-300 text-green-800'
|
||||
case 'pending': return 'bg-yellow-100 border-yellow-300 text-yellow-800'
|
||||
case 'completed': return 'bg-blue-100 border-blue-300 text-blue-800'
|
||||
case 'cancelled': return 'bg-red-100 border-red-300 text-red-800'
|
||||
default: return 'bg-gray-100 border-gray-300 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
const startTime = parseISO(booking.startTime)
|
||||
const endTime = parseISO(booking.endTime)
|
||||
const duration = (endTime.getTime() - startTime.getTime()) / (1000 * 60)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
minHeight: `${Math.max(40, duration * 0.8)}px`,
|
||||
...style
|
||||
}}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={`
|
||||
p-2 rounded border cursor-move transition-shadow hover:shadow-md
|
||||
${getStatusColor(booking.status)}
|
||||
${isDragging ? 'opacity-50 shadow-lg' : ''}
|
||||
`}
|
||||
title={`${booking.customer.first_name} ${booking.customer.last_name} - ${booking.service.name} (${format(startTime, 'HH:mm')} - ${format(endTime, 'HH:mm')})`}
|
||||
>
|
||||
<div className="text-xs font-semibold truncate">
|
||||
{booking.shortId}
|
||||
</div>
|
||||
<div className="text-xs truncate">
|
||||
{booking.customer.first_name} {booking.customer.last_name}
|
||||
</div>
|
||||
<div className="text-xs truncate opacity-75">
|
||||
{booking.service.name}
|
||||
</div>
|
||||
<div className="text-xs flex items-center gap-1 mt-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{format(startTime, 'HH:mm')} - {format(endTime, 'HH:mm')}
|
||||
</div>
|
||||
<div className="text-xs flex items-center gap-1 mt-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{booking.resource.name}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TimeSlotProps {
|
||||
time: Date
|
||||
bookings: Booking[]
|
||||
staffId: string
|
||||
onBookingDrop?: (bookingId: string, newTime: string, staffId: string) => void
|
||||
onSlotClick?: (time: Date, staffId: string) => void
|
||||
}
|
||||
|
||||
function TimeSlot({ time, bookings, staffId, onBookingDrop, onSlotClick }: TimeSlotProps) {
|
||||
const timeBookings = bookings.filter(booking =>
|
||||
booking.staff.id === staffId &&
|
||||
parseISO(booking.startTime).getHours() === time.getHours() &&
|
||||
parseISO(booking.startTime).getMinutes() === time.getMinutes()
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border-r border-gray-200 min-h-[60px] relative"
|
||||
onClick={() => onSlotClick && timeBookings.length === 0 && onSlotClick(time, staffId)}
|
||||
>
|
||||
{timeBookings.length === 0 && onSlotClick && (
|
||||
<div className="absolute inset-0 hover:bg-blue-50 cursor-pointer transition-colors flex items-center justify-center opacity-0 hover:opacity-100">
|
||||
<Plus className="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
)}
|
||||
{timeBookings.map(booking => (
|
||||
<SortableBooking
|
||||
key={booking.id}
|
||||
booking={booking}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface StaffColumnProps {
|
||||
staff: Staff
|
||||
date: Date
|
||||
bookings: Booking[]
|
||||
businessHours: { start: string, end: string }
|
||||
onBookingDrop?: (bookingId: string, newTime: string, staffId: string) => void
|
||||
onSlotClick?: (time: Date, staffId: string) => void
|
||||
}
|
||||
|
||||
function StaffColumn({ staff, date, bookings, businessHours, onBookingDrop, onSlotClick }: StaffColumnProps) {
|
||||
const staffBookings = bookings.filter(booking => booking.staff.id === staff.id)
|
||||
|
||||
const timeSlots = []
|
||||
|
||||
const [startHour, startMinute] = businessHours.start.split(':').map(Number)
|
||||
const [endHour, endMinute] = businessHours.end.split(':').map(Number)
|
||||
|
||||
let currentTime = new Date(date)
|
||||
currentTime.setHours(startHour, startMinute, 0, 0)
|
||||
|
||||
const endTime = new Date(date)
|
||||
endTime.setHours(endHour, endMinute, 0, 0)
|
||||
|
||||
while (currentTime < endTime) {
|
||||
timeSlots.push(new Date(currentTime))
|
||||
currentTime = addMinutes(currentTime, 15)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<div className="p-3 bg-gray-50 border-b font-semibold text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
{staff.display_name}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs mt-1">
|
||||
{staff.role}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{timeSlots.map((timeSlot, index) => (
|
||||
<div key={index} className="border-b border-gray-100 min-h-[60px]">
|
||||
<TimeSlot
|
||||
time={timeSlot}
|
||||
bookings={staffBookings}
|
||||
staffId={staff.id}
|
||||
onBookingDrop={onBookingDrop}
|
||||
onSlotClick={onSlotClick}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Main calendar component for multi-staff booking management
|
||||
* @returns {JSX.Element} Complete calendar interface with filters and drag-drop
|
||||
* @audit BUSINESS RULE: Calendar columns represent staff members with their bookings
|
||||
* @audit SECURITY: Only renders for authenticated admin/manager users
|
||||
* @audit PERFORMANCE: Memoized fetchCalendarData prevents unnecessary re-renders
|
||||
* @audit Validate: State updates trigger appropriate re-fetching of data
|
||||
*/
|
||||
export default function CalendarView() {
|
||||
const [currentDate, setCurrentDate] = useState(new Date())
|
||||
const [calendarData, setCalendarData] = useState<CalendarData | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedStaff, setSelectedStaff] = useState<string[]>([])
|
||||
const [selectedLocations, setSelectedLocations] = useState<string[]>([])
|
||||
const [rescheduleError, setRescheduleError] = useState<string | null>(null)
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
||||
|
||||
const [showCreateBooking, setShowCreateBooking] = useState(false)
|
||||
const [createBookingData, setCreateBookingData] = useState<{
|
||||
time: Date | null
|
||||
staffId: string | null
|
||||
customerId: string
|
||||
serviceId: string
|
||||
locationId: string
|
||||
notes: string
|
||||
}>({
|
||||
time: null,
|
||||
staffId: null,
|
||||
customerId: '',
|
||||
serviceId: '',
|
||||
locationId: '',
|
||||
notes: ''
|
||||
})
|
||||
const [createBookingError, setCreateBookingError] = useState<string | null>(null)
|
||||
const [services, setServices] = useState<any[]>([])
|
||||
const [customers, setCustomers] = useState<any[]>([])
|
||||
|
||||
const fetchServices = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/services')
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
setServices(data.services || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching services:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCustomers = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/customers')
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
setCustomers(data.customers || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching customers:', error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchServices()
|
||||
fetchCustomers()
|
||||
}, [])
|
||||
|
||||
const handleSlotClick = (time: Date, staffId: string) => {
|
||||
const locationId = selectedLocations.length > 0 ? selectedLocations[0] : (calendarData?.locations[0]?.id || '')
|
||||
setCreateBookingData({
|
||||
time,
|
||||
staffId,
|
||||
customerId: '',
|
||||
serviceId: '',
|
||||
locationId,
|
||||
notes: ''
|
||||
})
|
||||
setShowCreateBooking(true)
|
||||
setCreateBookingError(null)
|
||||
}
|
||||
|
||||
const handleCreateBooking = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setCreateBookingError(null)
|
||||
|
||||
if (!createBookingData.time || !createBookingData.staffId || !createBookingData.customerId || !createBookingData.serviceId || !createBookingData.locationId) {
|
||||
setCreateBookingError('Todos los campos son obligatorios')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const startTimeUtc = createBookingData.time.toISOString()
|
||||
|
||||
const response = await fetch('/api/bookings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
customer_id: createBookingData.customerId,
|
||||
service_id: createBookingData.serviceId,
|
||||
location_id: createBookingData.locationId,
|
||||
start_time_utc: startTimeUtc,
|
||||
staff_id: createBookingData.staffId,
|
||||
notes: createBookingData.notes || null
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
setShowCreateBooking(false)
|
||||
setCreateBookingData({
|
||||
time: null,
|
||||
staffId: null,
|
||||
customerId: '',
|
||||
serviceId: '',
|
||||
locationId: '',
|
||||
notes: ''
|
||||
})
|
||||
await fetchCalendarData()
|
||||
} else {
|
||||
setCreateBookingError(result.error || 'Error al crear la cita')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating booking:', error)
|
||||
setCreateBookingError('Error de conexión al crear la cita')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCalendarData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const startDate = format(startOfDay(currentDate), 'yyyy-MM-dd')
|
||||
const endDate = format(endOfDay(currentDate), 'yyyy-MM-dd')
|
||||
|
||||
const params = new URLSearchParams({
|
||||
start_date: `${startDate}T00:00:00Z`,
|
||||
end_date: `${endDate}T23:59:59Z`,
|
||||
})
|
||||
|
||||
if (selectedStaff.length > 0) {
|
||||
params.append('staff_ids', selectedStaff.join(','))
|
||||
}
|
||||
|
||||
if (selectedLocations.length > 0) {
|
||||
params.append('location_ids', selectedLocations.join(','))
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/aperture/calendar?${params}`)
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
setCalendarData(data)
|
||||
setLastUpdated(new Date())
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching calendar data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [currentDate, selectedStaff, selectedLocations])
|
||||
|
||||
useEffect(() => {
|
||||
fetchCalendarData()
|
||||
}, [fetchCalendarData])
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
fetchCalendarData()
|
||||
}, 30000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchCalendarData])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
const handlePreviousDay = () => {
|
||||
setCurrentDate(prev => addDays(prev, -1))
|
||||
}
|
||||
|
||||
const handleNextDay = () => {
|
||||
setCurrentDate(prev => addDays(prev, 1))
|
||||
}
|
||||
|
||||
const handleToday = () => {
|
||||
setCurrentDate(new Date())
|
||||
}
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
|
||||
if (!over) return
|
||||
|
||||
const bookingId = active.id as string
|
||||
const targetInfo = over.id as string
|
||||
|
||||
const [targetStaffId, targetTime] = targetInfo.includes('-') ? targetInfo.split('-') : [targetInfo, null]
|
||||
|
||||
try {
|
||||
setRescheduleError(null)
|
||||
|
||||
const currentStart = parseISO(bookingId)
|
||||
const newStartTime = new Date(currentStart.getTime() + (60 * 60 * 1000))
|
||||
|
||||
const response = await fetch(`/api/aperture/bookings/${bookingId}/reschedule`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
bookingId,
|
||||
newStartTime: newStartTime.toISOString(),
|
||||
newStaffId: targetStaffId,
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
await fetchCalendarData()
|
||||
setRescheduleError(null)
|
||||
} else {
|
||||
setRescheduleError(result.error || 'Error al reprogramar la cita')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error rescheduling booking:', error)
|
||||
setRescheduleError('Error de conexión al reprogramar la cita')
|
||||
}
|
||||
}
|
||||
|
||||
if (!calendarData) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<div className="text-center">
|
||||
<Calendar className="w-12 h-12 mx-auto mb-4 text-gray-400" />
|
||||
<p className="text-gray-500">Cargando calendario...</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Dialog open={showCreateBooking} onOpenChange={setShowCreateBooking}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Crear Nueva Cita</DialogTitle>
|
||||
<DialogDescription>
|
||||
{createBookingData.time && (
|
||||
<span className="text-sm">
|
||||
{format(createBookingData.time, 'EEEE, d MMMM yyyy HH:mm', { locale: es })}
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleCreateBooking} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="customer">Cliente</Label>
|
||||
<Select
|
||||
value={createBookingData.customerId}
|
||||
onValueChange={(value) => setCreateBookingData({ ...createBookingData, customerId: value })}
|
||||
>
|
||||
<SelectTrigger id="customer">
|
||||
<SelectValue placeholder="Seleccionar cliente" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{customers.map(customer => (
|
||||
<SelectItem key={customer.id} value={customer.id}>
|
||||
{customer.first_name} {customer.last_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="service">Servicio</Label>
|
||||
<Select
|
||||
value={createBookingData.serviceId}
|
||||
onValueChange={(value) => setCreateBookingData({ ...createBookingData, serviceId: value })}
|
||||
>
|
||||
<SelectTrigger id="service">
|
||||
<SelectValue placeholder="Seleccionar servicio" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{services.filter(s => s.location_id === createBookingData.locationId).map(service => (
|
||||
<SelectItem key={service.id} value={service.id}>
|
||||
{service.name} ({service.duration_minutes} min) - ${service.base_price}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="location">Ubicación</Label>
|
||||
<Select
|
||||
value={createBookingData.locationId}
|
||||
onValueChange={(value) => setCreateBookingData({ ...createBookingData, locationId: value })}
|
||||
>
|
||||
<SelectTrigger id="location">
|
||||
<SelectValue placeholder="Seleccionar ubicación" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{calendarData.locations.map(location => (
|
||||
<SelectItem key={location.id} value={location.id}>
|
||||
{location.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="staff">Staff Asignado</Label>
|
||||
<Select
|
||||
value={createBookingData.staffId || ''}
|
||||
onValueChange={(value) => setCreateBookingData({ ...createBookingData, staffId: value })}
|
||||
>
|
||||
<SelectTrigger id="staff">
|
||||
<SelectValue placeholder="Seleccionar staff" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{calendarData.staff.filter(staffMember => staffMember.location_id === createBookingData.locationId).map(staffMember => (
|
||||
<SelectItem key={staffMember.id} value={staffMember.id}>
|
||||
{staffMember.display_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes">Notas</Label>
|
||||
<Input
|
||||
id="notes"
|
||||
value={createBookingData.notes}
|
||||
onChange={(e) => setCreateBookingData({ ...createBookingData, notes: e.target.value })}
|
||||
placeholder="Notas adicionales (opcional)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{createBookingError && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-red-800 text-sm">{createBookingError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowCreateBooking(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Creando...' : 'Crear Cita'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5" />
|
||||
Calendario de Citas
|
||||
</CardTitle>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleToday}>
|
||||
Hoy
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handlePreviousDay}>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<span className="font-semibold min-w-[120px] text-center">
|
||||
{format(currentDate, 'EEEE, d MMMM', { locale: es })}
|
||||
</span>
|
||||
<div className="text-xs text-gray-500 ml-4">
|
||||
{lastUpdated && `Actualizado: ${format(lastUpdated, 'HH:mm:ss')}`}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleNextDay}>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Sucursal:</span>
|
||||
<Select
|
||||
value={selectedLocations.length === 0 ? 'all' : selectedLocations[0]}
|
||||
onValueChange={(value) => {
|
||||
value === 'all' ? setSelectedLocations([]) : setSelectedLocations([value])
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Seleccionar sucursal" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todas las sucursales</SelectItem>
|
||||
{calendarData.locations.map(location => (
|
||||
<SelectItem key={location.id} value={location.id}>
|
||||
{location.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Staff:</span>
|
||||
<Select
|
||||
value={selectedStaff.length === 0 ? 'all' : selectedStaff[0]}
|
||||
onValueChange={(value) => {
|
||||
value === 'all' ? setSelectedStaff([]) : setSelectedStaff([value])
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Seleccionar staff" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todo el staff</SelectItem>
|
||||
{calendarData.staff.map(staff => (
|
||||
<SelectItem key={staff.id} value={staff.id}>
|
||||
{staff.display_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rescheduleError && (
|
||||
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-red-800 text-sm">{rescheduleError}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex">
|
||||
<div className="w-20 bg-gray-50 border-r">
|
||||
<div className="p-3 border-b font-semibold text-sm text-center">
|
||||
Hora
|
||||
</div>
|
||||
{(() => {
|
||||
const timeSlots = []
|
||||
const [startHour] = calendarData.businessHours.start.split(':').map(Number)
|
||||
const [endHour] = calendarData.businessHours.end.split(':').map(Number)
|
||||
|
||||
for (let hour = startHour; hour <= endHour; hour++) {
|
||||
timeSlots.push(
|
||||
<div key={hour} className="border-b border-gray-100 p-2 text-xs text-center min-h-[60px] flex items-center justify-center">
|
||||
{hour.toString().padStart(2, '0')}:00
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return timeSlots
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-x-auto">
|
||||
{calendarData.staff.map(staff => (
|
||||
<StaffColumn
|
||||
key={staff.id}
|
||||
staff={staff}
|
||||
date={currentDate}
|
||||
bookings={calendarData.bookings}
|
||||
businessHours={calendarData.businessHours}
|
||||
onSlotClick={handleSlotClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
components/formbricks-provider.tsx
Normal file
28
components/formbricks-provider.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { usePathname, useSearchParams } from 'next/navigation'
|
||||
import formbricks from '@formbricks/js'
|
||||
|
||||
const FORMBRICKS_ENVIRONMENT_ID = process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || ''
|
||||
const FORMBRICKS_API_HOST = process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST || 'https://app.formbricks.com'
|
||||
|
||||
export function FormbricksProvider() {
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && FORMBRICKS_ENVIRONMENT_ID) {
|
||||
formbricks.init({
|
||||
environmentId: FORMBRICKS_ENVIRONMENT_ID,
|
||||
apiHost: FORMBRICKS_API_HOST
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
formbricks?.registerRouteChange()
|
||||
}, [pathname, searchParams])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* @description Kiosk booking confirmation interface for customers arriving with appointments
|
||||
* @audit BUSINESS RULE: Customers confirm appointments by entering 6-character short ID
|
||||
* @audit SECURITY: Authenticated via x-kiosk-api-key header for all API calls
|
||||
* @audit Validate: Only pending bookings can be confirmed; already confirmed shows warning
|
||||
* @audit PERFORMANCE: Large touch-friendly input optimized for self-service kiosks
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -12,7 +20,17 @@ interface BookingConfirmationProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* BookingConfirmation component that allows confirming a booking by short ID.
|
||||
* @description Booking confirmation component for kiosk self-service check-in
|
||||
* @param {string} apiKey - Kiosk API key for authentication
|
||||
* @param {Function} onConfirm - Callback when booking is successfully confirmed
|
||||
* @param {Function} onCancel - Callback when customer cancels the process
|
||||
* @returns {JSX.Element} Input form for 6-character booking code with confirmation options
|
||||
* @audit BUSINESS RULE: Search by short_id (6 characters) for quick customer lookup
|
||||
* @audit BUSINESS RULE: Only pending bookings can be confirmed; other statuses show error
|
||||
* @audit SECURITY: All API calls require valid kiosk API key in header
|
||||
* @audit Validate: Short ID must be exactly 6 characters
|
||||
* @audit PERFORMANCE: Single API call to fetch booking by short_id
|
||||
* @audit AUDIT: Booking confirmations logged through /api/kiosk/bookings/[shortId]/confirm
|
||||
*/
|
||||
export function BookingConfirmation({ apiKey, onConfirm, onCancel }: BookingConfirmationProps) {
|
||||
const [shortId, setShortId] = useState('')
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* @description Kiosk walk-in booking flow for in-store service reservations
|
||||
* @audit BUSINESS RULE: Walk-in flow designed for touch screen with large buttons and simple navigation
|
||||
* @audit SECURITY: Authenticated via x-kiosk-api-key header for all API calls
|
||||
* @audit Validate: Multi-step flow with service → customer → confirm → success states
|
||||
* @audit PERFORMANCE: Optimized for offline-capable touch interface
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -14,7 +22,17 @@ interface WalkInFlowProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* WalkInFlow component that manages the walk-in booking process in steps.
|
||||
* @description Walk-in booking flow component for kiosk terminals
|
||||
* @param {string} apiKey - Kiosk API key for authentication
|
||||
* @param {Function} onComplete - Callback when walk-in booking is completed successfully
|
||||
* @param {Function} onCancel - Callback when customer cancels the walk-in process
|
||||
* @returns {JSX.Element} Multi-step wizard for service selection, customer info, and confirmation
|
||||
* @audit BUSINESS RULE: 4-step flow: services → customer info → resource assignment → success
|
||||
* @audit BUSINESS RULE: Resources auto-assigned based on availability and service priority
|
||||
* @audit SECURITY: All API calls require valid kiosk API key in header
|
||||
* @audit Validate: Customer name and service selection required before booking
|
||||
* @audit PERFORMANCE: Single-page flow optimized for touch interaction
|
||||
* @audit AUDIT: Walk-in bookings logged through /api/kiosk/walkin endpoint
|
||||
*/
|
||||
export function WalkInFlow({ apiKey, onComplete, onCancel }: WalkInFlowProps) {
|
||||
const [step, setStep] = useState<'services' | 'customer' | 'confirm' | 'success'>('services')
|
||||
|
||||
388
components/kiosks-management.tsx
Normal file
388
components/kiosks-management.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Plus, Edit, Trash2, Smartphone, MapPin, Key, Wifi } from 'lucide-react'
|
||||
|
||||
interface Kiosk {
|
||||
id: string
|
||||
device_name: string
|
||||
display_name: string
|
||||
api_key: string
|
||||
ip_address?: string
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
location?: {
|
||||
id: string
|
||||
name: string
|
||||
address: string
|
||||
}
|
||||
}
|
||||
|
||||
interface Location {
|
||||
id: string
|
||||
name: string
|
||||
address: string
|
||||
}
|
||||
|
||||
export default function KiosksManagement() {
|
||||
const [kiosks, setKiosks] = useState<Kiosk[]>([])
|
||||
const [locations, setLocations] = useState<Location[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingKiosk, setEditingKiosk] = useState<Kiosk | null>(null)
|
||||
const [showApiKey, setShowApiKey] = useState<string | null>(null)
|
||||
const [formData, setFormData] = useState({
|
||||
device_name: '',
|
||||
display_name: '',
|
||||
location_id: '',
|
||||
ip_address: ''
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchKiosks()
|
||||
fetchLocations()
|
||||
}, [])
|
||||
|
||||
const fetchKiosks = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/aperture/kiosks')
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
setKiosks(data.kiosks)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching kiosks:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchLocations = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/aperture/locations')
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
setLocations(data.locations || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching locations:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
try {
|
||||
const url = editingKiosk
|
||||
? `/api/aperture/kiosks/${editingKiosk.id}`
|
||||
: '/api/aperture/kiosks'
|
||||
|
||||
const method = editingKiosk ? 'PUT' : 'POST'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
await fetchKiosks()
|
||||
setDialogOpen(false)
|
||||
setEditingKiosk(null)
|
||||
setFormData({ device_name: '', display_name: '', location_id: '', ip_address: '' })
|
||||
} else {
|
||||
alert(data.error || 'Error saving kiosk')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving kiosk:', error)
|
||||
alert('Error saving kiosk')
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (kiosk: Kiosk) => {
|
||||
setEditingKiosk(kiosk)
|
||||
setFormData({
|
||||
device_name: kiosk.device_name,
|
||||
display_name: kiosk.display_name,
|
||||
location_id: kiosk.location?.id || '',
|
||||
ip_address: kiosk.ip_address || ''
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (kiosk: Kiosk) => {
|
||||
if (!confirm(`¿Estás seguro de que quieres eliminar el kiosko "${kiosk.device_name}"?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/aperture/kiosks/${kiosk.id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
await fetchKiosks()
|
||||
} else {
|
||||
alert(data.error || 'Error deleting kiosk')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting kiosk:', error)
|
||||
alert('Error deleting kiosk')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleKioskStatus = async (kiosk: Kiosk) => {
|
||||
try {
|
||||
const response = await fetch(`/api/aperture/kiosks/${kiosk.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...kiosk,
|
||||
is_active: !kiosk.is_active
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
await fetchKiosks()
|
||||
} else {
|
||||
alert(data.error || 'Error updating kiosk status')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling kiosk status:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const copyApiKey = (apiKey: string) => {
|
||||
navigator.clipboard.writeText(apiKey)
|
||||
setShowApiKey(apiKey)
|
||||
setTimeout(() => setShowApiKey(null), 2000)
|
||||
}
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setEditingKiosk(null)
|
||||
setFormData({ device_name: '', display_name: '', location_id: '', ip_address: '' })
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">Gestión de Kioskos</h2>
|
||||
<p className="text-gray-600">Administra los dispositivos kiosko para check-in</p>
|
||||
</div>
|
||||
<Button onClick={openCreateDialog}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nuevo Kiosko
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Smartphone className="w-5 h-5" />
|
||||
Dispositivos Kiosko
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{kiosks.length} dispositivos registrados
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="text-center py-8">Cargando kioskos...</div>
|
||||
) : kiosks.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No hay kioskos registrados. Agrega uno para comenzar.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Dispositivo</TableHead>
|
||||
<TableHead>Ubicación</TableHead>
|
||||
<TableHead>IP</TableHead>
|
||||
<TableHead>API Key</TableHead>
|
||||
<TableHead>Estado</TableHead>
|
||||
<TableHead className="text-right">Acciones</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{kiosks.map((kiosk) => (
|
||||
<TableRow key={kiosk.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center">
|
||||
<Smartphone className="w-5 h-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{kiosk.device_name}</div>
|
||||
{kiosk.display_name !== kiosk.device_name && (
|
||||
<div className="text-sm text-gray-500">{kiosk.display_name}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{kiosk.location?.name || 'Sin ubicación'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{kiosk.ip_address ? (
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<Wifi className="w-3 h-3" />
|
||||
{kiosk.ip_address}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400">Sin IP</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button
|
||||
onClick={() => copyApiKey(kiosk.api_key)}
|
||||
className="flex items-center gap-1 text-sm font-mono bg-gray-100 px-2 py-1 rounded hover:bg-gray-200 transition-colors"
|
||||
title="Click para copiar"
|
||||
>
|
||||
<Key className="w-3 h-3" />
|
||||
{showApiKey === kiosk.api_key ? 'Copiado!' : `${kiosk.api_key.slice(0, 8)}...`}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={kiosk.is_active ? 'default' : 'secondary'}
|
||||
className={kiosk.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}
|
||||
>
|
||||
{kiosk.is_active ? 'Activo' : 'Inactivo'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toggleKioskStatus(kiosk)}
|
||||
>
|
||||
{kiosk.is_active ? 'Desactivar' : 'Activar'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(kiosk)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(kiosk)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingKiosk ? 'Editar Kiosko' : 'Nuevo Kiosko'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingKiosk ? 'Modifica la información del kiosko' : 'Agrega un nuevo dispositivo kiosko'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="device_name" className="text-right">
|
||||
Nombre *
|
||||
</Label>
|
||||
<Input
|
||||
id="device_name"
|
||||
value={formData.device_name}
|
||||
onChange={(e) => setFormData({...formData, device_name: e.target.value})}
|
||||
className="col-span-3"
|
||||
placeholder="Ej. Kiosko Principal"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="display_name" className="text-right">
|
||||
Display
|
||||
</Label>
|
||||
<Input
|
||||
id="display_name"
|
||||
value={formData.display_name}
|
||||
onChange={(e) => setFormData({...formData, display_name: e.target.value})}
|
||||
className="col-span-3"
|
||||
placeholder="Nombre a mostrar"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="location_id" className="text-right">
|
||||
Ubicación *
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.location_id}
|
||||
onValueChange={(value) => setFormData({...formData, location_id: value})}
|
||||
>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue placeholder="Seleccionar ubicación" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{locations.map((location) => (
|
||||
<SelectItem key={location.id} value={location.id}>
|
||||
{location.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="ip_address" className="text-right">
|
||||
IP
|
||||
</Label>
|
||||
<Input
|
||||
id="ip_address"
|
||||
value={formData.ip_address}
|
||||
onChange={(e) => setFormData({...formData, ip_address: e.target.value})}
|
||||
className="col-span-3"
|
||||
placeholder="192.168.1.100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit">
|
||||
{editingKiosk ? 'Actualizar' : 'Crear'} Kiosko
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
255
components/loading-screen.tsx
Normal file
255
components/loading-screen.tsx
Normal file
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user