diff --git a/README.md b/README.md index 70d0d7b..0071f56 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,11 @@ 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 **email**, + - busca o crea el cliente por **teléfono** (identifica al cliente; el email es opcional), - 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 @@ -96,8 +98,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: ... diff --git a/main.go b/main.go index f1dd79b..f79b6eb 100644 --- a/main.go +++ b/main.go @@ -16,6 +16,7 @@ import ( "regexp" "strconv" "strings" + "sync" "time" ) @@ -158,7 +159,7 @@ type eaCustomer struct { ID flexInt `json:"id,omitempty"` FirstName string `json:"firstName"` LastName string `json:"lastName"` - Email string `json:"email"` + Email string `json:"email,omitempty"` Phone string `json:"phone"` Notes string `json:"notes"` } @@ -199,7 +200,7 @@ var ( // 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 email, +// 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. func handleBook(ea *eaClient) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -219,7 +220,7 @@ func handleBook(ea *eaClient) http.HandlerFunc { req.Telefono = strings.TrimSpace(req.Telefono) req.Mensaje = strings.TrimSpace(req.Mensaje) - if req.Nombre == "" || req.Apellidos == "" || req.Email == "" || req.Telefono == "" || + if req.Nombre == "" || req.Apellidos == "" || normPhone(req.Telefono) == "" || len(req.ServiceIDs) == 0 || !reDate.MatchString(req.Date) || !reTime.MatchString(req.Time) { writeJSONError(w, http.StatusBadRequest, "datos_incompletos") @@ -278,11 +279,15 @@ func handleBook(ea *eaClient) http.HandlerFunc { } notesLines := []string{ "Nombre: " + req.Nombre + " " + req.Apellidos, - "Email: " + req.Email, "Teléfono: " + req.Telefono, - "Servicios: " + strings.Join(names, " + "), - fmt.Sprintf("Duración total: %d min", total), } + 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) } @@ -296,7 +301,7 @@ func handleBook(ea *eaClient) http.HandlerFunc { return } - // 6. cliente: buscar por email o crear (en EA). + // 6. cliente: buscar por teléfono o crear (en EA). customerID, err := ea.findOrCreateCustomer(req) if err != nil { log.Printf("book: error con cliente: %v", err) @@ -370,12 +375,137 @@ func handleConfig(ea *eaClient) http.HandlerFunc { } } -// findOrCreateCustomer busca un cliente por email en EA y, si no existe, lo crea. +// 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. 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(req.Email), nil, &found); err == nil { + if err := c.do(http.MethodGet, "/customers?q="+url.QueryEscape(q), nil, &found); err == nil { for _, cust := range found { - if strings.EqualFold(strings.TrimSpace(cust.Email), req.Email) { + if samePhone(cust.Phone, phone) { return int(cust.ID), nil } } @@ -386,7 +516,7 @@ func (c *eaClient) findOrCreateCustomer(req bookRequest) (int, error) { FirstName: req.Nombre, LastName: req.Apellidos, Email: req.Email, - Phone: req.Telefono, + Phone: phone, Notes: "Creado desde web MAY Studio", } if err := c.do(http.MethodPost, "/customers", body, &created); err != nil { @@ -468,6 +598,10 @@ func main() { // 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) diff --git a/main_test.go b/main_test.go index 5a70d0b..0028e52 100644 --- a/main_test.go +++ b/main_test.go @@ -6,7 +6,9 @@ import ( "net/http/httptest" "sort" "strings" + "sync/atomic" "testing" + "time" ) // La allowlist del proxy debe ser SOLO de lectura: cualquier escritura o búsqueda @@ -139,6 +141,82 @@ func TestHandleBook(t *testing.T) { } } +// 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 @@ -230,6 +308,82 @@ func TestProviderID(t *testing.T) { }) } +// /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 @@ -250,3 +404,26 @@ func TestHandleBookRequiresApellidos(t *testing.T) { 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") + } +} diff --git a/static/index.html b/static/index.html index aaeac73..33e7ff1 100644 --- a/static/index.html +++ b/static/index.html @@ -3,38 +3,39 @@ - MAY Studio — Peluquería en Barcelona - + MAY STUDIO — Peluquería en Barcelona + +
- Interior del salón MAY Studio + Interior del salón MAY STUDIO
-

MAY Studio
Tu peluquería en Horta

+

MAY STUDIO
Tu peluquería en Horta

Cortes, color, peinados y tratamientos capilares para toda la familia. Un espacio pensado para ti.

Reservar cita @@ -48,15 +49,15 @@
- Mayra, peluquera profesional en MAY Studio + Mayra, peluquera profesional en MAY STUDIO

Mayra

Peluquera & fundadora

-

Soy Mayra, fundadora de MAY Studio, un espacio dedicado al cuidado del cabello con un enfoque personalizado.

+

Soy Mayra, fundadora de MAY STUDIO, un espacio dedicado al cuidado del cabello con un enfoque personalizado.

Estoy especializada en color, mechas y cortes, buscando siempre resultados naturales, equilibrados y elegantes.

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.

-

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.

+

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.

@@ -66,7 +67,7 @@

Nuestro trabajo

-

Explora algunos de los estilos y transformaciones que creamos en MAY Studio. Haz clic en cualquier imagen para ampliar.

+

Explora algunos de los estilos y transformaciones que creamos en MAY STUDIO. Haz clic en cualquier imagen para ampliar.

- - + +
@@ -159,7 +160,7 @@
- +
@@ -175,22 +176,22 @@
Dirección -

Carrer d'Horta, 12
08032 Barcelona (Horta)

+

Carrer Pintor Josep Pinos, 22
08031 Barcelona

Teléfono -

933 333 333

+

690 918 815

Cómo llegar -

Metro L5 — Horta
Bus V19, 45, 102

+

Metro: L3 (verde), parada Valldaura; L5 (azul), parada Horta
Bus: 102, V21, V23, V25 (Pl. Botticelli); 102, N4, V25 (Av. l'Estatut de Catalunya - Campoamor)

@@ -200,8 +201,8 @@ @@ -404,6 +405,7 @@ // No lock to category anymore - services from different categories can be selected updateServiceSummary(); + loadMonthAvailability(); if (selectedDate) { selectedHora = null; @@ -435,6 +437,8 @@ 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']; @@ -506,12 +510,35 @@ 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; @@ -606,14 +633,14 @@ if (calYear === now.getFullYear() && calMonth === now.getMonth()) return; calMonth--; if (calMonth < 0) { calMonth = 11; calYear--; } - renderCalendar(); + loadMonthAvailability(); hideSlots(); }); document.getElementById('cal-next').addEventListener('click', () => { calMonth++; if (calMonth > 11) { calMonth = 0; calYear++; } - renderCalendar(); + loadMonthAvailability(); hideSlots(); }); @@ -632,10 +659,19 @@ } function initBookingForm() { - document.getElementById('booking-form').addEventListener('submit', async function(e) { + 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) { 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; } @@ -646,8 +682,8 @@ // EasyAppointments exige nombre y apellido al crear el cliente. if (!nombre || !apellidos) { alert('Escribe tu nombre y tus apellidos'); return; } - const email = form.email.value.trim(); - if (!email) { alert('El email es obligatorio'); return; } + const telefono = form.telefono.value.trim(); + if (!telefono) { alert('El teléfono es obligatorio'); return; } submit.disabled = true; submit.textContent = 'Enviando…'; @@ -659,8 +695,8 @@ const payload = { nombre: nombre, apellidos: apellidos, - email: email, - telefono: form.telefono.value.trim(), + email: form.email.value.trim(), + telefono: telefono, mensaje: form.mensaje.value.trim(), serviceIds: selectedServices.map(s => s.id).filter(Boolean), date: selectedDate, @@ -695,8 +731,8 @@ } result.style.display = 'block'; - submit.disabled = false; submit.textContent = 'Confirmar reserva'; + updateSubmitState(); }); } diff --git a/static/style.css b/static/style.css index c05f49c..bc31b20 100644 --- a/static/style.css +++ b/static/style.css @@ -72,6 +72,11 @@ img { max-width: 100%; display: block; } font-size: 0.85rem; } +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + /* ============ HERO ============ */ .hero { position: relative;