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:
2026-04-28 09:49:41 -05:00
commit 798bd14312
95 changed files with 5836 additions and 0 deletions

50
.gitignore vendored Normal file
View 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
View 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
View 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
View 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

View File

51
backend/alembic/env.py Normal file
View 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())

View File

View 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
View File

View File

View 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()

View 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

View 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

View 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"},
)

View 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

View 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
View 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"])

View File

View File

View 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)

View 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

View 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,
)

View File

View 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")

View 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)

View 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}

View 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)

View File

View 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)

View 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

View 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,
)

View 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>
}}
}}"""

View 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

View 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()

View File

View 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")

View 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)

View 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

View 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

View File

View 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)

View 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

View 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()

View 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)

View 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

View 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()]

View 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)

View 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")

View 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)

View 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}

View 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

View File

View 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()

View 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"}

View 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]

View 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,
)

View File

View File

View File

View File

3
backend/pytest.ini Normal file
View File

@ -0,0 +1,3 @@
[pytest]
asyncio_mode = auto
testpaths = tests

20
backend/requirements.txt Normal file
View 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

View File

115
backend/tests/conftest.py Normal file
View 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
View 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()

View 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?",
)

View 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:0018: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()

View 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)

View 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

View 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

View 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
View 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
View 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"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

44
frontend/src/App.jsx Normal file
View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View 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
View 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
View 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
View 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>
)

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View 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/, ''),
},
},
},
})