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

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