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:
20
frontend/index.html
Normal file
20
frontend/index.html
Normal file
@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/hermes-icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="HermesMessages — Reservas automáticas por WhatsApp con IA" />
|
||||
<title>HermesMessages</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,600;12..96,700&family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
42
frontend/package.json
Normal file
42
frontend/package.json
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "hermesmessages-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"axios": "^1.7.7",
|
||||
"date-fns": "^3.6.0",
|
||||
"lucide-react": "^0.447.0",
|
||||
"recharts": "^2.12.7",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^2.5.3",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
"@radix-ui/react-badge": "^1.0.0",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-label": "^2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
44
frontend/src/App.jsx
Normal file
44
frontend/src/App.jsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import Layout from '@/components/layout/Layout'
|
||||
import LoginPage from '@/pages/auth/LoginPage'
|
||||
import RegisterPage from '@/pages/auth/RegisterPage'
|
||||
import DashboardPage from '@/pages/DashboardPage'
|
||||
import ReservationsPage from '@/pages/ReservationsPage'
|
||||
import CalendarPage from '@/pages/CalendarPage'
|
||||
import ConfigPage from '@/pages/ConfigPage'
|
||||
import BillingPage from '@/pages/BillingPage'
|
||||
|
||||
function PrivateRoute({ children }) {
|
||||
const { isAuthenticated, loading } = useAuth()
|
||||
if (loading) return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-bg">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-primary-600 border-t-transparent animate-spin" />
|
||||
</div>
|
||||
)
|
||||
return isAuthenticated ? children : <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
function PublicRoute({ children }) {
|
||||
const { isAuthenticated, loading } = useAuth()
|
||||
if (loading) return null
|
||||
return isAuthenticated ? <Navigate to="/dashboard" replace /> : children
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<PublicRoute><LoginPage /></PublicRoute>} />
|
||||
<Route path="/register" element={<PublicRoute><RegisterPage /></PublicRoute>} />
|
||||
<Route path="/" element={<PrivateRoute><Layout /></PrivateRoute>}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="reservations" element={<ReservationsPage />} />
|
||||
<Route path="calendar" element={<CalendarPage />} />
|
||||
<Route path="config" element={<ConfigPage />} />
|
||||
<Route path="billing" element={<BillingPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
29
frontend/src/components/layout/Layout.jsx
Normal file
29
frontend/src/components/layout/Layout.jsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { Outlet, useLocation } from 'react-router-dom'
|
||||
import Sidebar from './Sidebar'
|
||||
|
||||
const PAGE_TITLES = {
|
||||
'/dashboard': 'Dashboard',
|
||||
'/reservations': 'Reservas',
|
||||
'/calendar': 'Disponibilidad',
|
||||
'/config': 'Configuración',
|
||||
'/billing': 'Plan y Facturación',
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const { pathname } = useLocation()
|
||||
const title = PAGE_TITLES[pathname] ?? 'HermesMessages'
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<header className="sticky top-0 z-10 bg-bg/80 backdrop-blur border-b border-border px-8 py-4">
|
||||
<h1 className="font-display text-xl font-semibold text-slate-900">{title}</h1>
|
||||
</header>
|
||||
<main className="flex-1 px-8 py-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
97
frontend/src/components/layout/Sidebar.jsx
Normal file
97
frontend/src/components/layout/Sidebar.jsx
Normal file
@ -0,0 +1,97 @@
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard, CalendarDays, BookOpen, Settings, CreditCard,
|
||||
MessageCircle, LogOut, ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
|
||||
{ to: '/reservations', icon: BookOpen, label: 'Reservas' },
|
||||
{ to: '/calendar', icon: CalendarDays, label: 'Disponibilidad' },
|
||||
{ to: '/config', icon: Settings, label: 'Configuración' },
|
||||
{ to: '/billing', icon: CreditCard, label: 'Plan y Facturación' },
|
||||
]
|
||||
|
||||
export default function Sidebar() {
|
||||
const { user, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
async function handleLogout() {
|
||||
await logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="flex flex-col w-60 min-h-screen bg-white border-r border-border flex-shrink-0">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2.5 px-5 py-5 border-b border-border">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary-600 flex items-center justify-center flex-shrink-0">
|
||||
<MessageCircle className="w-4.5 h-4.5 text-white" size={18} />
|
||||
</div>
|
||||
<span className="font-display font-semibold text-slate-900 text-base leading-tight">
|
||||
Hermes<span className="text-primary-600">Messages</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 px-3 py-4 flex flex-col gap-0.5">
|
||||
{NAV_ITEMS.map(({ to, icon: Icon, label }, i) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-150',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900',
|
||||
)
|
||||
}
|
||||
style={{ animationDelay: `${i * 40}ms` }}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<Icon
|
||||
size={17}
|
||||
className={cn(
|
||||
'flex-shrink-0 transition-colors duration-150',
|
||||
isActive ? 'text-primary-600' : 'text-slate-400',
|
||||
)}
|
||||
/>
|
||||
<span className="flex-1">{label}</span>
|
||||
{isActive && (
|
||||
<ChevronRight size={14} className="text-primary-400 flex-shrink-0" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Footer usuario */}
|
||||
<div className="border-t border-border px-3 py-3">
|
||||
<div className="flex items-center gap-3 px-3 py-2.5 rounded-lg">
|
||||
<div className="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xs font-semibold text-primary-700">
|
||||
{user?.email?.[0]?.toUpperCase() ?? 'U'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-slate-900 truncate">{user?.email}</p>
|
||||
<p className="text-xs text-slate-400">Propietario</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="btn-ghost w-full justify-start mt-1 text-slate-500 hover:text-danger-600 hover:bg-danger-50"
|
||||
>
|
||||
<LogOut size={15} />
|
||||
Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
57
frontend/src/contexts/AuthContext.jsx
Normal file
57
frontend/src/contexts/AuthContext.jsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||
import { authApi } from '@/lib/api'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
const stored = localStorage.getItem('user')
|
||||
if (stored) setUser(JSON.parse(stored))
|
||||
}
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
const login = useCallback(async (email, password) => {
|
||||
const { data } = await authApi.login({ email, password })
|
||||
localStorage.setItem('token', data.access_token)
|
||||
const userData = { email, business_id: data.business_id }
|
||||
localStorage.setItem('user', JSON.stringify(userData))
|
||||
setUser(userData)
|
||||
return data
|
||||
}, [])
|
||||
|
||||
const register = useCallback(async (payload) => {
|
||||
const { data } = await authApi.register(payload)
|
||||
localStorage.setItem('token', data.access_token)
|
||||
const userData = { email: payload.email, business_id: data.business_id }
|
||||
localStorage.setItem('user', JSON.stringify(userData))
|
||||
setUser(userData)
|
||||
return data
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try { await authApi.logout() } catch {}
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
setUser(null)
|
||||
}, [])
|
||||
|
||||
const isAuthenticated = Boolean(user)
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, isAuthenticated, login, register, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth must be used inside AuthProvider')
|
||||
return ctx
|
||||
}
|
||||
191
frontend/src/index.css
Normal file
191
frontend/src/index.css
Normal file
@ -0,0 +1,191 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ── Design tokens ── */
|
||||
:root {
|
||||
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
|
||||
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
/* ── Base ── */
|
||||
@layer base {
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
background-color: #f7f8fc;
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'Bricolage Grotesque', sans-serif;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
input, textarea, select, button {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
}
|
||||
|
||||
/* Scrollbar personalizado */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Componentes reutilizables ── */
|
||||
@layer components {
|
||||
|
||||
/* Botón base — scale on press (Emil Kowalski) */
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-lg font-medium
|
||||
transition-all duration-150 select-none cursor-pointer
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2
|
||||
disabled:opacity-50 disabled:pointer-events-none;
|
||||
transition-property: transform, background-color, box-shadow, opacity;
|
||||
transition-timing-function: var(--ease-out);
|
||||
}
|
||||
.btn:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn bg-primary-600 text-white hover:bg-primary-700 shadow-sm;
|
||||
@apply px-4 py-2.5 text-sm;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn bg-white text-slate-700 border border-border hover:bg-slate-50 shadow-card;
|
||||
@apply px-4 py-2.5 text-sm;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply btn text-slate-600 hover:bg-slate-100 hover:text-slate-900;
|
||||
@apply px-3 py-2 text-sm;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply btn bg-danger-600 text-white hover:bg-red-700;
|
||||
@apply px-4 py-2.5 text-sm;
|
||||
}
|
||||
|
||||
/* Input base — con label visible y helper text */
|
||||
.field {
|
||||
@apply flex flex-col gap-1.5;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
@apply text-sm font-medium text-slate-700;
|
||||
}
|
||||
|
||||
.field-label-required::after {
|
||||
content: ' *';
|
||||
@apply text-danger-600;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
@apply w-full rounded-lg border border-border bg-white px-3.5 py-2.5 text-sm
|
||||
text-slate-900 placeholder:text-slate-400
|
||||
transition-all duration-150
|
||||
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500
|
||||
disabled:bg-slate-50 disabled:text-slate-400 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.field-input-error {
|
||||
@apply border-danger-500 focus:ring-danger-500 focus:border-danger-500;
|
||||
}
|
||||
|
||||
.field-helper {
|
||||
@apply text-xs text-slate-500 leading-relaxed;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
@apply text-xs text-danger-600 flex items-center gap-1;
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
@apply bg-white rounded-xl border border-border shadow-card;
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@apply card transition-shadow duration-200 hover:shadow-card-hover cursor-pointer;
|
||||
}
|
||||
|
||||
/* Badge */
|
||||
.badge {
|
||||
@apply inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium;
|
||||
}
|
||||
|
||||
.badge-green {
|
||||
@apply badge bg-primary-100 text-primary-700;
|
||||
}
|
||||
|
||||
.badge-yellow {
|
||||
@apply badge bg-warning-100 text-warning-600;
|
||||
}
|
||||
|
||||
.badge-red {
|
||||
@apply badge bg-danger-100 text-danger-600;
|
||||
}
|
||||
|
||||
.badge-gray {
|
||||
@apply badge bg-slate-100 text-slate-600;
|
||||
}
|
||||
|
||||
/* Skeleton loader */
|
||||
.skeleton {
|
||||
@apply animate-skeleton rounded bg-slate-100;
|
||||
}
|
||||
|
||||
/* Auth background con dot grid */
|
||||
.auth-bg {
|
||||
background-color: #f7f8fc;
|
||||
background-image: radial-gradient(circle, #cbd5e1 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Utilities ── */
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Animaciones con prefers-reduced-motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.btn:active { transform: none; }
|
||||
.animate-stagger-1,
|
||||
.animate-stagger-2,
|
||||
.animate-stagger-3,
|
||||
.animate-stagger-4,
|
||||
.animate-stagger-5 {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
73
frontend/src/lib/api.js
Normal file
73
frontend/src/lib/api.js
Normal file
@ -0,0 +1,73 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
export const authApi = {
|
||||
register: (data) => api.post('/auth/register', data),
|
||||
login: (data) => api.post('/auth/login', data),
|
||||
logout: () => api.post('/auth/logout'),
|
||||
}
|
||||
|
||||
export const businessApi = {
|
||||
getMe: () => api.get('/business/me'),
|
||||
updateMe: (data) => api.put('/business/me', data),
|
||||
getConfig: () => api.get('/business/me/config'),
|
||||
updateConfig: (data) => api.put('/business/me/config', data),
|
||||
}
|
||||
|
||||
export const whatsappApi = {
|
||||
getStatus: () => api.get('/whatsapp/status'),
|
||||
connect: (data) => api.post('/whatsapp/connect', data),
|
||||
disconnect: () => api.post('/whatsapp/disconnect'),
|
||||
}
|
||||
|
||||
export const reservationsApi = {
|
||||
list: (params) => api.get('/reservations/', { params }),
|
||||
create: (data) => api.post('/reservations/', data),
|
||||
get: (id) => api.get(`/reservations/${id}`),
|
||||
update: (id, data) => api.put(`/reservations/${id}`, data),
|
||||
updateStatus: (id, status) => api.patch(`/reservations/${id}/status`, { status }),
|
||||
delete: (id) => api.delete(`/reservations/${id}`),
|
||||
}
|
||||
|
||||
export const calendarApi = {
|
||||
getAvailability: (date) => api.get('/calendar/availability', { params: { date } }),
|
||||
getAvailabilityRange: (start, end) =>
|
||||
api.get('/calendar/availability/range', { params: { start_date: start, end_date: end } }),
|
||||
blockDate: (date) => api.post('/calendar/blocked-dates', { date }),
|
||||
unblockDate: (date) => api.delete(`/calendar/blocked-dates/${date}`),
|
||||
}
|
||||
|
||||
export const dashboardApi = {
|
||||
getStats: () => api.get('/dashboard/stats'),
|
||||
getAgenda: (date) => api.get('/dashboard/agenda', { params: { date } }),
|
||||
getPeakHours: () => api.get('/dashboard/peak-hours'),
|
||||
}
|
||||
|
||||
export const billingApi = {
|
||||
getPlan: () => api.get('/billing/plan'),
|
||||
getUsage: () => api.get('/billing/usage'),
|
||||
upgrade: (plan) => api.post('/billing/upgrade', { plan }),
|
||||
}
|
||||
|
||||
export default api
|
||||
65
frontend/src/lib/utils.js
Normal file
65
frontend/src/lib/utils.js
Normal file
@ -0,0 +1,65 @@
|
||||
import { clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatDate(dateStr, opts = {}) {
|
||||
if (!dateStr) return '—'
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('es-ES', {
|
||||
day: '2-digit', month: 'short', year: 'numeric', ...opts,
|
||||
})
|
||||
}
|
||||
|
||||
export function formatTime(timeStr) {
|
||||
if (!timeStr) return '—'
|
||||
return timeStr.slice(0, 5)
|
||||
}
|
||||
|
||||
export function formatDateTime(dateStr) {
|
||||
if (!dateStr) return '—'
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleString('es-ES', {
|
||||
day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export const STATUS_LABELS = {
|
||||
pending: 'Pendiente',
|
||||
confirmed: 'Confirmada',
|
||||
cancelled: 'Cancelada',
|
||||
no_show: 'No asistió',
|
||||
}
|
||||
|
||||
export const STATUS_BADGE = {
|
||||
pending: 'badge-yellow',
|
||||
confirmed: 'badge-green',
|
||||
cancelled: 'badge-red',
|
||||
no_show: 'badge-gray',
|
||||
}
|
||||
|
||||
export const BUSINESS_TYPES = [
|
||||
{ value: 'restaurant', label: 'Restaurante' },
|
||||
{ value: 'clinic', label: 'Clínica / Consultorio' },
|
||||
{ value: 'salon', label: 'Salón de belleza' },
|
||||
{ value: 'spa', label: 'Spa / Bienestar' },
|
||||
{ value: 'barbershop', label: 'Barbería' },
|
||||
{ value: 'gym', label: 'Gimnasio / Entrenador' },
|
||||
{ value: 'other', label: 'Otro' },
|
||||
]
|
||||
|
||||
export const TIMEZONES = [
|
||||
{ value: 'America/Bogota', label: 'Bogotá (UTC-5)' },
|
||||
{ value: 'America/Mexico_City', label: 'Ciudad de México (UTC-6)' },
|
||||
{ value: 'America/Lima', label: 'Lima (UTC-5)' },
|
||||
{ value: 'America/Santiago', label: 'Santiago (UTC-4)' },
|
||||
{ value: 'America/Buenos_Aires', label: 'Buenos Aires (UTC-3)' },
|
||||
{ value: 'America/Caracas', label: 'Caracas (UTC-4)' },
|
||||
{ value: 'Europe/Madrid', label: 'Madrid (UTC+1/+2)' },
|
||||
{ value: 'UTC', label: 'UTC' },
|
||||
]
|
||||
|
||||
export const DAYS_ES = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom']
|
||||
export const DAYS_FULL = ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo']
|
||||
16
frontend/src/main.jsx
Normal file
16
frontend/src/main.jsx
Normal file
@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { AuthProvider } from '@/contexts/AuthContext'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
)
|
||||
259
frontend/src/pages/BillingPage.jsx
Normal file
259
frontend/src/pages/BillingPage.jsx
Normal file
@ -0,0 +1,259 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { CheckCircle, Zap, Shield, Star, MessageCircle, CalendarDays, Bot } from 'lucide-react'
|
||||
import { billingApi } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const PLANS = [
|
||||
{
|
||||
id: 'free',
|
||||
name: 'Gratis',
|
||||
price: '$0',
|
||||
period: 'para siempre',
|
||||
description: 'Ideal para probar la plataforma',
|
||||
icon: Zap,
|
||||
color: 'text-slate-600',
|
||||
bgColor: 'bg-slate-100',
|
||||
features: [
|
||||
'50 reservas por mes',
|
||||
'1 número de WhatsApp',
|
||||
'Bot básico de reservas',
|
||||
'Dashboard de reservas',
|
||||
],
|
||||
limits: {
|
||||
reservations: 50,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'basic',
|
||||
name: 'Básico',
|
||||
price: '$19',
|
||||
period: 'por mes',
|
||||
description: 'Para negocios en crecimiento',
|
||||
icon: Shield,
|
||||
color: 'text-primary-600',
|
||||
bgColor: 'bg-primary-100',
|
||||
popular: true,
|
||||
features: [
|
||||
'500 reservas por mes',
|
||||
'1 número de WhatsApp',
|
||||
'Bot inteligente con IA',
|
||||
'Notificaciones automáticas',
|
||||
'Configuración de horarios',
|
||||
'Soporte prioritario',
|
||||
],
|
||||
limits: {
|
||||
reservations: 500,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pro',
|
||||
name: 'Pro',
|
||||
price: '$49',
|
||||
period: 'por mes',
|
||||
description: 'Para negocios establecidos',
|
||||
icon: Star,
|
||||
color: 'text-warning-600',
|
||||
bgColor: 'bg-warning-100',
|
||||
features: [
|
||||
'Reservas ilimitadas',
|
||||
'3 números de WhatsApp',
|
||||
'Bot avanzado con IA',
|
||||
'Analytics completo',
|
||||
'API personalizada',
|
||||
'Soporte 24/7',
|
||||
],
|
||||
limits: {
|
||||
reservations: null,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
function UsageBar({ used, total, label }) {
|
||||
const pct = total ? Math.min((used / total) * 100, 100) : 0
|
||||
const color = pct >= 90 ? 'bg-danger-500' : pct >= 70 ? 'bg-warning-500' : 'bg-primary-500'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-slate-500">{label}</span>
|
||||
<span className={cn('font-medium', pct >= 90 ? 'text-danger-600' : 'text-slate-700')}>
|
||||
{used} {total ? `/ ${total}` : '/ ∞'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all duration-500', color)}
|
||||
style={{ width: total ? `${pct}%` : '0%' }}
|
||||
role="progressbar"
|
||||
aria-valuenow={used}
|
||||
aria-valuemax={total}
|
||||
aria-label={label}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function BillingPage() {
|
||||
const [planInfo, setPlanInfo] = useState(null)
|
||||
const [usage, setUsage] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [upgrading, setUpgrading] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [pRes, uRes] = await Promise.all([
|
||||
billingApi.getPlan(),
|
||||
billingApi.getUsage(),
|
||||
])
|
||||
setPlanInfo(pRes.data)
|
||||
setUsage(uRes.data)
|
||||
} catch {
|
||||
setPlanInfo({ plan: 'free', status: 'trial' })
|
||||
setUsage({ reservations_used: 12, reservations_limit: 50 })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
async function handleUpgrade(planId) {
|
||||
if (planId === planInfo?.plan) return
|
||||
setUpgrading(planId)
|
||||
try {
|
||||
await billingApi.upgrade(planId)
|
||||
setPlanInfo((p) => ({ ...p, plan: planId }))
|
||||
} finally {
|
||||
setUpgrading('')
|
||||
}
|
||||
}
|
||||
|
||||
const currentPlan = PLANS.find((p) => p.id === planInfo?.plan) ?? PLANS[0]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-7 max-w-4xl animate-fade-in">
|
||||
{/* Uso actual */}
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className={cn('w-8 h-8 rounded-lg flex items-center justify-center', currentPlan.bgColor)}>
|
||||
<currentPlan.icon size={16} className={currentPlan.color} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
Plan {currentPlan.name}
|
||||
{planInfo?.status === 'trial' && (
|
||||
<span className="ml-2 badge-yellow text-xs">Período de prueba</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">{currentPlan.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="skeleton h-8 rounded-lg" />
|
||||
) : (
|
||||
<UsageBar
|
||||
label="Reservas este mes"
|
||||
used={usage?.reservations_used ?? 0}
|
||||
total={usage?.reservations_limit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Planes */}
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-semibold text-slate-900 mb-4">
|
||||
Elige tu plan
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
{PLANS.map((plan, i) => {
|
||||
const Icon = plan.icon
|
||||
const isCurrent = planInfo?.plan === plan.id
|
||||
const isUpgrading = upgrading === plan.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.id}
|
||||
className={cn(
|
||||
'card p-6 flex flex-col gap-5 relative transition-shadow duration-200',
|
||||
plan.popular ? 'border-primary-300 shadow-card-hover ring-1 ring-primary-200' : '',
|
||||
isCurrent ? 'border-primary-400' : '',
|
||||
`animate-stagger-${i + 1}`,
|
||||
)}
|
||||
>
|
||||
{plan.popular && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||
<span className="badge-green text-xs px-3 py-1 shadow-sm">Más popular</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className={cn('w-10 h-10 rounded-xl flex items-center justify-center mb-3', plan.bgColor)}>
|
||||
<Icon size={18} className={plan.color} />
|
||||
</div>
|
||||
<p className="font-display text-base font-bold text-slate-900">{plan.name}</p>
|
||||
<div className="flex items-baseline gap-1 mt-1">
|
||||
<span className="text-2xl font-display font-bold text-slate-900">{plan.price}</span>
|
||||
<span className="text-sm text-slate-400">{plan.period}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1">{plan.description}</p>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-col gap-2 flex-1">
|
||||
{plan.features.map((f) => (
|
||||
<li key={f} className="flex items-start gap-2 text-sm text-slate-700">
|
||||
<CheckCircle size={14} className="text-primary-500 flex-shrink-0 mt-0.5" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
onClick={() => handleUpgrade(plan.id)}
|
||||
disabled={isCurrent || isUpgrading}
|
||||
className={cn(
|
||||
'btn w-full justify-center py-2.5 text-sm',
|
||||
isCurrent
|
||||
? 'bg-slate-100 text-slate-500 cursor-default'
|
||||
: plan.popular
|
||||
? 'btn-primary'
|
||||
: 'btn-secondary',
|
||||
)}
|
||||
>
|
||||
{isUpgrading ? (
|
||||
<span className="w-4 h-4 rounded-full border-2 border-current border-t-transparent animate-spin" />
|
||||
) : isCurrent ? (
|
||||
'Plan actual'
|
||||
) : (
|
||||
`Elegir ${plan.name}`
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features highlight */}
|
||||
<div className="grid md:grid-cols-3 gap-4 animate-stagger-4">
|
||||
{[
|
||||
{ icon: MessageCircle, title: 'Bot 24/7', desc: 'Tu asistente responde reservas a cualquier hora, sin que tengas que estar presente.' },
|
||||
{ icon: CalendarDays, title: 'Gestión automática', desc: 'Los horarios se actualizan en tiempo real según las reservas recibidas.' },
|
||||
{ icon: Bot, title: 'IA conversacional', desc: 'Claude entiende el lenguaje natural de tus clientes y los guía en el proceso.' },
|
||||
].map((f) => (
|
||||
<div key={f.title} className="card p-5 flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-primary-50 flex items-center justify-center flex-shrink-0">
|
||||
<f.icon size={17} className="text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{f.title}</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5 leading-relaxed">{f.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
224
frontend/src/pages/CalendarPage.jsx
Normal file
224
frontend/src/pages/CalendarPage.jsx
Normal file
@ -0,0 +1,224 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { format, addDays, startOfWeek, isSameDay, isToday, parseISO } from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
import { ChevronLeft, ChevronRight, Lock, Unlock, Clock } from 'lucide-react'
|
||||
import { calendarApi } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const WEEK_DAYS = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom']
|
||||
|
||||
export default function CalendarPage() {
|
||||
const [currentDate, setCurrentDate] = useState(new Date())
|
||||
const [selectedDate, setSelectedDate] = useState(new Date())
|
||||
const [slots, setSlots] = useState([])
|
||||
const [blockedDates, setBlockedDates] = useState([])
|
||||
const [loadingSlots, setLoadingSlots] = useState(false)
|
||||
const [togglingDate, setTogglingDate] = useState(false)
|
||||
|
||||
const weekStart = startOfWeek(currentDate, { weekStartsOn: 1 })
|
||||
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i))
|
||||
|
||||
const selectedStr = format(selectedDate, 'yyyy-MM-dd')
|
||||
const isBlocked = blockedDates.includes(selectedStr)
|
||||
|
||||
useEffect(() => {
|
||||
async function loadSlots() {
|
||||
setLoadingSlots(true)
|
||||
try {
|
||||
const { data } = await calendarApi.getAvailability(selectedStr)
|
||||
setSlots(data)
|
||||
} catch {
|
||||
setSlots([])
|
||||
} finally {
|
||||
setLoadingSlots(false)
|
||||
}
|
||||
}
|
||||
loadSlots()
|
||||
}, [selectedStr])
|
||||
|
||||
async function toggleBlockDate() {
|
||||
setTogglingDate(true)
|
||||
try {
|
||||
if (isBlocked) {
|
||||
await calendarApi.unblockDate(selectedStr)
|
||||
setBlockedDates((d) => d.filter((x) => x !== selectedStr))
|
||||
} else {
|
||||
await calendarApi.blockDate(selectedStr)
|
||||
setBlockedDates((d) => [...d, selectedStr])
|
||||
}
|
||||
} finally {
|
||||
setTogglingDate(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 max-w-4xl animate-fade-in">
|
||||
<div className="grid lg:grid-cols-3 gap-5">
|
||||
{/* Calendario semanal */}
|
||||
<div className="lg:col-span-2 card p-5">
|
||||
{/* Nav semana */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h3 className="font-display text-base font-semibold text-slate-800 capitalize">
|
||||
{format(weekStart, "MMMM yyyy", { locale: es })}
|
||||
</h3>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setCurrentDate((d) => addDays(d, -7))}
|
||||
className="btn-ghost p-1.5"
|
||||
aria-label="Semana anterior"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentDate(new Date())}
|
||||
className="btn-secondary px-3 py-1.5 text-xs"
|
||||
>
|
||||
Hoy
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentDate((d) => addDays(d, 7))}
|
||||
className="btn-ghost p-1.5"
|
||||
aria-label="Semana siguiente"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Días */}
|
||||
<div className="grid grid-cols-7 gap-1.5">
|
||||
{WEEK_DAYS.map((d) => (
|
||||
<div key={d} className="text-center text-xs font-medium text-slate-400 pb-1.5">{d}</div>
|
||||
))}
|
||||
{weekDays.map((day) => {
|
||||
const dayStr = format(day, 'yyyy-MM-dd')
|
||||
const selected = isSameDay(day, selectedDate)
|
||||
const today = isToday(day)
|
||||
const blocked = blockedDates.includes(dayStr)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dayStr}
|
||||
onClick={() => setSelectedDate(day)}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center justify-center rounded-xl py-3 px-1 text-sm font-medium transition-all duration-150',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
selected
|
||||
? 'bg-primary-600 text-white shadow-sm'
|
||||
: blocked
|
||||
? 'bg-slate-100 text-slate-400 line-through'
|
||||
: today
|
||||
? 'bg-primary-50 text-primary-700 ring-1 ring-primary-300'
|
||||
: 'hover:bg-slate-50 text-slate-700',
|
||||
)}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${format(day, 'd MMMM', { locale: es })}${blocked ? ', bloqueado' : ''}`}
|
||||
>
|
||||
<span className="text-base leading-none">{format(day, 'd')}</span>
|
||||
{blocked && !selected && (
|
||||
<Lock size={9} className="mt-1 opacity-50" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Panel del día seleccionado */}
|
||||
<div className="card p-5 flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-slate-400 uppercase tracking-wide font-medium">Día seleccionado</p>
|
||||
<p className="font-display text-lg font-semibold text-slate-900 capitalize mt-0.5">
|
||||
{format(selectedDate, "EEEE d 'de' MMMM", { locale: es })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bloquear/Desbloquear */}
|
||||
<div className={cn(
|
||||
'flex items-center gap-3 rounded-xl px-4 py-3.5 border transition-colors',
|
||||
isBlocked
|
||||
? 'bg-danger-50 border-danger-100'
|
||||
: 'bg-slate-50 border-border',
|
||||
)}>
|
||||
<div className={cn('flex-1', isBlocked ? 'text-danger-700' : 'text-slate-700')}>
|
||||
<p className="text-sm font-medium">{isBlocked ? 'Día bloqueado' : 'Día disponible'}</p>
|
||||
<p className="text-xs mt-0.5 opacity-70">
|
||||
{isBlocked
|
||||
? 'El bot no acepta reservas este día'
|
||||
: 'El bot acepta reservas según tu horario'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleBlockDate}
|
||||
disabled={togglingDate}
|
||||
className={cn(
|
||||
'btn flex-shrink-0 px-3 py-2 text-xs gap-1.5',
|
||||
isBlocked
|
||||
? 'bg-white border border-border text-slate-700 hover:bg-slate-50 shadow-card'
|
||||
: 'bg-danger-600 text-white hover:bg-red-700',
|
||||
)}
|
||||
aria-label={isBlocked ? 'Desbloquear fecha' : 'Bloquear fecha'}
|
||||
>
|
||||
{togglingDate
|
||||
? <span className="w-3.5 h-3.5 rounded-full border-2 border-current border-t-transparent animate-spin block" />
|
||||
: isBlocked
|
||||
? <><Unlock size={12} />Desbloquear</>
|
||||
: <><Lock size={12} />Bloquear</>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Slots */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-1.5 mb-3">
|
||||
<Clock size={14} className="text-slate-400" />
|
||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
Horarios disponibles
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loadingSlots ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="skeleton h-9 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : isBlocked ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Lock size={24} className="text-slate-200 mb-2" />
|
||||
<p className="text-sm text-slate-400">Día bloqueado</p>
|
||||
</div>
|
||||
) : slots.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Clock size={24} className="text-slate-200 mb-2" />
|
||||
<p className="text-sm text-slate-400">Sin horarios disponibles</p>
|
||||
<p className="text-xs text-slate-300 mt-1">Verifica tu configuración de horarios</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5 max-h-56 overflow-y-auto pr-1">
|
||||
{slots.map((slot) => (
|
||||
<div
|
||||
key={slot.time}
|
||||
className={cn(
|
||||
'flex items-center justify-between px-3.5 py-2.5 rounded-lg text-sm border transition-colors',
|
||||
slot.available > 0
|
||||
? 'bg-primary-50 border-primary-100 text-primary-700'
|
||||
: 'bg-slate-50 border-border text-slate-400',
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{slot.time?.slice(0, 5)}</span>
|
||||
<span className="text-xs">
|
||||
{slot.available > 0
|
||||
? `${slot.available} libre${slot.available > 1 ? 's' : ''}`
|
||||
: 'Lleno'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
326
frontend/src/pages/ConfigPage.jsx
Normal file
326
frontend/src/pages/ConfigPage.jsx
Normal file
@ -0,0 +1,326 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Save, CheckCircle, AlertCircle, Wifi, WifiOff, ExternalLink } from 'lucide-react'
|
||||
import { businessApi, whatsappApi } from '@/lib/api'
|
||||
import { DAYS_FULL, cn } from '@/lib/utils'
|
||||
|
||||
const TONE_OPTIONS = [
|
||||
{ value: 'formal', label: 'Formal', desc: 'Tono profesional y respetuoso' },
|
||||
{ value: 'casual', label: 'Casual', desc: 'Tono amigable y cercano' },
|
||||
]
|
||||
|
||||
function Section({ title, description, children }) {
|
||||
return (
|
||||
<div className="card p-6 flex flex-col gap-5">
|
||||
<div className="border-b border-border pb-4">
|
||||
<h3 className="font-display text-base font-semibold text-slate-900">{title}</h3>
|
||||
{description && <p className="text-sm text-slate-500 mt-0.5">{description}</p>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ConfigPage() {
|
||||
const [business, setBusiness] = useState(null)
|
||||
const [config, setConfig] = useState(null)
|
||||
const [waStatus, setWaStatus] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [bRes, cRes, wRes] = await Promise.all([
|
||||
businessApi.getMe(),
|
||||
businessApi.getConfig(),
|
||||
whatsappApi.getStatus().catch(() => ({ data: null })),
|
||||
])
|
||||
setBusiness(bRes.data)
|
||||
setConfig(cRes.data)
|
||||
setWaStatus(wRes.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
function updateConfig(key, val) {
|
||||
setConfig((c) => ({ ...c, [key]: val }))
|
||||
setSaved(false)
|
||||
setError('')
|
||||
}
|
||||
|
||||
function toggleDay(day) {
|
||||
const days = config.open_days ?? []
|
||||
const next = days.includes(day) ? days.filter((d) => d !== day) : [...days, day].sort()
|
||||
updateConfig('open_days', next)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await Promise.all([
|
||||
businessApi.updateMe({ name: business.name, timezone: business.timezone }),
|
||||
businessApi.updateConfig({
|
||||
open_days: config.open_days,
|
||||
open_time: config.open_time,
|
||||
close_time: config.close_time,
|
||||
slot_duration: config.slot_duration,
|
||||
max_per_slot: config.max_per_slot,
|
||||
assistant_name: config.assistant_name,
|
||||
tone: config.tone,
|
||||
welcome_message: config.welcome_message,
|
||||
}),
|
||||
])
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 3000)
|
||||
} catch {
|
||||
setError('No se pudo guardar la configuración. Intenta de nuevo.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-5 max-w-2xl">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="card p-6">
|
||||
<div className="skeleton h-5 w-40 rounded mb-4" />
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="skeleton h-10 rounded-lg" />
|
||||
<div className="skeleton h-10 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isWaConnected = Boolean(waStatus?.whatsapp_phone_number_id)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 max-w-2xl animate-fade-in">
|
||||
{/* WhatsApp status */}
|
||||
<div className={cn(
|
||||
'card p-5 flex items-center gap-4',
|
||||
isWaConnected ? 'border-primary-200 bg-primary-50' : 'border-warning-200 bg-warning-50',
|
||||
)}>
|
||||
<div className={cn(
|
||||
'w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0',
|
||||
isWaConnected ? 'bg-primary-100' : 'bg-warning-100',
|
||||
)}>
|
||||
{isWaConnected
|
||||
? <Wifi size={18} className="text-primary-600" />
|
||||
: <WifiOff size={18} className="text-warning-600" />
|
||||
}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className={cn('text-sm font-semibold', isWaConnected ? 'text-primary-800' : 'text-warning-800')}>
|
||||
WhatsApp {isWaConnected ? 'conectado' : 'no conectado'}
|
||||
</p>
|
||||
<p className={cn('text-xs mt-0.5', isWaConnected ? 'text-primary-600' : 'text-warning-600')}>
|
||||
{isWaConnected
|
||||
? `Número: ${waStatus.whatsapp_phone_number_id}`
|
||||
: 'Configura tu número de WhatsApp Business para recibir reservas.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Negocio */}
|
||||
<Section title="Datos del negocio" description="Nombre y zona horaria visible en el panel.">
|
||||
<div className="field">
|
||||
<label className="field-label field-label-required">Nombre del negocio</label>
|
||||
<input
|
||||
type="text"
|
||||
value={business?.name ?? ''}
|
||||
onChange={(e) => setBusiness((b) => ({ ...b, name: e.target.value }))}
|
||||
className="field-input"
|
||||
placeholder="Nombre de tu negocio"
|
||||
/>
|
||||
<span className="field-helper">Nombre que ven tus clientes en los mensajes del bot.</span>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Horarios */}
|
||||
<Section
|
||||
title="Horarios de atención"
|
||||
description="Define cuándo está disponible tu negocio para reservas."
|
||||
>
|
||||
{/* Días de la semana */}
|
||||
<div className="field">
|
||||
<label className="field-label field-label-required">Días de atención</label>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{DAYS_FULL.map((day, idx) => {
|
||||
const open = config?.open_days?.includes(idx)
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => toggleDay(idx)}
|
||||
className={cn(
|
||||
'btn px-3.5 py-2 text-xs font-medium transition-all duration-150',
|
||||
open
|
||||
? 'bg-primary-600 text-white hover:bg-primary-700'
|
||||
: 'bg-white border border-border text-slate-600 hover:bg-slate-50',
|
||||
)}
|
||||
aria-pressed={open}
|
||||
>
|
||||
{day.slice(0, 3)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<span className="field-helper">El bot solo aceptará reservas en los días marcados.</span>
|
||||
</div>
|
||||
|
||||
{/* Horario */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="field">
|
||||
<label className="field-label field-label-required">Hora de apertura</label>
|
||||
<input
|
||||
type="time"
|
||||
value={config?.open_time ?? '09:00'}
|
||||
onChange={(e) => updateConfig('open_time', e.target.value)}
|
||||
className="field-input"
|
||||
/>
|
||||
<span className="field-helper">Primera hora disponible para reservas.</span>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label field-label-required">Hora de cierre</label>
|
||||
<input
|
||||
type="time"
|
||||
value={config?.close_time ?? '18:00'}
|
||||
onChange={(e) => updateConfig('close_time', e.target.value)}
|
||||
className="field-input"
|
||||
/>
|
||||
<span className="field-helper">Última hora en que se generan turnos.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duración y máximo */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="field">
|
||||
<label className="field-label field-label-required">Duración del turno (min)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={15}
|
||||
max={240}
|
||||
step={15}
|
||||
value={config?.slot_duration ?? 60}
|
||||
onChange={(e) => updateConfig('slot_duration', Number(e.target.value))}
|
||||
className="field-input"
|
||||
/>
|
||||
<span className="field-helper">Tiempo que dura cada cita o reserva.</span>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label field-label-required">Máximo por turno</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={config?.max_per_slot ?? 1}
|
||||
onChange={(e) => updateConfig('max_per_slot', Number(e.target.value))}
|
||||
className="field-input"
|
||||
/>
|
||||
<span className="field-helper">Cuántas reservas se aceptan en el mismo horario.</span>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Bot */}
|
||||
<Section
|
||||
title="Configuración del asistente"
|
||||
description="Personaliza cómo se presenta el bot a tus clientes."
|
||||
>
|
||||
<div className="field">
|
||||
<label className="field-label field-label-required">Nombre del asistente</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config?.assistant_name ?? ''}
|
||||
onChange={(e) => updateConfig('assistant_name', e.target.value)}
|
||||
className="field-input"
|
||||
placeholder="Ej: Hermes, María, Asistente…"
|
||||
/>
|
||||
<span className="field-helper">
|
||||
Nombre con el que el bot se presentará: "Hola, soy <strong>{config?.assistant_name || 'Hermes'}</strong>…"
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">Tono de comunicación</label>
|
||||
<div className="grid grid-cols-2 gap-2 mt-1">
|
||||
{TONE_OPTIONS.map((t) => {
|
||||
const active = config?.tone === t.value
|
||||
return (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
onClick={() => updateConfig('tone', t.value)}
|
||||
className={cn(
|
||||
'flex flex-col items-start px-4 py-3.5 rounded-xl border text-left transition-all duration-150',
|
||||
active
|
||||
? 'border-primary-500 bg-primary-50 text-primary-700'
|
||||
: 'border-border bg-white text-slate-700 hover:bg-slate-50',
|
||||
)}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<span className="text-sm font-semibold">{t.label}</span>
|
||||
<span className="text-xs mt-0.5 opacity-70">{t.desc}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">Mensaje de bienvenida</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={config?.welcome_message ?? ''}
|
||||
onChange={(e) => updateConfig('welcome_message', e.target.value)}
|
||||
className="field-input resize-none"
|
||||
placeholder="Ej: ¡Bienvenido! ¿En qué fecha y hora te gustaría reservar?"
|
||||
/>
|
||||
<span className="field-helper">
|
||||
Primer mensaje que el bot envía al iniciar una conversación. Déjalo vacío para usar el mensaje predeterminado.
|
||||
</span>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Footer guardado */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-danger-700 bg-danger-50 border border-danger-100 rounded-lg px-4 py-3">
|
||||
<AlertCircle size={15} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pb-4">
|
||||
{saved && (
|
||||
<div className="flex items-center gap-2 text-sm text-primary-700 animate-fade-in">
|
||||
<CheckCircle size={15} />
|
||||
Configuración guardada
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="btn-primary gap-2"
|
||||
>
|
||||
{saving
|
||||
? <span className="w-4 h-4 rounded-full border-2 border-white border-t-transparent animate-spin" />
|
||||
: <Save size={15} />
|
||||
}
|
||||
{saving ? 'Guardando…' : 'Guardar cambios'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
199
frontend/src/pages/DashboardPage.jsx
Normal file
199
frontend/src/pages/DashboardPage.jsx
Normal file
@ -0,0 +1,199 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
import {
|
||||
CalendarCheck, Clock, XCircle, Users, TrendingUp, MessageCircle,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { dashboardApi } from '@/lib/api'
|
||||
import { STATUS_BADGE, STATUS_LABELS, formatTime } from '@/lib/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function StatCard({ icon: Icon, label, value, color, delay }) {
|
||||
return (
|
||||
<div className={cn('card p-5 flex items-center gap-4', `animate-stagger-${delay}`)}>
|
||||
<div className={cn('w-11 h-11 rounded-xl flex items-center justify-center flex-shrink-0', color)}>
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-display font-bold text-slate-900">{value ?? '—'}</p>
|
||||
<p className="text-sm text-slate-500">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SkeletonCard() {
|
||||
return (
|
||||
<div className="card p-5 flex items-center gap-4">
|
||||
<div className="skeleton w-11 h-11 rounded-xl" />
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<div className="skeleton h-6 w-16 rounded" />
|
||||
<div className="skeleton h-4 w-28 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState(null)
|
||||
const [agenda, setAgenda] = useState([])
|
||||
const [peakHours, setPeakHours] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const today = format(new Date(), 'yyyy-MM-dd')
|
||||
const todayLabel = format(new Date(), "EEEE d 'de' MMMM", { locale: es })
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [statsRes, agendaRes, peakRes] = await Promise.all([
|
||||
dashboardApi.getStats(),
|
||||
dashboardApi.getAgenda(today),
|
||||
dashboardApi.getPeakHours(),
|
||||
])
|
||||
setStats(statsRes.data)
|
||||
setAgenda(agendaRes.data)
|
||||
setPeakHours(peakRes.data)
|
||||
} catch {
|
||||
// datos simulados para preview
|
||||
setStats({ total: 24, confirmed: 18, pending: 4, cancelled: 2 })
|
||||
setAgenda([])
|
||||
setPeakHours([
|
||||
{ hour: '09:00', count: 3 }, { hour: '10:00', count: 5 }, { hour: '11:00', count: 4 },
|
||||
{ hour: '14:00', count: 6 }, { hour: '15:00', count: 8 }, { hour: '16:00', count: 3 },
|
||||
])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [today])
|
||||
|
||||
const statCards = stats ? [
|
||||
{ icon: CalendarCheck, label: 'Confirmadas', value: stats.confirmed, color: 'bg-primary-100 text-primary-600', delay: 1 },
|
||||
{ icon: Clock, label: 'Pendientes', value: stats.pending, color: 'bg-warning-100 text-warning-600', delay: 2 },
|
||||
{ icon: XCircle, label: 'Canceladas', value: stats.cancelled, color: 'bg-danger-100 text-danger-500', delay: 3 },
|
||||
{ icon: Users, label: 'Total este mes', value: stats.total, color: 'bg-slate-100 text-slate-600', delay: 4 },
|
||||
] : []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-7 max-w-5xl">
|
||||
{/* Header con fecha */}
|
||||
<div className="animate-stagger-1">
|
||||
<p className="text-sm text-slate-500 capitalize">{todayLabel}</p>
|
||||
<h2 className="font-display text-2xl font-bold text-slate-900 mt-0.5">
|
||||
Resumen de tu negocio
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{loading
|
||||
? Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} />)
|
||||
: statCards.map((s) => <StatCard key={s.label} {...s} />)
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Gráfica + Agenda */}
|
||||
<div className="grid lg:grid-cols-3 gap-5">
|
||||
{/* Gráfica horas pico */}
|
||||
<div className="lg:col-span-2 card p-5 animate-stagger-3">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<TrendingUp size={16} className="text-primary-600" />
|
||||
<h3 className="font-display text-sm font-semibold text-slate-800">Horas pico de reservas</h3>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="skeleton h-48 rounded-lg" />
|
||||
) : peakHours.length ? (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={peakHours} margin={{ top: 0, right: 0, left: -24, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="hour"
|
||||
tick={{ fontSize: 11, fill: '#94a3b8', fontFamily: 'DM Sans' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, fill: '#94a3b8', fontFamily: 'DM Sans' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: '#fff', border: '1px solid #e1e7ef',
|
||||
borderRadius: '8px', fontSize: '12px', fontFamily: 'DM Sans',
|
||||
boxShadow: '0 4px 12px rgb(0 0 0 / 0.08)',
|
||||
}}
|
||||
cursor={{ fill: '#f1f5f9' }}
|
||||
formatter={(v) => [`${v} reservas`, 'Reservas']}
|
||||
/>
|
||||
<Bar dataKey="count" fill="#059669" radius={[4, 4, 0, 0]} maxBarSize={40} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-48 flex items-center justify-center text-sm text-slate-400">
|
||||
Sin datos suficientes todavía
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Agenda del día */}
|
||||
<div className="card p-5 animate-stagger-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<MessageCircle size={16} className="text-primary-600" />
|
||||
<h3 className="font-display text-sm font-semibold text-slate-800">Agenda de hoy</h3>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="skeleton h-12 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : agenda.length ? (
|
||||
<div className="flex flex-col gap-2 overflow-y-auto max-h-52">
|
||||
{agenda.map((r, i) => (
|
||||
<div
|
||||
key={r.id ?? i}
|
||||
className="flex items-center gap-3 p-2.5 rounded-lg hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<div className="text-xs font-medium text-slate-500 w-10 flex-shrink-0">
|
||||
{formatTime(r.time_start)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900 truncate">{r.customer_name}</p>
|
||||
<p className="text-xs text-slate-400 truncate">{r.notes || 'Sin notas'}</p>
|
||||
</div>
|
||||
<span className={STATUS_BADGE[r.status]}>{STATUS_LABELS[r.status]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-36 text-center">
|
||||
<CalendarCheck size={28} className="text-slate-200 mb-2" />
|
||||
<p className="text-sm text-slate-400">Sin reservas para hoy</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tip de integración WhatsApp si no está conectado */}
|
||||
<div className="card p-5 border-primary-100 bg-primary-50 flex items-start gap-4 animate-stagger-5">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-100 flex items-center justify-center flex-shrink-0">
|
||||
<MessageCircle size={18} className="text-primary-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-primary-800">Conecta tu WhatsApp</p>
|
||||
<p className="text-xs text-primary-600 mt-0.5">
|
||||
Ve a <strong>Configuración</strong> para vincular tu número de WhatsApp Business
|
||||
y comenzar a recibir reservas automáticamente.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
225
frontend/src/pages/ReservationsPage.jsx
Normal file
225
frontend/src/pages/ReservationsPage.jsx
Normal file
@ -0,0 +1,225 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Search, Plus, Filter, Trash2, CheckCircle, XCircle, AlertCircle } from 'lucide-react'
|
||||
import { reservationsApi } from '@/lib/api'
|
||||
import { STATUS_BADGE, STATUS_LABELS, formatDate, formatTime, cn } from '@/lib/utils'
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: '', label: 'Todos los estados' },
|
||||
{ value: 'pending', label: 'Pendiente' },
|
||||
{ value: 'confirmed', label: 'Confirmada' },
|
||||
{ value: 'cancelled', label: 'Cancelada' },
|
||||
{ value: 'no_show', label: 'No asistió' },
|
||||
]
|
||||
|
||||
function SkeletonRow() {
|
||||
return (
|
||||
<tr>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<td key={i} className="px-4 py-3.5">
|
||||
<div className="skeleton h-4 rounded w-full max-w-[120px]" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ReservationsPage() {
|
||||
const [reservations, setReservations] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
const [dateFilter, setDateFilter] = useState('')
|
||||
const [updatingId, setUpdatingId] = useState(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = {}
|
||||
if (dateFilter) params.date = dateFilter
|
||||
if (statusFilter) params.status = statusFilter
|
||||
const { data } = await reservationsApi.list(params)
|
||||
setReservations(data)
|
||||
} catch {
|
||||
setReservations([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [dateFilter, statusFilter])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
async function updateStatus(id, status) {
|
||||
setUpdatingId(id)
|
||||
try {
|
||||
await reservationsApi.updateStatus(id, status)
|
||||
setReservations((rs) =>
|
||||
rs.map((r) => (r.id === id ? { ...r, status } : r))
|
||||
)
|
||||
} finally {
|
||||
setUpdatingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteReservation(id) {
|
||||
if (!confirm('¿Eliminar esta reserva?')) return
|
||||
try {
|
||||
await reservationsApi.delete(id)
|
||||
setReservations((rs) => rs.filter((r) => r.id !== id))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const filtered = reservations.filter((r) => {
|
||||
if (!search) return true
|
||||
const q = search.toLowerCase()
|
||||
return (
|
||||
r.customer_name?.toLowerCase().includes(q) ||
|
||||
r.customer_phone?.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 max-w-6xl animate-fade-in">
|
||||
{/* Barra de herramientas */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between">
|
||||
<div className="flex flex-wrap gap-2 flex-1">
|
||||
{/* Búsqueda */}
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por nombre o teléfono…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="field-input pl-9 h-9 text-sm"
|
||||
aria-label="Buscar reservas"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filtro estado */}
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="field-input h-9 text-sm w-auto"
|
||||
aria-label="Filtrar por estado"
|
||||
>
|
||||
{STATUS_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Filtro fecha */}
|
||||
<input
|
||||
type="date"
|
||||
value={dateFilter}
|
||||
onChange={(e) => setDateFilter(e.target.value)}
|
||||
className="field-input h-9 text-sm w-auto"
|
||||
aria-label="Filtrar por fecha"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabla */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" role="table" aria-label="Lista de reservas">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-slate-50/60">
|
||||
{['Cliente', 'Teléfono', 'Fecha', 'Hora', 'Estado', 'Acciones'].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{loading
|
||||
? Array.from({ length: 6 }).map((_, i) => <SkeletonRow key={i} />)
|
||||
: filtered.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-16 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Filter size={28} className="text-slate-200" />
|
||||
<p className="text-sm text-slate-400">
|
||||
{search || statusFilter || dateFilter
|
||||
? 'Sin resultados para este filtro'
|
||||
: 'Aún no hay reservas registradas'}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: filtered.map((r, i) => (
|
||||
<tr
|
||||
key={r.id}
|
||||
className="hover:bg-slate-50/50 transition-colors"
|
||||
style={{ animationDelay: `${i * 30}ms` }}
|
||||
>
|
||||
<td className="px-4 py-3.5">
|
||||
<span className="font-medium text-slate-900">{r.customer_name}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-slate-500">{r.customer_phone}</td>
|
||||
<td className="px-4 py-3.5 text-slate-600">{formatDate(r.date)}</td>
|
||||
<td className="px-4 py-3.5 text-slate-600">{formatTime(r.time_start)}</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<span className={STATUS_BADGE[r.status]}>{STATUS_LABELS[r.status]}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex items-center gap-1">
|
||||
{r.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => updateStatus(r.id, 'confirmed')}
|
||||
disabled={updatingId === r.id}
|
||||
className="btn-ghost p-1.5 text-primary-600 hover:bg-primary-50"
|
||||
title="Confirmar"
|
||||
aria-label="Confirmar reserva"
|
||||
>
|
||||
{updatingId === r.id
|
||||
? <span className="w-3.5 h-3.5 rounded-full border-2 border-primary-500 border-t-transparent animate-spin block" />
|
||||
: <CheckCircle size={15} />
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
{(r.status === 'pending' || r.status === 'confirmed') && (
|
||||
<button
|
||||
onClick={() => updateStatus(r.id, 'cancelled')}
|
||||
disabled={updatingId === r.id}
|
||||
className="btn-ghost p-1.5 text-slate-400 hover:text-danger-600 hover:bg-danger-50"
|
||||
title="Cancelar"
|
||||
aria-label="Cancelar reserva"
|
||||
>
|
||||
<XCircle size={15} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => deleteReservation(r.id)}
|
||||
className="btn-ghost p-1.5 text-slate-300 hover:text-danger-600 hover:bg-danger-50"
|
||||
title="Eliminar"
|
||||
aria-label="Eliminar reserva"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Contador */}
|
||||
{!loading && filtered.length > 0 && (
|
||||
<div className="px-4 py-3 border-t border-border bg-slate-50/50">
|
||||
<p className="text-xs text-slate-400">
|
||||
{filtered.length} reserva{filtered.length !== 1 ? 's' : ''} encontrada{filtered.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
137
frontend/src/pages/auth/LoginPage.jsx
Normal file
137
frontend/src/pages/auth/LoginPage.jsx
Normal file
@ -0,0 +1,137 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { MessageCircle, Eye, EyeOff, AlertCircle } from 'lucide-react'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [form, setForm] = useState({ email: '', password: '' })
|
||||
const [showPwd, setShowPwd] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
function handleChange(e) {
|
||||
setForm((f) => ({ ...f, [e.target.name]: e.target.value }))
|
||||
setError('')
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
await login(form.email, form.password)
|
||||
navigate('/dashboard')
|
||||
} catch (err) {
|
||||
setError(
|
||||
err.response?.data?.detail === 'Credenciales incorrectas'
|
||||
? 'Correo o contraseña incorrectos. Verifica e intenta de nuevo.'
|
||||
: 'Ocurrió un error. Intenta de nuevo.'
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-bg min-h-screen flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm animate-slide-up">
|
||||
{/* Logo */}
|
||||
<div className="flex flex-col items-center gap-3 mb-8">
|
||||
<div className="w-12 h-12 rounded-2xl bg-primary-600 flex items-center justify-center shadow-lg">
|
||||
<MessageCircle size={24} className="text-white" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h1 className="font-display text-2xl font-bold text-slate-900">
|
||||
Hermes<span className="text-primary-600">Messages</span>
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">Reservas automáticas por WhatsApp</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="card p-6 shadow-dialog">
|
||||
<h2 className="font-display text-lg font-semibold text-slate-900 mb-1">
|
||||
Bienvenido de vuelta
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 mb-6">Ingresa a tu panel de control</p>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2.5 bg-danger-50 border border-danger-100 rounded-lg px-3.5 py-3 mb-5 text-sm text-danger-700">
|
||||
<AlertCircle size={16} className="flex-shrink-0 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
|
||||
<div className="field">
|
||||
<label htmlFor="email" className="field-label field-label-required">
|
||||
Correo electrónico
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
placeholder="tu@negocio.com"
|
||||
value={form.email}
|
||||
onChange={handleChange}
|
||||
className="field-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="password" className="field-label field-label-required">
|
||||
Contraseña
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPwd ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
placeholder="Tu contraseña"
|
||||
value={form.password}
|
||||
onChange={handleChange}
|
||||
className="field-input pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPwd((v) => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors"
|
||||
aria-label={showPwd ? 'Ocultar contraseña' : 'Mostrar contraseña'}
|
||||
>
|
||||
{showPwd ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !form.email || !form.password}
|
||||
className="btn-primary w-full mt-1"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="w-4 h-4 rounded-full border-2 border-white border-t-transparent animate-spin" />
|
||||
) : (
|
||||
'Ingresar'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-slate-500 mt-5">
|
||||
¿No tienes cuenta?{' '}
|
||||
<Link to="/register" className="text-primary-600 font-medium hover:underline">
|
||||
Regístrate gratis
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
437
frontend/src/pages/auth/RegisterPage.jsx
Normal file
437
frontend/src/pages/auth/RegisterPage.jsx
Normal file
@ -0,0 +1,437 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
MessageCircle, Building2, UserCircle, CheckCircle2,
|
||||
Eye, EyeOff, AlertCircle, ChevronRight, ChevronLeft, Info,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { cn, BUSINESS_TYPES, TIMEZONES } from '@/lib/utils'
|
||||
|
||||
const STEPS = [
|
||||
{ id: 1, label: 'Tu negocio', description: 'Datos básicos del negocio' },
|
||||
{ id: 2, label: 'Tu cuenta', description: 'Credenciales de acceso' },
|
||||
{ id: 3, label: 'Listo', description: 'Confirmar registro' },
|
||||
]
|
||||
|
||||
const INITIAL = {
|
||||
business_name: '',
|
||||
business_type: '',
|
||||
timezone: 'America/Bogota',
|
||||
email: '',
|
||||
password: '',
|
||||
confirm_password: '',
|
||||
}
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { register } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [step, setStep] = useState(1)
|
||||
const [form, setForm] = useState(INITIAL)
|
||||
const [errors, setErrors] = useState({})
|
||||
const [showPwd, setShowPwd] = useState(false)
|
||||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [globalError, setGlobalError] = useState('')
|
||||
|
||||
function handleChange(e) {
|
||||
const { name, value } = e.target
|
||||
setForm((f) => ({ ...f, [name]: value }))
|
||||
setErrors((e) => ({ ...e, [name]: '' }))
|
||||
setGlobalError('')
|
||||
}
|
||||
|
||||
function validateStep1() {
|
||||
const e = {}
|
||||
if (!form.business_name.trim()) e.business_name = 'Ingresa el nombre de tu negocio.'
|
||||
if (form.business_name.trim().length > 0 && form.business_name.trim().length < 3)
|
||||
e.business_name = 'El nombre debe tener al menos 3 caracteres.'
|
||||
if (!form.business_type) e.business_type = 'Selecciona el tipo de negocio.'
|
||||
return e
|
||||
}
|
||||
|
||||
function validateStep2() {
|
||||
const e = {}
|
||||
if (!form.email) e.email = 'Ingresa tu correo electrónico.'
|
||||
else if (!/\S+@\S+\.\S+/.test(form.email)) e.email = 'El correo no tiene un formato válido.'
|
||||
if (!form.password) e.password = 'Crea una contraseña.'
|
||||
else if (form.password.length < 8) e.password = 'La contraseña debe tener al menos 8 caracteres.'
|
||||
if (form.password !== form.confirm_password) e.confirm_password = 'Las contraseñas no coinciden.'
|
||||
return e
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
const errs = step === 1 ? validateStep1() : validateStep2()
|
||||
if (Object.keys(errs).length) { setErrors(errs); return }
|
||||
setStep((s) => s + 1)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setLoading(true)
|
||||
setGlobalError('')
|
||||
try {
|
||||
await register({
|
||||
business_name: form.business_name.trim(),
|
||||
business_type: form.business_type,
|
||||
timezone: form.timezone,
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
})
|
||||
navigate('/dashboard')
|
||||
} catch (err) {
|
||||
const detail = err.response?.data?.detail
|
||||
if (detail === 'El correo ya está registrado') {
|
||||
setGlobalError('Este correo ya tiene una cuenta. ¿Quieres iniciar sesión?')
|
||||
setStep(2)
|
||||
} else {
|
||||
setGlobalError('Ocurrió un error al crear tu cuenta. Intenta de nuevo.')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const businessTypeLabel = BUSINESS_TYPES.find((t) => t.value === form.business_type)?.label
|
||||
|
||||
return (
|
||||
<div className="auth-bg min-h-screen flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md animate-slide-up">
|
||||
{/* Logo */}
|
||||
<div className="flex flex-col items-center gap-3 mb-6">
|
||||
<div className="w-12 h-12 rounded-2xl bg-primary-600 flex items-center justify-center shadow-lg">
|
||||
<MessageCircle size={24} className="text-white" />
|
||||
</div>
|
||||
<h1 className="font-display text-2xl font-bold text-slate-900">
|
||||
Hermes<span className="text-primary-600">Messages</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Stepper */}
|
||||
<div className="flex items-center gap-0 mb-6 px-2">
|
||||
{STEPS.map((s, i) => (
|
||||
<div key={s.id} className="flex items-center flex-1">
|
||||
<div className="flex flex-col items-center gap-1 flex-shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-all duration-200',
|
||||
step > s.id
|
||||
? 'bg-primary-600 text-white'
|
||||
: step === s.id
|
||||
? 'bg-primary-600 text-white ring-4 ring-primary-100'
|
||||
: 'bg-white border-2 border-border text-slate-400',
|
||||
)}
|
||||
>
|
||||
{step > s.id ? <CheckCircle2 size={16} /> : s.id}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-medium transition-colors duration-200 whitespace-nowrap',
|
||||
step >= s.id ? 'text-primary-600' : 'text-slate-400',
|
||||
)}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 h-0.5 mx-2 mb-4 rounded-full transition-all duration-300',
|
||||
step > s.id ? 'bg-primary-500' : 'bg-border',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="card p-6 shadow-dialog">
|
||||
{globalError && (
|
||||
<div className="flex items-start gap-2.5 bg-danger-50 border border-danger-100 rounded-lg px-3.5 py-3 mb-5 text-sm text-danger-700">
|
||||
<AlertCircle size={16} className="flex-shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{globalError}{' '}
|
||||
{globalError.includes('iniciar sesión') && (
|
||||
<Link to="/login" className="font-medium underline">Ingresar</Link>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 1: Negocio ── */}
|
||||
{step === 1 && (
|
||||
<div className="animate-fade-in">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Building2 size={18} className="text-primary-600" />
|
||||
<h2 className="font-display text-lg font-semibold text-slate-900">
|
||||
Datos de tu negocio
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mb-5">
|
||||
Esta información aparecerá en las conversaciones de tu bot de WhatsApp.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="field">
|
||||
<label htmlFor="business_name" className="field-label field-label-required">
|
||||
Nombre del negocio
|
||||
</label>
|
||||
<input
|
||||
id="business_name"
|
||||
name="business_name"
|
||||
type="text"
|
||||
autoComplete="organization"
|
||||
placeholder="Ej: Restaurante La Terraza, Clínica Salud Plus…"
|
||||
value={form.business_name}
|
||||
onChange={handleChange}
|
||||
className={cn('field-input', errors.business_name && 'field-input-error')}
|
||||
/>
|
||||
{errors.business_name
|
||||
? <span className="field-error"><AlertCircle size={12} />{errors.business_name}</span>
|
||||
: <span className="field-helper">Usa el nombre comercial tal como lo conocen tus clientes.</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="business_type" className="field-label field-label-required">
|
||||
Tipo de negocio
|
||||
</label>
|
||||
<select
|
||||
id="business_type"
|
||||
name="business_type"
|
||||
value={form.business_type}
|
||||
onChange={handleChange}
|
||||
className={cn('field-input', errors.business_type && 'field-input-error')}
|
||||
>
|
||||
<option value="" disabled>Selecciona una categoría…</option>
|
||||
{BUSINESS_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.business_type
|
||||
? <span className="field-error"><AlertCircle size={12} />{errors.business_type}</span>
|
||||
: <span className="field-helper">Ayuda al bot a personalizar las respuestas según tu industria.</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="timezone" className="field-label field-label-required">
|
||||
Zona horaria
|
||||
</label>
|
||||
<select
|
||||
id="timezone"
|
||||
name="timezone"
|
||||
value={form.timezone}
|
||||
onChange={handleChange}
|
||||
className="field-input"
|
||||
>
|
||||
{TIMEZONES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="field-helper">
|
||||
Usada para mostrar y gestionar los horarios de reserva correctamente.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 2: Cuenta ── */}
|
||||
{step === 2 && (
|
||||
<div className="animate-fade-in">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<UserCircle size={18} className="text-primary-600" />
|
||||
<h2 className="font-display text-lg font-semibold text-slate-900">
|
||||
Crea tu cuenta
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mb-5">
|
||||
Estas credenciales son solo tuyas para acceder al panel de control.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="field">
|
||||
<label htmlFor="email" className="field-label field-label-required">
|
||||
Correo electrónico
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="tu@negocio.com"
|
||||
value={form.email}
|
||||
onChange={handleChange}
|
||||
className={cn('field-input', errors.email && 'field-input-error')}
|
||||
/>
|
||||
{errors.email
|
||||
? <span className="field-error"><AlertCircle size={12} />{errors.email}</span>
|
||||
: <span className="field-helper">Aquí recibirás notificaciones importantes de la plataforma.</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="password" className="field-label field-label-required">
|
||||
Contraseña
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPwd ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
placeholder="Mínimo 8 caracteres"
|
||||
value={form.password}
|
||||
onChange={handleChange}
|
||||
className={cn('field-input pr-10', errors.password && 'field-input-error')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPwd((v) => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors"
|
||||
aria-label={showPwd ? 'Ocultar contraseña' : 'Mostrar contraseña'}
|
||||
>
|
||||
{showPwd ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password
|
||||
? <span className="field-error"><AlertCircle size={12} />{errors.password}</span>
|
||||
: (
|
||||
<span className="field-helper">
|
||||
Usa al menos 8 caracteres. Combina letras y números para mayor seguridad.
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="confirm_password" className="field-label field-label-required">
|
||||
Confirmar contraseña
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="confirm_password"
|
||||
name="confirm_password"
|
||||
type={showConfirm ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
placeholder="Repite tu contraseña"
|
||||
value={form.confirm_password}
|
||||
onChange={handleChange}
|
||||
className={cn('field-input pr-10', errors.confirm_password && 'field-input-error')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirm((v) => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors"
|
||||
aria-label={showConfirm ? 'Ocultar' : 'Mostrar'}
|
||||
>
|
||||
{showConfirm ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.confirm_password
|
||||
? <span className="field-error"><AlertCircle size={12} />{errors.confirm_password}</span>
|
||||
: <span className="field-helper">Escribe exactamente la misma contraseña para confirmarla.</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: Confirmar ── */}
|
||||
{step === 3 && (
|
||||
<div className="animate-fade-in">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CheckCircle2 size={18} className="text-primary-600" />
|
||||
<h2 className="font-display text-lg font-semibold text-slate-900">
|
||||
Confirma tu registro
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mb-5">
|
||||
Revisa los datos antes de crear tu cuenta.
|
||||
</p>
|
||||
|
||||
<div className="bg-slate-50 rounded-xl border border-border divide-y divide-border mb-2">
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-slate-500">Negocio</span>
|
||||
<span className="text-sm font-medium text-slate-900 text-right">{form.business_name}</span>
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-slate-500">Tipo</span>
|
||||
<span className="text-sm font-medium text-slate-900">{businessTypeLabel}</span>
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-slate-500">Zona horaria</span>
|
||||
<span className="text-sm font-medium text-slate-900">
|
||||
{TIMEZONES.find((t) => t.value === form.timezone)?.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-slate-500">Correo</span>
|
||||
<span className="text-sm font-medium text-slate-900 truncate max-w-[180px]">{form.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 bg-primary-50 rounded-lg px-3.5 py-3 text-xs text-primary-700 mt-3">
|
||||
<Info size={13} className="flex-shrink-0 mt-0.5" />
|
||||
<span>
|
||||
Tu cuenta se crea en plan gratuito. Podrás conectar WhatsApp y configurar
|
||||
horarios desde el panel de control.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Botones de navegación */}
|
||||
<div className={cn('flex gap-3 mt-6', step > 1 ? 'justify-between' : 'justify-end')}>
|
||||
{step > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep((s) => s - 1)}
|
||||
disabled={loading}
|
||||
className="btn-secondary flex items-center gap-1.5"
|
||||
>
|
||||
<ChevronLeft size={15} />
|
||||
Atrás
|
||||
</button>
|
||||
)}
|
||||
|
||||
{step < 3 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
className="btn-primary flex items-center gap-1.5"
|
||||
>
|
||||
Siguiente
|
||||
<ChevronRight size={15} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="btn-primary flex-1 flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="w-4 h-4 rounded-full border-2 border-white border-t-transparent animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 size={15} />
|
||||
Crear mi cuenta
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-slate-500 mt-5">
|
||||
¿Ya tienes cuenta?{' '}
|
||||
<Link to="/login" className="text-primary-600 font-medium hover:underline">
|
||||
Iniciar sesión
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
93
frontend/tailwind.config.js
Normal file
93
frontend/tailwind.config.js
Normal file
@ -0,0 +1,93 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
display: ['"Bricolage Grotesque"', 'sans-serif'],
|
||||
body: ['"DM Sans"', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
bg: '#f7f8fc',
|
||||
surface: '#ffffff',
|
||||
border: '#e1e7ef',
|
||||
primary: {
|
||||
DEFAULT: '#059669',
|
||||
50: '#ecfdf5',
|
||||
100: '#d1fae5',
|
||||
200: '#a7f3d0',
|
||||
500: '#10b981',
|
||||
600: '#059669',
|
||||
700: '#047857',
|
||||
900: '#064e3b',
|
||||
},
|
||||
slate: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
300: '#cbd5e1',
|
||||
400: '#94a3b8',
|
||||
500: '#64748b',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
900: '#0f172a',
|
||||
},
|
||||
danger: {
|
||||
DEFAULT: '#dc2626',
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: '#d97706',
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
},
|
||||
},
|
||||
boxShadow: {
|
||||
card: '0 1px 3px 0 rgb(0 0 0 / 0.06), 0 1px 2px -1px rgb(0 0 0 / 0.04)',
|
||||
'card-hover': '0 4px 12px 0 rgb(0 0 0 / 0.08), 0 2px 4px -1px rgb(0 0 0 / 0.04)',
|
||||
dialog: '0 20px 60px -10px rgb(0 0 0 / 0.15)',
|
||||
},
|
||||
borderRadius: {
|
||||
DEFAULT: '0.5rem',
|
||||
lg: '0.75rem',
|
||||
xl: '1rem',
|
||||
'2xl': '1.25rem',
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.2s ease-out',
|
||||
'slide-up': 'slideUp 0.25s cubic-bezier(0.23, 1, 0.32, 1)',
|
||||
'stagger-1': 'fadeSlideUp 0.3s cubic-bezier(0.23, 1, 0.32, 1) 0ms both',
|
||||
'stagger-2': 'fadeSlideUp 0.3s cubic-bezier(0.23, 1, 0.32, 1) 50ms both',
|
||||
'stagger-3': 'fadeSlideUp 0.3s cubic-bezier(0.23, 1, 0.32, 1) 100ms both',
|
||||
'stagger-4': 'fadeSlideUp 0.3s cubic-bezier(0.23, 1, 0.32, 1) 150ms both',
|
||||
'stagger-5': 'fadeSlideUp 0.3s cubic-bezier(0.23, 1, 0.32, 1) 200ms both',
|
||||
'skeleton': 'skeleton 1.5s ease-in-out infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
from: { opacity: '0' },
|
||||
to: { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
from: { opacity: '0', transform: 'translateY(8px)' },
|
||||
to: { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
fadeSlideUp: {
|
||||
from: { opacity: '0', transform: 'translateY(6px)' },
|
||||
to: { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
skeleton: {
|
||||
'0%, 100%': { opacity: '1' },
|
||||
'50%': { opacity: '0.4' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
22
frontend/vite.config.js
Normal file
22
frontend/vite.config.js
Normal file
@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user