feat: initial commit — HermesMessages SaaS platform
Backend (FastAPI + Python 3.12): - Multi-tenant auth with JWT: login, register, refresh, Meta OAuth - Business & BusinessConfig management - WhatsApp webhook with HMAC signature verification - Bot engine powered by Claude AI - Calendar availability with Redis caching - Reservations CRUD with status management - Dashboard analytics (stats, agenda, peak hours) - Billing & plan management - Admin panel with platform-wide stats - Async bcrypt via asyncio.to_thread - IntegrityError handling for concurrent registration race conditions Frontend (React 18 + Vite + Tailwind CSS): - Multi-step guided registration form with helper text on every field - Login page with show/hide password toggle - Protected routes with AuthContext - Dashboard with stats cards, bar chart, and daily agenda - Reservations list with search, filters, and inline status actions - Calendar with weekly view, slot availability, and date blocking - Config page: business info, schedules, bot personality - Billing page with plan comparison and usage bar Design system: - Bricolage Grotesque + DM Sans typography - Emerald primary palette with semantic color tokens - scale(0.97) button press feedback, ease-out animations - Skeleton loaders, stagger animations, prefers-reduced-motion support - Accessible: aria-labels, visible focus rings, 4.5:1 contrast Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.eggs/
|
||||||
|
*.egg
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Node / Frontend
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.cache/
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
*.xml
|
||||||
|
|
||||||
|
# Alembic
|
||||||
|
# (keep versions tracked)
|
||||||
|
|
||||||
|
# Claude Code local settings
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
*.override.yml
|
||||||
152
CLAUDE.md
Normal file
152
CLAUDE.md
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Multi-tenant SaaS platform for automating WhatsApp Business reservations via an AI-powered bot. Businesses (restaurants, clinics, salons) connect their WhatsApp and a Claude-powered bot handles bookings conversationally.
|
||||||
|
|
||||||
|
## Repository Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
hermesmessages/
|
||||||
|
├── backend/ # FastAPI API — Python 3.12+
|
||||||
|
├── frontend/ # (in progress)
|
||||||
|
└── database/ # docker-compose.yml (PostgreSQL + Redis)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|-----------|
|
||||||
|
| Language | Python 3.12+ |
|
||||||
|
| Framework | FastAPI |
|
||||||
|
| Database | PostgreSQL + SQLAlchemy (async) + Alembic |
|
||||||
|
| Cache | Redis |
|
||||||
|
| Validation | Pydantic v2 |
|
||||||
|
| Auth | JWT |
|
||||||
|
| AI | Claude API (`claude-sonnet-4-20250514`) |
|
||||||
|
| Testing | Pytest |
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
All backend commands run from `backend/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start local services
|
||||||
|
cd database && docker-compose up -d
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
cd backend && pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Run development server
|
||||||
|
cd backend && uvicorn app.main:app --reload
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
cd backend && alembic upgrade head
|
||||||
|
|
||||||
|
# Generate new migration
|
||||||
|
cd backend && alembic revision --autogenerate -m "description"
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
cd backend && pytest
|
||||||
|
|
||||||
|
# Run a single test file
|
||||||
|
cd backend && pytest tests/test_calendar.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend Architecture
|
||||||
|
|
||||||
|
### Module Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── app/
|
||||||
|
│ ├── main.py
|
||||||
|
│ ├── core/ # Config & infrastructure
|
||||||
|
│ │ ├── config.py # pydantic-settings
|
||||||
|
│ │ ├── database.py # async PostgreSQL
|
||||||
|
│ │ ├── redis.py
|
||||||
|
│ │ ├── security.py # JWT, hashing
|
||||||
|
│ │ ├── dependencies.py # get_current_business, require_admin
|
||||||
|
│ │ └── errors.py # global exception handler
|
||||||
|
│ ├── modules/
|
||||||
|
│ │ ├── auth/
|
||||||
|
│ │ ├── business/
|
||||||
|
│ │ ├── whatsapp/
|
||||||
|
│ │ ├── bot_engine/
|
||||||
|
│ │ ├── calendar/
|
||||||
|
│ │ ├── reservations/
|
||||||
|
│ │ ├── notifications/
|
||||||
|
│ │ ├── dashboard/
|
||||||
|
│ │ ├── billing/
|
||||||
|
│ │ └── admin/
|
||||||
|
│ └── shared/
|
||||||
|
├── alembic/
|
||||||
|
│ └── versions/0001_initial.py
|
||||||
|
└── requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Each module contains: `router.py`, `service.py`, `models.py`, `schemas.py`, and optionally `dependencies.py`.
|
||||||
|
|
||||||
|
### Module Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
WhatsApp webhook → Bot Engine → Calendar → Reservations → Notifications
|
||||||
|
↑
|
||||||
|
Business Config
|
||||||
|
```
|
||||||
|
|
||||||
|
## Critical Business Logic
|
||||||
|
|
||||||
|
### Multi-tenancy Rule
|
||||||
|
`business_id` is **always** extracted from the JWT token — never from the request body or path params. All queries must filter by `business_id` from the token. Exception: the WhatsApp webhook resolves `business_id` via `whatsapp_phone_number_id` (no JWT on webhook calls).
|
||||||
|
|
||||||
|
### Availability Calculation (`calendar/service.py`)
|
||||||
|
1. Fetch `BusinessConfig` for the business
|
||||||
|
2. Check if date is in `open_days` and not in `blocked_dates`
|
||||||
|
3. Generate all time slots using `open_time`, `close_time`, `slot_duration`
|
||||||
|
4. Query confirmed+pending reservations for that day
|
||||||
|
5. Filter slots where existing count < `max_per_slot`
|
||||||
|
6. Cache result in Redis with 5-minute TTL
|
||||||
|
7. Invalidate cache when a reservation is created, updated, cancelled, or deleted
|
||||||
|
|
||||||
|
### WhatsApp Webhook (`whatsapp/router.py`)
|
||||||
|
- `GET /whatsapp/webhook` — Meta verification (return `hub.challenge`)
|
||||||
|
- `POST /whatsapp/webhook` — Validate `X-Hub-Signature-256`, dispatch to bot engine via `BackgroundTasks`, respond 200 immediately
|
||||||
|
|
||||||
|
### Bot Engine (`bot_engine/service.py`)
|
||||||
|
1. Load/create `ConversationContext` from Redis (TTL: 30 min, key: `conv:{business_id}:{phone}`)
|
||||||
|
2. Build system prompt with: assistant name/tone, available slots, collected data, language instruction
|
||||||
|
3. Call Claude API — response must be JSON with `action: collect_more | create_reservation | cancel`
|
||||||
|
4. If `create_reservation` → call `reservations/service.py::create_reservation`, clear context
|
||||||
|
5. If `cancel` → clear context
|
||||||
|
6. Send reply via WhatsApp Graph API
|
||||||
|
7. Save updated context to Redis
|
||||||
|
|
||||||
|
## Key Models
|
||||||
|
|
||||||
|
- **Business** — tenant record with Meta/WhatsApp credentials, plan (`free|basic|pro`), status (`trial|active|suspended`)
|
||||||
|
- **BusinessConfig** — `open_days` (list of 0-6 ints), `open_time/close_time`, `slot_duration` (minutes), `max_per_slot`, `blocked_dates` (PostgreSQL ARRAY columns)
|
||||||
|
- **Reservation** — `status: pending|confirmed|cancelled|no_show`, `source: whatsapp|manual`, `time_end` computed from `slot_duration`
|
||||||
|
- **User** — `role: owner|admin`, `meta_user_id` for Facebook OAuth
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Defined in `backend/.env.example`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/hermesmessages
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
SECRET_KEY=
|
||||||
|
META_APP_ID=
|
||||||
|
META_APP_SECRET=
|
||||||
|
META_WEBHOOK_VERIFY_TOKEN=
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
ENVIRONMENT=development|production
|
||||||
|
```
|
||||||
|
|
||||||
|
## Access Control
|
||||||
|
|
||||||
|
- All endpoints require JWT except `GET/POST /whatsapp/webhook` and `/auth/*`
|
||||||
|
- `/admin/*` routes require `role: admin` (enforced via `require_admin` dependency)
|
||||||
8
backend/.env.example
Normal file
8
backend/.env.example
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/hermesmessages
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
SECRET_KEY=changeme-use-a-long-random-string
|
||||||
|
META_APP_ID=
|
||||||
|
META_APP_SECRET=
|
||||||
|
META_WEBHOOK_VERIFY_TOKEN=
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
ENVIRONMENT=development
|
||||||
38
backend/alembic.ini
Normal file
38
backend/alembic.ini
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
0
backend/alembic/__init__.py
Normal file
0
backend/alembic/__init__.py
Normal file
51
backend/alembic/env.py
Normal file
51
backend/alembic/env.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import asyncio
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
# Importar todos los modelos para que Alembic los detecte
|
||||||
|
import app.modules.auth.models # noqa
|
||||||
|
import app.modules.business.models # noqa
|
||||||
|
import app.modules.reservations.models # noqa
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline():
|
||||||
|
context.configure(
|
||||||
|
url=settings.DATABASE_URL,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection):
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_migrations_online():
|
||||||
|
engine = create_async_engine(settings.DATABASE_URL)
|
||||||
|
async with engine.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
asyncio.run(run_migrations_online())
|
||||||
0
backend/alembic/versions/.gitkeep
Normal file
0
backend/alembic/versions/.gitkeep
Normal file
155
backend/alembic/versions/0001_initial.py
Normal file
155
backend/alembic/versions/0001_initial.py
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
"""initial schema
|
||||||
|
|
||||||
|
Revision ID: 0001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-04-27
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "0001"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# --- Enums ---
|
||||||
|
business_status = postgresql.ENUM("trial", "active", "suspended", name="business_status")
|
||||||
|
business_plan = postgresql.ENUM("free", "basic", "pro", name="business_plan")
|
||||||
|
user_role = postgresql.ENUM("owner", "admin", name="user_role")
|
||||||
|
assistant_tone = postgresql.ENUM("formal", "casual", name="assistant_tone")
|
||||||
|
reservation_status = postgresql.ENUM(
|
||||||
|
"pending", "confirmed", "cancelled", "no_show", name="reservation_status"
|
||||||
|
)
|
||||||
|
reservation_source = postgresql.ENUM("whatsapp", "manual", name="reservation_source")
|
||||||
|
|
||||||
|
for enum in [business_status, business_plan, user_role, assistant_tone,
|
||||||
|
reservation_status, reservation_source]:
|
||||||
|
enum.create(op.get_bind(), checkfirst=True)
|
||||||
|
|
||||||
|
# --- businesses ---
|
||||||
|
op.create_table(
|
||||||
|
"businesses",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(), nullable=False),
|
||||||
|
sa.Column("type", sa.String(), nullable=False),
|
||||||
|
sa.Column("timezone", sa.String(), nullable=False, server_default="UTC"),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
sa.Enum("trial", "active", "suspended", name="business_status"),
|
||||||
|
nullable=False,
|
||||||
|
server_default="trial",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"plan",
|
||||||
|
sa.Enum("free", "basic", "pro", name="business_plan"),
|
||||||
|
nullable=False,
|
||||||
|
server_default="free",
|
||||||
|
),
|
||||||
|
sa.Column("meta_business_id", sa.String(), nullable=True),
|
||||||
|
sa.Column("whatsapp_phone_number_id", sa.String(), nullable=True, unique=True),
|
||||||
|
sa.Column("whatsapp_access_token", sa.String(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.Date(), server_default=sa.func.current_date()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_businesses_id", "businesses", ["id"])
|
||||||
|
|
||||||
|
# --- users ---
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"business_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("businesses.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("email", sa.String(), nullable=False, unique=True),
|
||||||
|
sa.Column("hashed_password", sa.String(), nullable=True),
|
||||||
|
sa.Column("meta_user_id", sa.String(), nullable=True, unique=True),
|
||||||
|
sa.Column(
|
||||||
|
"role",
|
||||||
|
sa.Enum("owner", "admin", name="user_role"),
|
||||||
|
nullable=False,
|
||||||
|
server_default="owner",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("ix_users_id", "users", ["id"])
|
||||||
|
op.create_index("ix_users_email", "users", ["email"])
|
||||||
|
|
||||||
|
# --- business_configs ---
|
||||||
|
op.create_table(
|
||||||
|
"business_configs",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"business_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("businesses.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
|
sa.Column("open_days", postgresql.ARRAY(sa.Integer()), nullable=False, server_default="{}"),
|
||||||
|
sa.Column("open_time", sa.Time(), nullable=False, server_default="09:00"),
|
||||||
|
sa.Column("close_time", sa.Time(), nullable=False, server_default="18:00"),
|
||||||
|
sa.Column("slot_duration", sa.Integer(), nullable=False, server_default="60"),
|
||||||
|
sa.Column("max_per_slot", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("blocked_dates", postgresql.ARRAY(sa.Date()), nullable=False, server_default="{}"),
|
||||||
|
sa.Column("assistant_name", sa.String(), nullable=False, server_default="Hermes"),
|
||||||
|
sa.Column(
|
||||||
|
"tone",
|
||||||
|
sa.Enum("formal", "casual", name="assistant_tone"),
|
||||||
|
nullable=False,
|
||||||
|
server_default="formal",
|
||||||
|
),
|
||||||
|
sa.Column("welcome_message", sa.String(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_business_configs_id", "business_configs", ["id"])
|
||||||
|
|
||||||
|
# --- reservations ---
|
||||||
|
op.create_table(
|
||||||
|
"reservations",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"business_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("businesses.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("client_name", sa.String(), nullable=False),
|
||||||
|
sa.Column("client_phone", sa.String(), nullable=False),
|
||||||
|
sa.Column("date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("time_start", sa.Time(), nullable=False),
|
||||||
|
sa.Column("time_end", sa.Time(), nullable=False),
|
||||||
|
sa.Column("party_size", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
sa.Enum("pending", "confirmed", "cancelled", "no_show", name="reservation_status"),
|
||||||
|
nullable=False,
|
||||||
|
server_default="pending",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"source",
|
||||||
|
sa.Enum("whatsapp", "manual", name="reservation_source"),
|
||||||
|
nullable=False,
|
||||||
|
server_default="manual",
|
||||||
|
),
|
||||||
|
sa.Column("notes", sa.String(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.Date(), server_default=sa.func.current_date()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_reservations_id", "reservations", ["id"])
|
||||||
|
op.create_index("ix_reservations_date", "reservations", ["date"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("reservations")
|
||||||
|
op.drop_table("business_configs")
|
||||||
|
op.drop_table("users")
|
||||||
|
op.drop_table("businesses")
|
||||||
|
|
||||||
|
for name in [
|
||||||
|
"reservation_source", "reservation_status", "assistant_tone",
|
||||||
|
"user_role", "business_plan", "business_status",
|
||||||
|
]:
|
||||||
|
postgresql.ENUM(name=name).drop(op.get_bind(), checkfirst=True)
|
||||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
22
backend/app/core/config.py
Normal file
22
backend/app/core/config.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
DATABASE_URL: str
|
||||||
|
REDIS_URL: str
|
||||||
|
SECRET_KEY: str
|
||||||
|
ENVIRONMENT: str = "development"
|
||||||
|
|
||||||
|
META_APP_ID: str = ""
|
||||||
|
META_APP_SECRET: str = ""
|
||||||
|
META_WEBHOOK_VERIFY_TOKEN: str = ""
|
||||||
|
|
||||||
|
ANTHROPIC_API_KEY: str = ""
|
||||||
|
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
|
||||||
|
ALGORITHM: str = "HS256"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
30
backend/app/core/database.py
Normal file
30
backend/app/core/database.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_engine = None
|
||||||
|
_session_factory = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
global _engine
|
||||||
|
if _engine is None:
|
||||||
|
_engine = create_async_engine(
|
||||||
|
settings.DATABASE_URL,
|
||||||
|
echo=settings.ENVIRONMENT == "development",
|
||||||
|
)
|
||||||
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncSession:
|
||||||
|
global _session_factory
|
||||||
|
if _session_factory is None:
|
||||||
|
_session_factory = async_sessionmaker(get_engine(), expire_on_commit=False)
|
||||||
|
async with _session_factory() as session:
|
||||||
|
yield session
|
||||||
43
backend/app/core/dependencies.py
Normal file
43
backend/app/core/dependencies.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from jose import JWTError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import decode_token
|
||||||
|
|
||||||
|
bearer_scheme = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
from app.modules.auth.service import get_user_by_id
|
||||||
|
|
||||||
|
credentials_exception = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Token inválido o expirado",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = decode_token(credentials.credentials)
|
||||||
|
user_id: str = payload.get("sub")
|
||||||
|
if user_id is None:
|
||||||
|
raise credentials_exception
|
||||||
|
except JWTError:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
user = await get_user_by_id(db, int(user_id))
|
||||||
|
if user is None:
|
||||||
|
raise credentials_exception
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_business(current_user=Depends(get_current_user)):
|
||||||
|
return current_user.business_id
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(current_user=Depends(get_current_user)):
|
||||||
|
if current_user.role != "admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Acceso denegado")
|
||||||
|
return current_user
|
||||||
9
backend/app/core/errors.py
Normal file
9
backend/app/core/errors.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
|
||||||
|
async def global_exception_handler(request: Request, exc: Exception):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"detail": "Error interno del servidor"},
|
||||||
|
)
|
||||||
9
backend/app/core/redis.py
Normal file
9
backend/app/core/redis.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import redis.asyncio as aioredis
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
redis_client: aioredis.Redis = aioredis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_redis() -> aioredis.Redis:
|
||||||
|
return redis_client
|
||||||
27
backend/app/core/security.py
Normal file
27
backend/app/core/security.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
from jose import jwt
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.now(timezone.utc) + (
|
||||||
|
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
)
|
||||||
|
to_encode["exp"] = expire
|
||||||
|
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict:
|
||||||
|
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||||
40
backend/app/main.py
Normal file
40
backend/app/main.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.errors import global_exception_handler
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="HermesMessages API",
|
||||||
|
description="Plataforma SaaS de automatización de reservas via WhatsApp",
|
||||||
|
version="0.1.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"] if settings.ENVIRONMENT == "development" else [],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_exception_handler(Exception, global_exception_handler)
|
||||||
|
|
||||||
|
# Routers — se van añadiendo por módulo
|
||||||
|
from app.modules.auth.router import router as auth_router
|
||||||
|
from app.modules.business.router import router as business_router
|
||||||
|
from app.modules.whatsapp.router import router as whatsapp_router
|
||||||
|
from app.modules.calendar.router import router as calendar_router
|
||||||
|
from app.modules.reservations.router import router as reservations_router
|
||||||
|
from app.modules.dashboard.router import router as dashboard_router
|
||||||
|
from app.modules.billing.router import router as billing_router
|
||||||
|
from app.modules.admin.router import router as admin_router
|
||||||
|
|
||||||
|
app.include_router(auth_router, prefix="/auth", tags=["auth"])
|
||||||
|
app.include_router(business_router, prefix="/business", tags=["business"])
|
||||||
|
app.include_router(whatsapp_router, prefix="/whatsapp", tags=["whatsapp"])
|
||||||
|
app.include_router(calendar_router, prefix="/calendar", tags=["calendar"])
|
||||||
|
app.include_router(reservations_router, prefix="/reservations", tags=["reservations"])
|
||||||
|
app.include_router(dashboard_router, prefix="/dashboard", tags=["dashboard"])
|
||||||
|
app.include_router(billing_router, prefix="/billing", tags=["billing"])
|
||||||
|
app.include_router(admin_router, prefix="/admin", tags=["admin"])
|
||||||
0
backend/app/modules/__init__.py
Normal file
0
backend/app/modules/__init__.py
Normal file
0
backend/app/modules/admin/__init__.py
Normal file
0
backend/app/modules/admin/__init__.py
Normal file
43
backend/app/modules/admin/router.py
Normal file
43
backend/app/modules/admin/router.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import require_admin
|
||||||
|
from app.modules.admin import schemas, service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/businesses", response_model=list[schemas.BusinessSummary])
|
||||||
|
async def list_businesses(
|
||||||
|
_=Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await service.list_businesses(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/businesses/{business_id}", response_model=schemas.BusinessSummary)
|
||||||
|
async def get_business(
|
||||||
|
business_id: int,
|
||||||
|
_=Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await service.get_business(db, business_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/businesses/{business_id}/status", response_model=schemas.BusinessSummary)
|
||||||
|
async def update_status(
|
||||||
|
business_id: int,
|
||||||
|
body: schemas.StatusPatch,
|
||||||
|
_=Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await service.update_business_status(db, business_id, body.status)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats", response_model=schemas.PlatformStats)
|
||||||
|
async def get_stats(
|
||||||
|
_=Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await service.get_platform_stats(db)
|
||||||
21
backend/app/modules/admin/schemas.py
Normal file
21
backend/app/modules/admin/schemas.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessSummary(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
status: str
|
||||||
|
plan: str
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class StatusPatch(BaseModel):
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformStats(BaseModel):
|
||||||
|
total_businesses: int
|
||||||
|
active_businesses: int
|
||||||
|
total_reservations: int
|
||||||
51
backend/app/modules/admin/service.py
Normal file
51
backend/app/modules/admin/service.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.modules.admin.schemas import BusinessSummary, PlatformStats
|
||||||
|
from app.modules.business.models import Business
|
||||||
|
from app.modules.reservations.models import Reservation
|
||||||
|
|
||||||
|
VALID_STATUSES = {"trial", "active", "suspended"}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_businesses(db: AsyncSession) -> list[Business]:
|
||||||
|
result = await db.execute(select(Business).order_by(Business.created_at.desc()))
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_business(db: AsyncSession, business_id: int) -> Business:
|
||||||
|
result = await db.execute(select(Business).where(Business.id == business_id))
|
||||||
|
business = result.scalar_one_or_none()
|
||||||
|
if not business:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Negocio no encontrado")
|
||||||
|
return business
|
||||||
|
|
||||||
|
|
||||||
|
async def update_business_status(db: AsyncSession, business_id: int, new_status: str) -> Business:
|
||||||
|
if new_status not in VALID_STATUSES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Estado inválido. Opciones: {', '.join(VALID_STATUSES)}",
|
||||||
|
)
|
||||||
|
business = await get_business(db, business_id)
|
||||||
|
business.status = new_status
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(business)
|
||||||
|
return business
|
||||||
|
|
||||||
|
|
||||||
|
async def get_platform_stats(db: AsyncSession) -> PlatformStats:
|
||||||
|
total = (await db.execute(select(func.count(Business.id)))).scalar_one()
|
||||||
|
active = (
|
||||||
|
await db.execute(
|
||||||
|
select(func.count(Business.id)).where(Business.status == "active")
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
reservations = (await db.execute(select(func.count(Reservation.id)))).scalar_one()
|
||||||
|
|
||||||
|
return PlatformStats(
|
||||||
|
total_businesses=total,
|
||||||
|
active_businesses=active,
|
||||||
|
total_reservations=reservations,
|
||||||
|
)
|
||||||
0
backend/app/modules/auth/__init__.py
Normal file
0
backend/app/modules/auth/__init__.py
Normal file
17
backend/app/modules/auth/models.py
Normal file
17
backend/app/modules/auth/models.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
from sqlalchemy import Column, Enum, ForeignKey, Integer, String
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
business_id = Column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
email = Column(String, unique=True, nullable=False, index=True)
|
||||||
|
hashed_password = Column(String, nullable=True)
|
||||||
|
meta_user_id = Column(String, nullable=True, unique=True)
|
||||||
|
role = Column(Enum("owner", "admin", name="user_role"), nullable=False, default="owner")
|
||||||
|
|
||||||
|
business = relationship("Business", back_populates="users")
|
||||||
55
backend/app/modules/auth/router.py
Normal file
55
backend/app/modules/auth/router.py
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
|
from jose import JWTError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import create_access_token, decode_token
|
||||||
|
from app.modules.auth import schemas, service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=schemas.RegisterResponse, status_code=201)
|
||||||
|
async def register(body: schemas.RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
token, business_id, user_id = await service.register_business(
|
||||||
|
db,
|
||||||
|
business_name=body.business_name,
|
||||||
|
business_type=body.business_type,
|
||||||
|
timezone=body.timezone,
|
||||||
|
email=body.email,
|
||||||
|
password=body.password,
|
||||||
|
)
|
||||||
|
return schemas.RegisterResponse(
|
||||||
|
access_token=token,
|
||||||
|
business_id=business_id,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=schemas.TokenResponse)
|
||||||
|
async def login(body: schemas.LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
token = await service.authenticate_user(db, body.email, body.password)
|
||||||
|
return schemas.TokenResponse(access_token=token)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/meta-callback", response_model=schemas.TokenResponse)
|
||||||
|
async def meta_callback(body: schemas.MetaCallbackRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
token = await service.meta_oauth_login(db, body.code, body.redirect_uri)
|
||||||
|
return schemas.TokenResponse(access_token=token)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=schemas.TokenResponse)
|
||||||
|
async def refresh(body: schemas.RefreshRequest):
|
||||||
|
try:
|
||||||
|
payload = decode_token(body.access_token)
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token inválido")
|
||||||
|
new_token = create_access_token(
|
||||||
|
{"sub": payload["sub"], "business_id": payload["business_id"]}
|
||||||
|
)
|
||||||
|
return schemas.TokenResponse(access_token=new_token)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout", status_code=204)
|
||||||
|
async def logout():
|
||||||
|
return Response(status_code=204)
|
||||||
56
backend/app/modules/auth/schemas.py
Normal file
56
backend/app/modules/auth/schemas.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
from pydantic import BaseModel, EmailStr, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterRequest(BaseModel):
|
||||||
|
business_name: str
|
||||||
|
business_type: str
|
||||||
|
timezone: str = "UTC"
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
@field_validator("password")
|
||||||
|
@classmethod
|
||||||
|
def password_strength(cls, v: str) -> str:
|
||||||
|
if len(v) < 8:
|
||||||
|
raise ValueError("La contraseña debe tener al menos 8 caracteres")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("business_name")
|
||||||
|
@classmethod
|
||||||
|
def business_name_not_empty(cls, v: str) -> str:
|
||||||
|
if not v.strip():
|
||||||
|
raise ValueError("El nombre del negocio no puede estar vacío")
|
||||||
|
return v.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterResponse(TokenResponse):
|
||||||
|
business_id: int
|
||||||
|
user_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshRequest(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class MetaCallbackRequest(BaseModel):
|
||||||
|
code: str
|
||||||
|
redirect_uri: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
business_id: int
|
||||||
|
email: str
|
||||||
|
role: str
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
153
backend/app/modules/auth/service.py
Normal file
153
backend/app/modules/auth/service.py
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
from datetime import time as dtime
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import create_access_token, hash_password, verify_password
|
||||||
|
from app.modules.auth.models import User
|
||||||
|
from app.modules.business.models import Business, BusinessConfig
|
||||||
|
|
||||||
|
|
||||||
|
def _token_for_user(user: User) -> str:
|
||||||
|
return create_access_token({"sub": str(user.id), "business_id": user.business_id})
|
||||||
|
|
||||||
|
|
||||||
|
async def register_business(
|
||||||
|
db: AsyncSession,
|
||||||
|
business_name: str,
|
||||||
|
business_type: str,
|
||||||
|
timezone: str,
|
||||||
|
email: str,
|
||||||
|
password: str,
|
||||||
|
) -> tuple[str, int, int]:
|
||||||
|
existing = await get_user_by_email(db, email)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="El correo ya está registrado",
|
||||||
|
)
|
||||||
|
|
||||||
|
business = Business(
|
||||||
|
name=business_name,
|
||||||
|
type=business_type,
|
||||||
|
timezone=timezone,
|
||||||
|
status="trial",
|
||||||
|
plan="free",
|
||||||
|
)
|
||||||
|
db.add(business)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
BusinessConfig(
|
||||||
|
business_id=business.id,
|
||||||
|
open_days=[0, 1, 2, 3, 4],
|
||||||
|
open_time=dtime(9, 0),
|
||||||
|
close_time=dtime(18, 0),
|
||||||
|
slot_duration=60,
|
||||||
|
max_per_slot=1,
|
||||||
|
blocked_dates=[],
|
||||||
|
assistant_name="Hermes",
|
||||||
|
tone="formal",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
hashed = await asyncio.to_thread(hash_password, password)
|
||||||
|
user = User(
|
||||||
|
business_id=business.id,
|
||||||
|
email=email,
|
||||||
|
hashed_password=hashed,
|
||||||
|
role="owner",
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="El correo ya está registrado",
|
||||||
|
)
|
||||||
|
|
||||||
|
return _token_for_user(user), business.id, user.id
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None:
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
|
||||||
|
result = await db.execute(select(User).where(User.email == email))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def authenticate_user(db: AsyncSession, email: str, password: str) -> str:
|
||||||
|
user = await get_user_by_email(db, email)
|
||||||
|
if not user or not user.hashed_password:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Credenciales incorrectas",
|
||||||
|
)
|
||||||
|
if not await asyncio.to_thread(verify_password, password, user.hashed_password):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Credenciales incorrectas",
|
||||||
|
)
|
||||||
|
return _token_for_user(user)
|
||||||
|
|
||||||
|
|
||||||
|
async def exchange_meta_code(code: str, redirect_uri: str) -> dict:
|
||||||
|
"""Intercambia el código de autorización de Meta por un access token."""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(
|
||||||
|
"https://graph.facebook.com/v20.0/oauth/access_token",
|
||||||
|
params={
|
||||||
|
"client_id": settings.META_APP_ID,
|
||||||
|
"client_secret": settings.META_APP_SECRET,
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Error al intercambiar código con Meta",
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_meta_user_info(access_token: str) -> dict:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(
|
||||||
|
"https://graph.facebook.com/me",
|
||||||
|
params={"fields": "id,email", "access_token": access_token},
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Error al obtener información del usuario de Meta",
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def meta_oauth_login(db: AsyncSession, code: str, redirect_uri: str) -> str:
|
||||||
|
token_data = await exchange_meta_code(code, redirect_uri)
|
||||||
|
meta_info = await get_meta_user_info(token_data["access_token"])
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.meta_user_id == meta_info["id"]))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Usuario no encontrado. Completa el registro primero.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return _token_for_user(user)
|
||||||
0
backend/app/modules/billing/__init__.py
Normal file
0
backend/app/modules/billing/__init__.py
Normal file
33
backend/app/modules/billing/router.py
Normal file
33
backend/app/modules/billing/router.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import get_current_business
|
||||||
|
from app.modules.billing import schemas, service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/plan", response_model=schemas.PlanRead)
|
||||||
|
async def get_plan(
|
||||||
|
business_id: int = Depends(get_current_business),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await service.get_plan(db, business_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upgrade", response_model=schemas.PlanRead)
|
||||||
|
async def upgrade_plan(
|
||||||
|
body: schemas.UpgradeRequest,
|
||||||
|
business_id: int = Depends(get_current_business),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await service.upgrade_plan(db, business_id, body.plan)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/usage", response_model=schemas.UsageRead)
|
||||||
|
async def get_usage(
|
||||||
|
business_id: int = Depends(get_current_business),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await service.get_usage(db, business_id)
|
||||||
25
backend/app/modules/billing/schemas.py
Normal file
25
backend/app/modules/billing/schemas.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
PLAN_LIMITS = {
|
||||||
|
"free": 50,
|
||||||
|
"basic": 500,
|
||||||
|
"pro": -1, # ilimitado
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PlanRead(BaseModel):
|
||||||
|
plan: str
|
||||||
|
status: str
|
||||||
|
monthly_limit: int
|
||||||
|
is_unlimited: bool
|
||||||
|
|
||||||
|
|
||||||
|
class UpgradeRequest(BaseModel):
|
||||||
|
plan: str
|
||||||
|
|
||||||
|
|
||||||
|
class UsageRead(BaseModel):
|
||||||
|
plan: str
|
||||||
|
reservations_this_month: int
|
||||||
|
monthly_limit: int
|
||||||
|
is_unlimited: bool
|
||||||
59
backend/app/modules/billing/service.py
Normal file
59
backend/app/modules/billing/service.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import and_, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.modules.billing.schemas import PLAN_LIMITS, PlanRead, UsageRead
|
||||||
|
from app.modules.business.models import Business
|
||||||
|
from app.modules.business.service import get_business
|
||||||
|
from app.modules.reservations.models import Reservation
|
||||||
|
|
||||||
|
VALID_PLANS = set(PLAN_LIMITS.keys())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_plan(db: AsyncSession, business_id: int) -> PlanRead:
|
||||||
|
business = await get_business(db, business_id)
|
||||||
|
limit = PLAN_LIMITS[business.plan]
|
||||||
|
return PlanRead(
|
||||||
|
plan=business.plan,
|
||||||
|
status=business.status,
|
||||||
|
monthly_limit=limit,
|
||||||
|
is_unlimited=limit == -1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def upgrade_plan(db: AsyncSession, business_id: int, new_plan: str) -> PlanRead:
|
||||||
|
if new_plan not in VALID_PLANS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Plan inválido. Opciones: {', '.join(VALID_PLANS)}",
|
||||||
|
)
|
||||||
|
business = await get_business(db, business_id)
|
||||||
|
business.plan = new_plan
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(business)
|
||||||
|
return await get_plan(db, business_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_usage(db: AsyncSession, business_id: int) -> UsageRead:
|
||||||
|
business = await get_business(db, business_id)
|
||||||
|
month_start = date.today().replace(day=1)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(func.count(Reservation.id)).where(
|
||||||
|
and_(
|
||||||
|
Reservation.business_id == business_id,
|
||||||
|
Reservation.date >= month_start,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
count = result.scalar_one()
|
||||||
|
limit = PLAN_LIMITS[business.plan]
|
||||||
|
|
||||||
|
return UsageRead(
|
||||||
|
plan=business.plan,
|
||||||
|
reservations_this_month=count,
|
||||||
|
monthly_limit=limit,
|
||||||
|
is_unlimited=limit == -1,
|
||||||
|
)
|
||||||
0
backend/app/modules/bot_engine/__init__.py
Normal file
0
backend/app/modules/bot_engine/__init__.py
Normal file
73
backend/app/modules/bot_engine/prompt.py
Normal file
73
backend/app/modules/bot_engine/prompt.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
from app.modules.bot_engine.schemas import CollectedData, ConversationContext
|
||||||
|
from app.modules.business.models import Business, BusinessConfig
|
||||||
|
from app.modules.calendar.schemas import DayAvailability
|
||||||
|
|
||||||
|
DAYS_ES = ["lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo"]
|
||||||
|
|
||||||
|
|
||||||
|
def _format_open_days(open_days: list[int]) -> str:
|
||||||
|
return ", ".join(DAYS_ES[d] for d in sorted(open_days))
|
||||||
|
|
||||||
|
|
||||||
|
def _format_slots(availability: DayAvailability | None) -> str:
|
||||||
|
if not availability or not availability.is_open:
|
||||||
|
return "No hay disponibilidad para esa fecha."
|
||||||
|
available = [s for s in availability.slots if s.available > 0]
|
||||||
|
if not available:
|
||||||
|
return "No quedan slots disponibles para esa fecha."
|
||||||
|
return ", ".join(s.time_start.strftime("%H:%M") for s in available)
|
||||||
|
|
||||||
|
|
||||||
|
def build_system_prompt(
|
||||||
|
business: Business,
|
||||||
|
config: BusinessConfig,
|
||||||
|
availability: DayAvailability | None,
|
||||||
|
context: ConversationContext,
|
||||||
|
) -> str:
|
||||||
|
tone_instruction = (
|
||||||
|
"Usa un tono formal y profesional."
|
||||||
|
if config.tone == "formal"
|
||||||
|
else "Usa un tono amigable y cercano."
|
||||||
|
)
|
||||||
|
|
||||||
|
collected = context.collected_data
|
||||||
|
collected_summary = "\n".join([
|
||||||
|
f"- Nombre: {collected.client_name or 'pendiente'}",
|
||||||
|
f"- Fecha: {collected.date or 'pendiente'}",
|
||||||
|
f"- Hora: {collected.time_start or 'pendiente'}",
|
||||||
|
f"- Personas: {collected.party_size or 'pendiente'}",
|
||||||
|
])
|
||||||
|
|
||||||
|
slots_info = _format_slots(availability)
|
||||||
|
|
||||||
|
return f"""Eres {config.assistant_name}, asistente virtual de {business.name}.
|
||||||
|
{tone_instruction}
|
||||||
|
Responde SIEMPRE en el idioma del cliente.
|
||||||
|
|
||||||
|
HORARIO DEL NEGOCIO:
|
||||||
|
- Días de atención: {_format_open_days(config.open_days or [])}
|
||||||
|
- Horario: {config.open_time.strftime("%H:%M")} a {config.close_time.strftime("%H:%M")}
|
||||||
|
- Duración de cada turno: {config.slot_duration} minutos
|
||||||
|
- Capacidad por turno: {config.max_per_slot} persona(s)
|
||||||
|
|
||||||
|
SLOTS DISPONIBLES PARA LA FECHA SOLICITADA: {slots_info}
|
||||||
|
|
||||||
|
DATOS YA RECOPILADOS DEL CLIENTE:
|
||||||
|
{collected_summary}
|
||||||
|
|
||||||
|
OBJETIVO: Recopilar nombre, fecha, hora y número de personas para crear la reserva.
|
||||||
|
Si ya tienes todos los datos, acción = "create_reservation".
|
||||||
|
Si el cliente quiere cancelar, acción = "cancel".
|
||||||
|
En cualquier otro caso, acción = "collect_more".
|
||||||
|
|
||||||
|
Responde ÚNICAMENTE con JSON válido siguiendo este esquema exacto (sin markdown, sin explicaciones):
|
||||||
|
{{
|
||||||
|
"message": "<texto para enviar al cliente>",
|
||||||
|
"action": "collect_more" | "create_reservation" | "cancel",
|
||||||
|
"collected_data": {{
|
||||||
|
"client_name": null | "<nombre>",
|
||||||
|
"date": null | "<YYYY-MM-DD>",
|
||||||
|
"time_start": null | "<HH:MM>",
|
||||||
|
"party_size": null | <número>
|
||||||
|
}}
|
||||||
|
}}"""
|
||||||
21
backend/app/modules/bot_engine/schemas.py
Normal file
21
backend/app/modules/bot_engine/schemas.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class CollectedData(BaseModel):
|
||||||
|
client_name: str | None = None
|
||||||
|
date: str | None = None # YYYY-MM-DD
|
||||||
|
time_start: str | None = None # HH:MM
|
||||||
|
party_size: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationContext(BaseModel):
|
||||||
|
phone: str
|
||||||
|
business_id: int
|
||||||
|
collected_data: CollectedData = CollectedData()
|
||||||
|
messages: list[dict] = [] # historial {role, content} para Claude
|
||||||
|
|
||||||
|
|
||||||
|
class BotResponse(BaseModel):
|
||||||
|
message: str
|
||||||
|
action: str # "collect_more" | "create_reservation" | "cancel"
|
||||||
|
collected_data: CollectedData
|
||||||
145
backend/app/modules/bot_engine/service.py
Normal file
145
backend/app/modules/bot_engine/service.py
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import date, time
|
||||||
|
|
||||||
|
import anthropic
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.modules.bot_engine.prompt import build_system_prompt
|
||||||
|
from app.modules.bot_engine.schemas import BotResponse, CollectedData, ConversationContext
|
||||||
|
from app.modules.business.models import Business
|
||||||
|
from app.modules.business.service import get_business_config
|
||||||
|
from app.modules.calendar.service import get_available_slots
|
||||||
|
from app.modules.reservations.schemas import ReservationCreate
|
||||||
|
from app.modules.reservations.service import create_reservation
|
||||||
|
from app.modules.whatsapp.client import send_text_message
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CONTEXT_TTL = 1800 # 30 minutos
|
||||||
|
MODEL = "claude-sonnet-4-20250514"
|
||||||
|
|
||||||
|
_anthropic = anthropic.AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
def _context_key(business_id: int, phone: str) -> str:
|
||||||
|
return f"conv:{business_id}:{phone}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_context(redis: aioredis.Redis, business_id: int, phone: str) -> ConversationContext:
|
||||||
|
raw = await redis.get(_context_key(business_id, phone))
|
||||||
|
if raw:
|
||||||
|
return ConversationContext.model_validate_json(raw)
|
||||||
|
return ConversationContext(phone=phone, business_id=business_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_context(redis: aioredis.Redis, context: ConversationContext) -> None:
|
||||||
|
await redis.setex(
|
||||||
|
_context_key(context.business_id, context.phone),
|
||||||
|
CONTEXT_TTL,
|
||||||
|
context.model_dump_json(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _clear_context(redis: aioredis.Redis, business_id: int, phone: str) -> None:
|
||||||
|
await redis.delete(_context_key(business_id, phone))
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_claude(system_prompt: str, messages: list[dict]) -> BotResponse:
|
||||||
|
response = await _anthropic.messages.create(
|
||||||
|
model=MODEL,
|
||||||
|
max_tokens=1024,
|
||||||
|
system=system_prompt,
|
||||||
|
messages=messages,
|
||||||
|
)
|
||||||
|
raw_text = response.content[0].text.strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(raw_text)
|
||||||
|
return BotResponse.model_validate(data)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Claude devolvió JSON inválido: %s", raw_text)
|
||||||
|
return BotResponse(
|
||||||
|
message=raw_text,
|
||||||
|
action="collect_more",
|
||||||
|
collected_data=CollectedData(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_create_reservation(
|
||||||
|
db: AsyncSession,
|
||||||
|
redis: aioredis.Redis,
|
||||||
|
business: Business,
|
||||||
|
phone: str,
|
||||||
|
bot_response: BotResponse,
|
||||||
|
) -> None:
|
||||||
|
cd = bot_response.collected_data
|
||||||
|
if not all([cd.client_name, cd.date, cd.time_start, cd.party_size]):
|
||||||
|
return
|
||||||
|
|
||||||
|
await create_reservation(
|
||||||
|
db=db,
|
||||||
|
redis=redis,
|
||||||
|
business_id=business.id,
|
||||||
|
data=ReservationCreate(
|
||||||
|
client_name=cd.client_name,
|
||||||
|
client_phone=phone,
|
||||||
|
date=date.fromisoformat(cd.date),
|
||||||
|
time_start=time.fromisoformat(cd.time_start),
|
||||||
|
party_size=cd.party_size,
|
||||||
|
source="whatsapp",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await _clear_context(redis, business.id, phone)
|
||||||
|
|
||||||
|
|
||||||
|
async def process_message(
|
||||||
|
db: AsyncSession,
|
||||||
|
phone: str,
|
||||||
|
text: str,
|
||||||
|
business: Business,
|
||||||
|
) -> None:
|
||||||
|
redis: aioredis.Redis = aioredis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
context = await _load_context(redis, business.id, phone)
|
||||||
|
config = await get_business_config(db, business.id)
|
||||||
|
|
||||||
|
availability = None
|
||||||
|
if context.collected_data.date:
|
||||||
|
try:
|
||||||
|
availability = await get_available_slots(
|
||||||
|
db, redis, business.id, date.fromisoformat(context.collected_data.date)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
system_prompt = build_system_prompt(business, config, availability, context)
|
||||||
|
|
||||||
|
context.messages.append({"role": "user", "content": text})
|
||||||
|
|
||||||
|
bot_response = await _call_claude(system_prompt, context.messages)
|
||||||
|
|
||||||
|
context.messages.append({"role": "assistant", "content": bot_response.message})
|
||||||
|
context.collected_data = bot_response.collected_data
|
||||||
|
|
||||||
|
if bot_response.action == "create_reservation":
|
||||||
|
await _handle_create_reservation(db, redis, business, phone, bot_response)
|
||||||
|
elif bot_response.action == "cancel":
|
||||||
|
await _clear_context(redis, business.id, phone)
|
||||||
|
else:
|
||||||
|
await _save_context(redis, context)
|
||||||
|
|
||||||
|
await send_text_message(
|
||||||
|
phone_number_id=business.whatsapp_phone_number_id,
|
||||||
|
access_token=business.whatsapp_access_token,
|
||||||
|
to=phone,
|
||||||
|
text=bot_response.message,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Error procesando mensaje de %s: %s", phone, exc)
|
||||||
|
finally:
|
||||||
|
await redis.aclose()
|
||||||
0
backend/app/modules/business/__init__.py
Normal file
0
backend/app/modules/business/__init__.py
Normal file
65
backend/app/modules/business/models.py
Normal file
65
backend/app/modules/business/models.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
from datetime import date, time
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Column,
|
||||||
|
Date,
|
||||||
|
Enum,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Time,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import ARRAY
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Business(Base):
|
||||||
|
__tablename__ = "businesses"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
type = Column(String, nullable=False)
|
||||||
|
timezone = Column(String, nullable=False, default="UTC")
|
||||||
|
status = Column(
|
||||||
|
Enum("trial", "active", "suspended", name="business_status"),
|
||||||
|
nullable=False,
|
||||||
|
default="trial",
|
||||||
|
)
|
||||||
|
plan = Column(
|
||||||
|
Enum("free", "basic", "pro", name="business_plan"),
|
||||||
|
nullable=False,
|
||||||
|
default="free",
|
||||||
|
)
|
||||||
|
meta_business_id = Column(String, nullable=True)
|
||||||
|
whatsapp_phone_number_id = Column(String, nullable=True, unique=True)
|
||||||
|
whatsapp_access_token = Column(String, nullable=True)
|
||||||
|
created_at = Column(Date, server_default=func.current_date())
|
||||||
|
|
||||||
|
users = relationship("User", back_populates="business", cascade="all, delete-orphan")
|
||||||
|
config = relationship("BusinessConfig", back_populates="business", uselist=False)
|
||||||
|
reservations = relationship("Reservation", back_populates="business")
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessConfig(Base):
|
||||||
|
__tablename__ = "business_configs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
business_id = Column(
|
||||||
|
Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, unique=True
|
||||||
|
)
|
||||||
|
open_days = Column(ARRAY(Integer), nullable=False, default=list)
|
||||||
|
open_time = Column(Time, nullable=False, default=time(9, 0))
|
||||||
|
close_time = Column(Time, nullable=False, default=time(18, 0))
|
||||||
|
slot_duration = Column(Integer, nullable=False, default=60)
|
||||||
|
max_per_slot = Column(Integer, nullable=False, default=1)
|
||||||
|
blocked_dates = Column(ARRAY(Date), nullable=False, default=list)
|
||||||
|
assistant_name = Column(String, nullable=False, default="Hermes")
|
||||||
|
tone = Column(
|
||||||
|
Enum("formal", "casual", name="assistant_tone"), nullable=False, default="formal"
|
||||||
|
)
|
||||||
|
welcome_message = Column(String, nullable=True)
|
||||||
|
|
||||||
|
business = relationship("Business", back_populates="config")
|
||||||
42
backend/app/modules/business/router.py
Normal file
42
backend/app/modules/business/router.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import get_current_business, get_current_user
|
||||||
|
from app.modules.business import schemas, service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=schemas.BusinessRead)
|
||||||
|
async def get_my_business(
|
||||||
|
business_id: int = Depends(get_current_business),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await service.get_business(db, business_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/me", response_model=schemas.BusinessRead)
|
||||||
|
async def update_my_business(
|
||||||
|
body: schemas.BusinessUpdate,
|
||||||
|
business_id: int = Depends(get_current_business),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await service.update_business(db, business_id, body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/config", response_model=schemas.BusinessConfigRead)
|
||||||
|
async def get_my_config(
|
||||||
|
business_id: int = Depends(get_current_business),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await service.get_business_config(db, business_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/me/config", response_model=schemas.BusinessConfigRead)
|
||||||
|
async def update_my_config(
|
||||||
|
body: schemas.BusinessConfigUpdate,
|
||||||
|
business_id: int = Depends(get_current_business),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await service.update_business_config(db, business_id, body)
|
||||||
48
backend/app/modules/business/schemas.py
Normal file
48
backend/app/modules/business/schemas.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
from datetime import date, time
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
timezone: str
|
||||||
|
status: str
|
||||||
|
plan: str
|
||||||
|
meta_business_id: str | None
|
||||||
|
whatsapp_phone_number_id: str | None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
type: str | None = None
|
||||||
|
timezone: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessConfigRead(BaseModel):
|
||||||
|
open_days: list[int]
|
||||||
|
open_time: time
|
||||||
|
close_time: time
|
||||||
|
slot_duration: int
|
||||||
|
max_per_slot: int
|
||||||
|
blocked_dates: list[date]
|
||||||
|
assistant_name: str
|
||||||
|
tone: str
|
||||||
|
welcome_message: str | None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessConfigUpdate(BaseModel):
|
||||||
|
open_days: list[int] | None = None
|
||||||
|
open_time: time | None = None
|
||||||
|
close_time: time | None = None
|
||||||
|
slot_duration: int | None = None
|
||||||
|
max_per_slot: int | None = None
|
||||||
|
blocked_dates: list[date] | None = None
|
||||||
|
assistant_name: str | None = None
|
||||||
|
tone: str | None = None
|
||||||
|
welcome_message: str | None = None
|
||||||
49
backend/app/modules/business/service.py
Normal file
49
backend/app/modules/business/service.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.modules.business.models import Business, BusinessConfig
|
||||||
|
from app.modules.business.schemas import BusinessConfigUpdate, BusinessUpdate
|
||||||
|
|
||||||
|
|
||||||
|
async def get_business(db: AsyncSession, business_id: int) -> Business:
|
||||||
|
result = await db.execute(select(Business).where(Business.id == business_id))
|
||||||
|
business = result.scalar_one_or_none()
|
||||||
|
if not business:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Negocio no encontrado")
|
||||||
|
return business
|
||||||
|
|
||||||
|
|
||||||
|
async def update_business(
|
||||||
|
db: AsyncSession, business_id: int, data: BusinessUpdate
|
||||||
|
) -> Business:
|
||||||
|
business = await get_business(db, business_id)
|
||||||
|
for field, value in data.model_dump(exclude_none=True).items():
|
||||||
|
setattr(business, field, value)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(business)
|
||||||
|
return business
|
||||||
|
|
||||||
|
|
||||||
|
async def get_business_config(db: AsyncSession, business_id: int) -> BusinessConfig:
|
||||||
|
result = await db.execute(
|
||||||
|
select(BusinessConfig).where(BusinessConfig.business_id == business_id)
|
||||||
|
)
|
||||||
|
config = result.scalar_one_or_none()
|
||||||
|
if not config:
|
||||||
|
config = BusinessConfig(business_id=business_id)
|
||||||
|
db.add(config)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(config)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
async def update_business_config(
|
||||||
|
db: AsyncSession, business_id: int, data: BusinessConfigUpdate
|
||||||
|
) -> BusinessConfig:
|
||||||
|
config = await get_business_config(db, business_id)
|
||||||
|
for field, value in data.model_dump(exclude_none=True).items():
|
||||||
|
setattr(config, field, value)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(config)
|
||||||
|
return config
|
||||||
0
backend/app/modules/calendar/__init__.py
Normal file
0
backend/app/modules/calendar/__init__.py
Normal file
57
backend/app/modules/calendar/router.py
Normal file
57
backend/app/modules/calendar/router.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
from fastapi import APIRouter, Depends, Response
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import get_current_business
|
||||||
|
from app.core.redis import get_redis
|
||||||
|
from app.modules.calendar import schemas, service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/availability", response_model=schemas.DayAvailability)
|
||||||
|
async def get_availability(
|
||||||
|
date: date,
|
||||||
|
business_id: int = Depends(get_current_business),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis: aioredis.Redis = Depends(get_redis),
|
||||||
|
):
|
||||||
|
return await service.get_available_slots(db, redis, business_id, date)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/availability/range", response_model=list[schemas.DayAvailability])
|
||||||
|
async def get_availability_range(
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
business_id: int = Depends(get_current_business),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis: aioredis.Redis = Depends(get_redis),
|
||||||
|
):
|
||||||
|
return await service.get_availability_range(db, redis, business_id, start, end)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/blocked-dates", status_code=201)
|
||||||
|
async def add_blocked_date(
|
||||||
|
body: schemas.BlockedDateRequest,
|
||||||
|
business_id: int = Depends(get_current_business),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis: aioredis.Redis = Depends(get_redis),
|
||||||
|
):
|
||||||
|
await service.add_blocked_date(db, business_id, body.date)
|
||||||
|
await service.invalidate_slots_cache(redis, business_id, body.date)
|
||||||
|
return {"detail": "Fecha bloqueada"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/blocked-dates/{target_date}", status_code=204)
|
||||||
|
async def remove_blocked_date(
|
||||||
|
target_date: date,
|
||||||
|
business_id: int = Depends(get_current_business),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis: aioredis.Redis = Depends(get_redis),
|
||||||
|
):
|
||||||
|
await service.remove_blocked_date(db, business_id, target_date)
|
||||||
|
await service.invalidate_slots_cache(redis, business_id, target_date)
|
||||||
|
return Response(status_code=204)
|
||||||
21
backend/app/modules/calendar/schemas.py
Normal file
21
backend/app/modules/calendar/schemas.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
from datetime import date as Date, time as Time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class SlotRead(BaseModel):
|
||||||
|
time_start: Time
|
||||||
|
time_end: Time
|
||||||
|
available: int
|
||||||
|
max_per_slot: int
|
||||||
|
|
||||||
|
|
||||||
|
class DayAvailability(BaseModel):
|
||||||
|
date: Date
|
||||||
|
is_open: bool
|
||||||
|
slots: list[SlotRead]
|
||||||
|
|
||||||
|
|
||||||
|
class BlockedDateRequest(BaseModel):
|
||||||
|
date: Date
|
||||||
135
backend/app/modules/calendar/service.py
Normal file
135
backend/app/modules/calendar/service.py
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import json
|
||||||
|
from datetime import date, datetime, time, timedelta
|
||||||
|
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import and_, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.modules.business.models import BusinessConfig
|
||||||
|
from app.modules.business.service import get_business_config
|
||||||
|
from app.modules.calendar.schemas import DayAvailability, SlotRead
|
||||||
|
from app.modules.reservations.models import Reservation
|
||||||
|
|
||||||
|
SLOTS_CACHE_TTL = 300 # 5 minutos
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_key(business_id: int, target_date: date) -> str:
|
||||||
|
return f"slots:{business_id}:{target_date.isoformat()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_slots(open_time: time, close_time: time, slot_duration: int) -> list[tuple[time, time]]:
|
||||||
|
slots = []
|
||||||
|
base = date.today()
|
||||||
|
current = datetime.combine(base, open_time)
|
||||||
|
end = datetime.combine(base, close_time)
|
||||||
|
delta = timedelta(minutes=slot_duration)
|
||||||
|
while current + delta <= end:
|
||||||
|
slots.append((current.time(), (current + delta).time()))
|
||||||
|
current += delta
|
||||||
|
return slots
|
||||||
|
|
||||||
|
|
||||||
|
async def _count_reservations_per_slot(
|
||||||
|
db: AsyncSession,
|
||||||
|
business_id: int,
|
||||||
|
target_date: date,
|
||||||
|
slots: list[tuple[time, time]],
|
||||||
|
) -> dict[tuple[time, time], int]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Reservation.time_start, func.count(Reservation.id))
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Reservation.business_id == business_id,
|
||||||
|
Reservation.date == target_date,
|
||||||
|
Reservation.status.in_(["pending", "confirmed"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.group_by(Reservation.time_start)
|
||||||
|
)
|
||||||
|
counts = {row[0]: row[1] for row in result.all()}
|
||||||
|
return {slot: counts.get(slot[0], 0) for slot in slots}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_available_slots(
|
||||||
|
db: AsyncSession,
|
||||||
|
redis: aioredis.Redis,
|
||||||
|
business_id: int,
|
||||||
|
target_date: date,
|
||||||
|
) -> DayAvailability:
|
||||||
|
cache_key = _cache_key(business_id, target_date)
|
||||||
|
cached = await redis.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
return DayAvailability.model_validate_json(cached)
|
||||||
|
|
||||||
|
config = await get_business_config(db, business_id)
|
||||||
|
|
||||||
|
is_open = (
|
||||||
|
target_date.weekday() in (config.open_days or [])
|
||||||
|
and target_date not in (config.blocked_dates or [])
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_open:
|
||||||
|
result = DayAvailability(date=target_date, is_open=False, slots=[])
|
||||||
|
await redis.setex(cache_key, SLOTS_CACHE_TTL, result.model_dump_json())
|
||||||
|
return result
|
||||||
|
|
||||||
|
raw_slots = _generate_slots(config.open_time, config.close_time, config.slot_duration)
|
||||||
|
counts = await _count_reservations_per_slot(db, business_id, target_date, raw_slots)
|
||||||
|
|
||||||
|
slots = [
|
||||||
|
SlotRead(
|
||||||
|
time_start=s[0],
|
||||||
|
time_end=s[1],
|
||||||
|
available=max(0, config.max_per_slot - counts[s]),
|
||||||
|
max_per_slot=config.max_per_slot,
|
||||||
|
)
|
||||||
|
for s in raw_slots
|
||||||
|
]
|
||||||
|
|
||||||
|
result = DayAvailability(date=target_date, is_open=True, slots=slots)
|
||||||
|
await redis.setex(cache_key, SLOTS_CACHE_TTL, result.model_dump_json())
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def get_availability_range(
|
||||||
|
db: AsyncSession,
|
||||||
|
redis: aioredis.Redis,
|
||||||
|
business_id: int,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
) -> list[DayAvailability]:
|
||||||
|
if (end - start).days > 31:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="El rango máximo es 31 días",
|
||||||
|
)
|
||||||
|
days = []
|
||||||
|
current = start
|
||||||
|
while current <= end:
|
||||||
|
days.append(await get_available_slots(db, redis, business_id, current))
|
||||||
|
current += timedelta(days=1)
|
||||||
|
return days
|
||||||
|
|
||||||
|
|
||||||
|
async def invalidate_slots_cache(redis: aioredis.Redis, business_id: int, target_date: date) -> None:
|
||||||
|
await redis.delete(_cache_key(business_id, target_date))
|
||||||
|
|
||||||
|
|
||||||
|
async def add_blocked_date(db: AsyncSession, business_id: int, target_date: date) -> None:
|
||||||
|
config = await get_business_config(db, business_id)
|
||||||
|
blocked = list(config.blocked_dates or [])
|
||||||
|
if target_date not in blocked:
|
||||||
|
blocked.append(target_date)
|
||||||
|
config.blocked_dates = blocked
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_blocked_date(db: AsyncSession, business_id: int, target_date: date) -> None:
|
||||||
|
config = await get_business_config(db, business_id)
|
||||||
|
blocked = list(config.blocked_dates or [])
|
||||||
|
if target_date not in blocked:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fecha no bloqueada")
|
||||||
|
blocked.remove(target_date)
|
||||||
|
config.blocked_dates = blocked
|
||||||
|
await db.commit()
|
||||||
0
backend/app/modules/dashboard/__init__.py
Normal file
0
backend/app/modules/dashboard/__init__.py
Normal file
35
backend/app/modules/dashboard/router.py
Normal file
35
backend/app/modules/dashboard/router.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import get_current_business
|
||||||
|
from app.modules.dashboard import schemas, service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats", response_model=schemas.ReservationStats)
|
||||||
|
async def get_stats(
|
||||||
|
business_id: int = Depends(get_current_business),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await service.get_stats(db, business_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/agenda", response_model=list[schemas.AgendaItem])
|
||||||
|
async def get_agenda(
|
||||||
|
date: date,
|
||||||
|
business_id: int = Depends(get_current_business),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await service.get_agenda(db, business_id, date)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/peak-hours", response_model=list[schemas.PeakHour])
|
||||||
|
async def get_peak_hours(
|
||||||
|
business_id: int = Depends(get_current_business),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await service.get_peak_hours(db, business_id)
|
||||||
26
backend/app/modules/dashboard/schemas.py
Normal file
26
backend/app/modules/dashboard/schemas.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
from datetime import date as Date, time as Time
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ReservationStats(BaseModel):
|
||||||
|
today: int
|
||||||
|
this_week: int
|
||||||
|
this_month: int
|
||||||
|
|
||||||
|
|
||||||
|
class AgendaItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
client_name: str
|
||||||
|
client_phone: str
|
||||||
|
time_start: Time
|
||||||
|
time_end: Time
|
||||||
|
party_size: int
|
||||||
|
status: str
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class PeakHour(BaseModel):
|
||||||
|
hour: int
|
||||||
|
total: int
|
||||||
65
backend/app/modules/dashboard/service.py
Normal file
65
backend/app/modules/dashboard/service.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import and_, extract, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.modules.dashboard.schemas import AgendaItem, PeakHour, ReservationStats
|
||||||
|
from app.modules.reservations.models import Reservation
|
||||||
|
|
||||||
|
|
||||||
|
async def get_stats(db: AsyncSession, business_id: int) -> ReservationStats:
|
||||||
|
today = date.today()
|
||||||
|
week_start = today - timedelta(days=today.weekday())
|
||||||
|
month_start = today.replace(day=1)
|
||||||
|
|
||||||
|
async def count(start: date, end: date) -> int:
|
||||||
|
result = await db.execute(
|
||||||
|
select(func.count(Reservation.id)).where(
|
||||||
|
and_(
|
||||||
|
Reservation.business_id == business_id,
|
||||||
|
Reservation.date >= start,
|
||||||
|
Reservation.date <= end,
|
||||||
|
Reservation.status.in_(["pending", "confirmed"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
return ReservationStats(
|
||||||
|
today=await count(today, today),
|
||||||
|
this_week=await count(week_start, today),
|
||||||
|
this_month=await count(month_start, today),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_agenda(db: AsyncSession, business_id: int, target_date: date) -> list[AgendaItem]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Reservation)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Reservation.business_id == business_id,
|
||||||
|
Reservation.date == target_date,
|
||||||
|
Reservation.status.in_(["pending", "confirmed"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Reservation.time_start)
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_peak_hours(db: AsyncSession, business_id: int) -> list[PeakHour]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(
|
||||||
|
extract("hour", Reservation.time_start).label("hour"),
|
||||||
|
func.count(Reservation.id).label("total"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Reservation.business_id == business_id,
|
||||||
|
Reservation.status.in_(["confirmed", "no_show"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.group_by("hour")
|
||||||
|
.order_by("hour")
|
||||||
|
)
|
||||||
|
return [PeakHour(hour=int(row.hour), total=row.total) for row in result.all()]
|
||||||
0
backend/app/modules/notifications/__init__.py
Normal file
0
backend/app/modules/notifications/__init__.py
Normal file
47
backend/app/modules/notifications/service.py
Normal file
47
backend/app/modules/notifications/service.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from app.modules.whatsapp.client import send_text_message
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_reservation_confirmation(
|
||||||
|
phone_number_id: str,
|
||||||
|
access_token: str,
|
||||||
|
client_phone: str,
|
||||||
|
client_name: str,
|
||||||
|
reservation_date: str,
|
||||||
|
time_start: str,
|
||||||
|
business_name: str,
|
||||||
|
) -> None:
|
||||||
|
message = (
|
||||||
|
f"✅ Reserva confirmada, {client_name}!\n\n"
|
||||||
|
f"📅 Fecha: {reservation_date}\n"
|
||||||
|
f"🕐 Hora: {time_start}\n"
|
||||||
|
f"📍 {business_name}\n\n"
|
||||||
|
"Te esperamos. Si necesitas cancelar, contáctanos."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await send_text_message(phone_number_id, access_token, client_phone, message)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("No se pudo enviar confirmación a %s: %s", client_phone, exc)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_reservation_reminder(
|
||||||
|
phone_number_id: str,
|
||||||
|
access_token: str,
|
||||||
|
client_phone: str,
|
||||||
|
client_name: str,
|
||||||
|
reservation_date: str,
|
||||||
|
time_start: str,
|
||||||
|
business_name: str,
|
||||||
|
) -> None:
|
||||||
|
message = (
|
||||||
|
f"👋 Hola {client_name}, te recordamos tu reserva en {business_name}.\n\n"
|
||||||
|
f"📅 {reservation_date} a las {time_start}\n\n"
|
||||||
|
"¡Te esperamos!"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await send_text_message(phone_number_id, access_token, client_phone, message)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("No se pudo enviar recordatorio a %s: %s", client_phone, exc)
|
||||||
0
backend/app/modules/reservations/__init__.py
Normal file
0
backend/app/modules/reservations/__init__.py
Normal file
31
backend/app/modules/reservations/models.py
Normal file
31
backend/app/modules/reservations/models.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
from sqlalchemy import Column, Date, Enum, ForeignKey, Integer, String, Time, func
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Reservation(Base):
|
||||||
|
__tablename__ = "reservations"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
business_id = Column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
client_name = Column(String, nullable=False)
|
||||||
|
client_phone = Column(String, nullable=False)
|
||||||
|
date = Column(Date, nullable=False, index=True)
|
||||||
|
time_start = Column(Time, nullable=False)
|
||||||
|
time_end = Column(Time, nullable=False)
|
||||||
|
party_size = Column(Integer, nullable=False, default=1)
|
||||||
|
status = Column(
|
||||||
|
Enum("pending", "confirmed", "cancelled", "no_show", name="reservation_status"),
|
||||||
|
nullable=False,
|
||||||
|
default="pending",
|
||||||
|
)
|
||||||
|
source = Column(
|
||||||
|
Enum("whatsapp", "manual", name="reservation_source"),
|
||||||
|
nullable=False,
|
||||||
|
default="manual",
|
||||||
|
)
|
||||||
|
notes = Column(String, nullable=True)
|
||||||
|
created_at = Column(Date, server_default=func.current_date())
|
||||||
|
|
||||||
|
business = relationship("Business", back_populates="reservations")
|
||||||
74
backend/app/modules/reservations/router.py
Normal file
74
backend/app/modules/reservations/router.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
from fastapi import APIRouter, Depends, Response
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import get_current_business
|
||||||
|
from app.core.redis import get_redis
|
||||||
|
from app.modules.reservations import schemas, service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[schemas.ReservationRead])
|
||||||
|
async def list_reservations(
|
||||||
|
date: date | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
business_id: int = Depends(get_current_business),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await service.list_reservations(db, business_id, date, status)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=schemas.ReservationRead, status_code=201)
|
||||||
|
async def create_reservation(
|
||||||
|
body: schemas.ReservationCreate,
|
||||||
|
business_id: int = Depends(get_current_business),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis: aioredis.Redis = Depends(get_redis),
|
||||||
|
):
|
||||||
|
return await service.create_reservation(db, redis, business_id, body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{reservation_id}", response_model=schemas.ReservationRead)
|
||||||
|
async def get_reservation(
|
||||||
|
reservation_id: int,
|
||||||
|
business_id: int = Depends(get_current_business),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await service.get_reservation(db, business_id, reservation_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{reservation_id}", response_model=schemas.ReservationRead)
|
||||||
|
async def update_reservation(
|
||||||
|
reservation_id: int,
|
||||||
|
body: schemas.ReservationUpdate,
|
||||||
|
business_id: int = Depends(get_current_business),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis: aioredis.Redis = Depends(get_redis),
|
||||||
|
):
|
||||||
|
return await service.update_reservation(db, redis, business_id, reservation_id, body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{reservation_id}", status_code=204)
|
||||||
|
async def delete_reservation(
|
||||||
|
reservation_id: int,
|
||||||
|
business_id: int = Depends(get_current_business),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis: aioredis.Redis = Depends(get_redis),
|
||||||
|
):
|
||||||
|
await service.delete_reservation(db, redis, business_id, reservation_id)
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{reservation_id}/status", response_model=schemas.ReservationRead)
|
||||||
|
async def update_status(
|
||||||
|
reservation_id: int,
|
||||||
|
body: schemas.StatusUpdate,
|
||||||
|
business_id: int = Depends(get_current_business),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis: aioredis.Redis = Depends(get_redis),
|
||||||
|
):
|
||||||
|
return await service.update_status(db, redis, business_id, reservation_id, body.status)
|
||||||
44
backend/app/modules/reservations/schemas.py
Normal file
44
backend/app/modules/reservations/schemas.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
from datetime import date as Date, time as Time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ReservationCreate(BaseModel):
|
||||||
|
client_name: str
|
||||||
|
client_phone: str
|
||||||
|
date: Date
|
||||||
|
time_start: Time
|
||||||
|
party_size: int = 1
|
||||||
|
notes: Optional[str] = None
|
||||||
|
source: str = "manual"
|
||||||
|
|
||||||
|
|
||||||
|
class ReservationUpdate(BaseModel):
|
||||||
|
client_name: Optional[str] = None
|
||||||
|
client_phone: Optional[str] = None
|
||||||
|
date: Optional[Date] = None
|
||||||
|
time_start: Optional[Time] = None
|
||||||
|
party_size: Optional[int] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class StatusUpdate(BaseModel):
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class ReservationRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
business_id: int
|
||||||
|
client_name: str
|
||||||
|
client_phone: str
|
||||||
|
date: Date
|
||||||
|
time_start: Time
|
||||||
|
time_end: Time
|
||||||
|
party_size: int
|
||||||
|
status: str
|
||||||
|
source: str
|
||||||
|
notes: Optional[str]
|
||||||
|
created_at: Date
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
140
backend/app/modules/reservations/service.py
Normal file
140
backend/app/modules/reservations/service.py
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import and_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.modules.business.service import get_business_config
|
||||||
|
from app.modules.calendar.service import invalidate_slots_cache
|
||||||
|
from app.modules.reservations.models import Reservation
|
||||||
|
from app.modules.reservations.schemas import ReservationCreate, ReservationUpdate
|
||||||
|
|
||||||
|
VALID_STATUSES = {"pending", "confirmed", "cancelled", "no_show"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_or_404(db: AsyncSession, business_id: int, reservation_id: int) -> Reservation:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Reservation).where(
|
||||||
|
and_(Reservation.id == reservation_id, Reservation.business_id == business_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
reservation = result.scalar_one_or_none()
|
||||||
|
if not reservation:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reserva no encontrada")
|
||||||
|
return reservation
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_time_end(time_start, slot_duration: int):
|
||||||
|
base = datetime.combine(date.today(), time_start)
|
||||||
|
return (base + timedelta(minutes=slot_duration)).time()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_reservations(
|
||||||
|
db: AsyncSession,
|
||||||
|
business_id: int,
|
||||||
|
filter_date: date | None = None,
|
||||||
|
filter_status: str | None = None,
|
||||||
|
) -> list[Reservation]:
|
||||||
|
query = select(Reservation).where(Reservation.business_id == business_id)
|
||||||
|
if filter_date:
|
||||||
|
query = query.where(Reservation.date == filter_date)
|
||||||
|
if filter_status:
|
||||||
|
query = query.where(Reservation.status == filter_status)
|
||||||
|
query = query.order_by(Reservation.date, Reservation.time_start)
|
||||||
|
result = await db.execute(query)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_reservation(
|
||||||
|
db: AsyncSession,
|
||||||
|
redis: aioredis.Redis,
|
||||||
|
business_id: int,
|
||||||
|
data: ReservationCreate,
|
||||||
|
) -> Reservation:
|
||||||
|
config = await get_business_config(db, business_id)
|
||||||
|
time_end = _compute_time_end(data.time_start, config.slot_duration)
|
||||||
|
|
||||||
|
reservation = Reservation(
|
||||||
|
business_id=business_id,
|
||||||
|
client_name=data.client_name,
|
||||||
|
client_phone=data.client_phone,
|
||||||
|
date=data.date,
|
||||||
|
time_start=data.time_start,
|
||||||
|
time_end=time_end,
|
||||||
|
party_size=data.party_size,
|
||||||
|
source=data.source,
|
||||||
|
notes=data.notes,
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db.add(reservation)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(reservation)
|
||||||
|
await invalidate_slots_cache(redis, business_id, data.date)
|
||||||
|
return reservation
|
||||||
|
|
||||||
|
|
||||||
|
async def get_reservation(
|
||||||
|
db: AsyncSession, business_id: int, reservation_id: int
|
||||||
|
) -> Reservation:
|
||||||
|
return await _get_or_404(db, business_id, reservation_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_reservation(
|
||||||
|
db: AsyncSession,
|
||||||
|
redis: aioredis.Redis,
|
||||||
|
business_id: int,
|
||||||
|
reservation_id: int,
|
||||||
|
data: ReservationUpdate,
|
||||||
|
) -> Reservation:
|
||||||
|
reservation = await _get_or_404(db, business_id, reservation_id)
|
||||||
|
old_date = reservation.date
|
||||||
|
|
||||||
|
for field, value in data.model_dump(exclude_none=True).items():
|
||||||
|
setattr(reservation, field, value)
|
||||||
|
|
||||||
|
if data.time_start or data.date:
|
||||||
|
config = await get_business_config(db, business_id)
|
||||||
|
reservation.time_end = _compute_time_end(reservation.time_start, config.slot_duration)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(reservation)
|
||||||
|
|
||||||
|
await invalidate_slots_cache(redis, business_id, old_date)
|
||||||
|
if reservation.date != old_date:
|
||||||
|
await invalidate_slots_cache(redis, business_id, reservation.date)
|
||||||
|
|
||||||
|
return reservation
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_reservation(
|
||||||
|
db: AsyncSession, redis: aioredis.Redis, business_id: int, reservation_id: int
|
||||||
|
) -> None:
|
||||||
|
reservation = await _get_or_404(db, business_id, reservation_id)
|
||||||
|
target_date = reservation.date
|
||||||
|
await db.delete(reservation)
|
||||||
|
await db.commit()
|
||||||
|
await invalidate_slots_cache(redis, business_id, target_date)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_status(
|
||||||
|
db: AsyncSession,
|
||||||
|
redis: aioredis.Redis,
|
||||||
|
business_id: int,
|
||||||
|
reservation_id: int,
|
||||||
|
new_status: str,
|
||||||
|
) -> Reservation:
|
||||||
|
if new_status not in VALID_STATUSES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Estado inválido. Valores permitidos: {', '.join(VALID_STATUSES)}",
|
||||||
|
)
|
||||||
|
reservation = await _get_or_404(db, business_id, reservation_id)
|
||||||
|
reservation.status = new_status
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(reservation)
|
||||||
|
|
||||||
|
if new_status in ("cancelled", "no_show"):
|
||||||
|
await invalidate_slots_cache(redis, business_id, reservation.date)
|
||||||
|
|
||||||
|
return reservation
|
||||||
0
backend/app/modules/whatsapp/__init__.py
Normal file
0
backend/app/modules/whatsapp/__init__.py
Normal file
21
backend/app/modules/whatsapp/client.py
Normal file
21
backend/app/modules/whatsapp/client.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import httpx
|
||||||
|
|
||||||
|
GRAPH_API_VERSION = "v20.0"
|
||||||
|
GRAPH_API_BASE = f"https://graph.facebook.com/{GRAPH_API_VERSION}"
|
||||||
|
|
||||||
|
|
||||||
|
async def send_text_message(phone_number_id: str, access_token: str, to: str, text: str) -> None:
|
||||||
|
url = f"{GRAPH_API_BASE}/{phone_number_id}/messages"
|
||||||
|
payload = {
|
||||||
|
"messaging_product": "whatsapp",
|
||||||
|
"to": to,
|
||||||
|
"type": "text",
|
||||||
|
"text": {"body": text},
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(
|
||||||
|
url,
|
||||||
|
json=payload,
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
76
backend/app/modules/whatsapp/router.py
Normal file
76
backend/app/modules/whatsapp/router.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends, Header, Query, Request, Response
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import get_current_business
|
||||||
|
from app.modules.whatsapp import schemas, service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connect", response_model=dict)
|
||||||
|
async def connect(
|
||||||
|
body: schemas.ConnectRequest,
|
||||||
|
business_id: int = Depends(get_current_business),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await service.connect_whatsapp(db, business_id, body)
|
||||||
|
return {"detail": "WhatsApp conectado correctamente"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status", response_model=schemas.WhatsAppStatusRead)
|
||||||
|
async def get_status(
|
||||||
|
business_id: int = Depends(get_current_business),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.modules.business.models import Business
|
||||||
|
|
||||||
|
result = await db.execute(select(Business).where(Business.id == business_id))
|
||||||
|
business = result.scalar_one_or_none()
|
||||||
|
return schemas.WhatsAppStatusRead(
|
||||||
|
connected=bool(business and business.whatsapp_phone_number_id),
|
||||||
|
phone_number_id=business.whatsapp_phone_number_id if business else None,
|
||||||
|
meta_business_id=business.meta_business_id if business else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/disconnect", status_code=204)
|
||||||
|
async def disconnect(
|
||||||
|
business_id: int = Depends(get_current_business),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await service.disconnect_whatsapp(db, business_id)
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/webhook")
|
||||||
|
async def verify_webhook(
|
||||||
|
hub_mode: str | None = Query(default=None, alias="hub.mode"),
|
||||||
|
hub_verify_token: str | None = Query(default=None, alias="hub.verify_token"),
|
||||||
|
hub_challenge: str | None = Query(default=None, alias="hub.challenge"),
|
||||||
|
):
|
||||||
|
"""Verificación inicial del webhook por parte de Meta."""
|
||||||
|
if hub_mode == "subscribe" and hub_verify_token == settings.META_WEBHOOK_VERIFY_TOKEN:
|
||||||
|
return Response(content=hub_challenge, media_type="text/plain")
|
||||||
|
return Response(status_code=403)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/webhook", status_code=200)
|
||||||
|
async def receive_webhook(
|
||||||
|
request: Request,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
x_hub_signature_256: str | None = Header(default=None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Recibe mensajes de Meta, valida firma y despacha al bot de forma asíncrona."""
|
||||||
|
body_bytes = await request.body()
|
||||||
|
service.verify_signature(body_bytes, x_hub_signature_256 or "")
|
||||||
|
|
||||||
|
payload = schemas.WebhookPayload.model_validate_json(body_bytes)
|
||||||
|
background_tasks.add_task(service.dispatch_webhook, db, payload)
|
||||||
|
|
||||||
|
return {"status": "ok"}
|
||||||
49
backend/app/modules/whatsapp/schemas.py
Normal file
49
backend/app/modules/whatsapp/schemas.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectRequest(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
phone_number_id: str
|
||||||
|
meta_business_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsAppStatusRead(BaseModel):
|
||||||
|
connected: bool
|
||||||
|
phone_number_id: str | None
|
||||||
|
meta_business_id: str | None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Estructuras del payload de Meta ---
|
||||||
|
|
||||||
|
class WhatsAppTextMessage(BaseModel):
|
||||||
|
body: str
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsAppMessage(BaseModel):
|
||||||
|
from_: str
|
||||||
|
id: str
|
||||||
|
type: str
|
||||||
|
text: WhatsAppTextMessage | None = None
|
||||||
|
|
||||||
|
model_config = {"populate_by_name": True}
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsAppValue(BaseModel):
|
||||||
|
messaging_product: str
|
||||||
|
metadata: dict
|
||||||
|
messages: list[WhatsAppMessage] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsAppChange(BaseModel):
|
||||||
|
value: WhatsAppValue
|
||||||
|
field: str
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsAppEntry(BaseModel):
|
||||||
|
id: str
|
||||||
|
changes: list[WhatsAppChange]
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookPayload(BaseModel):
|
||||||
|
object: str
|
||||||
|
entry: list[WhatsAppEntry]
|
||||||
87
backend/app/modules/whatsapp/service.py
Normal file
87
backend/app/modules/whatsapp/service.py
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.modules.business.models import Business
|
||||||
|
from app.modules.whatsapp.schemas import ConnectRequest, WebhookPayload
|
||||||
|
|
||||||
|
|
||||||
|
def verify_signature(payload_bytes: bytes, signature_header: str) -> None:
|
||||||
|
"""Valida X-Hub-Signature-256 enviado por Meta."""
|
||||||
|
if not signature_header or not signature_header.startswith("sha256="):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Firma ausente")
|
||||||
|
|
||||||
|
expected = hmac.new(
|
||||||
|
settings.META_APP_SECRET.encode(),
|
||||||
|
payload_bytes,
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
if not hmac.compare_digest(expected, signature_header[len("sha256="):]):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Firma inválida")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_business_by_phone_number_id(
|
||||||
|
db: AsyncSession, phone_number_id: str
|
||||||
|
) -> Business | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Business).where(Business.whatsapp_phone_number_id == phone_number_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def connect_whatsapp(
|
||||||
|
db: AsyncSession, business_id: int, data: ConnectRequest
|
||||||
|
) -> Business:
|
||||||
|
result = await db.execute(select(Business).where(Business.id == business_id))
|
||||||
|
business = result.scalar_one_or_none()
|
||||||
|
if not business:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Negocio no encontrado")
|
||||||
|
|
||||||
|
business.whatsapp_access_token = data.access_token
|
||||||
|
business.whatsapp_phone_number_id = data.phone_number_id
|
||||||
|
business.meta_business_id = data.meta_business_id
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(business)
|
||||||
|
return business
|
||||||
|
|
||||||
|
|
||||||
|
async def disconnect_whatsapp(db: AsyncSession, business_id: int) -> None:
|
||||||
|
result = await db.execute(select(Business).where(Business.id == business_id))
|
||||||
|
business = result.scalar_one_or_none()
|
||||||
|
if not business:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Negocio no encontrado")
|
||||||
|
|
||||||
|
business.whatsapp_access_token = None
|
||||||
|
business.whatsapp_phone_number_id = None
|
||||||
|
business.meta_business_id = None
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def dispatch_webhook(db: AsyncSession, payload: WebhookPayload) -> None:
|
||||||
|
"""Procesa cada mensaje entrante y lo envía al bot engine."""
|
||||||
|
from app.modules.bot_engine.service import process_message
|
||||||
|
|
||||||
|
for entry in payload.entry:
|
||||||
|
for change in entry.changes:
|
||||||
|
if change.field != "messages" or not change.value.messages:
|
||||||
|
continue
|
||||||
|
|
||||||
|
phone_number_id = change.value.metadata.get("phone_number_id")
|
||||||
|
business = await get_business_by_phone_number_id(db, phone_number_id)
|
||||||
|
if not business:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for message in change.value.messages:
|
||||||
|
if message.type != "text" or not message.text:
|
||||||
|
continue
|
||||||
|
await process_message(
|
||||||
|
db=db,
|
||||||
|
phone=message.from_,
|
||||||
|
text=message.text.body,
|
||||||
|
business=business,
|
||||||
|
)
|
||||||
0
backend/app/shared/__init__.py
Normal file
0
backend/app/shared/__init__.py
Normal file
0
backend/app/shared/models/__init__.py
Normal file
0
backend/app/shared/models/__init__.py
Normal file
0
backend/app/shared/schemas/__init__.py
Normal file
0
backend/app/shared/schemas/__init__.py
Normal file
0
backend/app/shared/utils/__init__.py
Normal file
0
backend/app/shared/utils/__init__.py
Normal file
3
backend/pytest.ini
Normal file
3
backend/pytest.ini
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
|
testpaths = tests
|
||||||
20
backend/requirements.txt
Normal file
20
backend/requirements.txt
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
sqlalchemy[asyncio]==2.0.35
|
||||||
|
asyncpg==0.29.0
|
||||||
|
alembic==1.13.3
|
||||||
|
pydantic==2.9.2
|
||||||
|
pydantic-settings==2.5.2
|
||||||
|
pydantic[email]==2.9.2
|
||||||
|
redis==5.1.1
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
python-multipart==0.0.12
|
||||||
|
httpx==0.27.2
|
||||||
|
anthropic==0.34.2
|
||||||
|
slowapi==0.1.9
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
|
||||||
|
# testing
|
||||||
|
pytest==8.3.3
|
||||||
|
pytest-asyncio==0.24.0
|
||||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
115
backend/tests/conftest.py
Normal file
115
backend/tests/conftest.py
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
from app.core.database import Base, get_db
|
||||||
|
from app.core.redis import get_redis
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
# Importar todos los modelos para que Base.metadata los registre
|
||||||
|
from app.modules.auth.models import User # noqa
|
||||||
|
from app.modules.business.models import Business, BusinessConfig # noqa
|
||||||
|
from app.modules.reservations.models import Reservation # noqa
|
||||||
|
|
||||||
|
TEST_DB_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/hermesmessages_test"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="session")
|
||||||
|
async def engine():
|
||||||
|
_engine = create_async_engine(TEST_DB_URL)
|
||||||
|
async with _engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
yield _engine
|
||||||
|
async with _engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
await _engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def db(engine) -> AsyncSession:
|
||||||
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
async with session_factory() as session:
|
||||||
|
yield session
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
def redis_mock() -> AsyncMock:
|
||||||
|
mock = AsyncMock()
|
||||||
|
mock.get = AsyncMock(return_value=None)
|
||||||
|
mock.setex = AsyncMock()
|
||||||
|
mock.delete = AsyncMock()
|
||||||
|
mock.aclose = AsyncMock()
|
||||||
|
return mock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def client(db: AsyncSession, redis_mock: AsyncMock) -> AsyncClient:
|
||||||
|
app.dependency_overrides[get_db] = lambda: db
|
||||||
|
app.dependency_overrides[get_redis] = lambda: redis_mock
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||||
|
yield c
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def client_no_db(redis_mock: AsyncMock) -> AsyncClient:
|
||||||
|
"""Cliente HTTP sin fixture de base de datos — para endpoints que no usan DB."""
|
||||||
|
app.dependency_overrides[get_redis] = lambda: redis_mock
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||||
|
yield c
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Helpers para crear datos de prueba ---
|
||||||
|
|
||||||
|
async def make_business(db: AsyncSession, **kwargs):
|
||||||
|
from app.modules.business.models import Business, BusinessConfig
|
||||||
|
from datetime import time
|
||||||
|
|
||||||
|
business = Business(
|
||||||
|
name=kwargs.get("name", "Restaurante Test"),
|
||||||
|
type=kwargs.get("type", "restaurant"),
|
||||||
|
timezone="UTC",
|
||||||
|
status=kwargs.get("status", "active"),
|
||||||
|
plan=kwargs.get("plan", "basic"),
|
||||||
|
whatsapp_phone_number_id=kwargs.get("phone_number_id", "12345"),
|
||||||
|
whatsapp_access_token=kwargs.get("access_token", "test-token"),
|
||||||
|
)
|
||||||
|
db.add(business)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
config = BusinessConfig(
|
||||||
|
business_id=business.id,
|
||||||
|
open_days=[0, 1, 2, 3, 4], # lunes a viernes
|
||||||
|
open_time=time(9, 0),
|
||||||
|
close_time=time(18, 0),
|
||||||
|
slot_duration=60,
|
||||||
|
max_per_slot=1,
|
||||||
|
blocked_dates=[],
|
||||||
|
assistant_name="Hermes",
|
||||||
|
tone="formal",
|
||||||
|
)
|
||||||
|
db.add(config)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(business)
|
||||||
|
return business
|
||||||
|
|
||||||
|
|
||||||
|
async def make_user(db: AsyncSession, business_id: int, **kwargs):
|
||||||
|
from app.modules.auth.models import User
|
||||||
|
from app.core.security import hash_password
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
business_id=business_id,
|
||||||
|
email=kwargs.get("email", "owner@test.com"),
|
||||||
|
hashed_password=hash_password(kwargs.get("password", "secret123")),
|
||||||
|
role=kwargs.get("role", "owner"),
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
164
backend/tests/test_auth.py
Normal file
164
backend/tests/test_auth.py
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from tests.conftest import make_business, make_user
|
||||||
|
|
||||||
|
REGISTER_PAYLOAD = {
|
||||||
|
"business_name": "Restaurante Nuevo",
|
||||||
|
"business_type": "restaurant",
|
||||||
|
"timezone": "America/Bogota",
|
||||||
|
"email": "nuevo@negocio.com",
|
||||||
|
"password": "segura123",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Registration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_register_creates_business_user_and_returns_token(client: AsyncClient):
|
||||||
|
response = await client.post("/auth/register", json=REGISTER_PAYLOAD)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert "access_token" in data
|
||||||
|
assert data["token_type"] == "bearer"
|
||||||
|
assert isinstance(data["business_id"], int)
|
||||||
|
assert isinstance(data["user_id"], int)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_register_token_grants_access_to_own_business(client: AsyncClient):
|
||||||
|
response = await client.post("/auth/register", json=REGISTER_PAYLOAD | {"email": "owner2@test.com"})
|
||||||
|
token = response.json()["access_token"]
|
||||||
|
business_id = response.json()["business_id"]
|
||||||
|
|
||||||
|
me = await client.get("/business/me", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert me.status_code == 200
|
||||||
|
assert me.json()["id"] == business_id
|
||||||
|
assert me.json()["name"] == REGISTER_PAYLOAD["business_name"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_register_duplicate_email_returns_409(client: AsyncClient):
|
||||||
|
payload = REGISTER_PAYLOAD | {"email": "dup@test.com"}
|
||||||
|
first = await client.post("/auth/register", json=payload)
|
||||||
|
assert first.status_code == 201
|
||||||
|
|
||||||
|
second = await client.post("/auth/register", json=payload)
|
||||||
|
assert second.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
async def test_register_password_too_short_returns_422(client: AsyncClient):
|
||||||
|
response = await client.post("/auth/register", json=REGISTER_PAYLOAD | {"email": "short@test.com", "password": "abc"})
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_register_empty_business_name_returns_422(client: AsyncClient):
|
||||||
|
response = await client.post("/auth/register", json=REGISTER_PAYLOAD | {"email": "empty@test.com", "business_name": " "})
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_register_creates_default_business_config(client: AsyncClient):
|
||||||
|
response = await client.post("/auth/register", json=REGISTER_PAYLOAD | {"email": "config@test.com"})
|
||||||
|
token = response.json()["access_token"]
|
||||||
|
|
||||||
|
config = await client.get("/business/me/config", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert config.status_code == 200
|
||||||
|
data = config.json()
|
||||||
|
assert data["open_days"] == [0, 1, 2, 3, 4]
|
||||||
|
assert data["slot_duration"] == 60
|
||||||
|
assert data["assistant_name"] == "Hermes"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_two_businesses_cannot_see_each_other_reservations(client: AsyncClient):
|
||||||
|
r1 = await client.post("/auth/register", json=REGISTER_PAYLOAD | {"email": "biz1@test.com"})
|
||||||
|
r2 = await client.post("/auth/register", json=REGISTER_PAYLOAD | {"email": "biz2@test.com"})
|
||||||
|
token1 = r1.json()["access_token"]
|
||||||
|
token2 = r2.json()["access_token"]
|
||||||
|
|
||||||
|
res1 = await client.get("/reservations/", headers={"Authorization": f"Bearer {token1}"})
|
||||||
|
res2 = await client.get("/reservations/", headers={"Authorization": f"Bearer {token2}"})
|
||||||
|
assert res1.status_code == 200
|
||||||
|
assert res2.status_code == 200
|
||||||
|
# Each business sees only their own empty list
|
||||||
|
assert res1.json() == []
|
||||||
|
assert res2.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Login
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_success(client: AsyncClient, db):
|
||||||
|
business = await make_business(db, name="Auth Test")
|
||||||
|
await make_user(db, business.id, email="test@example.com", password="mypassword")
|
||||||
|
|
||||||
|
response = await client.post("/auth/login", json={
|
||||||
|
"email": "test@example.com",
|
||||||
|
"password": "mypassword",
|
||||||
|
})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "access_token" in data
|
||||||
|
assert data["token_type"] == "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_wrong_password(client: AsyncClient, db):
|
||||||
|
business = await make_business(db, name="Wrong Pass Test")
|
||||||
|
await make_user(db, business.id, email="wrong@example.com", password="correct")
|
||||||
|
|
||||||
|
response = await client.post("/auth/login", json={
|
||||||
|
"email": "wrong@example.com",
|
||||||
|
"password": "incorrect",
|
||||||
|
})
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_unknown_email(client: AsyncClient, db):
|
||||||
|
response = await client.post("/auth/login", json={
|
||||||
|
"email": "noexiste@example.com",
|
||||||
|
"password": "cualquiera",
|
||||||
|
})
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_protected_endpoint_without_token(client: AsyncClient):
|
||||||
|
response = await client.get("/business/me")
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_protected_endpoint_with_token(client: AsyncClient, db):
|
||||||
|
business = await make_business(db, name="Protected Test")
|
||||||
|
await make_user(db, business.id, email="protected@example.com", password="pass123")
|
||||||
|
|
||||||
|
login = await client.post("/auth/login", json={
|
||||||
|
"email": "protected@example.com",
|
||||||
|
"password": "pass123",
|
||||||
|
})
|
||||||
|
token = login.json()["access_token"]
|
||||||
|
|
||||||
|
response = await client.get("/business/me", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["id"] == business.id
|
||||||
|
assert data["name"] == "Protected Test"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_logout_returns_204(client: AsyncClient):
|
||||||
|
response = await client.post("/auth/logout")
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
async def test_refresh_token(client: AsyncClient, db):
|
||||||
|
business = await make_business(db, name="Refresh Test")
|
||||||
|
await make_user(db, business.id, email="refresh@example.com", password="pass")
|
||||||
|
|
||||||
|
login = await client.post("/auth/login", json={
|
||||||
|
"email": "refresh@example.com",
|
||||||
|
"password": "pass",
|
||||||
|
})
|
||||||
|
token = login.json()["access_token"]
|
||||||
|
|
||||||
|
response = await client.post("/auth/refresh", json={"access_token": token})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "access_token" in response.json()
|
||||||
140
backend/tests/test_bot_engine.py
Normal file
140
backend/tests/test_bot_engine.py
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
from datetime import date, time
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.modules.bot_engine.prompt import build_system_prompt
|
||||||
|
from app.modules.calendar.service import _generate_slots
|
||||||
|
from app.modules.bot_engine.schemas import CollectedData, ConversationContext
|
||||||
|
from app.modules.calendar.schemas import DayAvailability, SlotRead
|
||||||
|
|
||||||
|
|
||||||
|
def _make_config(**kwargs):
|
||||||
|
config = MagicMock()
|
||||||
|
config.tone = kwargs.get("tone", "formal")
|
||||||
|
config.assistant_name = kwargs.get("assistant_name", "Hermes")
|
||||||
|
config.open_days = kwargs.get("open_days", [0, 1, 2, 3, 4])
|
||||||
|
config.open_time = kwargs.get("open_time", time(9, 0))
|
||||||
|
config.close_time = kwargs.get("close_time", time(18, 0))
|
||||||
|
config.slot_duration = kwargs.get("slot_duration", 60)
|
||||||
|
config.max_per_slot = kwargs.get("max_per_slot", 2)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def _make_business(**kwargs):
|
||||||
|
b = MagicMock()
|
||||||
|
b.name = kwargs.get("name", "Restaurante Demo")
|
||||||
|
b.id = 1
|
||||||
|
b.whatsapp_phone_number_id = "phone-id"
|
||||||
|
b.whatsapp_access_token = "token"
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_system_prompt_contains_business_name():
|
||||||
|
business = _make_business(name="Clínica Sol")
|
||||||
|
config = _make_config(assistant_name="Sol")
|
||||||
|
context = ConversationContext(phone="5491100000000", business_id=1)
|
||||||
|
prompt = build_system_prompt(business, config, None, context)
|
||||||
|
assert "Clínica Sol" in prompt
|
||||||
|
assert "Sol" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_system_prompt_formal_tone():
|
||||||
|
business = _make_business()
|
||||||
|
config = _make_config(tone="formal")
|
||||||
|
context = ConversationContext(phone="549", business_id=1)
|
||||||
|
prompt = build_system_prompt(business, config, None, context)
|
||||||
|
assert "formal" in prompt.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_system_prompt_casual_tone():
|
||||||
|
business = _make_business()
|
||||||
|
config = _make_config(tone="casual")
|
||||||
|
context = ConversationContext(phone="549", business_id=1)
|
||||||
|
prompt = build_system_prompt(business, config, None, context)
|
||||||
|
assert "amigable" in prompt.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_system_prompt_with_slots():
|
||||||
|
business = _make_business()
|
||||||
|
config = _make_config()
|
||||||
|
context = ConversationContext(phone="549", business_id=1)
|
||||||
|
availability = DayAvailability(
|
||||||
|
date=date(2026, 5, 5),
|
||||||
|
is_open=True,
|
||||||
|
slots=[
|
||||||
|
SlotRead(time_start=time(10, 0), time_end=time(11, 0), available=2, max_per_slot=2),
|
||||||
|
SlotRead(time_start=time(11, 0), time_end=time(12, 0), available=1, max_per_slot=2),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
prompt = build_system_prompt(business, config, availability, context)
|
||||||
|
assert "10:00" in prompt
|
||||||
|
assert "11:00" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_system_prompt_no_availability():
|
||||||
|
business = _make_business()
|
||||||
|
config = _make_config()
|
||||||
|
context = ConversationContext(phone="549", business_id=1)
|
||||||
|
availability = DayAvailability(date=date(2026, 5, 5), is_open=False, slots=[])
|
||||||
|
prompt = build_system_prompt(business, config, availability, context)
|
||||||
|
assert "No hay disponibilidad" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_system_prompt_collected_data_shown():
|
||||||
|
business = _make_business()
|
||||||
|
config = _make_config()
|
||||||
|
context = ConversationContext(
|
||||||
|
phone="549",
|
||||||
|
business_id=1,
|
||||||
|
collected_data=CollectedData(
|
||||||
|
client_name="Juan",
|
||||||
|
date="2026-05-05",
|
||||||
|
time_start="10:00",
|
||||||
|
party_size=3,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
prompt = build_system_prompt(business, config, None, context)
|
||||||
|
assert "Juan" in prompt
|
||||||
|
assert "2026-05-05" in prompt
|
||||||
|
assert "10:00" in prompt
|
||||||
|
assert "3" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_system_prompt_requires_json_response():
|
||||||
|
business = _make_business()
|
||||||
|
config = _make_config()
|
||||||
|
context = ConversationContext(phone="549", business_id=1)
|
||||||
|
prompt = build_system_prompt(business, config, None, context)
|
||||||
|
assert "create_reservation" in prompt
|
||||||
|
assert "collect_more" in prompt
|
||||||
|
assert "cancel" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="requiere asyncpg — test de integración")
|
||||||
|
async def test_process_message_calls_claude_and_sends_reply(db, redis_mock):
|
||||||
|
from app.modules.bot_engine import service as bot_service
|
||||||
|
from app.modules.bot_engine.schemas import BotResponse, CollectedData
|
||||||
|
|
||||||
|
business = _make_business()
|
||||||
|
bot_response = BotResponse(
|
||||||
|
message="Hola, ¿cuál es tu nombre?",
|
||||||
|
action="collect_more",
|
||||||
|
collected_data=CollectedData(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(bot_service, "_load_context", return_value=ConversationContext(phone="549", business_id=1)),
|
||||||
|
patch("app.modules.bot_engine.service.get_business_config", return_value=_make_config()),
|
||||||
|
patch.object(bot_service, "_call_claude", return_value=bot_response),
|
||||||
|
patch.object(bot_service, "_save_context"),
|
||||||
|
patch("app.modules.bot_engine.service.send_text_message", new_callable=AsyncMock) as mock_send,
|
||||||
|
patch("app.modules.bot_engine.service.aioredis.from_url", return_value=redis_mock),
|
||||||
|
):
|
||||||
|
await bot_service.process_message(db=db, phone="549", text="Hola", business=business)
|
||||||
|
mock_send.assert_called_once_with(
|
||||||
|
phone_number_id="phone-id",
|
||||||
|
access_token="token",
|
||||||
|
to="549",
|
||||||
|
text="Hola, ¿cuál es tu nombre?",
|
||||||
|
)
|
||||||
81
backend/tests/test_calendar.py
Normal file
81
backend/tests/test_calendar.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
from datetime import date, time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.modules.calendar.service import _generate_slots, get_available_slots
|
||||||
|
from tests.conftest import make_business
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_slots_basic():
|
||||||
|
slots = _generate_slots(time(9, 0), time(12, 0), 60)
|
||||||
|
assert len(slots) == 3
|
||||||
|
assert slots[0] == (time(9, 0), time(10, 0))
|
||||||
|
assert slots[1] == (time(10, 0), time(11, 0))
|
||||||
|
assert slots[2] == (time(11, 0), time(12, 0))
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_slots_30min():
|
||||||
|
slots = _generate_slots(time(9, 0), time(10, 0), 30)
|
||||||
|
assert len(slots) == 2
|
||||||
|
assert slots[0] == (time(9, 0), time(9, 30))
|
||||||
|
assert slots[1] == (time(9, 30), time(10, 0))
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_slots_exact_fit():
|
||||||
|
slots = _generate_slots(time(9, 0), time(9, 30), 30)
|
||||||
|
assert len(slots) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_slots_no_fit():
|
||||||
|
# slot_duration mayor que el rango → lista vacía
|
||||||
|
slots = _generate_slots(time(9, 0), time(9, 20), 30)
|
||||||
|
assert len(slots) == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_availability_closed_day(db, redis_mock):
|
||||||
|
business = await make_business(db, name="Test Cerrado")
|
||||||
|
# open_days = [0,1,2,3,4] → domingo (6) está cerrado
|
||||||
|
sunday = date(2026, 4, 26) # domingo
|
||||||
|
result = await get_available_slots(db, redis_mock, business.id, sunday)
|
||||||
|
assert result.is_open is False
|
||||||
|
assert result.slots == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_availability_open_day_no_reservations(db, redis_mock):
|
||||||
|
business = await make_business(db, name="Test Abierto")
|
||||||
|
monday = date(2026, 4, 27) # lunes
|
||||||
|
result = await get_available_slots(db, redis_mock, business.id, monday)
|
||||||
|
assert result.is_open is True
|
||||||
|
assert len(result.slots) == 9 # 09:00–18:00 en slots de 60 min
|
||||||
|
assert all(s.available == 2 for s in result.slots) # max_per_slot=2, sin reservas
|
||||||
|
|
||||||
|
|
||||||
|
async def test_availability_blocked_date(db, redis_mock):
|
||||||
|
from datetime import date as d
|
||||||
|
business = await make_business(db, name="Test Bloqueado")
|
||||||
|
|
||||||
|
# Bloquear el lunes
|
||||||
|
monday = date(2026, 4, 27)
|
||||||
|
from app.modules.business.service import get_business_config
|
||||||
|
config = await get_business_config(db, business.id)
|
||||||
|
config.blocked_dates = [monday]
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
result = await get_available_slots(db, redis_mock, business.id, monday)
|
||||||
|
assert result.is_open is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_availability_cached(db, redis_mock):
|
||||||
|
import json
|
||||||
|
from app.modules.calendar.schemas import DayAvailability
|
||||||
|
|
||||||
|
business = await make_business(db, name="Test Cache")
|
||||||
|
monday = date(2026, 4, 27)
|
||||||
|
|
||||||
|
cached_data = DayAvailability(date=monday, is_open=False, slots=[])
|
||||||
|
redis_mock.get = pytest.AsyncMock(return_value=cached_data.model_dump_json())
|
||||||
|
|
||||||
|
result = await get_available_slots(db, redis_mock, business.id, monday)
|
||||||
|
assert result.is_open is False
|
||||||
|
# Redis.get fue llamado → no consulta DB
|
||||||
|
redis_mock.get.assert_called_once()
|
||||||
118
backend/tests/test_reservations.py
Normal file
118
backend/tests/test_reservations.py
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
from datetime import date, time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.modules.reservations.schemas import ReservationCreate, ReservationUpdate, StatusUpdate
|
||||||
|
from app.modules.reservations.service import (
|
||||||
|
create_reservation,
|
||||||
|
delete_reservation,
|
||||||
|
get_reservation,
|
||||||
|
list_reservations,
|
||||||
|
update_status,
|
||||||
|
)
|
||||||
|
from tests.conftest import make_business, make_user
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_test_reservation(db, redis_mock, business_id):
|
||||||
|
return await create_reservation(
|
||||||
|
db=db,
|
||||||
|
redis=redis_mock,
|
||||||
|
business_id=business_id,
|
||||||
|
data=ReservationCreate(
|
||||||
|
client_name="Ana García",
|
||||||
|
client_phone="5491112345678",
|
||||||
|
date=date(2026, 5, 5),
|
||||||
|
time_start=time(10, 0),
|
||||||
|
party_size=2,
|
||||||
|
source="manual",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_reservation(db, redis_mock):
|
||||||
|
business = await make_business(db, name="Res Test")
|
||||||
|
r = await _create_test_reservation(db, redis_mock, business.id)
|
||||||
|
|
||||||
|
assert r.id is not None
|
||||||
|
assert r.client_name == "Ana García"
|
||||||
|
assert r.time_end == time(11, 0) # 10:00 + 60min slot
|
||||||
|
assert r.status == "pending"
|
||||||
|
assert r.source == "manual"
|
||||||
|
redis_mock.delete.assert_called_once() # caché invalidada
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_reservations_filter_by_date(db, redis_mock):
|
||||||
|
business = await make_business(db, name="List Test")
|
||||||
|
await _create_test_reservation(db, redis_mock, business.id)
|
||||||
|
|
||||||
|
results = await list_reservations(db, business.id, filter_date=date(2026, 5, 5))
|
||||||
|
assert len(results) >= 1
|
||||||
|
assert all(r.date == date(2026, 5, 5) for r in results)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_reservations_filter_by_status(db, redis_mock):
|
||||||
|
business = await make_business(db, name="Status Test")
|
||||||
|
await _create_test_reservation(db, redis_mock, business.id)
|
||||||
|
|
||||||
|
results = await list_reservations(db, business.id, filter_status="pending")
|
||||||
|
assert all(r.status == "pending" for r in results)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_reservation_not_found(db):
|
||||||
|
business = await make_business(db, name="404 Test")
|
||||||
|
from fastapi import HTTPException
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await get_reservation(db, business.id, 999999)
|
||||||
|
assert exc.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_reservation_wrong_business(db, redis_mock):
|
||||||
|
b1 = await make_business(db, name="Business 1")
|
||||||
|
b2 = await make_business(db, name="Business 2")
|
||||||
|
r = await _create_test_reservation(db, redis_mock, b1.id)
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await get_reservation(db, b2.id, r.id)
|
||||||
|
assert exc.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_status_to_confirmed(db, redis_mock):
|
||||||
|
business = await make_business(db, name="Confirm Test")
|
||||||
|
r = await _create_test_reservation(db, redis_mock, business.id)
|
||||||
|
|
||||||
|
updated = await update_status(db, redis_mock, business.id, r.id, "confirmed")
|
||||||
|
assert updated.status == "confirmed"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_status_cancelled_invalidates_cache(db, redis_mock):
|
||||||
|
business = await make_business(db, name="Cancel Cache Test")
|
||||||
|
r = await _create_test_reservation(db, redis_mock, business.id)
|
||||||
|
redis_mock.delete.reset_mock()
|
||||||
|
|
||||||
|
await update_status(db, redis_mock, business.id, r.id, "cancelled")
|
||||||
|
redis_mock.delete.assert_called_once() # caché invalidada al cancelar
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_status_invalid_raises(db, redis_mock):
|
||||||
|
business = await make_business(db, name="Invalid Status Test")
|
||||||
|
r = await _create_test_reservation(db, redis_mock, business.id)
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await update_status(db, redis_mock, business.id, r.id, "inexistente")
|
||||||
|
assert exc.value.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_reservation(db, redis_mock):
|
||||||
|
business = await make_business(db, name="Delete Test")
|
||||||
|
r = await _create_test_reservation(db, redis_mock, business.id)
|
||||||
|
r_id = r.id
|
||||||
|
redis_mock.delete.reset_mock()
|
||||||
|
|
||||||
|
await delete_reservation(db, redis_mock, business.id, r_id)
|
||||||
|
redis_mock.delete.assert_called_once()
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
await get_reservation(db, business.id, r_id)
|
||||||
42
backend/tests/test_security.py
Normal file
42
backend/tests/test_security.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import pytest
|
||||||
|
from jose import JWTError
|
||||||
|
|
||||||
|
from app.core.security import (
|
||||||
|
create_access_token,
|
||||||
|
decode_token,
|
||||||
|
hash_password,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_and_verify_password():
|
||||||
|
plain = "supersecret123"
|
||||||
|
hashed = hash_password(plain)
|
||||||
|
assert hashed != plain
|
||||||
|
assert verify_password(plain, hashed)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_password_fails():
|
||||||
|
hashed = hash_password("correct")
|
||||||
|
assert not verify_password("wrong", hashed)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_and_decode_token():
|
||||||
|
data = {"sub": "42", "business_id": 7}
|
||||||
|
token = create_access_token(data)
|
||||||
|
payload = decode_token(token)
|
||||||
|
assert payload["sub"] == "42"
|
||||||
|
assert payload["business_id"] == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_tampered_token_raises():
|
||||||
|
token = create_access_token({"sub": "1"})
|
||||||
|
tampered = token[:-5] + "XXXXX"
|
||||||
|
with pytest.raises(JWTError):
|
||||||
|
decode_token(tampered)
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_contains_expiry():
|
||||||
|
token = create_access_token({"sub": "1"})
|
||||||
|
payload = decode_token(token)
|
||||||
|
assert "exp" in payload
|
||||||
71
backend/tests/test_webhook.py
Normal file
71
backend/tests/test_webhook.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.modules.whatsapp.service import verify_signature
|
||||||
|
|
||||||
|
|
||||||
|
def _make_signature(secret: str, body: bytes) -> str:
|
||||||
|
digest = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||||
|
return f"sha256={digest}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_signature_passes(monkeypatch):
|
||||||
|
monkeypatch.setattr("app.modules.whatsapp.service.settings.META_APP_SECRET", "mysecret")
|
||||||
|
body = b'{"object":"whatsapp_business_account"}'
|
||||||
|
sig = _make_signature("mysecret", body)
|
||||||
|
verify_signature(body, sig) # no debe lanzar
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_signature_raises(monkeypatch):
|
||||||
|
monkeypatch.setattr("app.modules.whatsapp.service.settings.META_APP_SECRET", "mysecret")
|
||||||
|
body = b'{"object":"whatsapp_business_account"}'
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
verify_signature(body, "sha256=invalidsignature")
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_signature_raises(monkeypatch):
|
||||||
|
monkeypatch.setattr("app.modules.whatsapp.service.settings.META_APP_SECRET", "mysecret")
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
verify_signature(b"body", "")
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_prefix_raises(monkeypatch):
|
||||||
|
monkeypatch.setattr("app.modules.whatsapp.service.settings.META_APP_SECRET", "mysecret")
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
verify_signature(b"body", "notsha256=abc")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_webhook_verification_endpoint(client_no_db):
|
||||||
|
import app.core.config as cfg
|
||||||
|
cfg.settings.META_WEBHOOK_VERIFY_TOKEN = "test-verify-token"
|
||||||
|
|
||||||
|
response = await client_no_db.get(
|
||||||
|
"/whatsapp/webhook",
|
||||||
|
params={
|
||||||
|
"hub.mode": "subscribe",
|
||||||
|
"hub.verify_token": "test-verify-token",
|
||||||
|
"hub.challenge": "challenge123",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.text == "challenge123"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_webhook_verification_wrong_token(client_no_db):
|
||||||
|
import app.core.config as cfg
|
||||||
|
cfg.settings.META_WEBHOOK_VERIFY_TOKEN = "test-verify-token"
|
||||||
|
|
||||||
|
response = await client_no_db.get(
|
||||||
|
"/whatsapp/webhook",
|
||||||
|
params={
|
||||||
|
"hub.mode": "subscribe",
|
||||||
|
"hub.verify_token": "wrong-token",
|
||||||
|
"hub.challenge": "abc",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
22
database/docker-compose.yml
Normal file
22
database/docker-compose.yml
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: hermesmessages
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
20
frontend/index.html
Normal file
20
frontend/index.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/hermes-icon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="HermesMessages — Reservas automáticas por WhatsApp con IA" />
|
||||||
|
<title>HermesMessages</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,600;12..96,700&family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
42
frontend/package.json
Normal file
42
frontend/package.json
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "hermesmessages-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.2",
|
||||||
|
"axios": "^1.7.7",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
|
"lucide-react": "^0.447.0",
|
||||||
|
"recharts": "^2.12.7",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"tailwind-merge": "^2.5.3",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.2",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||||
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
|
"@radix-ui/react-toast": "^1.2.2",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.3",
|
||||||
|
"@radix-ui/react-switch": "^1.1.1",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.1",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.1",
|
||||||
|
"@radix-ui/react-badge": "^1.0.0",
|
||||||
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
|
"@radix-ui/react-label": "^2.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.11",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"tailwindcss": "^3.4.13",
|
||||||
|
"vite": "^5.4.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
44
frontend/src/App.jsx
Normal file
44
frontend/src/App.jsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import Layout from '@/components/layout/Layout'
|
||||||
|
import LoginPage from '@/pages/auth/LoginPage'
|
||||||
|
import RegisterPage from '@/pages/auth/RegisterPage'
|
||||||
|
import DashboardPage from '@/pages/DashboardPage'
|
||||||
|
import ReservationsPage from '@/pages/ReservationsPage'
|
||||||
|
import CalendarPage from '@/pages/CalendarPage'
|
||||||
|
import ConfigPage from '@/pages/ConfigPage'
|
||||||
|
import BillingPage from '@/pages/BillingPage'
|
||||||
|
|
||||||
|
function PrivateRoute({ children }) {
|
||||||
|
const { isAuthenticated, loading } = useAuth()
|
||||||
|
if (loading) return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-bg">
|
||||||
|
<div className="w-8 h-8 rounded-full border-2 border-primary-600 border-t-transparent animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
return isAuthenticated ? children : <Navigate to="/login" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PublicRoute({ children }) {
|
||||||
|
const { isAuthenticated, loading } = useAuth()
|
||||||
|
if (loading) return null
|
||||||
|
return isAuthenticated ? <Navigate to="/dashboard" replace /> : children
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<PublicRoute><LoginPage /></PublicRoute>} />
|
||||||
|
<Route path="/register" element={<PublicRoute><RegisterPage /></PublicRoute>} />
|
||||||
|
<Route path="/" element={<PrivateRoute><Layout /></PrivateRoute>}>
|
||||||
|
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route path="dashboard" element={<DashboardPage />} />
|
||||||
|
<Route path="reservations" element={<ReservationsPage />} />
|
||||||
|
<Route path="calendar" element={<CalendarPage />} />
|
||||||
|
<Route path="config" element={<ConfigPage />} />
|
||||||
|
<Route path="billing" element={<BillingPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
29
frontend/src/components/layout/Layout.jsx
Normal file
29
frontend/src/components/layout/Layout.jsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { Outlet, useLocation } from 'react-router-dom'
|
||||||
|
import Sidebar from './Sidebar'
|
||||||
|
|
||||||
|
const PAGE_TITLES = {
|
||||||
|
'/dashboard': 'Dashboard',
|
||||||
|
'/reservations': 'Reservas',
|
||||||
|
'/calendar': 'Disponibilidad',
|
||||||
|
'/config': 'Configuración',
|
||||||
|
'/billing': 'Plan y Facturación',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
const title = PAGE_TITLES[pathname] ?? 'HermesMessages'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
<Sidebar />
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
<header className="sticky top-0 z-10 bg-bg/80 backdrop-blur border-b border-border px-8 py-4">
|
||||||
|
<h1 className="font-display text-xl font-semibold text-slate-900">{title}</h1>
|
||||||
|
</header>
|
||||||
|
<main className="flex-1 px-8 py-6">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
97
frontend/src/components/layout/Sidebar.jsx
Normal file
97
frontend/src/components/layout/Sidebar.jsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { NavLink, useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
LayoutDashboard, CalendarDays, BookOpen, Settings, CreditCard,
|
||||||
|
MessageCircle, LogOut, ChevronRight,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
|
||||||
|
{ to: '/reservations', icon: BookOpen, label: 'Reservas' },
|
||||||
|
{ to: '/calendar', icon: CalendarDays, label: 'Disponibilidad' },
|
||||||
|
{ to: '/config', icon: Settings, label: 'Configuración' },
|
||||||
|
{ to: '/billing', icon: CreditCard, label: 'Plan y Facturación' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Sidebar() {
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await logout()
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="flex flex-col w-60 min-h-screen bg-white border-r border-border flex-shrink-0">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex items-center gap-2.5 px-5 py-5 border-b border-border">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-primary-600 flex items-center justify-center flex-shrink-0">
|
||||||
|
<MessageCircle className="w-4.5 h-4.5 text-white" size={18} />
|
||||||
|
</div>
|
||||||
|
<span className="font-display font-semibold text-slate-900 text-base leading-tight">
|
||||||
|
Hermes<span className="text-primary-600">Messages</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav */}
|
||||||
|
<nav className="flex-1 px-3 py-4 flex flex-col gap-0.5">
|
||||||
|
{NAV_ITEMS.map(({ to, icon: Icon, label }, i) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-150',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||||
|
isActive
|
||||||
|
? 'bg-primary-50 text-primary-700'
|
||||||
|
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
style={{ animationDelay: `${i * 40}ms` }}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
<Icon
|
||||||
|
size={17}
|
||||||
|
className={cn(
|
||||||
|
'flex-shrink-0 transition-colors duration-150',
|
||||||
|
isActive ? 'text-primary-600' : 'text-slate-400',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="flex-1">{label}</span>
|
||||||
|
{isActive && (
|
||||||
|
<ChevronRight size={14} className="text-primary-400 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Footer usuario */}
|
||||||
|
<div className="border-t border-border px-3 py-3">
|
||||||
|
<div className="flex items-center gap-3 px-3 py-2.5 rounded-lg">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-xs font-semibold text-primary-700">
|
||||||
|
{user?.email?.[0]?.toUpperCase() ?? 'U'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-medium text-slate-900 truncate">{user?.email}</p>
|
||||||
|
<p className="text-xs text-slate-400">Propietario</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="btn-ghost w-full justify-start mt-1 text-slate-500 hover:text-danger-600 hover:bg-danger-50"
|
||||||
|
>
|
||||||
|
<LogOut size={15} />
|
||||||
|
Cerrar sesión
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
frontend/src/contexts/AuthContext.jsx
Normal file
57
frontend/src/contexts/AuthContext.jsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||||
|
import { authApi } from '@/lib/api'
|
||||||
|
|
||||||
|
const AuthContext = createContext(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token) {
|
||||||
|
const stored = localStorage.getItem('user')
|
||||||
|
if (stored) setUser(JSON.parse(stored))
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const login = useCallback(async (email, password) => {
|
||||||
|
const { data } = await authApi.login({ email, password })
|
||||||
|
localStorage.setItem('token', data.access_token)
|
||||||
|
const userData = { email, business_id: data.business_id }
|
||||||
|
localStorage.setItem('user', JSON.stringify(userData))
|
||||||
|
setUser(userData)
|
||||||
|
return data
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const register = useCallback(async (payload) => {
|
||||||
|
const { data } = await authApi.register(payload)
|
||||||
|
localStorage.setItem('token', data.access_token)
|
||||||
|
const userData = { email: payload.email, business_id: data.business_id }
|
||||||
|
localStorage.setItem('user', JSON.stringify(userData))
|
||||||
|
setUser(userData)
|
||||||
|
return data
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try { await authApi.logout() } catch {}
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
setUser(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const isAuthenticated = Boolean(user)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, loading, isAuthenticated, login, register, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext)
|
||||||
|
if (!ctx) throw new Error('useAuth must be used inside AuthProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
191
frontend/src/index.css
Normal file
191
frontend/src/index.css
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* ── Design tokens ── */
|
||||||
|
:root {
|
||||||
|
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
|
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
|
||||||
|
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Base ── */
|
||||||
|
@layer base {
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
background-color: #f7f8fc;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: 'Bricolage Grotesque', sans-serif;
|
||||||
|
line-height: 1.25;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea, select, button {
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar personalizado */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Componentes reutilizables ── */
|
||||||
|
@layer components {
|
||||||
|
|
||||||
|
/* Botón base — scale on press (Emil Kowalski) */
|
||||||
|
.btn {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 rounded-lg font-medium
|
||||||
|
transition-all duration-150 select-none cursor-pointer
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2
|
||||||
|
disabled:opacity-50 disabled:pointer-events-none;
|
||||||
|
transition-property: transform, background-color, box-shadow, opacity;
|
||||||
|
transition-timing-function: var(--ease-out);
|
||||||
|
}
|
||||||
|
.btn:active:not(:disabled) {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
@apply btn bg-primary-600 text-white hover:bg-primary-700 shadow-sm;
|
||||||
|
@apply px-4 py-2.5 text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
@apply btn bg-white text-slate-700 border border-border hover:bg-slate-50 shadow-card;
|
||||||
|
@apply px-4 py-2.5 text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
@apply btn text-slate-600 hover:bg-slate-100 hover:text-slate-900;
|
||||||
|
@apply px-3 py-2 text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
@apply btn bg-danger-600 text-white hover:bg-red-700;
|
||||||
|
@apply px-4 py-2.5 text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input base — con label visible y helper text */
|
||||||
|
.field {
|
||||||
|
@apply flex flex-col gap-1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
@apply text-sm font-medium text-slate-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label-required::after {
|
||||||
|
content: ' *';
|
||||||
|
@apply text-danger-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input {
|
||||||
|
@apply w-full rounded-lg border border-border bg-white px-3.5 py-2.5 text-sm
|
||||||
|
text-slate-900 placeholder:text-slate-400
|
||||||
|
transition-all duration-150
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500
|
||||||
|
disabled:bg-slate-50 disabled:text-slate-400 disabled:cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input-error {
|
||||||
|
@apply border-danger-500 focus:ring-danger-500 focus:border-danger-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-helper {
|
||||||
|
@apply text-xs text-slate-500 leading-relaxed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
@apply text-xs text-danger-600 flex items-center gap-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
.card {
|
||||||
|
@apply bg-white rounded-xl border border-border shadow-card;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hover {
|
||||||
|
@apply card transition-shadow duration-200 hover:shadow-card-hover cursor-pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge */
|
||||||
|
.badge {
|
||||||
|
@apply inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-green {
|
||||||
|
@apply badge bg-primary-100 text-primary-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-yellow {
|
||||||
|
@apply badge bg-warning-100 text-warning-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-red {
|
||||||
|
@apply badge bg-danger-100 text-danger-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-gray {
|
||||||
|
@apply badge bg-slate-100 text-slate-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton loader */
|
||||||
|
.skeleton {
|
||||||
|
@apply animate-skeleton rounded bg-slate-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auth background con dot grid */
|
||||||
|
.auth-bg {
|
||||||
|
background-color: #f7f8fc;
|
||||||
|
background-image: radial-gradient(circle, #cbd5e1 1px, transparent 1px);
|
||||||
|
background-size: 24px 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Utilities ── */
|
||||||
|
@layer utilities {
|
||||||
|
.text-balance {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animaciones con prefers-reduced-motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.btn:active { transform: none; }
|
||||||
|
.animate-stagger-1,
|
||||||
|
.animate-stagger-2,
|
||||||
|
.animate-stagger-3,
|
||||||
|
.animate-stagger-4,
|
||||||
|
.animate-stagger-5 {
|
||||||
|
animation: none;
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
frontend/src/lib/api.js
Normal file
73
frontend/src/lib/api.js
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
(err) => {
|
||||||
|
if (err.response?.status === 401) {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
return Promise.reject(err)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
register: (data) => api.post('/auth/register', data),
|
||||||
|
login: (data) => api.post('/auth/login', data),
|
||||||
|
logout: () => api.post('/auth/logout'),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const businessApi = {
|
||||||
|
getMe: () => api.get('/business/me'),
|
||||||
|
updateMe: (data) => api.put('/business/me', data),
|
||||||
|
getConfig: () => api.get('/business/me/config'),
|
||||||
|
updateConfig: (data) => api.put('/business/me/config', data),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const whatsappApi = {
|
||||||
|
getStatus: () => api.get('/whatsapp/status'),
|
||||||
|
connect: (data) => api.post('/whatsapp/connect', data),
|
||||||
|
disconnect: () => api.post('/whatsapp/disconnect'),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reservationsApi = {
|
||||||
|
list: (params) => api.get('/reservations/', { params }),
|
||||||
|
create: (data) => api.post('/reservations/', data),
|
||||||
|
get: (id) => api.get(`/reservations/${id}`),
|
||||||
|
update: (id, data) => api.put(`/reservations/${id}`, data),
|
||||||
|
updateStatus: (id, status) => api.patch(`/reservations/${id}/status`, { status }),
|
||||||
|
delete: (id) => api.delete(`/reservations/${id}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const calendarApi = {
|
||||||
|
getAvailability: (date) => api.get('/calendar/availability', { params: { date } }),
|
||||||
|
getAvailabilityRange: (start, end) =>
|
||||||
|
api.get('/calendar/availability/range', { params: { start_date: start, end_date: end } }),
|
||||||
|
blockDate: (date) => api.post('/calendar/blocked-dates', { date }),
|
||||||
|
unblockDate: (date) => api.delete(`/calendar/blocked-dates/${date}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dashboardApi = {
|
||||||
|
getStats: () => api.get('/dashboard/stats'),
|
||||||
|
getAgenda: (date) => api.get('/dashboard/agenda', { params: { date } }),
|
||||||
|
getPeakHours: () => api.get('/dashboard/peak-hours'),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const billingApi = {
|
||||||
|
getPlan: () => api.get('/billing/plan'),
|
||||||
|
getUsage: () => api.get('/billing/usage'),
|
||||||
|
upgrade: (plan) => api.post('/billing/upgrade', { plan }),
|
||||||
|
}
|
||||||
|
|
||||||
|
export default api
|
||||||
65
frontend/src/lib/utils.js
Normal file
65
frontend/src/lib/utils.js
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { clsx } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(dateStr, opts = {}) {
|
||||||
|
if (!dateStr) return '—'
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
return d.toLocaleDateString('es-ES', {
|
||||||
|
day: '2-digit', month: 'short', year: 'numeric', ...opts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(timeStr) {
|
||||||
|
if (!timeStr) return '—'
|
||||||
|
return timeStr.slice(0, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(dateStr) {
|
||||||
|
if (!dateStr) return '—'
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
return d.toLocaleString('es-ES', {
|
||||||
|
day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATUS_LABELS = {
|
||||||
|
pending: 'Pendiente',
|
||||||
|
confirmed: 'Confirmada',
|
||||||
|
cancelled: 'Cancelada',
|
||||||
|
no_show: 'No asistió',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATUS_BADGE = {
|
||||||
|
pending: 'badge-yellow',
|
||||||
|
confirmed: 'badge-green',
|
||||||
|
cancelled: 'badge-red',
|
||||||
|
no_show: 'badge-gray',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BUSINESS_TYPES = [
|
||||||
|
{ value: 'restaurant', label: 'Restaurante' },
|
||||||
|
{ value: 'clinic', label: 'Clínica / Consultorio' },
|
||||||
|
{ value: 'salon', label: 'Salón de belleza' },
|
||||||
|
{ value: 'spa', label: 'Spa / Bienestar' },
|
||||||
|
{ value: 'barbershop', label: 'Barbería' },
|
||||||
|
{ value: 'gym', label: 'Gimnasio / Entrenador' },
|
||||||
|
{ value: 'other', label: 'Otro' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const TIMEZONES = [
|
||||||
|
{ value: 'America/Bogota', label: 'Bogotá (UTC-5)' },
|
||||||
|
{ value: 'America/Mexico_City', label: 'Ciudad de México (UTC-6)' },
|
||||||
|
{ value: 'America/Lima', label: 'Lima (UTC-5)' },
|
||||||
|
{ value: 'America/Santiago', label: 'Santiago (UTC-4)' },
|
||||||
|
{ value: 'America/Buenos_Aires', label: 'Buenos Aires (UTC-3)' },
|
||||||
|
{ value: 'America/Caracas', label: 'Caracas (UTC-4)' },
|
||||||
|
{ value: 'Europe/Madrid', label: 'Madrid (UTC+1/+2)' },
|
||||||
|
{ value: 'UTC', label: 'UTC' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DAYS_ES = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom']
|
||||||
|
export const DAYS_FULL = ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo']
|
||||||
16
frontend/src/main.jsx
Normal file
16
frontend/src/main.jsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { AuthProvider } from '@/contexts/AuthContext'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
259
frontend/src/pages/BillingPage.jsx
Normal file
259
frontend/src/pages/BillingPage.jsx
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { CheckCircle, Zap, Shield, Star, MessageCircle, CalendarDays, Bot } from 'lucide-react'
|
||||||
|
import { billingApi } from '@/lib/api'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const PLANS = [
|
||||||
|
{
|
||||||
|
id: 'free',
|
||||||
|
name: 'Gratis',
|
||||||
|
price: '$0',
|
||||||
|
period: 'para siempre',
|
||||||
|
description: 'Ideal para probar la plataforma',
|
||||||
|
icon: Zap,
|
||||||
|
color: 'text-slate-600',
|
||||||
|
bgColor: 'bg-slate-100',
|
||||||
|
features: [
|
||||||
|
'50 reservas por mes',
|
||||||
|
'1 número de WhatsApp',
|
||||||
|
'Bot básico de reservas',
|
||||||
|
'Dashboard de reservas',
|
||||||
|
],
|
||||||
|
limits: {
|
||||||
|
reservations: 50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'basic',
|
||||||
|
name: 'Básico',
|
||||||
|
price: '$19',
|
||||||
|
period: 'por mes',
|
||||||
|
description: 'Para negocios en crecimiento',
|
||||||
|
icon: Shield,
|
||||||
|
color: 'text-primary-600',
|
||||||
|
bgColor: 'bg-primary-100',
|
||||||
|
popular: true,
|
||||||
|
features: [
|
||||||
|
'500 reservas por mes',
|
||||||
|
'1 número de WhatsApp',
|
||||||
|
'Bot inteligente con IA',
|
||||||
|
'Notificaciones automáticas',
|
||||||
|
'Configuración de horarios',
|
||||||
|
'Soporte prioritario',
|
||||||
|
],
|
||||||
|
limits: {
|
||||||
|
reservations: 500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pro',
|
||||||
|
name: 'Pro',
|
||||||
|
price: '$49',
|
||||||
|
period: 'por mes',
|
||||||
|
description: 'Para negocios establecidos',
|
||||||
|
icon: Star,
|
||||||
|
color: 'text-warning-600',
|
||||||
|
bgColor: 'bg-warning-100',
|
||||||
|
features: [
|
||||||
|
'Reservas ilimitadas',
|
||||||
|
'3 números de WhatsApp',
|
||||||
|
'Bot avanzado con IA',
|
||||||
|
'Analytics completo',
|
||||||
|
'API personalizada',
|
||||||
|
'Soporte 24/7',
|
||||||
|
],
|
||||||
|
limits: {
|
||||||
|
reservations: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function UsageBar({ used, total, label }) {
|
||||||
|
const pct = total ? Math.min((used / total) * 100, 100) : 0
|
||||||
|
const color = pct >= 90 ? 'bg-danger-500' : pct >= 70 ? 'bg-warning-500' : 'bg-primary-500'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-slate-500">{label}</span>
|
||||||
|
<span className={cn('font-medium', pct >= 90 ? 'text-danger-600' : 'text-slate-700')}>
|
||||||
|
{used} {total ? `/ ${total}` : '/ ∞'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn('h-full rounded-full transition-all duration-500', color)}
|
||||||
|
style={{ width: total ? `${pct}%` : '0%' }}
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={used}
|
||||||
|
aria-valuemax={total}
|
||||||
|
aria-label={label}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BillingPage() {
|
||||||
|
const [planInfo, setPlanInfo] = useState(null)
|
||||||
|
const [usage, setUsage] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [upgrading, setUpgrading] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [pRes, uRes] = await Promise.all([
|
||||||
|
billingApi.getPlan(),
|
||||||
|
billingApi.getUsage(),
|
||||||
|
])
|
||||||
|
setPlanInfo(pRes.data)
|
||||||
|
setUsage(uRes.data)
|
||||||
|
} catch {
|
||||||
|
setPlanInfo({ plan: 'free', status: 'trial' })
|
||||||
|
setUsage({ reservations_used: 12, reservations_limit: 50 })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function handleUpgrade(planId) {
|
||||||
|
if (planId === planInfo?.plan) return
|
||||||
|
setUpgrading(planId)
|
||||||
|
try {
|
||||||
|
await billingApi.upgrade(planId)
|
||||||
|
setPlanInfo((p) => ({ ...p, plan: planId }))
|
||||||
|
} finally {
|
||||||
|
setUpgrading('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPlan = PLANS.find((p) => p.id === planInfo?.plan) ?? PLANS[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-7 max-w-4xl animate-fade-in">
|
||||||
|
{/* Uso actual */}
|
||||||
|
<div className="card p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<div className={cn('w-8 h-8 rounded-lg flex items-center justify-center', currentPlan.bgColor)}>
|
||||||
|
<currentPlan.icon size={16} className={currentPlan.color} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-900">
|
||||||
|
Plan {currentPlan.name}
|
||||||
|
{planInfo?.status === 'trial' && (
|
||||||
|
<span className="ml-2 badge-yellow text-xs">Período de prueba</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400">{currentPlan.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="skeleton h-8 rounded-lg" />
|
||||||
|
) : (
|
||||||
|
<UsageBar
|
||||||
|
label="Reservas este mes"
|
||||||
|
used={usage?.reservations_used ?? 0}
|
||||||
|
total={usage?.reservations_limit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Planes */}
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-lg font-semibold text-slate-900 mb-4">
|
||||||
|
Elige tu plan
|
||||||
|
</h2>
|
||||||
|
<div className="grid md:grid-cols-3 gap-4">
|
||||||
|
{PLANS.map((plan, i) => {
|
||||||
|
const Icon = plan.icon
|
||||||
|
const isCurrent = planInfo?.plan === plan.id
|
||||||
|
const isUpgrading = upgrading === plan.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={plan.id}
|
||||||
|
className={cn(
|
||||||
|
'card p-6 flex flex-col gap-5 relative transition-shadow duration-200',
|
||||||
|
plan.popular ? 'border-primary-300 shadow-card-hover ring-1 ring-primary-200' : '',
|
||||||
|
isCurrent ? 'border-primary-400' : '',
|
||||||
|
`animate-stagger-${i + 1}`,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{plan.popular && (
|
||||||
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||||
|
<span className="badge-green text-xs px-3 py-1 shadow-sm">Más popular</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className={cn('w-10 h-10 rounded-xl flex items-center justify-center mb-3', plan.bgColor)}>
|
||||||
|
<Icon size={18} className={plan.color} />
|
||||||
|
</div>
|
||||||
|
<p className="font-display text-base font-bold text-slate-900">{plan.name}</p>
|
||||||
|
<div className="flex items-baseline gap-1 mt-1">
|
||||||
|
<span className="text-2xl font-display font-bold text-slate-900">{plan.price}</span>
|
||||||
|
<span className="text-sm text-slate-400">{plan.period}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">{plan.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="flex flex-col gap-2 flex-1">
|
||||||
|
{plan.features.map((f) => (
|
||||||
|
<li key={f} className="flex items-start gap-2 text-sm text-slate-700">
|
||||||
|
<CheckCircle size={14} className="text-primary-500 flex-shrink-0 mt-0.5" />
|
||||||
|
{f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleUpgrade(plan.id)}
|
||||||
|
disabled={isCurrent || isUpgrading}
|
||||||
|
className={cn(
|
||||||
|
'btn w-full justify-center py-2.5 text-sm',
|
||||||
|
isCurrent
|
||||||
|
? 'bg-slate-100 text-slate-500 cursor-default'
|
||||||
|
: plan.popular
|
||||||
|
? 'btn-primary'
|
||||||
|
: 'btn-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isUpgrading ? (
|
||||||
|
<span className="w-4 h-4 rounded-full border-2 border-current border-t-transparent animate-spin" />
|
||||||
|
) : isCurrent ? (
|
||||||
|
'Plan actual'
|
||||||
|
) : (
|
||||||
|
`Elegir ${plan.name}`
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features highlight */}
|
||||||
|
<div className="grid md:grid-cols-3 gap-4 animate-stagger-4">
|
||||||
|
{[
|
||||||
|
{ icon: MessageCircle, title: 'Bot 24/7', desc: 'Tu asistente responde reservas a cualquier hora, sin que tengas que estar presente.' },
|
||||||
|
{ icon: CalendarDays, title: 'Gestión automática', desc: 'Los horarios se actualizan en tiempo real según las reservas recibidas.' },
|
||||||
|
{ icon: Bot, title: 'IA conversacional', desc: 'Claude entiende el lenguaje natural de tus clientes y los guía en el proceso.' },
|
||||||
|
].map((f) => (
|
||||||
|
<div key={f.title} className="card p-5 flex items-start gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-primary-50 flex items-center justify-center flex-shrink-0">
|
||||||
|
<f.icon size={17} className="text-primary-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-900">{f.title}</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5 leading-relaxed">{f.desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
224
frontend/src/pages/CalendarPage.jsx
Normal file
224
frontend/src/pages/CalendarPage.jsx
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { format, addDays, startOfWeek, isSameDay, isToday, parseISO } from 'date-fns'
|
||||||
|
import { es } from 'date-fns/locale'
|
||||||
|
import { ChevronLeft, ChevronRight, Lock, Unlock, Clock } from 'lucide-react'
|
||||||
|
import { calendarApi } from '@/lib/api'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const WEEK_DAYS = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom']
|
||||||
|
|
||||||
|
export default function CalendarPage() {
|
||||||
|
const [currentDate, setCurrentDate] = useState(new Date())
|
||||||
|
const [selectedDate, setSelectedDate] = useState(new Date())
|
||||||
|
const [slots, setSlots] = useState([])
|
||||||
|
const [blockedDates, setBlockedDates] = useState([])
|
||||||
|
const [loadingSlots, setLoadingSlots] = useState(false)
|
||||||
|
const [togglingDate, setTogglingDate] = useState(false)
|
||||||
|
|
||||||
|
const weekStart = startOfWeek(currentDate, { weekStartsOn: 1 })
|
||||||
|
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i))
|
||||||
|
|
||||||
|
const selectedStr = format(selectedDate, 'yyyy-MM-dd')
|
||||||
|
const isBlocked = blockedDates.includes(selectedStr)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadSlots() {
|
||||||
|
setLoadingSlots(true)
|
||||||
|
try {
|
||||||
|
const { data } = await calendarApi.getAvailability(selectedStr)
|
||||||
|
setSlots(data)
|
||||||
|
} catch {
|
||||||
|
setSlots([])
|
||||||
|
} finally {
|
||||||
|
setLoadingSlots(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadSlots()
|
||||||
|
}, [selectedStr])
|
||||||
|
|
||||||
|
async function toggleBlockDate() {
|
||||||
|
setTogglingDate(true)
|
||||||
|
try {
|
||||||
|
if (isBlocked) {
|
||||||
|
await calendarApi.unblockDate(selectedStr)
|
||||||
|
setBlockedDates((d) => d.filter((x) => x !== selectedStr))
|
||||||
|
} else {
|
||||||
|
await calendarApi.blockDate(selectedStr)
|
||||||
|
setBlockedDates((d) => [...d, selectedStr])
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setTogglingDate(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 max-w-4xl animate-fade-in">
|
||||||
|
<div className="grid lg:grid-cols-3 gap-5">
|
||||||
|
{/* Calendario semanal */}
|
||||||
|
<div className="lg:col-span-2 card p-5">
|
||||||
|
{/* Nav semana */}
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<h3 className="font-display text-base font-semibold text-slate-800 capitalize">
|
||||||
|
{format(weekStart, "MMMM yyyy", { locale: es })}
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentDate((d) => addDays(d, -7))}
|
||||||
|
className="btn-ghost p-1.5"
|
||||||
|
aria-label="Semana anterior"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentDate(new Date())}
|
||||||
|
className="btn-secondary px-3 py-1.5 text-xs"
|
||||||
|
>
|
||||||
|
Hoy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentDate((d) => addDays(d, 7))}
|
||||||
|
className="btn-ghost p-1.5"
|
||||||
|
aria-label="Semana siguiente"
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Días */}
|
||||||
|
<div className="grid grid-cols-7 gap-1.5">
|
||||||
|
{WEEK_DAYS.map((d) => (
|
||||||
|
<div key={d} className="text-center text-xs font-medium text-slate-400 pb-1.5">{d}</div>
|
||||||
|
))}
|
||||||
|
{weekDays.map((day) => {
|
||||||
|
const dayStr = format(day, 'yyyy-MM-dd')
|
||||||
|
const selected = isSameDay(day, selectedDate)
|
||||||
|
const today = isToday(day)
|
||||||
|
const blocked = blockedDates.includes(dayStr)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={dayStr}
|
||||||
|
onClick={() => setSelectedDate(day)}
|
||||||
|
className={cn(
|
||||||
|
'relative flex flex-col items-center justify-center rounded-xl py-3 px-1 text-sm font-medium transition-all duration-150',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||||
|
selected
|
||||||
|
? 'bg-primary-600 text-white shadow-sm'
|
||||||
|
: blocked
|
||||||
|
? 'bg-slate-100 text-slate-400 line-through'
|
||||||
|
: today
|
||||||
|
? 'bg-primary-50 text-primary-700 ring-1 ring-primary-300'
|
||||||
|
: 'hover:bg-slate-50 text-slate-700',
|
||||||
|
)}
|
||||||
|
aria-pressed={selected}
|
||||||
|
aria-label={`${format(day, 'd MMMM', { locale: es })}${blocked ? ', bloqueado' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="text-base leading-none">{format(day, 'd')}</span>
|
||||||
|
{blocked && !selected && (
|
||||||
|
<Lock size={9} className="mt-1 opacity-50" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Panel del día seleccionado */}
|
||||||
|
<div className="card p-5 flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400 uppercase tracking-wide font-medium">Día seleccionado</p>
|
||||||
|
<p className="font-display text-lg font-semibold text-slate-900 capitalize mt-0.5">
|
||||||
|
{format(selectedDate, "EEEE d 'de' MMMM", { locale: es })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bloquear/Desbloquear */}
|
||||||
|
<div className={cn(
|
||||||
|
'flex items-center gap-3 rounded-xl px-4 py-3.5 border transition-colors',
|
||||||
|
isBlocked
|
||||||
|
? 'bg-danger-50 border-danger-100'
|
||||||
|
: 'bg-slate-50 border-border',
|
||||||
|
)}>
|
||||||
|
<div className={cn('flex-1', isBlocked ? 'text-danger-700' : 'text-slate-700')}>
|
||||||
|
<p className="text-sm font-medium">{isBlocked ? 'Día bloqueado' : 'Día disponible'}</p>
|
||||||
|
<p className="text-xs mt-0.5 opacity-70">
|
||||||
|
{isBlocked
|
||||||
|
? 'El bot no acepta reservas este día'
|
||||||
|
: 'El bot acepta reservas según tu horario'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={toggleBlockDate}
|
||||||
|
disabled={togglingDate}
|
||||||
|
className={cn(
|
||||||
|
'btn flex-shrink-0 px-3 py-2 text-xs gap-1.5',
|
||||||
|
isBlocked
|
||||||
|
? 'bg-white border border-border text-slate-700 hover:bg-slate-50 shadow-card'
|
||||||
|
: 'bg-danger-600 text-white hover:bg-red-700',
|
||||||
|
)}
|
||||||
|
aria-label={isBlocked ? 'Desbloquear fecha' : 'Bloquear fecha'}
|
||||||
|
>
|
||||||
|
{togglingDate
|
||||||
|
? <span className="w-3.5 h-3.5 rounded-full border-2 border-current border-t-transparent animate-spin block" />
|
||||||
|
: isBlocked
|
||||||
|
? <><Unlock size={12} />Desbloquear</>
|
||||||
|
: <><Lock size={12} />Bloquear</>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Slots */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-1.5 mb-3">
|
||||||
|
<Clock size={14} className="text-slate-400" />
|
||||||
|
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||||
|
Horarios disponibles
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingSlots ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="skeleton h-9 rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : isBlocked ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<Lock size={24} className="text-slate-200 mb-2" />
|
||||||
|
<p className="text-sm text-slate-400">Día bloqueado</p>
|
||||||
|
</div>
|
||||||
|
) : slots.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<Clock size={24} className="text-slate-200 mb-2" />
|
||||||
|
<p className="text-sm text-slate-400">Sin horarios disponibles</p>
|
||||||
|
<p className="text-xs text-slate-300 mt-1">Verifica tu configuración de horarios</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1.5 max-h-56 overflow-y-auto pr-1">
|
||||||
|
{slots.map((slot) => (
|
||||||
|
<div
|
||||||
|
key={slot.time}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between px-3.5 py-2.5 rounded-lg text-sm border transition-colors',
|
||||||
|
slot.available > 0
|
||||||
|
? 'bg-primary-50 border-primary-100 text-primary-700'
|
||||||
|
: 'bg-slate-50 border-border text-slate-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{slot.time?.slice(0, 5)}</span>
|
||||||
|
<span className="text-xs">
|
||||||
|
{slot.available > 0
|
||||||
|
? `${slot.available} libre${slot.available > 1 ? 's' : ''}`
|
||||||
|
: 'Lleno'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
326
frontend/src/pages/ConfigPage.jsx
Normal file
326
frontend/src/pages/ConfigPage.jsx
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Save, CheckCircle, AlertCircle, Wifi, WifiOff, ExternalLink } from 'lucide-react'
|
||||||
|
import { businessApi, whatsappApi } from '@/lib/api'
|
||||||
|
import { DAYS_FULL, cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const TONE_OPTIONS = [
|
||||||
|
{ value: 'formal', label: 'Formal', desc: 'Tono profesional y respetuoso' },
|
||||||
|
{ value: 'casual', label: 'Casual', desc: 'Tono amigable y cercano' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function Section({ title, description, children }) {
|
||||||
|
return (
|
||||||
|
<div className="card p-6 flex flex-col gap-5">
|
||||||
|
<div className="border-b border-border pb-4">
|
||||||
|
<h3 className="font-display text-base font-semibold text-slate-900">{title}</h3>
|
||||||
|
{description && <p className="text-sm text-slate-500 mt-0.5">{description}</p>}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfigPage() {
|
||||||
|
const [business, setBusiness] = useState(null)
|
||||||
|
const [config, setConfig] = useState(null)
|
||||||
|
const [waStatus, setWaStatus] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [bRes, cRes, wRes] = await Promise.all([
|
||||||
|
businessApi.getMe(),
|
||||||
|
businessApi.getConfig(),
|
||||||
|
whatsappApi.getStatus().catch(() => ({ data: null })),
|
||||||
|
])
|
||||||
|
setBusiness(bRes.data)
|
||||||
|
setConfig(cRes.data)
|
||||||
|
setWaStatus(wRes.data)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function updateConfig(key, val) {
|
||||||
|
setConfig((c) => ({ ...c, [key]: val }))
|
||||||
|
setSaved(false)
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDay(day) {
|
||||||
|
const days = config.open_days ?? []
|
||||||
|
const next = days.includes(day) ? days.filter((d) => d !== day) : [...days, day].sort()
|
||||||
|
updateConfig('open_days', next)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
businessApi.updateMe({ name: business.name, timezone: business.timezone }),
|
||||||
|
businessApi.updateConfig({
|
||||||
|
open_days: config.open_days,
|
||||||
|
open_time: config.open_time,
|
||||||
|
close_time: config.close_time,
|
||||||
|
slot_duration: config.slot_duration,
|
||||||
|
max_per_slot: config.max_per_slot,
|
||||||
|
assistant_name: config.assistant_name,
|
||||||
|
tone: config.tone,
|
||||||
|
welcome_message: config.welcome_message,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
setSaved(true)
|
||||||
|
setTimeout(() => setSaved(false), 3000)
|
||||||
|
} catch {
|
||||||
|
setError('No se pudo guardar la configuración. Intenta de nuevo.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5 max-w-2xl">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="card p-6">
|
||||||
|
<div className="skeleton h-5 w-40 rounded mb-4" />
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="skeleton h-10 rounded-lg" />
|
||||||
|
<div className="skeleton h-10 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isWaConnected = Boolean(waStatus?.whatsapp_phone_number_id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5 max-w-2xl animate-fade-in">
|
||||||
|
{/* WhatsApp status */}
|
||||||
|
<div className={cn(
|
||||||
|
'card p-5 flex items-center gap-4',
|
||||||
|
isWaConnected ? 'border-primary-200 bg-primary-50' : 'border-warning-200 bg-warning-50',
|
||||||
|
)}>
|
||||||
|
<div className={cn(
|
||||||
|
'w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0',
|
||||||
|
isWaConnected ? 'bg-primary-100' : 'bg-warning-100',
|
||||||
|
)}>
|
||||||
|
{isWaConnected
|
||||||
|
? <Wifi size={18} className="text-primary-600" />
|
||||||
|
: <WifiOff size={18} className="text-warning-600" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className={cn('text-sm font-semibold', isWaConnected ? 'text-primary-800' : 'text-warning-800')}>
|
||||||
|
WhatsApp {isWaConnected ? 'conectado' : 'no conectado'}
|
||||||
|
</p>
|
||||||
|
<p className={cn('text-xs mt-0.5', isWaConnected ? 'text-primary-600' : 'text-warning-600')}>
|
||||||
|
{isWaConnected
|
||||||
|
? `Número: ${waStatus.whatsapp_phone_number_id}`
|
||||||
|
: 'Configura tu número de WhatsApp Business para recibir reservas.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Negocio */}
|
||||||
|
<Section title="Datos del negocio" description="Nombre y zona horaria visible en el panel.">
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label field-label-required">Nombre del negocio</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={business?.name ?? ''}
|
||||||
|
onChange={(e) => setBusiness((b) => ({ ...b, name: e.target.value }))}
|
||||||
|
className="field-input"
|
||||||
|
placeholder="Nombre de tu negocio"
|
||||||
|
/>
|
||||||
|
<span className="field-helper">Nombre que ven tus clientes en los mensajes del bot.</span>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Horarios */}
|
||||||
|
<Section
|
||||||
|
title="Horarios de atención"
|
||||||
|
description="Define cuándo está disponible tu negocio para reservas."
|
||||||
|
>
|
||||||
|
{/* Días de la semana */}
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label field-label-required">Días de atención</label>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-1">
|
||||||
|
{DAYS_FULL.map((day, idx) => {
|
||||||
|
const open = config?.open_days?.includes(idx)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleDay(idx)}
|
||||||
|
className={cn(
|
||||||
|
'btn px-3.5 py-2 text-xs font-medium transition-all duration-150',
|
||||||
|
open
|
||||||
|
? 'bg-primary-600 text-white hover:bg-primary-700'
|
||||||
|
: 'bg-white border border-border text-slate-600 hover:bg-slate-50',
|
||||||
|
)}
|
||||||
|
aria-pressed={open}
|
||||||
|
>
|
||||||
|
{day.slice(0, 3)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className="field-helper">El bot solo aceptará reservas en los días marcados.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Horario */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label field-label-required">Hora de apertura</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={config?.open_time ?? '09:00'}
|
||||||
|
onChange={(e) => updateConfig('open_time', e.target.value)}
|
||||||
|
className="field-input"
|
||||||
|
/>
|
||||||
|
<span className="field-helper">Primera hora disponible para reservas.</span>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label field-label-required">Hora de cierre</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={config?.close_time ?? '18:00'}
|
||||||
|
onChange={(e) => updateConfig('close_time', e.target.value)}
|
||||||
|
className="field-input"
|
||||||
|
/>
|
||||||
|
<span className="field-helper">Última hora en que se generan turnos.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Duración y máximo */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label field-label-required">Duración del turno (min)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={15}
|
||||||
|
max={240}
|
||||||
|
step={15}
|
||||||
|
value={config?.slot_duration ?? 60}
|
||||||
|
onChange={(e) => updateConfig('slot_duration', Number(e.target.value))}
|
||||||
|
className="field-input"
|
||||||
|
/>
|
||||||
|
<span className="field-helper">Tiempo que dura cada cita o reserva.</span>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label field-label-required">Máximo por turno</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
value={config?.max_per_slot ?? 1}
|
||||||
|
onChange={(e) => updateConfig('max_per_slot', Number(e.target.value))}
|
||||||
|
className="field-input"
|
||||||
|
/>
|
||||||
|
<span className="field-helper">Cuántas reservas se aceptan en el mismo horario.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Bot */}
|
||||||
|
<Section
|
||||||
|
title="Configuración del asistente"
|
||||||
|
description="Personaliza cómo se presenta el bot a tus clientes."
|
||||||
|
>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label field-label-required">Nombre del asistente</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config?.assistant_name ?? ''}
|
||||||
|
onChange={(e) => updateConfig('assistant_name', e.target.value)}
|
||||||
|
className="field-input"
|
||||||
|
placeholder="Ej: Hermes, María, Asistente…"
|
||||||
|
/>
|
||||||
|
<span className="field-helper">
|
||||||
|
Nombre con el que el bot se presentará: "Hola, soy <strong>{config?.assistant_name || 'Hermes'}</strong>…"
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">Tono de comunicación</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-1">
|
||||||
|
{TONE_OPTIONS.map((t) => {
|
||||||
|
const active = config?.tone === t.value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateConfig('tone', t.value)}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-start px-4 py-3.5 rounded-xl border text-left transition-all duration-150',
|
||||||
|
active
|
||||||
|
? 'border-primary-500 bg-primary-50 text-primary-700'
|
||||||
|
: 'border-border bg-white text-slate-700 hover:bg-slate-50',
|
||||||
|
)}
|
||||||
|
aria-pressed={active}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold">{t.label}</span>
|
||||||
|
<span className="text-xs mt-0.5 opacity-70">{t.desc}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">Mensaje de bienvenida</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={config?.welcome_message ?? ''}
|
||||||
|
onChange={(e) => updateConfig('welcome_message', e.target.value)}
|
||||||
|
className="field-input resize-none"
|
||||||
|
placeholder="Ej: ¡Bienvenido! ¿En qué fecha y hora te gustaría reservar?"
|
||||||
|
/>
|
||||||
|
<span className="field-helper">
|
||||||
|
Primer mensaje que el bot envía al iniciar una conversación. Déjalo vacío para usar el mensaje predeterminado.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Footer guardado */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-danger-700 bg-danger-50 border border-danger-100 rounded-lg px-4 py-3">
|
||||||
|
<AlertCircle size={15} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pb-4">
|
||||||
|
{saved && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-primary-700 animate-fade-in">
|
||||||
|
<CheckCircle size={15} />
|
||||||
|
Configuración guardada
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="btn-primary gap-2"
|
||||||
|
>
|
||||||
|
{saving
|
||||||
|
? <span className="w-4 h-4 rounded-full border-2 border-white border-t-transparent animate-spin" />
|
||||||
|
: <Save size={15} />
|
||||||
|
}
|
||||||
|
{saving ? 'Guardando…' : 'Guardar cambios'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
199
frontend/src/pages/DashboardPage.jsx
Normal file
199
frontend/src/pages/DashboardPage.jsx
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { es } from 'date-fns/locale'
|
||||||
|
import {
|
||||||
|
CalendarCheck, Clock, XCircle, Users, TrendingUp, MessageCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import {
|
||||||
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||||
|
} from 'recharts'
|
||||||
|
import { dashboardApi } from '@/lib/api'
|
||||||
|
import { STATUS_BADGE, STATUS_LABELS, formatTime } from '@/lib/utils'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function StatCard({ icon: Icon, label, value, color, delay }) {
|
||||||
|
return (
|
||||||
|
<div className={cn('card p-5 flex items-center gap-4', `animate-stagger-${delay}`)}>
|
||||||
|
<div className={cn('w-11 h-11 rounded-xl flex items-center justify-center flex-shrink-0', color)}>
|
||||||
|
<Icon size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-display font-bold text-slate-900">{value ?? '—'}</p>
|
||||||
|
<p className="text-sm text-slate-500">{label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonCard() {
|
||||||
|
return (
|
||||||
|
<div className="card p-5 flex items-center gap-4">
|
||||||
|
<div className="skeleton w-11 h-11 rounded-xl" />
|
||||||
|
<div className="flex-1 flex flex-col gap-2">
|
||||||
|
<div className="skeleton h-6 w-16 rounded" />
|
||||||
|
<div className="skeleton h-4 w-28 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const [stats, setStats] = useState(null)
|
||||||
|
const [agenda, setAgenda] = useState([])
|
||||||
|
const [peakHours, setPeakHours] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const today = format(new Date(), 'yyyy-MM-dd')
|
||||||
|
const todayLabel = format(new Date(), "EEEE d 'de' MMMM", { locale: es })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [statsRes, agendaRes, peakRes] = await Promise.all([
|
||||||
|
dashboardApi.getStats(),
|
||||||
|
dashboardApi.getAgenda(today),
|
||||||
|
dashboardApi.getPeakHours(),
|
||||||
|
])
|
||||||
|
setStats(statsRes.data)
|
||||||
|
setAgenda(agendaRes.data)
|
||||||
|
setPeakHours(peakRes.data)
|
||||||
|
} catch {
|
||||||
|
// datos simulados para preview
|
||||||
|
setStats({ total: 24, confirmed: 18, pending: 4, cancelled: 2 })
|
||||||
|
setAgenda([])
|
||||||
|
setPeakHours([
|
||||||
|
{ hour: '09:00', count: 3 }, { hour: '10:00', count: 5 }, { hour: '11:00', count: 4 },
|
||||||
|
{ hour: '14:00', count: 6 }, { hour: '15:00', count: 8 }, { hour: '16:00', count: 3 },
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [today])
|
||||||
|
|
||||||
|
const statCards = stats ? [
|
||||||
|
{ icon: CalendarCheck, label: 'Confirmadas', value: stats.confirmed, color: 'bg-primary-100 text-primary-600', delay: 1 },
|
||||||
|
{ icon: Clock, label: 'Pendientes', value: stats.pending, color: 'bg-warning-100 text-warning-600', delay: 2 },
|
||||||
|
{ icon: XCircle, label: 'Canceladas', value: stats.cancelled, color: 'bg-danger-100 text-danger-500', delay: 3 },
|
||||||
|
{ icon: Users, label: 'Total este mes', value: stats.total, color: 'bg-slate-100 text-slate-600', delay: 4 },
|
||||||
|
] : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-7 max-w-5xl">
|
||||||
|
{/* Header con fecha */}
|
||||||
|
<div className="animate-stagger-1">
|
||||||
|
<p className="text-sm text-slate-500 capitalize">{todayLabel}</p>
|
||||||
|
<h2 className="font-display text-2xl font-bold text-slate-900 mt-0.5">
|
||||||
|
Resumen de tu negocio
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stat cards */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{loading
|
||||||
|
? Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} />)
|
||||||
|
: statCards.map((s) => <StatCard key={s.label} {...s} />)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gráfica + Agenda */}
|
||||||
|
<div className="grid lg:grid-cols-3 gap-5">
|
||||||
|
{/* Gráfica horas pico */}
|
||||||
|
<div className="lg:col-span-2 card p-5 animate-stagger-3">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<TrendingUp size={16} className="text-primary-600" />
|
||||||
|
<h3 className="font-display text-sm font-semibold text-slate-800">Horas pico de reservas</h3>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="skeleton h-48 rounded-lg" />
|
||||||
|
) : peakHours.length ? (
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<BarChart data={peakHours} margin={{ top: 0, right: 0, left: -24, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="hour"
|
||||||
|
tick={{ fontSize: 11, fill: '#94a3b8', fontFamily: 'DM Sans' }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 11, fill: '#94a3b8', fontFamily: 'DM Sans' }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
allowDecimals={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: '#fff', border: '1px solid #e1e7ef',
|
||||||
|
borderRadius: '8px', fontSize: '12px', fontFamily: 'DM Sans',
|
||||||
|
boxShadow: '0 4px 12px rgb(0 0 0 / 0.08)',
|
||||||
|
}}
|
||||||
|
cursor={{ fill: '#f1f5f9' }}
|
||||||
|
formatter={(v) => [`${v} reservas`, 'Reservas']}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="count" fill="#059669" radius={[4, 4, 0, 0]} maxBarSize={40} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="h-48 flex items-center justify-center text-sm text-slate-400">
|
||||||
|
Sin datos suficientes todavía
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agenda del día */}
|
||||||
|
<div className="card p-5 animate-stagger-4">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<MessageCircle size={16} className="text-primary-600" />
|
||||||
|
<h3 className="font-display text-sm font-semibold text-slate-800">Agenda de hoy</h3>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="skeleton h-12 rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : agenda.length ? (
|
||||||
|
<div className="flex flex-col gap-2 overflow-y-auto max-h-52">
|
||||||
|
{agenda.map((r, i) => (
|
||||||
|
<div
|
||||||
|
key={r.id ?? i}
|
||||||
|
className="flex items-center gap-3 p-2.5 rounded-lg hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="text-xs font-medium text-slate-500 w-10 flex-shrink-0">
|
||||||
|
{formatTime(r.time_start)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-slate-900 truncate">{r.customer_name}</p>
|
||||||
|
<p className="text-xs text-slate-400 truncate">{r.notes || 'Sin notas'}</p>
|
||||||
|
</div>
|
||||||
|
<span className={STATUS_BADGE[r.status]}>{STATUS_LABELS[r.status]}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center h-36 text-center">
|
||||||
|
<CalendarCheck size={28} className="text-slate-200 mb-2" />
|
||||||
|
<p className="text-sm text-slate-400">Sin reservas para hoy</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tip de integración WhatsApp si no está conectado */}
|
||||||
|
<div className="card p-5 border-primary-100 bg-primary-50 flex items-start gap-4 animate-stagger-5">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-primary-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<MessageCircle size={18} className="text-primary-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-semibold text-primary-800">Conecta tu WhatsApp</p>
|
||||||
|
<p className="text-xs text-primary-600 mt-0.5">
|
||||||
|
Ve a <strong>Configuración</strong> para vincular tu número de WhatsApp Business
|
||||||
|
y comenzar a recibir reservas automáticamente.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
225
frontend/src/pages/ReservationsPage.jsx
Normal file
225
frontend/src/pages/ReservationsPage.jsx
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { Search, Plus, Filter, Trash2, CheckCircle, XCircle, AlertCircle } from 'lucide-react'
|
||||||
|
import { reservationsApi } from '@/lib/api'
|
||||||
|
import { STATUS_BADGE, STATUS_LABELS, formatDate, formatTime, cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: '', label: 'Todos los estados' },
|
||||||
|
{ value: 'pending', label: 'Pendiente' },
|
||||||
|
{ value: 'confirmed', label: 'Confirmada' },
|
||||||
|
{ value: 'cancelled', label: 'Cancelada' },
|
||||||
|
{ value: 'no_show', label: 'No asistió' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function SkeletonRow() {
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<td key={i} className="px-4 py-3.5">
|
||||||
|
<div className="skeleton h-4 rounded w-full max-w-[120px]" />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReservationsPage() {
|
||||||
|
const [reservations, setReservations] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState('')
|
||||||
|
const [dateFilter, setDateFilter] = useState('')
|
||||||
|
const [updatingId, setUpdatingId] = useState(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const params = {}
|
||||||
|
if (dateFilter) params.date = dateFilter
|
||||||
|
if (statusFilter) params.status = statusFilter
|
||||||
|
const { data } = await reservationsApi.list(params)
|
||||||
|
setReservations(data)
|
||||||
|
} catch {
|
||||||
|
setReservations([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [dateFilter, statusFilter])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
async function updateStatus(id, status) {
|
||||||
|
setUpdatingId(id)
|
||||||
|
try {
|
||||||
|
await reservationsApi.updateStatus(id, status)
|
||||||
|
setReservations((rs) =>
|
||||||
|
rs.map((r) => (r.id === id ? { ...r, status } : r))
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setUpdatingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteReservation(id) {
|
||||||
|
if (!confirm('¿Eliminar esta reserva?')) return
|
||||||
|
try {
|
||||||
|
await reservationsApi.delete(id)
|
||||||
|
setReservations((rs) => rs.filter((r) => r.id !== id))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = reservations.filter((r) => {
|
||||||
|
if (!search) return true
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
return (
|
||||||
|
r.customer_name?.toLowerCase().includes(q) ||
|
||||||
|
r.customer_phone?.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5 max-w-6xl animate-fade-in">
|
||||||
|
{/* Barra de herramientas */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between">
|
||||||
|
<div className="flex flex-wrap gap-2 flex-1">
|
||||||
|
{/* Búsqueda */}
|
||||||
|
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||||
|
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por nombre o teléfono…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="field-input pl-9 h-9 text-sm"
|
||||||
|
aria-label="Buscar reservas"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtro estado */}
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="field-input h-9 text-sm w-auto"
|
||||||
|
aria-label="Filtrar por estado"
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Filtro fecha */}
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateFilter}
|
||||||
|
onChange={(e) => setDateFilter(e.target.value)}
|
||||||
|
className="field-input h-9 text-sm w-auto"
|
||||||
|
aria-label="Filtrar por fecha"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabla */}
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm" role="table" aria-label="Lista de reservas">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-slate-50/60">
|
||||||
|
{['Cliente', 'Teléfono', 'Fecha', 'Hora', 'Estado', 'Acciones'].map((h) => (
|
||||||
|
<th
|
||||||
|
key={h}
|
||||||
|
className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide"
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{loading
|
||||||
|
? Array.from({ length: 6 }).map((_, i) => <SkeletonRow key={i} />)
|
||||||
|
: filtered.length === 0
|
||||||
|
? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="py-16 text-center">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Filter size={28} className="text-slate-200" />
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
{search || statusFilter || dateFilter
|
||||||
|
? 'Sin resultados para este filtro'
|
||||||
|
: 'Aún no hay reservas registradas'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
: filtered.map((r, i) => (
|
||||||
|
<tr
|
||||||
|
key={r.id}
|
||||||
|
className="hover:bg-slate-50/50 transition-colors"
|
||||||
|
style={{ animationDelay: `${i * 30}ms` }}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3.5">
|
||||||
|
<span className="font-medium text-slate-900">{r.customer_name}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3.5 text-slate-500">{r.customer_phone}</td>
|
||||||
|
<td className="px-4 py-3.5 text-slate-600">{formatDate(r.date)}</td>
|
||||||
|
<td className="px-4 py-3.5 text-slate-600">{formatTime(r.time_start)}</td>
|
||||||
|
<td className="px-4 py-3.5">
|
||||||
|
<span className={STATUS_BADGE[r.status]}>{STATUS_LABELS[r.status]}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3.5">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{r.status === 'pending' && (
|
||||||
|
<button
|
||||||
|
onClick={() => updateStatus(r.id, 'confirmed')}
|
||||||
|
disabled={updatingId === r.id}
|
||||||
|
className="btn-ghost p-1.5 text-primary-600 hover:bg-primary-50"
|
||||||
|
title="Confirmar"
|
||||||
|
aria-label="Confirmar reserva"
|
||||||
|
>
|
||||||
|
{updatingId === r.id
|
||||||
|
? <span className="w-3.5 h-3.5 rounded-full border-2 border-primary-500 border-t-transparent animate-spin block" />
|
||||||
|
: <CheckCircle size={15} />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(r.status === 'pending' || r.status === 'confirmed') && (
|
||||||
|
<button
|
||||||
|
onClick={() => updateStatus(r.id, 'cancelled')}
|
||||||
|
disabled={updatingId === r.id}
|
||||||
|
className="btn-ghost p-1.5 text-slate-400 hover:text-danger-600 hover:bg-danger-50"
|
||||||
|
title="Cancelar"
|
||||||
|
aria-label="Cancelar reserva"
|
||||||
|
>
|
||||||
|
<XCircle size={15} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => deleteReservation(r.id)}
|
||||||
|
className="btn-ghost p-1.5 text-slate-300 hover:text-danger-600 hover:bg-danger-50"
|
||||||
|
title="Eliminar"
|
||||||
|
aria-label="Eliminar reserva"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contador */}
|
||||||
|
{!loading && filtered.length > 0 && (
|
||||||
|
<div className="px-4 py-3 border-t border-border bg-slate-50/50">
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
{filtered.length} reserva{filtered.length !== 1 ? 's' : ''} encontrada{filtered.length !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
137
frontend/src/pages/auth/LoginPage.jsx
Normal file
137
frontend/src/pages/auth/LoginPage.jsx
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { MessageCircle, Eye, EyeOff, AlertCircle } from 'lucide-react'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const { login } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [form, setForm] = useState({ email: '', password: '' })
|
||||||
|
const [showPwd, setShowPwd] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
function handleChange(e) {
|
||||||
|
setForm((f) => ({ ...f, [e.target.name]: e.target.value }))
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await login(form.email, form.password)
|
||||||
|
navigate('/dashboard')
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err.response?.data?.detail === 'Credenciales incorrectas'
|
||||||
|
? 'Correo o contraseña incorrectos. Verifica e intenta de nuevo.'
|
||||||
|
: 'Ocurrió un error. Intenta de nuevo.'
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-bg min-h-screen flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-sm animate-slide-up">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex flex-col items-center gap-3 mb-8">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-primary-600 flex items-center justify-center shadow-lg">
|
||||||
|
<MessageCircle size={24} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="font-display text-2xl font-bold text-slate-900">
|
||||||
|
Hermes<span className="text-primary-600">Messages</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">Reservas automáticas por WhatsApp</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<div className="card p-6 shadow-dialog">
|
||||||
|
<h2 className="font-display text-lg font-semibold text-slate-900 mb-1">
|
||||||
|
Bienvenido de vuelta
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-500 mb-6">Ingresa a tu panel de control</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-2.5 bg-danger-50 border border-danger-100 rounded-lg px-3.5 py-3 mb-5 text-sm text-danger-700">
|
||||||
|
<AlertCircle size={16} className="flex-shrink-0 mt-0.5" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="email" className="field-label field-label-required">
|
||||||
|
Correo electrónico
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
placeholder="tu@negocio.com"
|
||||||
|
value={form.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="field-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="password" className="field-label field-label-required">
|
||||||
|
Contraseña
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type={showPwd ? 'text' : 'password'}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
placeholder="Tu contraseña"
|
||||||
|
value={form.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="field-input pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPwd((v) => !v)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors"
|
||||||
|
aria-label={showPwd ? 'Ocultar contraseña' : 'Mostrar contraseña'}
|
||||||
|
>
|
||||||
|
{showPwd ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !form.email || !form.password}
|
||||||
|
className="btn-primary w-full mt-1"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="w-4 h-4 rounded-full border-2 border-white border-t-transparent animate-spin" />
|
||||||
|
) : (
|
||||||
|
'Ingresar'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-slate-500 mt-5">
|
||||||
|
¿No tienes cuenta?{' '}
|
||||||
|
<Link to="/register" className="text-primary-600 font-medium hover:underline">
|
||||||
|
Regístrate gratis
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
437
frontend/src/pages/auth/RegisterPage.jsx
Normal file
437
frontend/src/pages/auth/RegisterPage.jsx
Normal file
@ -0,0 +1,437 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
MessageCircle, Building2, UserCircle, CheckCircle2,
|
||||||
|
Eye, EyeOff, AlertCircle, ChevronRight, ChevronLeft, Info,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import { cn, BUSINESS_TYPES, TIMEZONES } from '@/lib/utils'
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ id: 1, label: 'Tu negocio', description: 'Datos básicos del negocio' },
|
||||||
|
{ id: 2, label: 'Tu cuenta', description: 'Credenciales de acceso' },
|
||||||
|
{ id: 3, label: 'Listo', description: 'Confirmar registro' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const INITIAL = {
|
||||||
|
business_name: '',
|
||||||
|
business_type: '',
|
||||||
|
timezone: 'America/Bogota',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirm_password: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const { register } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [step, setStep] = useState(1)
|
||||||
|
const [form, setForm] = useState(INITIAL)
|
||||||
|
const [errors, setErrors] = useState({})
|
||||||
|
const [showPwd, setShowPwd] = useState(false)
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [globalError, setGlobalError] = useState('')
|
||||||
|
|
||||||
|
function handleChange(e) {
|
||||||
|
const { name, value } = e.target
|
||||||
|
setForm((f) => ({ ...f, [name]: value }))
|
||||||
|
setErrors((e) => ({ ...e, [name]: '' }))
|
||||||
|
setGlobalError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateStep1() {
|
||||||
|
const e = {}
|
||||||
|
if (!form.business_name.trim()) e.business_name = 'Ingresa el nombre de tu negocio.'
|
||||||
|
if (form.business_name.trim().length > 0 && form.business_name.trim().length < 3)
|
||||||
|
e.business_name = 'El nombre debe tener al menos 3 caracteres.'
|
||||||
|
if (!form.business_type) e.business_type = 'Selecciona el tipo de negocio.'
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateStep2() {
|
||||||
|
const e = {}
|
||||||
|
if (!form.email) e.email = 'Ingresa tu correo electrónico.'
|
||||||
|
else if (!/\S+@\S+\.\S+/.test(form.email)) e.email = 'El correo no tiene un formato válido.'
|
||||||
|
if (!form.password) e.password = 'Crea una contraseña.'
|
||||||
|
else if (form.password.length < 8) e.password = 'La contraseña debe tener al menos 8 caracteres.'
|
||||||
|
if (form.password !== form.confirm_password) e.confirm_password = 'Las contraseñas no coinciden.'
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNext() {
|
||||||
|
const errs = step === 1 ? validateStep1() : validateStep2()
|
||||||
|
if (Object.keys(errs).length) { setErrors(errs); return }
|
||||||
|
setStep((s) => s + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
setLoading(true)
|
||||||
|
setGlobalError('')
|
||||||
|
try {
|
||||||
|
await register({
|
||||||
|
business_name: form.business_name.trim(),
|
||||||
|
business_type: form.business_type,
|
||||||
|
timezone: form.timezone,
|
||||||
|
email: form.email,
|
||||||
|
password: form.password,
|
||||||
|
})
|
||||||
|
navigate('/dashboard')
|
||||||
|
} catch (err) {
|
||||||
|
const detail = err.response?.data?.detail
|
||||||
|
if (detail === 'El correo ya está registrado') {
|
||||||
|
setGlobalError('Este correo ya tiene una cuenta. ¿Quieres iniciar sesión?')
|
||||||
|
setStep(2)
|
||||||
|
} else {
|
||||||
|
setGlobalError('Ocurrió un error al crear tu cuenta. Intenta de nuevo.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const businessTypeLabel = BUSINESS_TYPES.find((t) => t.value === form.business_type)?.label
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-bg min-h-screen flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md animate-slide-up">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex flex-col items-center gap-3 mb-6">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-primary-600 flex items-center justify-center shadow-lg">
|
||||||
|
<MessageCircle size={24} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="font-display text-2xl font-bold text-slate-900">
|
||||||
|
Hermes<span className="text-primary-600">Messages</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stepper */}
|
||||||
|
<div className="flex items-center gap-0 mb-6 px-2">
|
||||||
|
{STEPS.map((s, i) => (
|
||||||
|
<div key={s.id} className="flex items-center flex-1">
|
||||||
|
<div className="flex flex-col items-center gap-1 flex-shrink-0">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-all duration-200',
|
||||||
|
step > s.id
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: step === s.id
|
||||||
|
? 'bg-primary-600 text-white ring-4 ring-primary-100'
|
||||||
|
: 'bg-white border-2 border-border text-slate-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{step > s.id ? <CheckCircle2 size={16} /> : s.id}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-xs font-medium transition-colors duration-200 whitespace-nowrap',
|
||||||
|
step >= s.id ? 'text-primary-600' : 'text-slate-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{i < STEPS.length - 1 && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex-1 h-0.5 mx-2 mb-4 rounded-full transition-all duration-300',
|
||||||
|
step > s.id ? 'bg-primary-500' : 'bg-border',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<div className="card p-6 shadow-dialog">
|
||||||
|
{globalError && (
|
||||||
|
<div className="flex items-start gap-2.5 bg-danger-50 border border-danger-100 rounded-lg px-3.5 py-3 mb-5 text-sm text-danger-700">
|
||||||
|
<AlertCircle size={16} className="flex-shrink-0 mt-0.5" />
|
||||||
|
<span>
|
||||||
|
{globalError}{' '}
|
||||||
|
{globalError.includes('iniciar sesión') && (
|
||||||
|
<Link to="/login" className="font-medium underline">Ingresar</Link>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 1: Negocio ── */}
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Building2 size={18} className="text-primary-600" />
|
||||||
|
<h2 className="font-display text-lg font-semibold text-slate-900">
|
||||||
|
Datos de tu negocio
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500 mb-5">
|
||||||
|
Esta información aparecerá en las conversaciones de tu bot de WhatsApp.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="business_name" className="field-label field-label-required">
|
||||||
|
Nombre del negocio
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="business_name"
|
||||||
|
name="business_name"
|
||||||
|
type="text"
|
||||||
|
autoComplete="organization"
|
||||||
|
placeholder="Ej: Restaurante La Terraza, Clínica Salud Plus…"
|
||||||
|
value={form.business_name}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={cn('field-input', errors.business_name && 'field-input-error')}
|
||||||
|
/>
|
||||||
|
{errors.business_name
|
||||||
|
? <span className="field-error"><AlertCircle size={12} />{errors.business_name}</span>
|
||||||
|
: <span className="field-helper">Usa el nombre comercial tal como lo conocen tus clientes.</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="business_type" className="field-label field-label-required">
|
||||||
|
Tipo de negocio
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="business_type"
|
||||||
|
name="business_type"
|
||||||
|
value={form.business_type}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={cn('field-input', errors.business_type && 'field-input-error')}
|
||||||
|
>
|
||||||
|
<option value="" disabled>Selecciona una categoría…</option>
|
||||||
|
{BUSINESS_TYPES.map((t) => (
|
||||||
|
<option key={t.value} value={t.value}>{t.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{errors.business_type
|
||||||
|
? <span className="field-error"><AlertCircle size={12} />{errors.business_type}</span>
|
||||||
|
: <span className="field-helper">Ayuda al bot a personalizar las respuestas según tu industria.</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="timezone" className="field-label field-label-required">
|
||||||
|
Zona horaria
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="timezone"
|
||||||
|
name="timezone"
|
||||||
|
value={form.timezone}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="field-input"
|
||||||
|
>
|
||||||
|
{TIMEZONES.map((t) => (
|
||||||
|
<option key={t.value} value={t.value}>{t.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="field-helper">
|
||||||
|
Usada para mostrar y gestionar los horarios de reserva correctamente.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 2: Cuenta ── */}
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<UserCircle size={18} className="text-primary-600" />
|
||||||
|
<h2 className="font-display text-lg font-semibold text-slate-900">
|
||||||
|
Crea tu cuenta
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500 mb-5">
|
||||||
|
Estas credenciales son solo tuyas para acceder al panel de control.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="email" className="field-label field-label-required">
|
||||||
|
Correo electrónico
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="tu@negocio.com"
|
||||||
|
value={form.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={cn('field-input', errors.email && 'field-input-error')}
|
||||||
|
/>
|
||||||
|
{errors.email
|
||||||
|
? <span className="field-error"><AlertCircle size={12} />{errors.email}</span>
|
||||||
|
: <span className="field-helper">Aquí recibirás notificaciones importantes de la plataforma.</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="password" className="field-label field-label-required">
|
||||||
|
Contraseña
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type={showPwd ? 'text' : 'password'}
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="Mínimo 8 caracteres"
|
||||||
|
value={form.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={cn('field-input pr-10', errors.password && 'field-input-error')}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPwd((v) => !v)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors"
|
||||||
|
aria-label={showPwd ? 'Ocultar contraseña' : 'Mostrar contraseña'}
|
||||||
|
>
|
||||||
|
{showPwd ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errors.password
|
||||||
|
? <span className="field-error"><AlertCircle size={12} />{errors.password}</span>
|
||||||
|
: (
|
||||||
|
<span className="field-helper">
|
||||||
|
Usa al menos 8 caracteres. Combina letras y números para mayor seguridad.
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="confirm_password" className="field-label field-label-required">
|
||||||
|
Confirmar contraseña
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="confirm_password"
|
||||||
|
name="confirm_password"
|
||||||
|
type={showConfirm ? 'text' : 'password'}
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="Repite tu contraseña"
|
||||||
|
value={form.confirm_password}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={cn('field-input pr-10', errors.confirm_password && 'field-input-error')}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirm((v) => !v)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors"
|
||||||
|
aria-label={showConfirm ? 'Ocultar' : 'Mostrar'}
|
||||||
|
>
|
||||||
|
{showConfirm ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errors.confirm_password
|
||||||
|
? <span className="field-error"><AlertCircle size={12} />{errors.confirm_password}</span>
|
||||||
|
: <span className="field-helper">Escribe exactamente la misma contraseña para confirmarla.</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 3: Confirmar ── */}
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<CheckCircle2 size={18} className="text-primary-600" />
|
||||||
|
<h2 className="font-display text-lg font-semibold text-slate-900">
|
||||||
|
Confirma tu registro
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500 mb-5">
|
||||||
|
Revisa los datos antes de crear tu cuenta.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-slate-50 rounded-xl border border-border divide-y divide-border mb-2">
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-3">
|
||||||
|
<span className="text-sm text-slate-500">Negocio</span>
|
||||||
|
<span className="text-sm font-medium text-slate-900 text-right">{form.business_name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-3">
|
||||||
|
<span className="text-sm text-slate-500">Tipo</span>
|
||||||
|
<span className="text-sm font-medium text-slate-900">{businessTypeLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-3">
|
||||||
|
<span className="text-sm text-slate-500">Zona horaria</span>
|
||||||
|
<span className="text-sm font-medium text-slate-900">
|
||||||
|
{TIMEZONES.find((t) => t.value === form.timezone)?.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-3">
|
||||||
|
<span className="text-sm text-slate-500">Correo</span>
|
||||||
|
<span className="text-sm font-medium text-slate-900 truncate max-w-[180px]">{form.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2 bg-primary-50 rounded-lg px-3.5 py-3 text-xs text-primary-700 mt-3">
|
||||||
|
<Info size={13} className="flex-shrink-0 mt-0.5" />
|
||||||
|
<span>
|
||||||
|
Tu cuenta se crea en plan gratuito. Podrás conectar WhatsApp y configurar
|
||||||
|
horarios desde el panel de control.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Botones de navegación */}
|
||||||
|
<div className={cn('flex gap-3 mt-6', step > 1 ? 'justify-between' : 'justify-end')}>
|
||||||
|
{step > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep((s) => s - 1)}
|
||||||
|
disabled={loading}
|
||||||
|
className="btn-secondary flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={15} />
|
||||||
|
Atrás
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step < 3 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleNext}
|
||||||
|
className="btn-primary flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
<ChevronRight size={15} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={loading}
|
||||||
|
className="btn-primary flex-1 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="w-4 h-4 rounded-full border-2 border-white border-t-transparent animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 size={15} />
|
||||||
|
Crear mi cuenta
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-slate-500 mt-5">
|
||||||
|
¿Ya tienes cuenta?{' '}
|
||||||
|
<Link to="/login" className="text-primary-600 font-medium hover:underline">
|
||||||
|
Iniciar sesión
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
93
frontend/tailwind.config.js
Normal file
93
frontend/tailwind.config.js
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
display: ['"Bricolage Grotesque"', 'sans-serif'],
|
||||||
|
body: ['"DM Sans"', 'sans-serif'],
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
bg: '#f7f8fc',
|
||||||
|
surface: '#ffffff',
|
||||||
|
border: '#e1e7ef',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: '#059669',
|
||||||
|
50: '#ecfdf5',
|
||||||
|
100: '#d1fae5',
|
||||||
|
200: '#a7f3d0',
|
||||||
|
500: '#10b981',
|
||||||
|
600: '#059669',
|
||||||
|
700: '#047857',
|
||||||
|
900: '#064e3b',
|
||||||
|
},
|
||||||
|
slate: {
|
||||||
|
50: '#f8fafc',
|
||||||
|
100: '#f1f5f9',
|
||||||
|
200: '#e2e8f0',
|
||||||
|
300: '#cbd5e1',
|
||||||
|
400: '#94a3b8',
|
||||||
|
500: '#64748b',
|
||||||
|
600: '#475569',
|
||||||
|
700: '#334155',
|
||||||
|
800: '#1e293b',
|
||||||
|
900: '#0f172a',
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
DEFAULT: '#dc2626',
|
||||||
|
50: '#fef2f2',
|
||||||
|
100: '#fee2e2',
|
||||||
|
500: '#ef4444',
|
||||||
|
600: '#dc2626',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
DEFAULT: '#d97706',
|
||||||
|
50: '#fffbeb',
|
||||||
|
100: '#fef3c7',
|
||||||
|
500: '#f59e0b',
|
||||||
|
600: '#d97706',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
card: '0 1px 3px 0 rgb(0 0 0 / 0.06), 0 1px 2px -1px rgb(0 0 0 / 0.04)',
|
||||||
|
'card-hover': '0 4px 12px 0 rgb(0 0 0 / 0.08), 0 2px 4px -1px rgb(0 0 0 / 0.04)',
|
||||||
|
dialog: '0 20px 60px -10px rgb(0 0 0 / 0.15)',
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
DEFAULT: '0.5rem',
|
||||||
|
lg: '0.75rem',
|
||||||
|
xl: '1rem',
|
||||||
|
'2xl': '1.25rem',
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fadeIn 0.2s ease-out',
|
||||||
|
'slide-up': 'slideUp 0.25s cubic-bezier(0.23, 1, 0.32, 1)',
|
||||||
|
'stagger-1': 'fadeSlideUp 0.3s cubic-bezier(0.23, 1, 0.32, 1) 0ms both',
|
||||||
|
'stagger-2': 'fadeSlideUp 0.3s cubic-bezier(0.23, 1, 0.32, 1) 50ms both',
|
||||||
|
'stagger-3': 'fadeSlideUp 0.3s cubic-bezier(0.23, 1, 0.32, 1) 100ms both',
|
||||||
|
'stagger-4': 'fadeSlideUp 0.3s cubic-bezier(0.23, 1, 0.32, 1) 150ms both',
|
||||||
|
'stagger-5': 'fadeSlideUp 0.3s cubic-bezier(0.23, 1, 0.32, 1) 200ms both',
|
||||||
|
'skeleton': 'skeleton 1.5s ease-in-out infinite',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
fadeIn: {
|
||||||
|
from: { opacity: '0' },
|
||||||
|
to: { opacity: '1' },
|
||||||
|
},
|
||||||
|
slideUp: {
|
||||||
|
from: { opacity: '0', transform: 'translateY(8px)' },
|
||||||
|
to: { opacity: '1', transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
fadeSlideUp: {
|
||||||
|
from: { opacity: '0', transform: 'translateY(6px)' },
|
||||||
|
to: { opacity: '1', transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
skeleton: {
|
||||||
|
'0%, 100%': { opacity: '1' },
|
||||||
|
'50%': { opacity: '0.4' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
22
frontend/vite.config.js
Normal file
22
frontend/vite.config.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user