Compare commits

..
2 Commits
Author SHA1 Message Date
juan 22a4a32dca revision de cloud code 2026-06-21 17:29:16 +02:00
juan 6f7a9b2b60 cambios profundos 2026-06-20 20:01:40 +02:00
8 changed files with 95 additions and 695 deletions
-5
View File
@@ -13,8 +13,3 @@ MYSQL_ROOT_PASSWORD=changeme-contrasena-fuerte
# URL pública del panel de EasyAppointments (cámbiala en producción).
EA_BASE_URL=http://localhost:8888
# Opcional: id del provider contra el que se reserva. Si no se define,
# el servidor lo autodescubre preguntando a EA (usa el primero, y en este
# salón solo hay uno). Defínelo solo si algún día hay varios providers.
#EA_PROVIDER_ID=5
+2 -4
View File
@@ -61,11 +61,9 @@ docker compose up -d --build
ni clientes, ni citas, ni servicios. Todo se lee y se escribe contra la API de EA.
- Los servicios y la disponibilidad los lee el frontend de EA (a través del proxy de solo lectura).
- El calendario deshabilita los días sin ningún hueco vía `GET /api/month-availability`
(agregado server-side día a día contra EA, con caché de 10 min por mes+servicio).
- Al reservar, el navegador envía la selección a `POST /api/book` y **el servidor**:
- lee los servicios de EA (fuente de verdad de nombre y duración),
- busca o crea el cliente por **teléfono** (identifica al cliente; el email es opcional),
- busca o crea el cliente por **email**,
- crea **una** cita con el servicio de mayor duración y el bloque total en las notas.
- La gestión completa (clientes, servicios, citas, proveedores, etc.) se hace desde http://localhost:8888
@@ -98,8 +96,8 @@ Cómo funciona actualmente con EasyAppointments:
- En el campo `notes` se guarda el detalle completo:
```
Nombre: ...
Email: ...
Teléfono: ...
Email: ... (solo si el cliente lo dio)
Servicios: Corte + Tinte + ...
Duración total: 120 min
Mensaje: ...
-2
View File
@@ -8,8 +8,6 @@ services:
environment:
# Token de API de EasyAppointments. Definido en .env (ver .env.example).
- EA_API_KEY=${EA_API_KEY}
# Opcional: fija el provider; vacío = autodescubierto en EA.
- EA_PROVIDER_ID=${EA_PROVIDER_ID:-}
depends_on:
- easyappointments
+31 -217
View File
@@ -16,7 +16,6 @@ import (
"regexp"
"strconv"
"strings"
"sync"
"time"
)
@@ -36,9 +35,6 @@ var eaAllowedRoutes = []struct {
{http.MethodGet, regexp.MustCompile(`^/ea-api/v1/services/?$`)},
{http.MethodGet, regexp.MustCompile(`^/ea-api/v1/availabilities/?$`)},
{http.MethodGet, regexp.MustCompile(`^/ea-api/v1/providers/\d+/?$`)},
// Solo este ajuste concreto (horario del negocio); el resto de /settings
// sigue bloqueado porque contiene datos sensibles.
{http.MethodGet, regexp.MustCompile(`^/ea-api/v1/settings/company_working_plan/?$`)},
}
func eaRouteAllowed(method, path string) bool {
@@ -159,15 +155,11 @@ type eaCustomer struct {
ID flexInt `json:"id,omitempty"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Email string `json:"email,omitempty"`
Email string `json:"email"`
Phone string `json:"phone"`
Notes string `json:"notes"`
}
type eaProvider struct {
ID flexInt `json:"id"`
}
type eaAppointment struct {
Start string `json:"start"`
End string `json:"end"`
@@ -182,10 +174,10 @@ type eaAppointment struct {
type bookRequest struct {
Nombre string `json:"nombre"`
Apellidos string `json:"apellidos"`
Email string `json:"email"`
Telefono string `json:"telefono"`
Mensaje string `json:"mensaje"`
ProviderID int `json:"providerId"`
ServiceIDs []int `json:"serviceIds"`
Date string `json:"date"` // YYYY-MM-DD
Time string `json:"time"` // HH:MM
@@ -199,9 +191,8 @@ var (
// handleBook orquesta la reserva contra EasyAppointments:
// 1. valida la entrada,
// 2. lee los servicios de EA (fuente de verdad de nombre/duración),
// 3. resuelve el provider (server-side, ver providerID),
// 4. busca o crea el cliente por teléfono (es el dato que lo identifica; el email es opcional),
// 5. crea UNA cita con el servicio de mayor duración y el bloque total en notas.
// 3. busca o crea el cliente por email,
// 4. crea UNA cita con el servicio de mayor duración y el bloque total en notas.
func handleBook(ea *eaClient) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -215,13 +206,12 @@ func handleBook(ea *eaClient) http.HandlerFunc {
return
}
req.Nombre = strings.TrimSpace(req.Nombre)
req.Apellidos = strings.TrimSpace(req.Apellidos)
req.Email = strings.TrimSpace(req.Email)
req.Telefono = strings.TrimSpace(req.Telefono)
req.Mensaje = strings.TrimSpace(req.Mensaje)
if req.Nombre == "" || req.Apellidos == "" || normPhone(req.Telefono) == "" ||
len(req.ServiceIDs) == 0 ||
if req.Nombre == "" || req.Email == "" || req.Telefono == "" ||
len(req.ServiceIDs) == 0 || req.ProviderID <= 0 ||
!reDate.MatchString(req.Date) || !reTime.MatchString(req.Time) {
writeJSONError(w, http.StatusBadRequest, "datos_incompletos")
return
@@ -278,30 +268,18 @@ func handleBook(ea *eaClient) http.HandlerFunc {
names[i] = s.Name
}
notesLines := []string{
"Nombre: " + req.Nombre + " " + req.Apellidos,
"Nombre: " + req.Nombre,
"Email: " + req.Email,
"Teléfono: " + req.Telefono,
}
if req.Email != "" {
notesLines = append(notesLines, "Email: "+req.Email)
}
notesLines = append(notesLines,
"Servicios: " + strings.Join(names, " + "),
fmt.Sprintf("Duración total: %d min", total),
)
}
if req.Mensaje != "" {
notesLines = append(notesLines, "Mensaje: "+req.Mensaje)
}
notes := strings.Join(notesLines, "\n")
// 5. provider resuelto server-side: el navegador no elige contra quién reserva.
providerID, err := ea.providerID()
if err != nil {
log.Printf("book: error resolviendo provider: %v", err)
writeJSONError(w, http.StatusBadGateway, "proveedor_no_disponible")
return
}
// 6. cliente: buscar por teléfono o crear (en EA).
// 5. cliente: buscar por email o crear (en EA).
customerID, err := ea.findOrCreateCustomer(req)
if err != nil {
log.Printf("book: error con cliente: %v", err)
@@ -309,12 +287,12 @@ func handleBook(ea *eaClient) http.HandlerFunc {
return
}
// 7. crear la cita (en EA).
// 6. crear la cita (en EA).
appt := eaAppointment{
Start: start.Format(layout),
End: end.Format(layout),
ServiceID: int(primary.ID),
ProviderID: providerID,
ProviderID: req.ProviderID,
CustomerID: customerID,
Notes: notes,
Status: "booked",
@@ -331,192 +309,24 @@ func handleBook(ea *eaClient) http.HandlerFunc {
}
}
// providerID resuelve el id del provider contra el que se reserva. Con
// EA_PROVIDER_ID definido se usa ese valor; si no, se pregunta a EA y se toma
// el primero (el salón tiene un único provider). Así el id no va hardcodeado
// y cada entorno (desarrollo, producción) resuelve el suyo.
func (c *eaClient) providerID() (int, error) {
if v := os.Getenv("EA_PROVIDER_ID"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
return 0, fmt.Errorf("EA_PROVIDER_ID inválido: %q", v)
}
return n, nil
}
var provs []eaProvider
if err := c.do(http.MethodGet, "/providers", nil, &provs); err != nil {
return 0, err
}
if len(provs) == 0 {
return 0, fmt.Errorf("EA no tiene ningún provider")
}
if len(provs) > 1 {
log.Printf("aviso: EA tiene %d providers; se usa el primero (id %d). Fija EA_PROVIDER_ID para elegir otro.", len(provs), int(provs[0].ID))
}
return int(provs[0].ID), nil
}
// handleConfig expone al frontend la configuración mínima que necesita para
// consultar disponibilidad por el proxy: solo el id del provider.
func handleConfig(ea *eaClient) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSONError(w, http.StatusMethodNotAllowed, "method_not_allowed")
return
}
id, err := ea.providerID()
if err != nil {
log.Printf("config: error resolviendo provider: %v", err)
writeJSONError(w, http.StatusBadGateway, "proveedor_no_disponible")
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"providerId": %d}`, id)
}
}
// normPhone deja solo los dígitos ("690 91 88 15" → "690918815") para que el
// mismo número escrito de formas distintas identifique al mismo cliente.
func normPhone(s string) string {
var b strings.Builder
for _, r := range s {
if r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
return b.String()
}
// samePhone compara dos teléfonos por sus dígitos, tolerando prefijos de país:
// "+34 690918815" y "690918815" son el mismo número (sufijo común de ≥9 dígitos).
func samePhone(a, b string) bool {
a, b = normPhone(a), normPhone(b)
if a == "" || b == "" {
return false
}
if len(a) > len(b) {
a, b = b, a
}
return a == b || (len(a) >= 9 && strings.HasSuffix(b, a))
}
// handleMonthAvailability devuelve los días de un mes sin NINGÚN hueco libre
// para un servicio ("unavailableDays"), para que el calendario los deshabilite
// sin que el usuario tenga que pulsarlos. Consulta las availabilities de EA
// día a día (en paralelo, saltando los pasados) y cachea el resultado 10 min
// por mes+servicio para no repetir ~30 llamadas a EA en cada render.
func handleMonthAvailability(ea *eaClient) http.HandlerFunc {
type cacheEntry struct {
expires time.Time
days []string
}
var (
mu sync.Mutex
cache = make(map[string]cacheEntry)
)
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSONError(w, http.StatusMethodNotAllowed, "method_not_allowed")
return
}
month := r.URL.Query().Get("month")
first, errMonth := time.Parse("2006-01", month)
serviceID, errService := strconv.Atoi(r.URL.Query().Get("serviceId"))
// Solo del mes actual a 12 meses vista: es lo único que el calendario
// puede pedir y acota el abanico de llamadas a EA.
now := time.Now()
currentMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
if errMonth != nil || errService != nil || serviceID <= 0 ||
first.Before(currentMonth) || first.After(currentMonth.AddDate(1, 0, 0)) {
writeJSONError(w, http.StatusBadRequest, "parametros_invalidos")
return
}
key := fmt.Sprintf("%s:%d", month, serviceID)
mu.Lock()
e, hit := cache[key]
mu.Unlock()
if !hit || !now.Before(e.expires) {
providerID, err := ea.providerID()
if err != nil {
log.Printf("month-availability: error resolviendo provider: %v", err)
writeJSONError(w, http.StatusBadGateway, "proveedor_no_disponible")
return
}
daysInMonth := first.AddDate(0, 1, -1).Day()
today := now.Format("2006-01-02")
unavailable := make([]bool, daysInMonth+1)
var wg sync.WaitGroup
sem := make(chan struct{}, 6) // máx. 6 llamadas a EA en paralelo
for d := 1; d <= daysInMonth; d++ {
date := fmt.Sprintf("%s-%02d", month, d)
if date < today { // los días pasados ya los bloquea el navegador
continue
}
wg.Add(1)
go func(d int, date string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
var slots []string
path := fmt.Sprintf("/availabilities?providerId=%d&serviceId=%d&date=%s", providerID, serviceID, date)
// Si EA falla, el día se deja clicable: al seleccionarlo
// se consulta de nuevo y allí se informa del error.
if err := ea.do(http.MethodGet, path, nil, &slots); err == nil && len(slots) == 0 {
unavailable[d] = true
}
}(d, date)
}
wg.Wait()
days := []string{}
for d := 1; d <= daysInMonth; d++ {
if unavailable[d] {
days = append(days, fmt.Sprintf("%s-%02d", month, d))
}
}
e = cacheEntry{expires: time.Now().Add(10 * time.Minute), days: days}
mu.Lock()
cache[key] = e
mu.Unlock()
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string][]string{"unavailableDays": e.days})
}
}
// findOrCreateCustomer busca el cliente por TELÉFONO en EA (es el dato que lo
// identifica; el email es opcional) y, si no existe, lo crea. Así todas las
// citas hechas con el mismo teléfono quedan asignadas al mismo cliente.
// findOrCreateCustomer busca un cliente por email en EA y, si no existe, lo crea.
func (c *eaClient) findOrCreateCustomer(req bookRequest) (int, error) {
phone := normPhone(req.Telefono)
// La búsqueda usa los últimos 9 dígitos (número nacional) para encontrar
// también registros guardados con prefijo de país.
q := phone
if len(q) > 9 {
q = q[len(q)-9:]
}
var found []eaCustomer
if err := c.do(http.MethodGet, "/customers?q="+url.QueryEscape(q), nil, &found); err == nil {
if err := c.do(http.MethodGet, "/customers?q="+url.QueryEscape(req.Email), nil, &found); err == nil {
for _, cust := range found {
if samePhone(cust.Phone, phone) {
if strings.EqualFold(strings.TrimSpace(cust.Email), req.Email) {
return int(cust.ID), nil
}
}
}
first, last := splitName(req.Nombre)
var created eaCustomer
body := eaCustomer{
FirstName: req.Nombre,
LastName: req.Apellidos,
FirstName: first,
LastName: last,
Email: req.Email,
Phone: phone,
Phone: req.Telefono,
Notes: "Creado desde web MAY Studio",
}
if err := c.do(http.MethodPost, "/customers", body, &created); err != nil {
@@ -528,6 +338,18 @@ func (c *eaClient) findOrCreateCustomer(req bookRequest) (int, error) {
return int(created.ID), nil
}
func splitName(full string) (first, last string) {
parts := strings.Fields(full)
switch len(parts) {
case 0:
return full, ""
case 1:
return parts[0], ""
default:
return parts[0], strings.Join(parts[1:], " ")
}
}
func main() {
port := flag.Int("port", 8080, "puerto del servidor")
flag.Parse()
@@ -594,14 +416,6 @@ func main() {
// Reservas: orquestadas server-side contra EasyAppointments.
http.HandleFunc("/api/book", handleBook(ea))
// Config para el frontend: el id del provider se resuelve aquí (EA o
// EA_PROVIDER_ID), nunca va hardcodeado en el HTML.
http.HandleFunc("/api/config", handleConfig(ea))
// Días del mes visible sin ningún hueco libre: el calendario los pinta
// deshabilitados para ahorrar clics en días completos o festivos.
http.HandleFunc("/api/month-availability", handleMonthAvailability(ea))
http.Handle("/", http.FileServer(http.FS(staticFS)))
addr := fmt.Sprintf(":%d", *port)
+21 -282
View File
@@ -6,9 +6,7 @@ import (
"net/http/httptest"
"sort"
"strings"
"sync/atomic"
"testing"
"time"
)
// La allowlist del proxy debe ser SOLO de lectura: cualquier escritura o búsqueda
@@ -25,7 +23,6 @@ func TestEaRouteAllowed(t *testing.T) {
{"GET", "/ea-api/v1/services", true},
{"GET", "/ea-api/v1/availabilities", true},
{"GET", "/ea-api/v1/providers/1", true},
{"GET", "/ea-api/v1/settings/company_working_plan", true},
// Bloqueadas: clientes y citas ya NO pasan por el proxy
{"GET", "/ea-api/v1/customers", false},
@@ -37,8 +34,6 @@ func TestEaRouteAllowed(t *testing.T) {
{"GET", "/ea-api/v1/providers", false}, // listar todos
{"GET", "/ea-api/v1/providers/abc", false}, // id no numérico
{"GET", "/ea-api/v1/settings", false},
{"GET", "/ea-api/v1/settings/email_footer", false}, // otros settings no
{"POST", "/ea-api/v1/settings/company_working_plan", false},
{"POST", "/ea-api/v1/services", false},
}
for _, c := range cases {
@@ -48,6 +43,22 @@ func TestEaRouteAllowed(t *testing.T) {
}
}
func TestSplitName(t *testing.T) {
cases := []struct{ in, first, last string }{
{"", "", ""},
{"Ana", "Ana", ""},
{"Ana López", "Ana", "López"},
{"Ana María López Gil", "Ana", "María López Gil"},
{" Ana López ", "Ana", "López"},
}
for _, c := range cases {
f, l := splitName(c.in)
if f != c.first || l != c.last {
t.Errorf("splitName(%q) = (%q,%q), want (%q,%q)", c.in, f, l, c.first, c.last)
}
}
}
func TestFlexIntUnmarshal(t *testing.T) {
var s struct {
A flexInt `json:"a"`
@@ -68,7 +79,6 @@ func TestFlexIntUnmarshal(t *testing.T) {
// duración total y servicio principal en el servidor.
func TestHandleBook(t *testing.T) {
var gotAppt eaAppointment
var gotCust eaCustomer
var hits []string
mux := http.NewServeMux()
@@ -86,7 +96,6 @@ func TestHandleBook(t *testing.T) {
return
}
hits = append(hits, "POST customers")
json.NewDecoder(r.Body).Decode(&gotCust)
json.NewEncoder(w).Encode(eaCustomer{ID: 99})
})
mux.HandleFunc("/index.php/api/v1/appointments", func(w http.ResponseWriter, r *http.Request) {
@@ -95,17 +104,13 @@ func TestHandleBook(t *testing.T) {
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"id":5}`))
})
mux.HandleFunc("/index.php/api/v1/providers", func(w http.ResponseWriter, r *http.Request) {
hits = append(hits, "GET providers")
json.NewEncoder(w).Encode([]eaProvider{{ID: 7}})
})
srv := httptest.NewServer(mux)
defer srv.Close()
ea := &eaClient{base: srv.URL, http: srv.Client()}
body := `{"nombre":"Ana","apellidos":"López Gil","email":"ana@x.com","telefono":"600","mensaje":"hola",
"serviceIds":[1,2],"date":"2026-07-01","time":"10:00"}`
body := `{"nombre":"Ana López","email":"ana@x.com","telefono":"600","mensaje":"hola",
"providerId":1,"serviceIds":[1,2],"date":"2026-07-01","time":"10:00"}`
req := httptest.NewRequest(http.MethodPost, "/api/book", strings.NewReader(body))
rec := httptest.NewRecorder()
handleBook(ea)(rec, req)
@@ -121,102 +126,21 @@ func TestHandleBook(t *testing.T) {
if gotAppt.Start != "2026-07-01 10:00:00" || gotAppt.End != "2026-07-01 11:15:00" {
t.Errorf("start/end = %q/%q, want 10:00:00/11:15:00", gotAppt.Start, gotAppt.End)
}
// El provider lo resuelve el servidor contra EA, no el navegador.
if gotAppt.CustomerID != 99 || gotAppt.ProviderID != 7 || gotAppt.Status != "booked" {
if gotAppt.CustomerID != 99 || gotAppt.ProviderID != 1 || gotAppt.Status != "booked" {
t.Errorf("appt = %+v", gotAppt)
}
// Nombre y apellidos viajan por separado hasta EA, sin adivinar cuál es cuál.
if gotCust.FirstName != "Ana" || gotCust.LastName != "López Gil" {
t.Errorf("cliente = %q %q, want \"Ana\" \"López Gil\"", gotCust.FirstName, gotCust.LastName)
}
if !strings.Contains(gotAppt.Notes, "Duración total: 75 min") ||
!strings.Contains(gotAppt.Notes, "Servicios: Corte + Tinte") {
t.Errorf("notes = %q", gotAppt.Notes)
}
// Toda la gestión pasó por EA y nada más.
sort.Strings(hits)
want := []string{"GET customers", "GET providers", "GET services", "POST appointments", "POST customers"}
want := []string{"GET customers", "GET services", "POST appointments", "POST customers"}
if strings.Join(hits, ",") != strings.Join(want, ",") {
t.Errorf("llamadas a EA = %v, want %v", hits, want)
}
}
// El teléfono identifica al cliente: si ya existe en EA se reutiliza (no se
// duplica) y se le pueden crear varias citas. El email ya no es obligatorio.
func TestHandleBookReusesCustomerByPhone(t *testing.T) {
customersCreated := 0
apptsCreated := 0
var lastAppt eaAppointment
mux := http.NewServeMux()
mux.HandleFunc("/index.php/api/v1/services", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode([]eaService{{ID: 1, Name: "Corte", Duration: 30}})
})
mux.HandleFunc("/index.php/api/v1/providers", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode([]eaProvider{{ID: 7}})
})
mux.HandleFunc("/index.php/api/v1/customers", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
if q := r.URL.Query().Get("q"); q != "690918815" {
t.Errorf("q = %q, want los últimos 9 dígitos del teléfono", q)
}
// En EA está guardado con prefijo y espacios: debe reconocerse igual.
json.NewEncoder(w).Encode([]eaCustomer{{ID: 42, Phone: "+34 690 91 88 15"}})
return
}
customersCreated++
json.NewEncoder(w).Encode(eaCustomer{ID: 99})
})
mux.HandleFunc("/index.php/api/v1/appointments", func(w http.ResponseWriter, r *http.Request) {
apptsCreated++
json.NewDecoder(r.Body).Decode(&lastAppt)
w.WriteHeader(http.StatusCreated)
})
srv := httptest.NewServer(mux)
defer srv.Close()
ea := &eaClient{base: srv.URL, http: srv.Client()}
// Dos reservas sin email y con el teléfono escrito distinto cada vez.
for _, tel := range []string{"690 918 815", "+34690918815"} {
body := `{"nombre":"Ana","apellidos":"López","telefono":"` + tel + `","serviceIds":[1],"date":"2026-07-01","time":"10:00"}`
rec := httptest.NewRecorder()
handleBook(ea)(rec, httptest.NewRequest(http.MethodPost, "/api/book", strings.NewReader(body)))
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
}
}
if customersCreated != 0 {
t.Errorf("se crearon %d clientes nuevos; ese teléfono ya existía en EA", customersCreated)
}
if apptsCreated != 2 {
t.Errorf("citas creadas = %d, want 2 (varias citas para el mismo cliente)", apptsCreated)
}
if lastAppt.CustomerID != 42 {
t.Errorf("customerId = %d, want 42 (el cliente existente)", lastAppt.CustomerID)
}
}
func TestSamePhone(t *testing.T) {
cases := []struct {
a, b string
want bool
}{
{"690918815", "690 91 88 15", true},
{"+34 690918815", "690918815", true},
{"0034690918815", "690.91.88.15", true},
{"690918815", "690918816", false},
{"600", "600", true},
{"600", "222600", false}, // sufijos cortos no identifican
{"", "690918815", false},
}
for _, c := range cases {
if got := samePhone(c.a, c.b); got != c.want {
t.Errorf("samePhone(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
}
}
}
// Servicio inválido (no existe en EA) ⇒ 400 y NO se crea ninguna cita.
func TestHandleBookRejectsUnknownService(t *testing.T) {
created := false
@@ -231,7 +155,7 @@ func TestHandleBookRejectsUnknownService(t *testing.T) {
defer srv.Close()
ea := &eaClient{base: srv.URL, http: srv.Client()}
body := `{"nombre":"Ana","apellidos":"López","email":"a@x.com","telefono":"600","serviceIds":[999],"date":"2026-07-01","time":"10:00"}`
body := `{"nombre":"Ana","email":"a@x.com","telefono":"600","providerId":1,"serviceIds":[999],"date":"2026-07-01","time":"10:00"}`
rec := httptest.NewRecorder()
handleBook(ea)(rec, httptest.NewRequest(http.MethodPost, "/api/book", strings.NewReader(body)))
@@ -242,188 +166,3 @@ func TestHandleBookRejectsUnknownService(t *testing.T) {
t.Error("se creó una cita con un servicio inexistente")
}
}
// El id del provider se resuelve contra EA (autodescubierto) o vía
// EA_PROVIDER_ID, nunca hardcodeado: es distinto en cada entorno.
func TestProviderID(t *testing.T) {
calls := 0
mux := http.NewServeMux()
mux.HandleFunc("/index.php/api/v1/providers", func(w http.ResponseWriter, r *http.Request) {
calls++
json.NewEncoder(w).Encode([]eaProvider{{ID: 5}, {ID: 9}})
})
srv := httptest.NewServer(mux)
defer srv.Close()
ea := &eaClient{base: srv.URL, http: srv.Client()}
t.Run("autodescubre el primero de EA", func(t *testing.T) {
id, err := ea.providerID()
if err != nil || id != 5 {
t.Errorf("providerID() = %d, %v; want 5, nil", id, err)
}
})
t.Run("EA_PROVIDER_ID tiene prioridad y no llama a EA", func(t *testing.T) {
t.Setenv("EA_PROVIDER_ID", "9")
before := calls
id, err := ea.providerID()
if err != nil || id != 9 {
t.Errorf("providerID() = %d, %v; want 9, nil", id, err)
}
if calls != before {
t.Error("con EA_PROVIDER_ID definido no debería consultar EA")
}
})
t.Run("EA_PROVIDER_ID inválido es error", func(t *testing.T) {
t.Setenv("EA_PROVIDER_ID", "mayra")
if _, err := ea.providerID(); err == nil {
t.Error("EA_PROVIDER_ID no numérico debería dar error")
}
})
t.Run("sin providers en EA es error", func(t *testing.T) {
empty := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`[]`))
}))
defer empty.Close()
ea := &eaClient{base: empty.URL, http: empty.Client()}
if _, err := ea.providerID(); err == nil {
t.Error("sin providers debería dar error")
}
})
t.Run("/api/config lo expone al frontend", func(t *testing.T) {
rec := httptest.NewRecorder()
handleConfig(ea)(rec, httptest.NewRequest(http.MethodGet, "/api/config", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var got struct {
ProviderID int `json:"providerId"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil || got.ProviderID != 5 {
t.Errorf("body = %s (err %v), want providerId 5", rec.Body.String(), err)
}
})
}
// /api/month-availability agrega las availabilities de EA día a día: devuelve
// los días sin ningún hueco para que el calendario los deshabilite, y cachea
// el resultado (una sola pasada de llamadas a EA por mes+servicio).
func TestHandleMonthAvailability(t *testing.T) {
// Mes siguiente al actual: así ningún día es pasado y se consultan todos.
now := time.Now()
first := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC).AddDate(0, 1, 0)
month := first.Format("2006-01")
daysInMonth := first.AddDate(0, 1, -1).Day()
full1, full2 := month+"-15", month+"-16"
var eaCalls atomic.Int64
mux := http.NewServeMux()
mux.HandleFunc("/index.php/api/v1/providers", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode([]eaProvider{{ID: 7}})
})
mux.HandleFunc("/index.php/api/v1/availabilities", func(w http.ResponseWriter, r *http.Request) {
eaCalls.Add(1)
if r.URL.Query().Get("serviceId") != "3" || r.URL.Query().Get("providerId") != "7" {
t.Errorf("query inesperada: %s", r.URL.RawQuery)
}
if date := r.URL.Query().Get("date"); date == full1 || date == full2 {
w.Write([]byte(`[]`)) // día completo: sin ningún hueco
return
}
w.Write([]byte(`["10:00","10:30"]`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
handler := handleMonthAvailability(&eaClient{base: srv.URL, http: srv.Client()})
get := func() *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
handler(rec, httptest.NewRequest(http.MethodGet, "/api/month-availability?month="+month+"&serviceId=3", nil))
return rec
}
rec := get()
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var got struct {
UnavailableDays []string `json:"unavailableDays"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
if strings.Join(got.UnavailableDays, ",") != full1+","+full2 {
t.Errorf("unavailableDays = %v, want [%s %s]", got.UnavailableDays, full1, full2)
}
if n := eaCalls.Load(); n != int64(daysInMonth) {
t.Errorf("llamadas a EA = %d, want %d (una por día del mes)", n, daysInMonth)
}
// Segunda petición: servida desde la caché, sin más llamadas a EA.
if rec := get(); rec.Code != http.StatusOK {
t.Fatalf("status con caché = %d, want 200", rec.Code)
}
if n := eaCalls.Load(); n != int64(daysInMonth) {
t.Errorf("la caché no se usó: %d llamadas a EA", n)
}
// Parámetros inválidos ⇒ 400 (meses pasados, sin servicio, formato malo).
for _, qs := range []string{
"month=2020-01&serviceId=3",
"month=" + month,
"month=13-2026&serviceId=3",
} {
rec := httptest.NewRecorder()
handler(rec, httptest.NewRequest(http.MethodGet, "/api/month-availability?"+qs, nil))
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 (%s)", rec.Code, qs)
}
}
}
// Sin apellidos ⇒ 400 sin llegar a llamar a EA.
func TestHandleBookRequiresApellidos(t *testing.T) {
called := false
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { called = true })
srv := httptest.NewServer(mux)
defer srv.Close()
ea := &eaClient{base: srv.URL, http: srv.Client()}
body := `{"nombre":"Ana","email":"a@x.com","telefono":"600","serviceIds":[1],"date":"2026-07-01","time":"10:00"}`
rec := httptest.NewRecorder()
handleBook(ea)(rec, httptest.NewRequest(http.MethodPost, "/api/book", strings.NewReader(body)))
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
if called {
t.Error("se llamó a EA con datos incompletos")
}
}
// Sin teléfono (o sin ningún dígito) ⇒ 400 sin llamar a EA: es el dato que
// identifica al cliente. El email en cambio ya no es obligatorio.
func TestHandleBookRequiresTelefono(t *testing.T) {
called := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true }))
defer srv.Close()
ea := &eaClient{base: srv.URL, http: srv.Client()}
for _, body := range []string{
`{"nombre":"Ana","apellidos":"López","serviceIds":[1],"date":"2026-07-01","time":"10:00"}`,
`{"nombre":"Ana","apellidos":"López","telefono":"--","serviceIds":[1],"date":"2026-07-01","time":"10:00"}`,
} {
rec := httptest.NewRecorder()
handleBook(ea)(rec, httptest.NewRequest(http.MethodPost, "/api/book", strings.NewReader(body)))
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 (body %s)", rec.Code, body)
}
}
if called {
t.Error("se llamó a EA con datos incompletos")
}
}
-32
View File
@@ -1,32 +0,0 @@
#!/usr/bin/env bash
# Backup diario. Se ejecuta EN EL SERVIDOR (cron): dump consistente de MySQL
# con el contenedor vivo + copia de .env, en /srv/backups/may, que es la
# carpeta que zerobyte monta por SSH desde la otra máquina.
# Instalación, zerobyte y restauración: SIGUIENTES-PASOS.md → "Backups".
set -euo pipefail
umask 077
BACKUP_DIR=/srv/backups/may
APP_DIR=$(cd "$(dirname "$0")/.." && pwd)
STAMP=$(date +%F_%H%M)
mkdir -p "$BACKUP_DIR/mysql" "$BACKUP_DIR/config"
# --single-transaction: snapshot InnoDB consistente sin bloquear ni parar nada.
# MYSQL_PWD se expande DENTRO del contenedor (comillas simples): la contraseña
# no pasa por este script ni por la lista de procesos del host.
TMP="$BACKUP_DIR/mysql/.easyappointments_$STAMP.part"
docker exec may-ea-mysql sh -c \
'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysqldump --single-transaction --quick \
--routines --triggers --events --databases easyappointments' > "$TMP"
grep -q 'Dump completed' "$TMP" # mysqldump lo escribe al final: si falta, quedó truncado
mv "$TMP" "$BACKUP_DIR/mysql/easyappointments_$STAMP.sql"
# Lo único necesario para restaurar que no está en git.
install -m 600 "$APP_DIR/.env" "$BACKUP_DIR/config/env"
# Retención local corta; el histórico largo son los snapshots de zerobyte.
find "$BACKUP_DIR/mysql" -name '*.sql' -mtime +7 -delete
find "$BACKUP_DIR/mysql" -name '.*.part' -mtime +1 -delete
echo "$(date -Is) OK easyappointments_$STAMP.sql"
+40 -147
View File
@@ -3,39 +3,38 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MAY STUDIO — Peluquería en Barcelona</title>
<meta name="description" content="MAY STUDIO, peluquería en Horta, Barcelona. Cortes, color, peinados y tratamientos para señoras, caballeros y niños.">
<title>MAY Studio — Peluquería en Barcelona</title>
<meta name="description" content="MAY Studio, peluquería en Horta, Barcelona. Cortes, color, peinados y tratamientos para señoras, caballeros y niños.">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/style.css">
<script defer src="https://umami.darma.cc/script.js" data-website-id="2653f3ec-6c3f-422f-b91c-3b6a7f3fe73e"></script>
</head>
<body>
<!-- NAV -->
<nav class="nav">
<div class="container nav-inner">
<a href="#" class="logo">MAY <span>STUDIO</span></a>
<a href="#" class="logo">MAY <span>Studio</span></a>
<ul class="nav-links">
<li><a href="#profesional">La profesional</a></li>
<li><a href="#galeria">Nuestro trabajo</a></li>
<li><a href="#reservar">Reservar</a></li>
<li><a href="#contacto">Contacto</a></li>
</ul>
<a href="tel:+34690918815" class="btn btn-primary btn-nav">Llamar</a>
<a href="tel:+34933333333" class="btn btn-primary btn-nav">Llamar</a>
</div>
</nav>
<!-- HERO -->
<header class="hero">
<div class="hero-bg">
<img src="/assets/hero.jpg" alt="Interior del salón MAY STUDIO">
<img src="/assets/hero.jpg" alt="Interior del salón MAY Studio">
</div>
<div class="container hero-content">
<h1>MAY STUDIO<br>Tu peluquería en Horta</h1>
<h1>MAY Studio<br>Tu peluquería en Horta</h1>
<p class="hero-sub">Cortes, color, peinados y tratamientos capilares para toda la familia. Un espacio pensado para ti.</p>
<div class="hero-actions">
<a href="#reservar" class="btn btn-primary">Reservar cita</a>
@@ -49,15 +48,15 @@
<div class="container">
<div class="profesional-grid">
<div class="profesional-img">
<img src="/assets/may.jpg" alt="Mayra, peluquera profesional en MAY STUDIO">
<img src="/assets/may.jpg" alt="Mayra, peluquera profesional en MAY Studio">
</div>
<div class="profesional-info">
<h3>Mayra</h3>
<p class="profesional-role">Peluquera &amp; fundadora</p>
<p>Soy Mayra, fundadora de MAY STUDIO, un espacio dedicado al cuidado del cabello con un enfoque personalizado.</p>
<p>Soy Mayra, fundadora de MAY Studio, un espacio dedicado al cuidado del cabello con un enfoque personalizado.</p>
<p>Estoy especializada en color, mechas y cortes, buscando siempre resultados naturales, equilibrados y elegantes.</p>
<p>Cada cliente recibe una atención individualizada, analizando su cabello y su estilo para crear un resultado que sea bonito, favorecedor y fácil de mantener día a día.</p>
<p>MAY STUDIO nace con la idea de ofrecer un ambiente tranquilo donde el cuidado del cabello y la atención al detalle son lo más importante.</p>
<p>MAY Studio nace con la idea de ofrecer un ambiente tranquilo donde el cuidado del cabello y la atención al detalle son lo más importante.</p>
</div>
</div>
</div>
@@ -67,7 +66,7 @@
<section id="galeria" class="section section-alt">
<div class="container">
<h2 class="section-title">Nuestro trabajo</h2>
<p class="section-subtitle">Explora algunos de los estilos y transformaciones que creamos en MAY STUDIO. Haz clic en cualquier imagen para ampliar.</p>
<p class="section-subtitle">Explora algunos de los estilos y transformaciones que creamos en MAY Studio. Haz clic en cualquier imagen para ampliar.</p>
<div class="gallery-grid">
<div class="gallery-item"><img src="/assets/galeria-1.jpg" alt="Trabajo de peluquería"></div>
<div class="gallery-item"><img src="/assets/galeria-2.jpg" alt="Trabajo de peluquería"></div>
@@ -139,18 +138,14 @@
<div class="form-row">
<div class="form-group">
<label for="nombre">Nombre *</label>
<input type="text" id="nombre" name="nombre" autocomplete="given-name" required>
<input type="text" id="nombre" name="nombre" autocomplete="name" required>
</div>
<div class="form-group">
<label for="apellidos">Apellidos *</label>
<input type="text" id="apellidos" name="apellidos" autocomplete="family-name" required>
<label for="email">Email *</label>
<input type="email" id="email" name="email" autocomplete="email" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="email">Email (opcional)</label>
<input type="email" id="email" name="email" autocomplete="email">
</div>
<div class="form-group">
<label for="telefono">Teléfono *</label>
<input type="tel" id="telefono" name="telefono" autocomplete="tel" required>
@@ -160,7 +155,7 @@
<label for="mensaje">Mensaje (opcional)</label>
<textarea id="mensaje" name="mensaje" rows="2" placeholder="Algo que debamos saber…"></textarea>
</div>
<button type="submit" class="btn btn-primary" id="booking-submit" disabled>Confirmar reserva</button>
<button type="submit" class="btn btn-primary" id="booking-submit">Confirmar reserva</button>
</form>
</div>
</div>
@@ -176,22 +171,22 @@
<div class="contact-info">
<div class="contact-item">
<strong>Dirección</strong>
<p>Carrer Pintor Josep Pinos, 22<br>08031 Barcelona</p>
<p>Carrer d'Horta, 12<br>08032 Barcelona (Horta)</p>
</div>
<div class="contact-item">
<strong>Teléfono</strong>
<p><a href="tel:+34690918815">690 918 815</a></p>
<p><a href="tel:+34933333333">933 333 333</a></p>
</div>
<div class="contact-item">
<strong>Cómo llegar</strong>
<p>Metro: L3 (verde), parada Valldaura; L5 (azul), parada Horta<br>Bus: 102, V21, V23, V25 (Pl. Botticelli); 102, N4, V25 (Av. l'Estatut de Catalunya - Campoamor)</p>
<p>Metro L5 — Horta<br>Bus V19, 45, 102</p>
</div>
</div>
<div class="contact-map">
<iframe
src="https://www.openstreetmap.org/export/embed.html?bbox=2.1535%2C41.4332%2C2.1635%2C41.4382&layer=mapnik&marker=41.4357%2C2.1585"
src="https://www.openstreetmap.org/export/embed.html?bbox=2.1550%2C41.4380%2C2.1650%2C41.4430&layer=mapnik&marker=41.4405%2C2.1600"
width="100%" height="300" style="border:0; border-radius: 12px;"
loading="lazy" title="Mapa MAY STUDIO">
loading="lazy" title="Mapa MAY Studio">
</iframe>
</div>
</div>
@@ -201,8 +196,8 @@
<!-- FOOTER -->
<footer class="footer">
<div class="container footer-inner">
<span class="logo">MAY <span>STUDIO</span></span>
<p>&copy; 2026 MAY STUDIO. Tu peluquería en Horta.</p>
<span class="logo">MAY <span>Studio</span></span>
<p>&copy; 2026 MAY Studio. Tu peluquería en Horta.</p>
</div>
</footer>
@@ -212,66 +207,9 @@
}
// === EasyAppointments config ===
// El id del provider lo resuelve el servidor (/api/config): autodescubierto
// en EasyAppointments o fijado con EA_PROVIDER_ID en .env. Aquí nunca va
// hardcodeado, así el mismo HTML sirve en desarrollo y en producción.
let PROVIDER_ID = null;
async function loadConfig() {
const res = await fetch('/api/config');
if (!res.ok) throw new Error('HTTP ' + res.status);
PROVIDER_ID = (await res.json()).providerId;
}
// ---- Horario desde EasyAppointments (Settings → Business, company_working_plan) ----
// Si la petición falla se mantiene el horario estático del HTML.
async function loadSchedule() {
try {
const res = await fetch('/ea-api/v1/settings/company_working_plan');
if (!res.ok) return;
const setting = await res.json();
const plan = JSON.parse(setting.value);
const DAYS = [
['monday', 'Lunes'], ['tuesday', 'Martes'], ['wednesday', 'Miércoles'],
['thursday', 'Jueves'], ['friday', 'Viernes'], ['saturday', 'Sábado'], ['sunday', 'Domingo']
];
const fmt = t => t.replace(/^0/, '');
// "09:00-18:00 con pausa 14:30-15:00" → "9:00 — 14:30 / 15:00 — 18:00"
const dayLabel = p => {
if (!p || !p.start || !p.end) return null; // cerrado
const breaks = (p.breaks || []).slice().sort((a, b) => a.start.localeCompare(b.start));
const segs = [];
let cur = p.start;
for (const b of breaks) {
if (b.start > cur) segs.push(fmt(cur) + ' — ' + fmt(b.start));
if (b.end > cur) cur = b.end;
}
if (cur < p.end) segs.push(fmt(cur) + ' — ' + fmt(p.end));
return segs.length ? segs.join(' / ') : null;
};
// Agrupa días consecutivos con el mismo horario.
const groups = [];
for (const [key, nombre] of DAYS) {
const label = dayLabel(plan[key]);
const last = groups[groups.length - 1];
if (last && last.label === label) last.dias.push(nombre);
else groups.push({ label, dias: [nombre] });
}
const rows = groups.map(g => {
const dias = g.dias.length === 1 ? g.dias[0]
: g.dias.length === 2 ? g.dias[0] + ' y ' + g.dias[1]
: g.dias[0] + ' a ' + g.dias[g.dias.length - 1];
return g.label
? `<div class="schedule-row"><span>${esc(dias)}</span><span>${esc(g.label)}</span></div>`
: `<div class="schedule-row closed"><span>${esc(dias)}</span><span>Cerrado</span></div>`;
}).join('');
if (rows) document.querySelector('.schedule').innerHTML = rows;
} catch (e) {
console.warn('No se pudo cargar el horario desde EA; se muestra el estático.');
}
}
// IMPORTANTE: Después de configurar EasyAppointments, ve al admin (http://localhost:8888)
// y crea al menos un Provider. Anota su ID y ponlo aquí.
const PROVIDER_ID = 1;
// ---- Service selector state ----
let selectedServices = []; // [{id, nombre, duracion}]
@@ -355,7 +293,7 @@
renderServiceSelector(categorias);
} catch (e) {
console.error(e);
if (selectorContainer) selectorContainer.innerHTML = '<p class="services-loading">No se pudieron cargar los servicios. Inténtalo más tarde o llámanos.</p>';
if (selectorContainer) selectorContainer.innerHTML = '<p class="services-loading">No se pudieron cargar los servicios desde EasyAppointments.</p>';
}
}
@@ -405,7 +343,6 @@
// No lock to category anymore - services from different categories can be selected
updateServiceSummary();
loadMonthAvailability();
if (selectedDate) {
selectedHora = null;
@@ -437,8 +374,6 @@
let selectedDate = null;
let selectedHora = null;
let availableSlotsForDate = []; // array de "HH:mm" devueltos por EA
let unavailableDays = new Set(); // días "YYYY-MM-DD" del mes sin ningún hueco (según EA)
let monthAvailSeq = 0; // para descartar respuestas que llegan tarde
const MONTH_NAMES = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'];
@@ -510,35 +445,12 @@
btn.textContent = d;
btn.className = 'cal-day';
if (date < today || dow === 0) { btn.disabled = true; btn.classList.add('cal-disabled'); }
if (unavailableDays.has(dateStr)) { btn.disabled = true; btn.classList.add('cal-disabled'); }
if (dateStr === selectedDate) btn.classList.add('cal-selected');
btn.addEventListener('click', () => selectDay(dateStr, dow));
grid.appendChild(btn);
}
}
// Pide al servidor los días del mes visible sin ningún hueco libre para el
// servicio principal y los deshabilita en el calendario. Si la petición
// falla, los días quedan clicables y el aviso sale al seleccionar el día.
async function loadMonthAvailability() {
const seq = ++monthAvailSeq;
unavailableDays = new Set();
renderCalendar();
const serviceId = getPrimaryServiceId();
if (!serviceId) return;
try {
const month = calYear + '-' + pad(calMonth + 1);
const res = await fetch('/api/month-availability?month=' + month + '&serviceId=' + serviceId);
if (!res.ok) return;
const data = await res.json();
if (seq !== monthAvailSeq) return; // ya se pidió otro mes u otro servicio
unavailableDays = new Set(data.unavailableDays || []);
renderCalendar();
} catch (e) {
console.warn('No se pudo cargar la disponibilidad del mes');
}
}
function selectDay(dateStr, dow) {
selectedDate = dateStr;
selectedHora = null;
@@ -633,14 +545,14 @@
if (calYear === now.getFullYear() && calMonth === now.getMonth()) return;
calMonth--;
if (calMonth < 0) { calMonth = 11; calYear--; }
loadMonthAvailability();
renderCalendar();
hideSlots();
});
document.getElementById('cal-next').addEventListener('click', () => {
calMonth++;
if (calMonth > 11) { calMonth = 0; calYear++; }
loadMonthAvailability();
renderCalendar();
hideSlots();
});
@@ -659,31 +571,17 @@
}
function initBookingForm() {
const form = document.getElementById('booking-form');
const submit = document.getElementById('booking-submit');
// El botón de reserva solo se activa con nombre, apellidos y teléfono
// rellenos; el email es opcional (hay clientes que no tienen).
function updateSubmitState() {
submit.disabled = !(form.nombre.value.trim() && form.apellidos.value.trim() && form.telefono.value.trim());
}
form.addEventListener('input', updateSubmitState);
updateSubmitState();
form.addEventListener('submit', async function(e) {
document.getElementById('booking-form').addEventListener('submit', async function(e) {
e.preventDefault();
const form = e.target;
const submit = document.getElementById('booking-submit');
const result = document.getElementById('booking-result');
if (selectedServices.length === 0) { alert('Selecciona al menos un servicio'); return; }
if (!selectedDate || !selectedHora) { alert('Selecciona día y hora'); return; }
const nombre = form.nombre.value.trim();
const apellidos = form.apellidos.value.trim();
// EasyAppointments exige nombre y apellido al crear el cliente.
if (!nombre || !apellidos) { alert('Escribe tu nombre y tus apellidos'); return; }
const telefono = form.telefono.value.trim();
if (!telefono) { alert('El teléfono es obligatorio'); return; }
const email = form.email.value.trim();
if (!email) { alert('El email es obligatorio'); return; }
submit.disabled = true;
submit.textContent = 'Enviando…';
@@ -693,11 +591,11 @@
// El servidor (server-side) busca/crea el cliente y crea la cita
// contra EasyAppointments. El navegador solo envía la selección.
const payload = {
nombre: nombre,
apellidos: apellidos,
email: form.email.value.trim(),
telefono: telefono,
nombre: form.nombre.value.trim(),
email: email,
telefono: form.telefono.value.trim(),
mensaje: form.mensaje.value.trim(),
providerId: PROVIDER_ID,
serviceIds: selectedServices.map(s => s.id).filter(Boolean),
date: selectedDate,
time: selectedHora
@@ -718,7 +616,7 @@
renderSlots(savedDate, savedDow);
form.style.display = 'none';
result.className = 'booking-result booking-success';
result.textContent = '¡Reserva confirmada! Te esperamos.';
result.textContent = '¡Reserva confirmada! Te esperamos. Gestionada con EasyAppointments.';
} else {
const err = await res.json().catch(() => ({}));
result.className = 'booking-result booking-error';
@@ -727,22 +625,17 @@
} catch (err) {
console.error(err);
result.className = 'booking-result booking-error';
result.textContent = 'No se pudo completar la reserva. Inténtalo de nuevo o llámanos.';
result.textContent = err.message || 'No se pudo conectar con EasyAppointments.';
}
result.style.display = 'block';
submit.disabled = false;
submit.textContent = 'Confirmar reserva';
updateSubmitState();
});
}
document.addEventListener('DOMContentLoaded', function() {
loadSchedule();
// loadServices filtra por provider y las availabilities usan su id:
// primero la config. Si falla, los servicios se cargan igual (sin filtro).
loadConfig().catch(function(e) {
console.error('Error cargando config:', e);
}).finally(loadServices);
loadServices();
initCalendar();
initBookingForm();
});
-5
View File
@@ -72,11 +72,6 @@ img { max-width: 100%; display: block; }
font-size: 0.85rem;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ============ HERO ============ */
.hero {
position: relative;