cambios propuestos por dru
This commit is contained in:
@@ -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.
|
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).
|
- 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**:
|
- 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),
|
- 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.
|
- 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
|
- 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:
|
- En el campo `notes` se guarda el detalle completo:
|
||||||
```
|
```
|
||||||
Nombre: ...
|
Nombre: ...
|
||||||
Email: ...
|
|
||||||
Teléfono: ...
|
Teléfono: ...
|
||||||
|
Email: ... (solo si el cliente lo dio)
|
||||||
Servicios: Corte + Tinte + ...
|
Servicios: Corte + Tinte + ...
|
||||||
Duración total: 120 min
|
Duración total: 120 min
|
||||||
Mensaje: ...
|
Mensaje: ...
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -158,7 +159,7 @@ type eaCustomer struct {
|
|||||||
ID flexInt `json:"id,omitempty"`
|
ID flexInt `json:"id,omitempty"`
|
||||||
FirstName string `json:"firstName"`
|
FirstName string `json:"firstName"`
|
||||||
LastName string `json:"lastName"`
|
LastName string `json:"lastName"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email,omitempty"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
}
|
}
|
||||||
@@ -199,7 +200,7 @@ var (
|
|||||||
// 1. valida la entrada,
|
// 1. valida la entrada,
|
||||||
// 2. lee los servicios de EA (fuente de verdad de nombre/duración),
|
// 2. lee los servicios de EA (fuente de verdad de nombre/duración),
|
||||||
// 3. resuelve el provider (server-side, ver providerID),
|
// 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.
|
// 5. crea UNA cita con el servicio de mayor duración y el bloque total en notas.
|
||||||
func handleBook(ea *eaClient) http.HandlerFunc {
|
func handleBook(ea *eaClient) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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.Telefono = strings.TrimSpace(req.Telefono)
|
||||||
req.Mensaje = strings.TrimSpace(req.Mensaje)
|
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 ||
|
len(req.ServiceIDs) == 0 ||
|
||||||
!reDate.MatchString(req.Date) || !reTime.MatchString(req.Time) {
|
!reDate.MatchString(req.Date) || !reTime.MatchString(req.Time) {
|
||||||
writeJSONError(w, http.StatusBadRequest, "datos_incompletos")
|
writeJSONError(w, http.StatusBadRequest, "datos_incompletos")
|
||||||
@@ -278,11 +279,15 @@ func handleBook(ea *eaClient) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
notesLines := []string{
|
notesLines := []string{
|
||||||
"Nombre: " + req.Nombre + " " + req.Apellidos,
|
"Nombre: " + req.Nombre + " " + req.Apellidos,
|
||||||
"Email: " + req.Email,
|
|
||||||
"Teléfono: " + req.Telefono,
|
"Teléfono: " + req.Telefono,
|
||||||
|
}
|
||||||
|
if req.Email != "" {
|
||||||
|
notesLines = append(notesLines, "Email: "+req.Email)
|
||||||
|
}
|
||||||
|
notesLines = append(notesLines,
|
||||||
"Servicios: "+strings.Join(names, " + "),
|
"Servicios: "+strings.Join(names, " + "),
|
||||||
fmt.Sprintf("Duración total: %d min", total),
|
fmt.Sprintf("Duración total: %d min", total),
|
||||||
}
|
)
|
||||||
if req.Mensaje != "" {
|
if req.Mensaje != "" {
|
||||||
notesLines = append(notesLines, "Mensaje: "+req.Mensaje)
|
notesLines = append(notesLines, "Mensaje: "+req.Mensaje)
|
||||||
}
|
}
|
||||||
@@ -296,7 +301,7 @@ func handleBook(ea *eaClient) http.HandlerFunc {
|
|||||||
return
|
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)
|
customerID, err := ea.findOrCreateCustomer(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("book: error con cliente: %v", err)
|
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) {
|
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
|
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 {
|
for _, cust := range found {
|
||||||
if strings.EqualFold(strings.TrimSpace(cust.Email), req.Email) {
|
if samePhone(cust.Phone, phone) {
|
||||||
return int(cust.ID), nil
|
return int(cust.ID), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -386,7 +516,7 @@ func (c *eaClient) findOrCreateCustomer(req bookRequest) (int, error) {
|
|||||||
FirstName: req.Nombre,
|
FirstName: req.Nombre,
|
||||||
LastName: req.Apellidos,
|
LastName: req.Apellidos,
|
||||||
Email: req.Email,
|
Email: req.Email,
|
||||||
Phone: req.Telefono,
|
Phone: phone,
|
||||||
Notes: "Creado desde web MAY Studio",
|
Notes: "Creado desde web MAY Studio",
|
||||||
}
|
}
|
||||||
if err := c.do(http.MethodPost, "/customers", body, &created); err != nil {
|
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.
|
// EA_PROVIDER_ID), nunca va hardcodeado en el HTML.
|
||||||
http.HandleFunc("/api/config", handleConfig(ea))
|
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)))
|
http.Handle("/", http.FileServer(http.FS(staticFS)))
|
||||||
|
|
||||||
addr := fmt.Sprintf(":%d", *port)
|
addr := fmt.Sprintf(":%d", *port)
|
||||||
|
|||||||
+177
@@ -6,7 +6,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// La allowlist del proxy debe ser SOLO de lectura: cualquier escritura o búsqueda
|
// 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.
|
// Servicio inválido (no existe en EA) ⇒ 400 y NO se crea ninguna cita.
|
||||||
func TestHandleBookRejectsUnknownService(t *testing.T) {
|
func TestHandleBookRejectsUnknownService(t *testing.T) {
|
||||||
created := false
|
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.
|
// Sin apellidos ⇒ 400 sin llegar a llamar a EA.
|
||||||
func TestHandleBookRequiresApellidos(t *testing.T) {
|
func TestHandleBookRequiresApellidos(t *testing.T) {
|
||||||
called := false
|
called := false
|
||||||
@@ -250,3 +404,26 @@ func TestHandleBookRequiresApellidos(t *testing.T) {
|
|||||||
t.Error("se llamó a EA con datos incompletos")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+66
-30
@@ -3,38 +3,39 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>MAY Studio — Peluquería en Barcelona</title>
|
<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.">
|
<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" href="/favicon.ico" sizes="any">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
<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="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<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 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">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- NAV -->
|
<!-- NAV -->
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<div class="container nav-inner">
|
<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">
|
<ul class="nav-links">
|
||||||
<li><a href="#profesional">La profesional</a></li>
|
<li><a href="#profesional">La profesional</a></li>
|
||||||
<li><a href="#galeria">Nuestro trabajo</a></li>
|
<li><a href="#galeria">Nuestro trabajo</a></li>
|
||||||
<li><a href="#reservar">Reservar</a></li>
|
<li><a href="#reservar">Reservar</a></li>
|
||||||
<li><a href="#contacto">Contacto</a></li>
|
<li><a href="#contacto">Contacto</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<a href="tel:+34933333333" class="btn btn-primary btn-nav">Llamar</a>
|
<a href="tel:+34690918815" class="btn btn-primary btn-nav">Llamar</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- HERO -->
|
<!-- HERO -->
|
||||||
<header class="hero">
|
<header class="hero">
|
||||||
<div class="hero-bg">
|
<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>
|
||||||
<div class="container hero-content">
|
<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>
|
<p class="hero-sub">Cortes, color, peinados y tratamientos capilares para toda la familia. Un espacio pensado para ti.</p>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a href="#reservar" class="btn btn-primary">Reservar cita</a>
|
<a href="#reservar" class="btn btn-primary">Reservar cita</a>
|
||||||
@@ -48,15 +49,15 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="profesional-grid">
|
<div class="profesional-grid">
|
||||||
<div class="profesional-img">
|
<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>
|
||||||
<div class="profesional-info">
|
<div class="profesional-info">
|
||||||
<h3>Mayra</h3>
|
<h3>Mayra</h3>
|
||||||
<p class="profesional-role">Peluquera & fundadora</p>
|
<p class="profesional-role">Peluquera & 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>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>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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,7 +67,7 @@
|
|||||||
<section id="galeria" class="section section-alt">
|
<section id="galeria" class="section section-alt">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="section-title">Nuestro trabajo</h2>
|
<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-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-1.jpg" alt="Trabajo de peluquería"></div>
|
||||||
<div class="gallery-item"><img src="/assets/galeria-2.jpg" alt="Trabajo de peluquería"></div>
|
<div class="gallery-item"><img src="/assets/galeria-2.jpg" alt="Trabajo de peluquería"></div>
|
||||||
@@ -147,8 +148,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="email">Email *</label>
|
<label for="email">Email (opcional)</label>
|
||||||
<input type="email" id="email" name="email" autocomplete="email" required>
|
<input type="email" id="email" name="email" autocomplete="email">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="telefono">Teléfono *</label>
|
<label for="telefono">Teléfono *</label>
|
||||||
@@ -159,7 +160,7 @@
|
|||||||
<label for="mensaje">Mensaje (opcional)</label>
|
<label for="mensaje">Mensaje (opcional)</label>
|
||||||
<textarea id="mensaje" name="mensaje" rows="2" placeholder="Algo que debamos saber…"></textarea>
|
<textarea id="mensaje" name="mensaje" rows="2" placeholder="Algo que debamos saber…"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary" id="booking-submit">Confirmar reserva</button>
|
<button type="submit" class="btn btn-primary" id="booking-submit" disabled>Confirmar reserva</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,22 +176,22 @@
|
|||||||
<div class="contact-info">
|
<div class="contact-info">
|
||||||
<div class="contact-item">
|
<div class="contact-item">
|
||||||
<strong>Dirección</strong>
|
<strong>Dirección</strong>
|
||||||
<p>Carrer d'Horta, 12<br>08032 Barcelona (Horta)</p>
|
<p>Carrer Pintor Josep Pinos, 22<br>08031 Barcelona</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="contact-item">
|
<div class="contact-item">
|
||||||
<strong>Teléfono</strong>
|
<strong>Teléfono</strong>
|
||||||
<p><a href="tel:+34933333333">933 333 333</a></p>
|
<p><a href="tel:+34690918815">690 918 815</a></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="contact-item">
|
<div class="contact-item">
|
||||||
<strong>Cómo llegar</strong>
|
<strong>Cómo llegar</strong>
|
||||||
<p>Metro L5 — Horta<br>Bus V19, 45, 102</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="contact-map">
|
<div class="contact-map">
|
||||||
<iframe
|
<iframe
|
||||||
src="https://www.openstreetmap.org/export/embed.html?bbox=2.1550%2C41.4380%2C2.1650%2C41.4430&layer=mapnik&marker=41.4405%2C2.1600"
|
src="https://www.openstreetmap.org/export/embed.html?bbox=2.1535%2C41.4332%2C2.1635%2C41.4382&layer=mapnik&marker=41.4357%2C2.1585"
|
||||||
width="100%" height="300" style="border:0; border-radius: 12px;"
|
width="100%" height="300" style="border:0; border-radius: 12px;"
|
||||||
loading="lazy" title="Mapa MAY Studio">
|
loading="lazy" title="Mapa MAY STUDIO">
|
||||||
</iframe>
|
</iframe>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,8 +201,8 @@
|
|||||||
<!-- FOOTER -->
|
<!-- FOOTER -->
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<div class="container footer-inner">
|
<div class="container footer-inner">
|
||||||
<span class="logo">MAY <span>Studio</span></span>
|
<span class="logo">MAY <span>STUDIO</span></span>
|
||||||
<p>© 2026 MAY Studio. Tu peluquería en Horta.</p>
|
<p>© 2026 MAY STUDIO. Tu peluquería en Horta.</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
@@ -404,6 +405,7 @@
|
|||||||
|
|
||||||
// No lock to category anymore - services from different categories can be selected
|
// No lock to category anymore - services from different categories can be selected
|
||||||
updateServiceSummary();
|
updateServiceSummary();
|
||||||
|
loadMonthAvailability();
|
||||||
|
|
||||||
if (selectedDate) {
|
if (selectedDate) {
|
||||||
selectedHora = null;
|
selectedHora = null;
|
||||||
@@ -435,6 +437,8 @@
|
|||||||
let selectedDate = null;
|
let selectedDate = null;
|
||||||
let selectedHora = null;
|
let selectedHora = null;
|
||||||
let availableSlotsForDate = []; // array de "HH:mm" devueltos por EA
|
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'];
|
const MONTH_NAMES = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'];
|
||||||
|
|
||||||
@@ -506,12 +510,35 @@
|
|||||||
btn.textContent = d;
|
btn.textContent = d;
|
||||||
btn.className = 'cal-day';
|
btn.className = 'cal-day';
|
||||||
if (date < today || dow === 0) { btn.disabled = true; btn.classList.add('cal-disabled'); }
|
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');
|
if (dateStr === selectedDate) btn.classList.add('cal-selected');
|
||||||
btn.addEventListener('click', () => selectDay(dateStr, dow));
|
btn.addEventListener('click', () => selectDay(dateStr, dow));
|
||||||
grid.appendChild(btn);
|
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) {
|
function selectDay(dateStr, dow) {
|
||||||
selectedDate = dateStr;
|
selectedDate = dateStr;
|
||||||
selectedHora = null;
|
selectedHora = null;
|
||||||
@@ -606,14 +633,14 @@
|
|||||||
if (calYear === now.getFullYear() && calMonth === now.getMonth()) return;
|
if (calYear === now.getFullYear() && calMonth === now.getMonth()) return;
|
||||||
calMonth--;
|
calMonth--;
|
||||||
if (calMonth < 0) { calMonth = 11; calYear--; }
|
if (calMonth < 0) { calMonth = 11; calYear--; }
|
||||||
renderCalendar();
|
loadMonthAvailability();
|
||||||
hideSlots();
|
hideSlots();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('cal-next').addEventListener('click', () => {
|
document.getElementById('cal-next').addEventListener('click', () => {
|
||||||
calMonth++;
|
calMonth++;
|
||||||
if (calMonth > 11) { calMonth = 0; calYear++; }
|
if (calMonth > 11) { calMonth = 0; calYear++; }
|
||||||
renderCalendar();
|
loadMonthAvailability();
|
||||||
hideSlots();
|
hideSlots();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -632,10 +659,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initBookingForm() {
|
function initBookingForm() {
|
||||||
document.getElementById('booking-form').addEventListener('submit', async function(e) {
|
const form = document.getElementById('booking-form');
|
||||||
e.preventDefault();
|
|
||||||
const form = e.target;
|
|
||||||
const submit = document.getElementById('booking-submit');
|
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 result = document.getElementById('booking-result');
|
const result = document.getElementById('booking-result');
|
||||||
|
|
||||||
if (selectedServices.length === 0) { alert('Selecciona al menos un servicio'); return; }
|
if (selectedServices.length === 0) { alert('Selecciona al menos un servicio'); return; }
|
||||||
@@ -646,8 +682,8 @@
|
|||||||
// EasyAppointments exige nombre y apellido al crear el cliente.
|
// EasyAppointments exige nombre y apellido al crear el cliente.
|
||||||
if (!nombre || !apellidos) { alert('Escribe tu nombre y tus apellidos'); return; }
|
if (!nombre || !apellidos) { alert('Escribe tu nombre y tus apellidos'); return; }
|
||||||
|
|
||||||
const email = form.email.value.trim();
|
const telefono = form.telefono.value.trim();
|
||||||
if (!email) { alert('El email es obligatorio'); return; }
|
if (!telefono) { alert('El teléfono es obligatorio'); return; }
|
||||||
|
|
||||||
submit.disabled = true;
|
submit.disabled = true;
|
||||||
submit.textContent = 'Enviando…';
|
submit.textContent = 'Enviando…';
|
||||||
@@ -659,8 +695,8 @@
|
|||||||
const payload = {
|
const payload = {
|
||||||
nombre: nombre,
|
nombre: nombre,
|
||||||
apellidos: apellidos,
|
apellidos: apellidos,
|
||||||
email: email,
|
email: form.email.value.trim(),
|
||||||
telefono: form.telefono.value.trim(),
|
telefono: telefono,
|
||||||
mensaje: form.mensaje.value.trim(),
|
mensaje: form.mensaje.value.trim(),
|
||||||
serviceIds: selectedServices.map(s => s.id).filter(Boolean),
|
serviceIds: selectedServices.map(s => s.id).filter(Boolean),
|
||||||
date: selectedDate,
|
date: selectedDate,
|
||||||
@@ -695,8 +731,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
result.style.display = 'block';
|
result.style.display = 'block';
|
||||||
submit.disabled = false;
|
|
||||||
submit.textContent = 'Confirmar reserva';
|
submit.textContent = 'Confirmar reserva';
|
||||||
|
updateSubmitState();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ img { max-width: 100%; display: block; }
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============ HERO ============ */
|
/* ============ HERO ============ */
|
||||||
.hero {
|
.hero {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
Reference in New Issue
Block a user