revision de cloud code

This commit is contained in:
2026-06-21 17:29:16 +02:00
parent 6f7a9b2b60
commit 22a4a32dca
22 changed files with 614 additions and 206 deletions
+6
View File
@@ -0,0 +1,6 @@
.git
.gitignore
.dockerignore
.env
.env.example
*.md
+15
View File
@@ -0,0 +1,15 @@
# Copia este archivo a .env y rellena los valores reales:
# cp .env.example .env
# .env está en .gitignore: nunca se sube al repositorio.
# Token de API (admin) de EasyAppointments.
# Créalo en el panel de EA → Settings → API.
# IMPORTANTE: si reutilizas un token que estuvo en git, ROTALO primero.
EA_API_KEY=changeme-token-de-easyappointments
# Contraseña de MySQL (usuario root, usado por EasyAppointments).
# Usa una cadena larga y aleatoria.
MYSQL_ROOT_PASSWORD=changeme-contrasena-fuerte
# URL pública del panel de EasyAppointments (cámbiala en producción).
EA_BASE_URL=http://localhost:8888
+8
View File
@@ -0,0 +1,8 @@
# Secretos locales (usar .env.example como plantilla)
.env
# Binario compilado
/server
# Runbook con secretos a purgar (NO commitear)
SIGUIENTES-PASOS.md
+35 -13
View File
@@ -13,7 +13,16 @@ El frontend (HTML/JS puro) sigue siendo el de la landing, pero **las reservas, c
## Cómo ejecutar (Desarrollo y Producción) ## Cómo ejecutar (Desarrollo y Producción)
### 1. Levantar todo ### 1. Configurar secretos
```sh
cp .env.example .env
# Edita .env: pon tu EA_API_KEY y una MYSQL_ROOT_PASSWORD fuerte.
```
`.env` está en `.gitignore` y nunca se sube al repositorio.
### 2. Levantar todo
```sh ```sh
docker compose up -d --build docker compose up -d --build
@@ -22,7 +31,7 @@ docker compose up -d --build
- Landing + reservas: **http://localhost:8234** - Landing + reservas: **http://localhost:8234**
- Panel de administración EasyAppointments: **http://localhost:8888** - Panel de administración EasyAppointments: **http://localhost:8888**
### 2. Primer arranque (importante) ### 3. Primer arranque (importante)
1. Abre http://localhost:8888 1. Abre http://localhost:8888
2. Completa el asistente de instalación de EasyAppointments. 2. Completa el asistente de instalación de EasyAppointments.
@@ -33,13 +42,11 @@ docker compose up -d --build
7. Configura el **Working Plan** del proveedor (horario). 7. Configura el **Working Plan** del proveedor (horario).
8. **Anota el ID del Provider** (normalmente 1) y edita `static/index.html` → línea `const PROVIDER_ID = 1;` 8. **Anota el ID del Provider** (normalmente 1) y edita `static/index.html` → línea `const PROVIDER_ID = 1;`
### 3. Variables importantes en compose ### Variables (en `.env`)
Edita `docker-compose.yml` antes de levantar: - `EA_API_KEY` — token de API de EasyAppointments (Settings → API en el panel).
- `MYSQL_ROOT_PASSWORD` — contraseña de la base de datos.
- `EA_API_USERNAME` + `EA_API_PASSWORD` (o mejor: configura **API Key** en Settings de EA y usa `EA_API_KEY`) - `EA_BASE_URL` — URL pública del panel de EA (cámbiala en producción).
- `BASE_URL` del servicio `easyappointments`
- Contraseña de MySQL
### Actualizar ### Actualizar
@@ -50,12 +57,27 @@ docker compose up -d --build
## Notas sobre la integración ## Notas sobre la integración
- Los servicios y la disponibilidad los obtiene el frontend directamente de la API de EasyAppointments. **EasyAppointments es la única fuente de verdad.** El servidor Go no guarda nada:
- Al reservar: ni clientes, ni citas, ni servicios. Todo se lee y se escribe contra la API de EA.
- Busca o crea automáticamente el cliente usando el **email**.
- Crea la cita en EasyAppointments (con todos los servicios seleccionados en las notas). - Los servicios y la disponibilidad los lee el frontend de EA (a través del proxy de solo lectura).
- 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**,
- 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
- La API antigua (carpeta `api/` + admin.html antigua + services.yml/mail.yml) ha sido eliminada completamente.
## Seguridad del proxy
El servidor Go **no** expone la API de EasyAppointments tal cual:
- El proxy `/ea-api/` solo deja pasar **lecturas no sensibles** (categorías, servicios,
proveedor, disponibilidad), añadiendo la auth admin en el servidor. La lista blanca
está en `main.go` (`eaAllowedRoutes`); cualquier otra ruta/método recibe `404`
**antes** de inyectar credenciales.
- Las **escrituras y la búsqueda de clientes** no pasan por el proxy: se orquestan
server-side en `POST /api/book`. Así el navegador nunca puede listar/borrar/modificar
clientes ni citas, ni volcar datos personales de EA.
## Comportamiento multiservicio ## Comportamiento multiservicio
+13 -7
View File
@@ -6,9 +6,8 @@ services:
ports: ports:
- "8234:8080" - "8234:8080"
environment: environment:
- EA_API_USERNAME=may # Usuario de EA (ver ea_user_settings.username del admin). Usa tu password real. # Token de API de EasyAppointments. Definido en .env (ver .env.example).
# EA_API_PASSWORD=yourpassword - EA_API_KEY=${EA_API_KEY}
- EA_API_KEY=t9T6IdUkAVGRoB1Shm8MgLcoIuMvgkWJSFGde4trAUEUIOC4iRtc57TvFO6gXMVr # Recomendado: en el panel EA ve a Settings y configura "API Token", luego usa esta variable (mejor que user/pass)
depends_on: depends_on:
- easyappointments - easyappointments
@@ -20,22 +19,29 @@ services:
ports: ports:
- "8888:80" # Panel admin: http://localhost:8888 - "8888:80" # Panel admin: http://localhost:8888
depends_on: depends_on:
- mysql mysql:
condition: service_healthy
environment: environment:
- BASE_URL=http://localhost:8888 # Cambia en producción - BASE_URL=${EA_BASE_URL:-http://localhost:8888}
- DEBUG_MODE=FALSE - DEBUG_MODE=FALSE
- DB_HOST=mysql - DB_HOST=mysql
- DB_NAME=easyappointments - DB_NAME=easyappointments
- DB_USERNAME=root - DB_USERNAME=root
- DB_PASSWORD=secret # Cambia por contraseña fuerte - DB_PASSWORD=${MYSQL_ROOT_PASSWORD}
mysql: mysql:
image: mysql:8.0 image: mysql:8.0
container_name: may-ea-mysql container_name: may-ea-mysql
restart: unless-stopped restart: unless-stopped
environment: environment:
- MYSQL_ROOT_PASSWORD=secret - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
- MYSQL_DATABASE=easyappointments - MYSQL_DATABASE=easyappointments
healthcheck:
# EA solo arranca bien si MySQL ya está listo (no solo "iniciado").
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p\"$$MYSQL_ROOT_PASSWORD\" --silent"]
interval: 10s
timeout: 5s
retries: 10
volumes: volumes:
- ea-mysql-data:/var/lib/mysql - ea-mysql-data:/var/lib/mysql
+1 -1
View File
@@ -1,3 +1,3 @@
module barbershop module may
go 1.24.7 go 1.24.7
+352 -13
View File
@@ -1,7 +1,9 @@
package main package main
import ( import (
"bytes"
"embed" "embed"
"encoding/json"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@@ -11,12 +13,343 @@ import (
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
"os" "os"
"regexp"
"strconv"
"strings" "strings"
"time"
) )
//go:embed all:static //go:embed all:static
var static embed.FS var static embed.FS
// Endpoints de EasyAppointments de SOLO LECTURA y no sensibles que el frontend
// puede consultar a través del proxy. Las escrituras (crear cliente y cita) y la
// búsqueda de clientes NO están aquí: se orquestan server-side en /api/book, de
// modo que el navegador nunca puede listar/borrar/modificar datos de EA.
var eaAllowedRoutes = []struct {
method string
path *regexp.Regexp
}{
{http.MethodGet, regexp.MustCompile(`^/ea-api/v1/categories/?$`)},
{http.MethodGet, regexp.MustCompile(`^/ea-api/v1/service_categories/?$`)},
{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+/?$`)},
}
func eaRouteAllowed(method, path string) bool {
for _, route := range eaAllowedRoutes {
if route.method == method && route.path.MatchString(path) {
return true
}
}
return false
}
func writeJSONError(w http.ResponseWriter, status int, code string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
fmt.Fprintf(w, `{"error": %q}`, code)
}
// flexInt acepta enteros que llegan como número JSON o como string ("30", 30,
// 30.0). La API de EasyAppointments mezcla ambos formatos según el campo.
type flexInt int
func (f *flexInt) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), `"`)
if s == "" || s == "null" {
*f = 0
return nil
}
if n, err := strconv.Atoi(s); err == nil {
*f = flexInt(n)
return nil
}
ff, err := strconv.ParseFloat(s, 64)
if err != nil {
return fmt.Errorf("flexInt: valor no numérico %q", s)
}
*f = flexInt(ff)
return nil
}
// eaClient hace las llamadas server-side a EasyAppointments con las credenciales
// admin. EasyAppointments es la ÚNICA fuente de verdad: este servidor no almacena
// clientes ni citas, solo orquesta llamadas a su API REST.
type eaClient struct {
base string
apiKey string
user string
pass string
http *http.Client
}
func newEAClient() *eaClient {
base := "http://easyappointments:80"
if t := os.Getenv("EA_TARGET"); t != "" {
base = t
}
return &eaClient{
base: strings.TrimRight(base, "/"),
apiKey: os.Getenv("EA_API_KEY"),
user: os.Getenv("EA_API_USERNAME"),
pass: os.Getenv("EA_API_PASSWORD"),
http: &http.Client{Timeout: 15 * time.Second},
}
}
func (c *eaClient) auth(req *http.Request) {
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
} else if c.user != "" && c.pass != "" {
req.SetBasicAuth(c.user, c.pass)
}
}
// do llama a la API v1 de EA y decodifica la respuesta JSON en out (si no es nil).
// Devuelve error si el status no es 2xx.
func (c *eaClient) do(method, path string, body, out interface{}) error {
var reader io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(buf)
}
req, err := http.NewRequest(method, c.base+"/index.php/api/v1"+path, reader)
if err != nil {
return err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
c.auth(req)
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("EA %s %s -> %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(snippet)))
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
return nil
}
// ---- Tipos de EA usados en la orquestación ----
type eaService struct {
ID flexInt `json:"id"`
Name string `json:"name"`
Duration flexInt `json:"duration"`
}
type eaCustomer struct {
ID flexInt `json:"id,omitempty"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Email string `json:"email"`
Phone string `json:"phone"`
Notes string `json:"notes"`
}
type eaAppointment struct {
Start string `json:"start"`
End string `json:"end"`
ServiceID int `json:"serviceId"`
ProviderID int `json:"providerId"`
CustomerID int `json:"customerId"`
Notes string `json:"notes"`
Status string `json:"status"`
}
// ---- Petición de reserva desde el navegador ----
type bookRequest struct {
Nombre string `json:"nombre"`
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
}
var (
reDate = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
reTime = regexp.MustCompile(`^\d{2}:\d{2}$`)
)
// handleBook orquesta la reserva contra EasyAppointments:
// 1. valida la entrada,
// 2. lee los servicios de EA (fuente de verdad de nombre/duración),
// 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 {
writeJSONError(w, http.StatusMethodNotAllowed, "method_not_allowed")
return
}
var req bookRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid_json")
return
}
req.Nombre = strings.TrimSpace(req.Nombre)
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 == "" ||
len(req.ServiceIDs) == 0 || req.ProviderID <= 0 ||
!reDate.MatchString(req.Date) || !reTime.MatchString(req.Time) {
writeJSONError(w, http.StatusBadRequest, "datos_incompletos")
return
}
// 2. Servicios desde EA: no confiamos en nombres/duraciones del cliente.
var services []eaService
if err := ea.do(http.MethodGet, "/services", nil, &services); err != nil {
log.Printf("book: error leyendo servicios: %v", err)
writeJSONError(w, http.StatusBadGateway, "servicios_no_disponibles")
return
}
byID := make(map[int]eaService, len(services))
for _, s := range services {
byID[int(s.ID)] = s
}
var (
chosen []eaService
total int
primary eaService
maxDur = -1
)
for _, id := range req.ServiceIDs {
s, ok := byID[id]
if !ok {
writeJSONError(w, http.StatusBadRequest, "servicio_invalido")
return
}
dur := int(s.Duration)
if dur <= 0 {
dur = 30
}
total += dur
chosen = append(chosen, s)
if dur > maxDur {
maxDur = dur
primary = s
}
}
// 3. start/end calculados a partir de la duración total REAL de EA.
start, err := time.Parse("2006-01-02 15:04", req.Date+" "+req.Time)
if err != nil {
writeJSONError(w, http.StatusBadRequest, "fecha_invalida")
return
}
const layout = "2006-01-02 15:04:05"
end := start.Add(time.Duration(total) * time.Minute)
// 4. notas con el detalle completo (multiservicio).
names := make([]string, len(chosen))
for i, s := range chosen {
names[i] = s.Name
}
notesLines := []string{
"Nombre: " + req.Nombre,
"Email: " + req.Email,
"Teléfono: " + req.Telefono,
"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. cliente: buscar por email o crear (en EA).
customerID, err := ea.findOrCreateCustomer(req)
if err != nil {
log.Printf("book: error con cliente: %v", err)
writeJSONError(w, http.StatusBadGateway, "cliente_no_disponible")
return
}
// 6. crear la cita (en EA).
appt := eaAppointment{
Start: start.Format(layout),
End: end.Format(layout),
ServiceID: int(primary.ID),
ProviderID: req.ProviderID,
CustomerID: customerID,
Notes: notes,
Status: "booked",
}
if err := ea.do(http.MethodPost, "/appointments", appt, nil); err != nil {
log.Printf("book: error creando cita: %v", err)
writeJSONError(w, http.StatusBadGateway, "cita_no_creada")
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
fmt.Fprint(w, `{"ok": true}`)
}
}
// findOrCreateCustomer busca un cliente por email en EA y, si no existe, lo crea.
func (c *eaClient) findOrCreateCustomer(req bookRequest) (int, error) {
var found []eaCustomer
if err := c.do(http.MethodGet, "/customers?q="+url.QueryEscape(req.Email), nil, &found); err == nil {
for _, cust := range found {
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: first,
LastName: last,
Email: req.Email,
Phone: req.Telefono,
Notes: "Creado desde web MAY Studio",
}
if err := c.do(http.MethodPost, "/customers", body, &created); err != nil {
return 0, err
}
if int(created.ID) == 0 {
return 0, fmt.Errorf("EA no devolvió id de cliente")
}
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() { func main() {
port := flag.Int("port", 8080, "puerto del servidor") port := flag.Int("port", 8080, "puerto del servidor")
flag.Parse() flag.Parse()
@@ -26,13 +359,12 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
// Proxy a EasyAppointments API ea := newEAClient()
// El frontend llama a /ea-api/v1/... y aquí se reescribe y se añade auth
eaTarget := "http://easyappointments:80" // Proxy de SOLO LECTURA a EasyAppointments.
if t := os.Getenv("EA_TARGET"); t != "" { // El frontend llama a /ea-api/v1/... (servicios, categorías, proveedor,
eaTarget = t // disponibilidad) y aquí se reescribe la ruta y se añade la auth admin.
} eaURL, err := url.Parse(ea.base)
eaURL, err := url.Parse(eaTarget)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@@ -62,21 +394,28 @@ func main() {
} }
http.HandleFunc("/ea-api/", func(w http.ResponseWriter, r *http.Request) { http.HandleFunc("/ea-api/", func(w http.ResponseWriter, r *http.Request) {
// Allowlist método+ruta ANTES de inyectar credenciales: solo lecturas.
if !eaRouteAllowed(r.Method, r.URL.Path) {
writeJSONError(w, http.StatusNotFound, "not_found")
return
}
// /ea-api/v1/services → /index.php/api/v1/services // /ea-api/v1/services → /index.php/api/v1/services
r.URL.Path = strings.Replace(r.URL.Path, "/ea-api", "/index.php/api", 1) r.URL.Path = strings.Replace(r.URL.Path, "/ea-api", "/index.php/api", 1)
// Auth (server-side, nunca se expone al navegador) // Auth (server-side, nunca se expone al navegador)
if apiKey := os.Getenv("EA_API_KEY"); apiKey != "" { if ea.apiKey != "" {
r.Header.Set("Authorization", "Bearer " + apiKey) r.Header.Set("Authorization", "Bearer "+ea.apiKey)
} else if user := os.Getenv("EA_API_USERNAME"); user != "" { } else if ea.user != "" && ea.pass != "" {
if pass := os.Getenv("EA_API_PASSWORD"); pass != "" { r.SetBasicAuth(ea.user, ea.pass)
r.SetBasicAuth(user, pass)
}
} }
eaProxy.ServeHTTP(w, r) eaProxy.ServeHTTP(w, r)
}) })
// Reservas: orquestadas server-side contra EasyAppointments.
http.HandleFunc("/api/book", handleBook(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)
+168
View File
@@ -0,0 +1,168 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sort"
"strings"
"testing"
)
// La allowlist del proxy debe ser SOLO de lectura: cualquier escritura o búsqueda
// de clientes se hace server-side en /api/book, nunca por el proxy.
func TestEaRouteAllowed(t *testing.T) {
cases := []struct {
method string
path string
want bool
}{
// Lecturas permitidas (las que usa el frontend)
{"GET", "/ea-api/v1/categories", true},
{"GET", "/ea-api/v1/service_categories", true},
{"GET", "/ea-api/v1/services", true},
{"GET", "/ea-api/v1/availabilities", true},
{"GET", "/ea-api/v1/providers/1", true},
// Bloqueadas: clientes y citas ya NO pasan por el proxy
{"GET", "/ea-api/v1/customers", false},
{"POST", "/ea-api/v1/customers", false},
{"POST", "/ea-api/v1/appointments", false},
{"GET", "/ea-api/v1/appointments", false},
{"DELETE", "/ea-api/v1/appointments/3", false},
{"PUT", "/ea-api/v1/customers/5", false},
{"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},
{"POST", "/ea-api/v1/services", false},
}
for _, c := range cases {
if got := eaRouteAllowed(c.method, c.path); got != c.want {
t.Errorf("eaRouteAllowed(%q, %q) = %v, want %v", c.method, c.path, got, c.want)
}
}
}
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"`
B flexInt `json:"b"`
C flexInt `json:"c"`
D flexInt `json:"d"`
}
if err := json.Unmarshal([]byte(`{"a":30,"b":"45","c":"60.0","d":null}`), &s); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if s.A != 30 || s.B != 45 || s.C != 60 || s.D != 0 {
t.Errorf("got %d,%d,%d,%d want 30,45,60,0", s.A, s.B, s.C, s.D)
}
}
// TestHandleBook comprueba que una reserva se orquesta ENTERAMENTE contra la API
// de EasyAppointments: lee servicios, crea el cliente y crea la cita, calculando
// duración total y servicio principal en el servidor.
func TestHandleBook(t *testing.T) {
var gotAppt eaAppointment
var hits []string
mux := http.NewServeMux()
mux.HandleFunc("/index.php/api/v1/services", func(w http.ResponseWriter, r *http.Request) {
hits = append(hits, "GET services")
json.NewEncoder(w).Encode([]eaService{
{ID: 1, Name: "Corte", Duration: 30},
{ID: 2, Name: "Tinte", Duration: 45},
})
})
mux.HandleFunc("/index.php/api/v1/customers", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
hits = append(hits, "GET customers")
json.NewEncoder(w).Encode([]eaCustomer{}) // no existe → se crea
return
}
hits = append(hits, "POST customers")
json.NewEncoder(w).Encode(eaCustomer{ID: 99})
})
mux.HandleFunc("/index.php/api/v1/appointments", func(w http.ResponseWriter, r *http.Request) {
hits = append(hits, "POST appointments")
json.NewDecoder(r.Body).Decode(&gotAppt)
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"id":5}`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
ea := &eaClient{base: srv.URL, http: srv.Client()}
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)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
}
// Servicio principal = el de mayor duración (Tinte, id 2).
if gotAppt.ServiceID != 2 {
t.Errorf("serviceId = %d, want 2 (mayor duración)", gotAppt.ServiceID)
}
// Fin = inicio + 30 + 45 = 75 min.
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)
}
if gotAppt.CustomerID != 99 || gotAppt.ProviderID != 1 || gotAppt.Status != "booked" {
t.Errorf("appt = %+v", gotAppt)
}
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 services", "POST appointments", "POST customers"}
if strings.Join(hits, ",") != strings.Join(want, ",") {
t.Errorf("llamadas a EA = %v, want %v", hits, want)
}
}
// Servicio inválido (no existe en EA) ⇒ 400 y NO se crea ninguna cita.
func TestHandleBookRejectsUnknownService(t *testing.T) {
created := false
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/appointments", func(w http.ResponseWriter, r *http.Request) {
created = 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":[999],"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 created {
t.Error("se creó una cita con un servicio inexistente")
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

+16 -81
View File
@@ -5,6 +5,9 @@
<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" 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 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">
@@ -211,7 +214,6 @@
// ---- Service selector state ---- // ---- Service selector state ----
let selectedServices = []; // [{id, nombre, duracion}] let selectedServices = []; // [{id, nombre, duracion}]
let totalDuration = 0; let totalDuration = 0;
let allServices = []; // cache de servicios EA
// ---- Categories and Services from EasyAppointments ---- // ---- Categories and Services from EasyAppointments ----
async function loadCategories() { async function loadCategories() {
@@ -237,7 +239,6 @@
]); ]);
if (!servicesRes.ok) throw new Error('HTTP ' + servicesRes.status); if (!servicesRes.ok) throw new Error('HTTP ' + servicesRes.status);
const services = await servicesRes.json(); const services = await servicesRes.json();
allServices = services;
// Filter services to those assigned to this provider // Filter services to those assigned to this provider
let displayServices = services; let displayServices = services;
@@ -264,7 +265,6 @@
const catInfo = catMap[catId] || {}; const catInfo = catMap[catId] || {};
grouped[catId] = { grouped[catId] = {
nombre: catInfo.name || catInfo.title || 'Otros servicios', nombre: catInfo.name || catInfo.title || 'Otros servicios',
imagen: '',
servicios: [] servicios: []
}; };
} }
@@ -570,45 +570,6 @@
document.getElementById('booking-form').style.display = 'none'; document.getElementById('booking-form').style.display = 'none';
} }
// Busca cliente por email o lo crea en EasyAppointments
async function findOrCreateCustomer(nombre, email, telefono) {
// Buscar
try {
const searchRes = await fetch(`/ea-api/v1/customers?q=${encodeURIComponent(email)}`);
if (searchRes.ok) {
const list = await searchRes.json();
if (Array.isArray(list) && list.length > 0) {
return list[0].id;
}
}
} catch (e) { console.warn('Búsqueda de cliente falló', e); }
// Crear nuevo
const parts = nombre.trim().split(/\s+/);
const firstName = parts[0] || nombre;
const lastName = parts.slice(1).join(' ') || '';
const createRes = await fetch('/ea-api/v1/customers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: firstName,
lastName: lastName,
email: email,
phone: telefono || '',
notes: 'Creado desde web MAY Studio'
})
});
if (!createRes.ok) {
const errText = await createRes.text().catch(() => '');
throw new Error('No se pudo crear el cliente en EasyAppointments: ' + errText);
}
const created = await createRes.json();
return created.id;
}
function initBookingForm() { function initBookingForm() {
document.getElementById('booking-form').addEventListener('submit', async function(e) { document.getElementById('booking-form').addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();
@@ -627,49 +588,23 @@
result.style.display = 'none'; result.style.display = 'none';
try { try {
// 1. Cliente (por email) // El servidor (server-side) busca/crea el cliente y crea la cita
const customerId = await findOrCreateCustomer( // contra EasyAppointments. El navegador solo envía la selección.
form.nombre.value.trim(), const payload = {
email, nombre: form.nombre.value.trim(),
form.telefono.value.trim() email: email,
); telefono: form.telefono.value.trim(),
mensaje: form.mensaje.value.trim(),
// 2. Preparar appointment (usamos el servicio principal - el de mayor duración - + notas con todos)
const primaryServiceId = getPrimaryServiceId();
const primaryService = selectedServices.find(s => s.id === primaryServiceId) || selectedServices[0];
if (!primaryService || !primaryService.id) {
throw new Error('No se encontró ID de servicio válido');
}
const start = `${selectedDate} ${selectedHora}:00`;
// Duración total (EA usará la del servicio, pero ponemos nota)
const endDate = new Date(`${selectedDate}T${selectedHora}`);
endDate.setMinutes(endDate.getMinutes() + totalDuration);
const endHour = pad(endDate.getHours()) + ':' + pad(endDate.getMinutes());
const notes = [
`Nombre: ${form.nombre.value.trim()}`,
`Email: ${email}`,
`Teléfono: ${form.telefono.value.trim()}`,
`Servicios: ${selectedServices.map(s => s.nombre).join(' + ')}`,
`Duración total: ${totalDuration} min`,
form.mensaje.value.trim() ? `Mensaje: ${form.mensaje.value.trim()}` : ''
].filter(Boolean).join('\n');
const appointment = {
start: start,
end: `${selectedDate} ${endHour}:00`,
serviceId: primaryService.id,
providerId: PROVIDER_ID, providerId: PROVIDER_ID,
customerId: customerId, serviceIds: selectedServices.map(s => s.id).filter(Boolean),
notes: notes, date: selectedDate,
status: 'booked' time: selectedHora
}; };
const res = await fetch('/ea-api/v1/appointments', { const res = await fetch('/api/book', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(appointment), body: JSON.stringify(payload),
}); });
if (res.ok) { if (res.ok) {
@@ -685,7 +620,7 @@
} else { } else {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
result.className = 'booking-result booking-error'; result.className = 'booking-result booking-error';
result.textContent = (err.message || err.error || JSON.stringify(err)) || 'Error creando la cita en EasyAppointments.'; result.textContent = err.error || 'No se pudo crear la reserva. Inténtalo de nuevo o llámanos.';
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
-91
View File
@@ -108,15 +108,6 @@ img { max-width: 100%; display: block; }
); );
} }
/* Fallback gradient when no image is loaded */
.hero-bg img[src=""]::before,
.hero-bg img:not([src])::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(160deg, var(--bg) 40%, var(--bg-alt) 100%);
}
.hero-content { .hero-content {
position: relative; position: relative;
z-index: 1; z-index: 1;
@@ -190,72 +181,6 @@ img { max-width: 100%; display: block; }
margin-right: auto; margin-right: auto;
} }
/* ============ SERVICES ============ */
.services-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.service-card {
background: var(--white);
border-radius: var(--radius);
overflow: hidden;
border: 1px solid rgba(0,0,0,0.05);
transition: transform 0.2s, box-shadow 0.2s;
}
.service-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0,0,0,0.06);
}
.service-img {
width: 100%;
height: 220px;
overflow: hidden;
background: var(--bg-alt);
}
.service-img img {
width: 100%;
height: 100%;
object-fit: cover;
}
.service-body {
padding: 24px;
}
.service-body h3 {
font-family: 'DM Serif Display', serif;
font-size: 1.3rem;
margin-bottom: 16px;
}
.service-list {
list-style: none;
}
.service-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid rgba(0,0,0,0.06);
font-size: 0.9rem;
color: var(--text-mid);
}
.service-list li:last-child {
border-bottom: none;
}
.price {
font-weight: 600;
color: var(--accent);
white-space: nowrap;
}
/* ============ GALLERY ============ */ /* ============ GALLERY ============ */
.gallery-grid { .gallery-grid {
display: grid; display: grid;
@@ -538,14 +463,6 @@ img { max-width: 100%; display: block; }
color: var(--accent); color: var(--accent);
} }
.slot-occupied {
background: var(--bg-alt);
color: rgba(0,0,0,0.25);
text-decoration: line-through;
cursor: default;
border-color: transparent;
}
.slot-selected { .slot-selected {
background: var(--accent) !important; background: var(--accent) !important;
color: var(--white) !important; color: var(--white) !important;
@@ -624,11 +541,6 @@ img { max-width: 100%; display: block; }
transition: opacity 0.2s; transition: opacity 0.2s;
} }
.svc-cat-card.svc-cat-locked {
opacity: 0.35;
pointer-events: none;
}
.svc-cat-title { .svc-cat-title {
font-family: 'DM Serif Display', serif; font-family: 'DM Serif Display', serif;
font-size: 1rem; font-size: 1rem;
@@ -714,9 +626,6 @@ img { max-width: 100%; display: block; }
/* ============ RESPONSIVE ============ */ /* ============ RESPONSIVE ============ */
@media (max-width: 768px) { @media (max-width: 768px) {
.services-grid {
grid-template-columns: 1fr;
}
.gallery-grid { .gallery-grid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }