feat: initial commit — HermesMessages SaaS platform

Backend (FastAPI + Python 3.12):
- Multi-tenant auth with JWT: login, register, refresh, Meta OAuth
- Business & BusinessConfig management
- WhatsApp webhook with HMAC signature verification
- Bot engine powered by Claude AI
- Calendar availability with Redis caching
- Reservations CRUD with status management
- Dashboard analytics (stats, agenda, peak hours)
- Billing & plan management
- Admin panel with platform-wide stats
- Async bcrypt via asyncio.to_thread
- IntegrityError handling for concurrent registration race conditions

Frontend (React 18 + Vite + Tailwind CSS):
- Multi-step guided registration form with helper text on every field
- Login page with show/hide password toggle
- Protected routes with AuthContext
- Dashboard with stats cards, bar chart, and daily agenda
- Reservations list with search, filters, and inline status actions
- Calendar with weekly view, slot availability, and date blocking
- Config page: business info, schedules, bot personality
- Billing page with plan comparison and usage bar

Design system:
- Bricolage Grotesque + DM Sans typography
- Emerald primary palette with semantic color tokens
- scale(0.97) button press feedback, ease-out animations
- Skeleton loaders, stagger animations, prefers-reduced-motion support
- Accessible: aria-labels, visible focus rings, 4.5:1 contrast

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 09:49:41 -05:00
commit 798bd14312
95 changed files with 5836 additions and 0 deletions

73
frontend/src/lib/api.js Normal file
View File

@ -0,0 +1,73 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
headers: { 'Content-Type': 'application/json' },
})
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(err)
}
)
export const authApi = {
register: (data) => api.post('/auth/register', data),
login: (data) => api.post('/auth/login', data),
logout: () => api.post('/auth/logout'),
}
export const businessApi = {
getMe: () => api.get('/business/me'),
updateMe: (data) => api.put('/business/me', data),
getConfig: () => api.get('/business/me/config'),
updateConfig: (data) => api.put('/business/me/config', data),
}
export const whatsappApi = {
getStatus: () => api.get('/whatsapp/status'),
connect: (data) => api.post('/whatsapp/connect', data),
disconnect: () => api.post('/whatsapp/disconnect'),
}
export const reservationsApi = {
list: (params) => api.get('/reservations/', { params }),
create: (data) => api.post('/reservations/', data),
get: (id) => api.get(`/reservations/${id}`),
update: (id, data) => api.put(`/reservations/${id}`, data),
updateStatus: (id, status) => api.patch(`/reservations/${id}/status`, { status }),
delete: (id) => api.delete(`/reservations/${id}`),
}
export const calendarApi = {
getAvailability: (date) => api.get('/calendar/availability', { params: { date } }),
getAvailabilityRange: (start, end) =>
api.get('/calendar/availability/range', { params: { start_date: start, end_date: end } }),
blockDate: (date) => api.post('/calendar/blocked-dates', { date }),
unblockDate: (date) => api.delete(`/calendar/blocked-dates/${date}`),
}
export const dashboardApi = {
getStats: () => api.get('/dashboard/stats'),
getAgenda: (date) => api.get('/dashboard/agenda', { params: { date } }),
getPeakHours: () => api.get('/dashboard/peak-hours'),
}
export const billingApi = {
getPlan: () => api.get('/billing/plan'),
getUsage: () => api.get('/billing/usage'),
upgrade: (plan) => api.post('/billing/upgrade', { plan }),
}
export default api

65
frontend/src/lib/utils.js Normal file
View File

@ -0,0 +1,65 @@
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs) {
return twMerge(clsx(inputs))
}
export function formatDate(dateStr, opts = {}) {
if (!dateStr) return '—'
const d = new Date(dateStr)
return d.toLocaleDateString('es-ES', {
day: '2-digit', month: 'short', year: 'numeric', ...opts,
})
}
export function formatTime(timeStr) {
if (!timeStr) return '—'
return timeStr.slice(0, 5)
}
export function formatDateTime(dateStr) {
if (!dateStr) return '—'
const d = new Date(dateStr)
return d.toLocaleString('es-ES', {
day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit',
})
}
export const STATUS_LABELS = {
pending: 'Pendiente',
confirmed: 'Confirmada',
cancelled: 'Cancelada',
no_show: 'No asistió',
}
export const STATUS_BADGE = {
pending: 'badge-yellow',
confirmed: 'badge-green',
cancelled: 'badge-red',
no_show: 'badge-gray',
}
export const BUSINESS_TYPES = [
{ value: 'restaurant', label: 'Restaurante' },
{ value: 'clinic', label: 'Clínica / Consultorio' },
{ value: 'salon', label: 'Salón de belleza' },
{ value: 'spa', label: 'Spa / Bienestar' },
{ value: 'barbershop', label: 'Barbería' },
{ value: 'gym', label: 'Gimnasio / Entrenador' },
{ value: 'other', label: 'Otro' },
]
export const TIMEZONES = [
{ value: 'America/Bogota', label: 'Bogotá (UTC-5)' },
{ value: 'America/Mexico_City', label: 'Ciudad de México (UTC-6)' },
{ value: 'America/Lima', label: 'Lima (UTC-5)' },
{ value: 'America/Santiago', label: 'Santiago (UTC-4)' },
{ value: 'America/Buenos_Aires', label: 'Buenos Aires (UTC-3)' },
{ value: 'America/Caracas', label: 'Caracas (UTC-4)' },
{ value: 'Europe/Madrid', label: 'Madrid (UTC+1/+2)' },
{ value: 'UTC', label: 'UTC' },
]
export const DAYS_ES = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom']
export const DAYS_FULL = ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo']