diff --git a/main.go b/main.go index c6d68d0..72c3f22 100644 --- a/main.go +++ b/main.go @@ -35,6 +35,9 @@ 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 { @@ -174,6 +177,7 @@ 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"` @@ -206,11 +210,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.Email == "" || req.Telefono == "" || + if req.Nombre == "" || req.Apellidos == "" || 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") @@ -268,7 +273,7 @@ func handleBook(ea *eaClient) http.HandlerFunc { names[i] = s.Name } notesLines := []string{ - "Nombre: " + req.Nombre, + "Nombre: " + req.Nombre + " " + req.Apellidos, "Email: " + req.Email, "Teléfono: " + req.Telefono, "Servicios: " + strings.Join(names, " + "), @@ -320,11 +325,10 @@ func (c *eaClient) findOrCreateCustomer(req bookRequest) (int, error) { } } - first, last := splitName(req.Nombre) var created eaCustomer body := eaCustomer{ - FirstName: first, - LastName: last, + FirstName: req.Nombre, + LastName: req.Apellidos, Email: req.Email, Phone: req.Telefono, Notes: "Creado desde web MAY Studio", @@ -338,18 +342,6 @@ 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() diff --git a/main_test.go b/main_test.go index b9b3fec..39f9f55 100644 --- a/main_test.go +++ b/main_test.go @@ -23,6 +23,7 @@ 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}, @@ -34,6 +35,8 @@ 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 { @@ -43,22 +46,6 @@ 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"` @@ -79,6 +66,7 @@ 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() @@ -96,6 +84,7 @@ 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) { @@ -109,7 +98,7 @@ func TestHandleBook(t *testing.T) { ea := &eaClient{base: srv.URL, http: srv.Client()} - body := `{"nombre":"Ana López","email":"ana@x.com","telefono":"600","mensaje":"hola", + body := `{"nombre":"Ana","apellidos":"López Gil","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() @@ -129,6 +118,10 @@ func TestHandleBook(t *testing.T) { 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) @@ -155,7 +148,7 @@ func TestHandleBookRejectsUnknownService(t *testing.T) { defer srv.Close() ea := &eaClient{base: srv.URL, http: srv.Client()} - body := `{"nombre":"Ana","email":"a@x.com","telefono":"600","providerId":1,"serviceIds":[999],"date":"2026-07-01","time":"10:00"}` + body := `{"nombre":"Ana","apellidos":"López","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))) @@ -166,3 +159,24 @@ func TestHandleBookRejectsUnknownService(t *testing.T) { t.Error("se creó una cita con un servicio inexistente") } } + +// 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","providerId":1,"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") + } +} diff --git a/static/index.html b/static/index.html index 41a1fb5..76d2b32 100644 --- a/static/index.html +++ b/static/index.html @@ -138,14 +138,18 @@
- +
+
+ + +
+
+
-
-
@@ -209,7 +213,57 @@ // === EasyAppointments config === // 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; + const PROVIDER_ID = 5; + + // ---- 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 + ? `
${esc(dias)}${esc(g.label)}
` + : `
${esc(dias)}Cerrado
`; + }).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.'); + } + } // ---- Service selector state ---- let selectedServices = []; // [{id, nombre, duracion}] @@ -293,7 +347,7 @@ renderServiceSelector(categorias); } catch (e) { console.error(e); - if (selectorContainer) selectorContainer.innerHTML = '

No se pudieron cargar los servicios desde EasyAppointments.

'; + if (selectorContainer) selectorContainer.innerHTML = '

No se pudieron cargar los servicios. Inténtalo más tarde o llámanos.

'; } } @@ -580,6 +634,11 @@ 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 email = form.email.value.trim(); if (!email) { alert('El email es obligatorio'); return; } @@ -591,7 +650,8 @@ // 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: form.nombre.value.trim(), + nombre: nombre, + apellidos: apellidos, email: email, telefono: form.telefono.value.trim(), mensaje: form.mensaje.value.trim(), @@ -616,7 +676,7 @@ renderSlots(savedDate, savedDow); form.style.display = 'none'; result.className = 'booking-result booking-success'; - result.textContent = '¡Reserva confirmada! Te esperamos. Gestionada con EasyAppointments.'; + result.textContent = '¡Reserva confirmada! Te esperamos.'; } else { const err = await res.json().catch(() => ({})); result.className = 'booking-result booking-error'; @@ -625,7 +685,7 @@ } catch (err) { console.error(err); result.className = 'booking-result booking-error'; - result.textContent = err.message || 'No se pudo conectar con EasyAppointments.'; + result.textContent = 'No se pudo completar la reserva. Inténtalo de nuevo o llámanos.'; } result.style.display = 'block'; @@ -635,6 +695,7 @@ } document.addEventListener('DOMContentLoaded', function() { + loadSchedule(); loadServices(); initCalendar(); initBookingForm();