Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
449667fc80 | ||
|
|
e40536023d | ||
|
|
6400485b6d | ||
|
|
61a43ef481 | ||
|
|
8c381d98a6 | ||
|
|
98fd85226b |
@@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.dockerignore
|
||||||
|
.env
|
||||||
|
.env.example
|
||||||
|
*.md
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
# Opcional: id del provider contra el que se reserva. Si no se define,
|
||||||
|
# el servidor lo autodescubre preguntando a EA (usa el primero, y en este
|
||||||
|
# salón solo hay uno). Defínelo solo si algún día hay varios providers.
|
||||||
|
#EA_PROVIDER_ID=5
|
||||||
@@ -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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# May Hair Care — Landing Page + EasyAppointments
|
# MAY Studio — Landing Page + EasyAppointments
|
||||||
|
|
||||||
Landing page bonita para May HairCare (Horta, Barcelona) con reservas gestionadas por **EasyAppointments**.
|
Landing page bonita para MAY Studio (Horta, Barcelona) con reservas gestionadas por **EasyAppointments**.
|
||||||
|
|
||||||
El frontend (HTML/JS puro) sigue siendo el de la landing, pero **las reservas, clientes y servicios se gestionan completamente con EasyAppointments**.
|
El frontend (HTML/JS puro) sigue siendo el de la landing, pero **las reservas, clientes y servicios se gestionan completamente con EasyAppointments**.
|
||||||
|
|
||||||
@@ -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,29 @@ 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).
|
||||||
|
- El calendario deshabilita los días sin ningún hueco vía `GET /api/month-availability`
|
||||||
|
(agregado server-side día a día contra EA, con caché de 10 min por mes+servicio).
|
||||||
|
- Al reservar, el navegador envía la selección a `POST /api/book` y **el servidor**:
|
||||||
|
- lee los servicios de EA (fuente de verdad de nombre y duración),
|
||||||
|
- busca o crea el cliente por **teléfono** (identifica al cliente; el email es opcional),
|
||||||
|
- 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
|
||||||
|
|
||||||
@@ -64,16 +88,18 @@ El formulario permite seleccionar **varios servicios** (multi-checkbox).
|
|||||||
Cómo funciona actualmente con EasyAppointments:
|
Cómo funciona actualmente con EasyAppointments:
|
||||||
|
|
||||||
- **UI**: Puedes seleccionar múltiples servicios. Se muestra el resumen con nombres unidos por "+" y la duración total sumada.
|
- **UI**: Puedes seleccionar múltiples servicios. Se muestra el resumen con nombres unidos por "+" y la duración total sumada.
|
||||||
- **Comprobación de disponibilidad**: Se consulta el endpoint de EA usando el servicio de **mayor duración** de los seleccionados. EA devuelve los slots libres según ese servicio + el horario del proveedor.
|
- **Comprobación de disponibilidad**: Se consulta el endpoint de EA usando el servicio de **mayor duración** de los seleccionados. Luego se filtra client-side para exigir un bloque contiguo de la **duración total** (suma de todos).
|
||||||
|
- Ejemplo: servicios de 45min + 30min → total 75 min. Solo se ofrecen horas de inicio desde las que quepan 75 min seguidos sin solape.
|
||||||
- **Al crear la reserva**:
|
- **Al crear la reserva**:
|
||||||
- Se crea **una sola cita** en EasyAppointments.
|
- Se crea **una sola cita** en EasyAppointments.
|
||||||
- Se usa como `serviceId` el servicio de mayor duración (para que coincida con la disponibilidad consultada).
|
- Se usa como `serviceId` el servicio de mayor duración (para que coincida con la disponibilidad consultada).
|
||||||
- Se calcula la hora de fin sumando la **duración total** de todos los servicios seleccionados.
|
- Se calcula la hora de fin sumando la **duración total** de todos los servicios seleccionados y se envía explícitamente el campo `end`. Así EA bloquea el tiempo completo en el calendario de la empleada.
|
||||||
|
- Solo una empleada (May) por ahora → el bloque queda reservado y no se puede reservar otra cosa en ese intervalo.
|
||||||
- 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: ...
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8234:8080"
|
- "8234:8080"
|
||||||
environment:
|
environment:
|
||||||
- EA_API_USERNAME=admin # Usuario admin de EA (o mejor usa EA_API_KEY)
|
# Token de API de EasyAppointments. Definido en .env (ver .env.example).
|
||||||
- EA_API_PASSWORD=yourpassword
|
- EA_API_KEY=${EA_API_KEY}
|
||||||
# - EA_API_KEY=tu-api-key # Recomendado: crea API Key en Settings de EA
|
# Opcional: fija el provider; vacío = autodescubierto en EA.
|
||||||
|
- EA_PROVIDER_ID=${EA_PROVIDER_ID:-}
|
||||||
depends_on:
|
depends_on:
|
||||||
- easyappointments
|
- easyappointments
|
||||||
|
|
||||||
@@ -20,22 +21,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,21 +1,533 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"embed"
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"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+/?$`)},
|
||||||
|
// 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 {
|
||||||
|
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,omitempty"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type eaProvider struct {
|
||||||
|
ID flexInt `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type eaAppointment struct {
|
||||||
|
Start string `json:"start"`
|
||||||
|
End string `json:"end"`
|
||||||
|
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"`
|
||||||
|
Apellidos string `json:"apellidos"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Telefono string `json:"telefono"`
|
||||||
|
Mensaje string `json:"mensaje"`
|
||||||
|
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. resuelve el provider (server-side, ver providerID),
|
||||||
|
// 4. busca o crea el cliente por teléfono (es el dato que lo identifica; el email es opcional),
|
||||||
|
// 5. crea UNA cita con el servicio de mayor duración y el bloque total en notas.
|
||||||
|
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.Apellidos = strings.TrimSpace(req.Apellidos)
|
||||||
|
req.Email = strings.TrimSpace(req.Email)
|
||||||
|
req.Telefono = strings.TrimSpace(req.Telefono)
|
||||||
|
req.Mensaje = strings.TrimSpace(req.Mensaje)
|
||||||
|
|
||||||
|
if req.Nombre == "" || req.Apellidos == "" || normPhone(req.Telefono) == "" ||
|
||||||
|
len(req.ServiceIDs) == 0 ||
|
||||||
|
!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 + " " + req.Apellidos,
|
||||||
|
"Teléfono: " + req.Telefono,
|
||||||
|
}
|
||||||
|
if req.Email != "" {
|
||||||
|
notesLines = append(notesLines, "Email: "+req.Email)
|
||||||
|
}
|
||||||
|
notesLines = append(notesLines,
|
||||||
|
"Servicios: "+strings.Join(names, " + "),
|
||||||
|
fmt.Sprintf("Duración total: %d min", total),
|
||||||
|
)
|
||||||
|
if req.Mensaje != "" {
|
||||||
|
notesLines = append(notesLines, "Mensaje: "+req.Mensaje)
|
||||||
|
}
|
||||||
|
notes := strings.Join(notesLines, "\n")
|
||||||
|
|
||||||
|
// 5. provider resuelto server-side: el navegador no elige contra quién reserva.
|
||||||
|
providerID, err := ea.providerID()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("book: error resolviendo provider: %v", err)
|
||||||
|
writeJSONError(w, http.StatusBadGateway, "proveedor_no_disponible")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. cliente: buscar por teléfono o crear (en EA).
|
||||||
|
customerID, err := ea.findOrCreateCustomer(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("book: error con cliente: %v", err)
|
||||||
|
writeJSONError(w, http.StatusBadGateway, "cliente_no_disponible")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. crear la cita (en EA).
|
||||||
|
appt := eaAppointment{
|
||||||
|
Start: start.Format(layout),
|
||||||
|
End: end.Format(layout),
|
||||||
|
ServiceID: int(primary.ID),
|
||||||
|
ProviderID: 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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// providerID resuelve el id del provider contra el que se reserva. Con
|
||||||
|
// EA_PROVIDER_ID definido se usa ese valor; si no, se pregunta a EA y se toma
|
||||||
|
// el primero (el salón tiene un único provider). Así el id no va hardcodeado
|
||||||
|
// y cada entorno (desarrollo, producción) resuelve el suyo.
|
||||||
|
func (c *eaClient) providerID() (int, error) {
|
||||||
|
if v := os.Getenv("EA_PROVIDER_ID"); v != "" {
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return 0, fmt.Errorf("EA_PROVIDER_ID inválido: %q", v)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
var provs []eaProvider
|
||||||
|
if err := c.do(http.MethodGet, "/providers", nil, &provs); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if len(provs) == 0 {
|
||||||
|
return 0, fmt.Errorf("EA no tiene ningún provider")
|
||||||
|
}
|
||||||
|
if len(provs) > 1 {
|
||||||
|
log.Printf("aviso: EA tiene %d providers; se usa el primero (id %d). Fija EA_PROVIDER_ID para elegir otro.", len(provs), int(provs[0].ID))
|
||||||
|
}
|
||||||
|
return int(provs[0].ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleConfig expone al frontend la configuración mínima que necesita para
|
||||||
|
// consultar disponibilidad por el proxy: solo el id del provider.
|
||||||
|
func handleConfig(ea *eaClient) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeJSONError(w, http.StatusMethodNotAllowed, "method_not_allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := ea.providerID()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("config: error resolviendo provider: %v", err)
|
||||||
|
writeJSONError(w, http.StatusBadGateway, "proveedor_no_disponible")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprintf(w, `{"providerId": %d}`, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// normPhone deja solo los dígitos ("690 91 88 15" → "690918815") para que el
|
||||||
|
// mismo número escrito de formas distintas identifique al mismo cliente.
|
||||||
|
func normPhone(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
if r >= '0' && r <= '9' {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// samePhone compara dos teléfonos por sus dígitos, tolerando prefijos de país:
|
||||||
|
// "+34 690918815" y "690918815" son el mismo número (sufijo común de ≥9 dígitos).
|
||||||
|
func samePhone(a, b string) bool {
|
||||||
|
a, b = normPhone(a), normPhone(b)
|
||||||
|
if a == "" || b == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(a) > len(b) {
|
||||||
|
a, b = b, a
|
||||||
|
}
|
||||||
|
return a == b || (len(a) >= 9 && strings.HasSuffix(b, a))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMonthAvailability devuelve los días de un mes sin NINGÚN hueco libre
|
||||||
|
// para un servicio ("unavailableDays"), para que el calendario los deshabilite
|
||||||
|
// sin que el usuario tenga que pulsarlos. Consulta las availabilities de EA
|
||||||
|
// día a día (en paralelo, saltando los pasados) y cachea el resultado 10 min
|
||||||
|
// por mes+servicio para no repetir ~30 llamadas a EA en cada render.
|
||||||
|
func handleMonthAvailability(ea *eaClient) http.HandlerFunc {
|
||||||
|
type cacheEntry struct {
|
||||||
|
expires time.Time
|
||||||
|
days []string
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
cache = make(map[string]cacheEntry)
|
||||||
|
)
|
||||||
|
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeJSONError(w, http.StatusMethodNotAllowed, "method_not_allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
month := r.URL.Query().Get("month")
|
||||||
|
first, errMonth := time.Parse("2006-01", month)
|
||||||
|
serviceID, errService := strconv.Atoi(r.URL.Query().Get("serviceId"))
|
||||||
|
|
||||||
|
// Solo del mes actual a 12 meses vista: es lo único que el calendario
|
||||||
|
// puede pedir y acota el abanico de llamadas a EA.
|
||||||
|
now := time.Now()
|
||||||
|
currentMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
if errMonth != nil || errService != nil || serviceID <= 0 ||
|
||||||
|
first.Before(currentMonth) || first.After(currentMonth.AddDate(1, 0, 0)) {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "parametros_invalidos")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key := fmt.Sprintf("%s:%d", month, serviceID)
|
||||||
|
mu.Lock()
|
||||||
|
e, hit := cache[key]
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
if !hit || !now.Before(e.expires) {
|
||||||
|
providerID, err := ea.providerID()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("month-availability: error resolviendo provider: %v", err)
|
||||||
|
writeJSONError(w, http.StatusBadGateway, "proveedor_no_disponible")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
daysInMonth := first.AddDate(0, 1, -1).Day()
|
||||||
|
today := now.Format("2006-01-02")
|
||||||
|
unavailable := make([]bool, daysInMonth+1)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
sem := make(chan struct{}, 6) // máx. 6 llamadas a EA en paralelo
|
||||||
|
for d := 1; d <= daysInMonth; d++ {
|
||||||
|
date := fmt.Sprintf("%s-%02d", month, d)
|
||||||
|
if date < today { // los días pasados ya los bloquea el navegador
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
go func(d int, date string) {
|
||||||
|
defer wg.Done()
|
||||||
|
sem <- struct{}{}
|
||||||
|
defer func() { <-sem }()
|
||||||
|
var slots []string
|
||||||
|
path := fmt.Sprintf("/availabilities?providerId=%d&serviceId=%d&date=%s", providerID, serviceID, date)
|
||||||
|
// Si EA falla, el día se deja clicable: al seleccionarlo
|
||||||
|
// se consulta de nuevo y allí se informa del error.
|
||||||
|
if err := ea.do(http.MethodGet, path, nil, &slots); err == nil && len(slots) == 0 {
|
||||||
|
unavailable[d] = true
|
||||||
|
}
|
||||||
|
}(d, date)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
days := []string{}
|
||||||
|
for d := 1; d <= daysInMonth; d++ {
|
||||||
|
if unavailable[d] {
|
||||||
|
days = append(days, fmt.Sprintf("%s-%02d", month, d))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e = cacheEntry{expires: time.Now().Add(10 * time.Minute), days: days}
|
||||||
|
mu.Lock()
|
||||||
|
cache[key] = e
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string][]string{"unavailableDays": e.days})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findOrCreateCustomer busca el cliente por TELÉFONO en EA (es el dato que lo
|
||||||
|
// identifica; el email es opcional) y, si no existe, lo crea. Así todas las
|
||||||
|
// citas hechas con el mismo teléfono quedan asignadas al mismo cliente.
|
||||||
|
func (c *eaClient) findOrCreateCustomer(req bookRequest) (int, error) {
|
||||||
|
phone := normPhone(req.Telefono)
|
||||||
|
|
||||||
|
// La búsqueda usa los últimos 9 dígitos (número nacional) para encontrar
|
||||||
|
// también registros guardados con prefijo de país.
|
||||||
|
q := phone
|
||||||
|
if len(q) > 9 {
|
||||||
|
q = q[len(q)-9:]
|
||||||
|
}
|
||||||
|
var found []eaCustomer
|
||||||
|
if err := c.do(http.MethodGet, "/customers?q="+url.QueryEscape(q), nil, &found); err == nil {
|
||||||
|
for _, cust := range found {
|
||||||
|
if samePhone(cust.Phone, phone) {
|
||||||
|
return int(cust.ID), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var created eaCustomer
|
||||||
|
body := eaCustomer{
|
||||||
|
FirstName: req.Nombre,
|
||||||
|
LastName: req.Apellidos,
|
||||||
|
Email: req.Email,
|
||||||
|
Phone: phone,
|
||||||
|
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 main() {
|
func main() {
|
||||||
port := flag.Int("port", 8080, "puerto del servidor")
|
port := flag.Int("port", 8080, "puerto del servidor")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@@ -25,34 +537,71 @@ 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)
|
||||||
}
|
}
|
||||||
eaProxy := httputil.NewSingleHostReverseProxy(eaURL)
|
eaProxy := httputil.NewSingleHostReverseProxy(eaURL)
|
||||||
|
|
||||||
|
// Evita que el navegador muestre el popup de credenciales.
|
||||||
|
// Nunca exponemos WWW-Authenticate ni mensajes de auth del backend.
|
||||||
|
eaProxy.ModifyResponse = func(resp *http.Response) error {
|
||||||
|
resp.Header.Del("Www-Authenticate")
|
||||||
|
// Evitamos que cookies de sesión de EA lleguen al navegador del usuario público
|
||||||
|
resp.Header.Del("Set-Cookie")
|
||||||
|
// Limpiamos cabeceras que revelan el backend
|
||||||
|
resp.Header.Del("Server")
|
||||||
|
resp.Header.Del("X-Powered-By")
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||||
|
resp.StatusCode = http.StatusBadGateway
|
||||||
|
resp.Status = http.StatusText(http.StatusBadGateway)
|
||||||
|
// Reemplazamos el body para no filtrar detalles del backend
|
||||||
|
resp.Body.Close()
|
||||||
|
resp.Body = io.NopCloser(strings.NewReader(`{"error": "service_unavailable"}`))
|
||||||
|
resp.ContentLength = -1
|
||||||
|
resp.Header.Set("Content-Type", "application/json")
|
||||||
|
resp.Header.Del("Content-Length")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
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))
|
||||||
|
|
||||||
|
// Config para el frontend: el id del provider se resuelve aquí (EA o
|
||||||
|
// EA_PROVIDER_ID), nunca va hardcodeado en el HTML.
|
||||||
|
http.HandleFunc("/api/config", handleConfig(ea))
|
||||||
|
|
||||||
|
// Días del mes visible sin ningún hueco libre: el calendario los pinta
|
||||||
|
// deshabilitados para ahorrar clics en días completos o festivos.
|
||||||
|
http.HandleFunc("/api/month-availability", handleMonthAvailability(ea))
|
||||||
|
|
||||||
http.Handle("/", http.FileServer(http.FS(staticFS)))
|
http.Handle("/", http.FileServer(http.FS(staticFS)))
|
||||||
|
|
||||||
addr := fmt.Sprintf(":%d", *port)
|
addr := fmt.Sprintf(":%d", *port)
|
||||||
|
|||||||
@@ -0,0 +1,429 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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},
|
||||||
|
{"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},
|
||||||
|
{"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},
|
||||||
|
{"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 {
|
||||||
|
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 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 gotCust eaCustomer
|
||||||
|
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.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) {
|
||||||
|
hits = append(hits, "POST appointments")
|
||||||
|
json.NewDecoder(r.Body).Decode(&gotAppt)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
w.Write([]byte(`{"id":5}`))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/index.php/api/v1/providers", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits = append(hits, "GET providers")
|
||||||
|
json.NewEncoder(w).Encode([]eaProvider{{ID: 7}})
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ea := &eaClient{base: srv.URL, http: srv.Client()}
|
||||||
|
|
||||||
|
body := `{"nombre":"Ana","apellidos":"López Gil","email":"ana@x.com","telefono":"600","mensaje":"hola",
|
||||||
|
"serviceIds":[1,2],"date":"2026-07-01","time":"10:00"}`
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
// El provider lo resuelve el servidor contra EA, no el navegador.
|
||||||
|
if gotAppt.CustomerID != 99 || gotAppt.ProviderID != 7 || gotAppt.Status != "booked" {
|
||||||
|
t.Errorf("appt = %+v", gotAppt)
|
||||||
|
}
|
||||||
|
// Nombre y apellidos viajan por separado hasta EA, sin adivinar cuál es cuál.
|
||||||
|
if gotCust.FirstName != "Ana" || gotCust.LastName != "López Gil" {
|
||||||
|
t.Errorf("cliente = %q %q, want \"Ana\" \"López Gil\"", gotCust.FirstName, gotCust.LastName)
|
||||||
|
}
|
||||||
|
if !strings.Contains(gotAppt.Notes, "Duración total: 75 min") ||
|
||||||
|
!strings.Contains(gotAppt.Notes, "Servicios: Corte + Tinte") {
|
||||||
|
t.Errorf("notes = %q", gotAppt.Notes)
|
||||||
|
}
|
||||||
|
// Toda la gestión pasó por EA y nada más.
|
||||||
|
sort.Strings(hits)
|
||||||
|
want := []string{"GET customers", "GET providers", "GET services", "POST appointments", "POST customers"}
|
||||||
|
if strings.Join(hits, ",") != strings.Join(want, ",") {
|
||||||
|
t.Errorf("llamadas a EA = %v, want %v", hits, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// El teléfono identifica al cliente: si ya existe en EA se reutiliza (no se
|
||||||
|
// duplica) y se le pueden crear varias citas. El email ya no es obligatorio.
|
||||||
|
func TestHandleBookReusesCustomerByPhone(t *testing.T) {
|
||||||
|
customersCreated := 0
|
||||||
|
apptsCreated := 0
|
||||||
|
var lastAppt eaAppointment
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/index.php/api/v1/services", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
json.NewEncoder(w).Encode([]eaService{{ID: 1, Name: "Corte", Duration: 30}})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/index.php/api/v1/providers", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
json.NewEncoder(w).Encode([]eaProvider{{ID: 7}})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/index.php/api/v1/customers", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
if q := r.URL.Query().Get("q"); q != "690918815" {
|
||||||
|
t.Errorf("q = %q, want los últimos 9 dígitos del teléfono", q)
|
||||||
|
}
|
||||||
|
// En EA está guardado con prefijo y espacios: debe reconocerse igual.
|
||||||
|
json.NewEncoder(w).Encode([]eaCustomer{{ID: 42, Phone: "+34 690 91 88 15"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
customersCreated++
|
||||||
|
json.NewEncoder(w).Encode(eaCustomer{ID: 99})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/index.php/api/v1/appointments", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
apptsCreated++
|
||||||
|
json.NewDecoder(r.Body).Decode(&lastAppt)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
ea := &eaClient{base: srv.URL, http: srv.Client()}
|
||||||
|
|
||||||
|
// Dos reservas sin email y con el teléfono escrito distinto cada vez.
|
||||||
|
for _, tel := range []string{"690 918 815", "+34690918815"} {
|
||||||
|
body := `{"nombre":"Ana","apellidos":"López","telefono":"` + tel + `","serviceIds":[1],"date":"2026-07-01","time":"10:00"}`
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleBook(ea)(rec, httptest.NewRequest(http.MethodPost, "/api/book", strings.NewReader(body)))
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if customersCreated != 0 {
|
||||||
|
t.Errorf("se crearon %d clientes nuevos; ese teléfono ya existía en EA", customersCreated)
|
||||||
|
}
|
||||||
|
if apptsCreated != 2 {
|
||||||
|
t.Errorf("citas creadas = %d, want 2 (varias citas para el mismo cliente)", apptsCreated)
|
||||||
|
}
|
||||||
|
if lastAppt.CustomerID != 42 {
|
||||||
|
t.Errorf("customerId = %d, want 42 (el cliente existente)", lastAppt.CustomerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSamePhone(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
a, b string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"690918815", "690 91 88 15", true},
|
||||||
|
{"+34 690918815", "690918815", true},
|
||||||
|
{"0034690918815", "690.91.88.15", true},
|
||||||
|
{"690918815", "690918816", false},
|
||||||
|
{"600", "600", true},
|
||||||
|
{"600", "222600", false}, // sufijos cortos no identifican
|
||||||
|
{"", "690918815", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := samePhone(c.a, c.b); got != c.want {
|
||||||
|
t.Errorf("samePhone(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Servicio inválido (no existe en EA) ⇒ 400 y NO se crea ninguna cita.
|
||||||
|
func TestHandleBookRejectsUnknownService(t *testing.T) {
|
||||||
|
created := false
|
||||||
|
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","apellidos":"López","email":"a@x.com","telefono":"600","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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// El id del provider se resuelve contra EA (autodescubierto) o vía
|
||||||
|
// EA_PROVIDER_ID, nunca hardcodeado: es distinto en cada entorno.
|
||||||
|
func TestProviderID(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/index.php/api/v1/providers", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls++
|
||||||
|
json.NewEncoder(w).Encode([]eaProvider{{ID: 5}, {ID: 9}})
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
ea := &eaClient{base: srv.URL, http: srv.Client()}
|
||||||
|
|
||||||
|
t.Run("autodescubre el primero de EA", func(t *testing.T) {
|
||||||
|
id, err := ea.providerID()
|
||||||
|
if err != nil || id != 5 {
|
||||||
|
t.Errorf("providerID() = %d, %v; want 5, nil", id, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("EA_PROVIDER_ID tiene prioridad y no llama a EA", func(t *testing.T) {
|
||||||
|
t.Setenv("EA_PROVIDER_ID", "9")
|
||||||
|
before := calls
|
||||||
|
id, err := ea.providerID()
|
||||||
|
if err != nil || id != 9 {
|
||||||
|
t.Errorf("providerID() = %d, %v; want 9, nil", id, err)
|
||||||
|
}
|
||||||
|
if calls != before {
|
||||||
|
t.Error("con EA_PROVIDER_ID definido no debería consultar EA")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("EA_PROVIDER_ID inválido es error", func(t *testing.T) {
|
||||||
|
t.Setenv("EA_PROVIDER_ID", "mayra")
|
||||||
|
if _, err := ea.providerID(); err == nil {
|
||||||
|
t.Error("EA_PROVIDER_ID no numérico debería dar error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("sin providers en EA es error", func(t *testing.T) {
|
||||||
|
empty := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(`[]`))
|
||||||
|
}))
|
||||||
|
defer empty.Close()
|
||||||
|
ea := &eaClient{base: empty.URL, http: empty.Client()}
|
||||||
|
if _, err := ea.providerID(); err == nil {
|
||||||
|
t.Error("sin providers debería dar error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("/api/config lo expone al frontend", func(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleConfig(ea)(rec, httptest.NewRequest(http.MethodGet, "/api/config", nil))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
ProviderID int `json:"providerId"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil || got.ProviderID != 5 {
|
||||||
|
t.Errorf("body = %s (err %v), want providerId 5", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/month-availability agrega las availabilities de EA día a día: devuelve
|
||||||
|
// los días sin ningún hueco para que el calendario los deshabilite, y cachea
|
||||||
|
// el resultado (una sola pasada de llamadas a EA por mes+servicio).
|
||||||
|
func TestHandleMonthAvailability(t *testing.T) {
|
||||||
|
// Mes siguiente al actual: así ningún día es pasado y se consultan todos.
|
||||||
|
now := time.Now()
|
||||||
|
first := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC).AddDate(0, 1, 0)
|
||||||
|
month := first.Format("2006-01")
|
||||||
|
daysInMonth := first.AddDate(0, 1, -1).Day()
|
||||||
|
full1, full2 := month+"-15", month+"-16"
|
||||||
|
|
||||||
|
var eaCalls atomic.Int64
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/index.php/api/v1/providers", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
json.NewEncoder(w).Encode([]eaProvider{{ID: 7}})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/index.php/api/v1/availabilities", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
eaCalls.Add(1)
|
||||||
|
if r.URL.Query().Get("serviceId") != "3" || r.URL.Query().Get("providerId") != "7" {
|
||||||
|
t.Errorf("query inesperada: %s", r.URL.RawQuery)
|
||||||
|
}
|
||||||
|
if date := r.URL.Query().Get("date"); date == full1 || date == full2 {
|
||||||
|
w.Write([]byte(`[]`)) // día completo: sin ningún hueco
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write([]byte(`["10:00","10:30"]`))
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
handler := handleMonthAvailability(&eaClient{base: srv.URL, http: srv.Client()})
|
||||||
|
|
||||||
|
get := func() *httptest.ResponseRecorder {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler(rec, httptest.NewRequest(http.MethodGet, "/api/month-availability?month="+month+"&serviceId=3", nil))
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := get()
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
UnavailableDays []string `json:"unavailableDays"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("json: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Join(got.UnavailableDays, ",") != full1+","+full2 {
|
||||||
|
t.Errorf("unavailableDays = %v, want [%s %s]", got.UnavailableDays, full1, full2)
|
||||||
|
}
|
||||||
|
if n := eaCalls.Load(); n != int64(daysInMonth) {
|
||||||
|
t.Errorf("llamadas a EA = %d, want %d (una por día del mes)", n, daysInMonth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segunda petición: servida desde la caché, sin más llamadas a EA.
|
||||||
|
if rec := get(); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status con caché = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if n := eaCalls.Load(); n != int64(daysInMonth) {
|
||||||
|
t.Errorf("la caché no se usó: %d llamadas a EA", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parámetros inválidos ⇒ 400 (meses pasados, sin servicio, formato malo).
|
||||||
|
for _, qs := range []string{
|
||||||
|
"month=2020-01&serviceId=3",
|
||||||
|
"month=" + month,
|
||||||
|
"month=13-2026&serviceId=3",
|
||||||
|
} {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler(rec, httptest.NewRequest(http.MethodGet, "/api/month-availability?"+qs, nil))
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400 (%s)", rec.Code, qs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sin apellidos ⇒ 400 sin llegar a llamar a EA.
|
||||||
|
func TestHandleBookRequiresApellidos(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { called = true })
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
ea := &eaClient{base: srv.URL, http: srv.Client()}
|
||||||
|
|
||||||
|
body := `{"nombre":"Ana","email":"a@x.com","telefono":"600","serviceIds":[1],"date":"2026-07-01","time":"10:00"}`
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleBook(ea)(rec, httptest.NewRequest(http.MethodPost, "/api/book", strings.NewReader(body)))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Error("se llamó a EA con datos incompletos")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sin teléfono (o sin ningún dígito) ⇒ 400 sin llamar a EA: es el dato que
|
||||||
|
// identifica al cliente. El email en cambio ya no es obligatorio.
|
||||||
|
func TestHandleBookRequiresTelefono(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true }))
|
||||||
|
defer srv.Close()
|
||||||
|
ea := &eaClient{base: srv.URL, http: srv.Client()}
|
||||||
|
|
||||||
|
for _, body := range []string{
|
||||||
|
`{"nombre":"Ana","apellidos":"López","serviceIds":[1],"date":"2026-07-01","time":"10:00"}`,
|
||||||
|
`{"nombre":"Ana","apellidos":"López","telefono":"--","serviceIds":[1],"date":"2026-07-01","time":"10:00"}`,
|
||||||
|
} {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleBook(ea)(rec, httptest.NewRequest(http.MethodPost, "/api/book", strings.NewReader(body)))
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400 (body %s)", rec.Code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Error("se llamó a EA con datos incompletos")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Backup diario. Se ejecuta EN EL SERVIDOR (cron): dump consistente de MySQL
|
||||||
|
# con el contenedor vivo + copia de .env, en /srv/backups/may, que es la
|
||||||
|
# carpeta que zerobyte monta por SSH desde la otra máquina.
|
||||||
|
# Instalación, zerobyte y restauración: SIGUIENTES-PASOS.md → "Backups".
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
BACKUP_DIR=/srv/backups/may
|
||||||
|
APP_DIR=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
|
STAMP=$(date +%F_%H%M)
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR/mysql" "$BACKUP_DIR/config"
|
||||||
|
|
||||||
|
# --single-transaction: snapshot InnoDB consistente sin bloquear ni parar nada.
|
||||||
|
# MYSQL_PWD se expande DENTRO del contenedor (comillas simples): la contraseña
|
||||||
|
# no pasa por este script ni por la lista de procesos del host.
|
||||||
|
TMP="$BACKUP_DIR/mysql/.easyappointments_$STAMP.part"
|
||||||
|
docker exec may-ea-mysql sh -c \
|
||||||
|
'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysqldump --single-transaction --quick \
|
||||||
|
--routines --triggers --events --databases easyappointments' > "$TMP"
|
||||||
|
grep -q 'Dump completed' "$TMP" # mysqldump lo escribe al final: si falta, quedó truncado
|
||||||
|
mv "$TMP" "$BACKUP_DIR/mysql/easyappointments_$STAMP.sql"
|
||||||
|
|
||||||
|
# Lo único necesario para restaurar que no está en git.
|
||||||
|
install -m 600 "$APP_DIR/.env" "$BACKUP_DIR/config/env"
|
||||||
|
|
||||||
|
# Retención local corta; el histórico largo son los snapshots de zerobyte.
|
||||||
|
find "$BACKUP_DIR/mysql" -name '*.sql' -mtime +7 -delete
|
||||||
|
find "$BACKUP_DIR/mysql" -name '.*.part' -mtime +1 -delete
|
||||||
|
|
||||||
|
echo "$(date -Is) OK easyappointments_$STAMP.sql"
|
||||||
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 868 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -3,61 +3,71 @@
|
|||||||
<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 HairCare — Peluquería en Barcelona</title>
|
<title>MAY STUDIO — Peluquería en Barcelona</title>
|
||||||
<meta name="description" content="May HairCare, 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">
|
||||||
|
<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>HairCare</span></a>
|
<a href="#" class="logo">MAY <span>STUDIO</span></a>
|
||||||
<ul class="nav-links">
|
<ul class="nav-links">
|
||||||
<li><a href="#servicios">Servicios</a></li>
|
|
||||||
<li><a href="#galeria">Galería</a></li>
|
|
||||||
<li><a href="#profesional">La profesional</a></li>
|
<li><a href="#profesional">La profesional</a></li>
|
||||||
<li><a href="#horario">Horario</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 HairCare">
|
<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 HairCare<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>
|
||||||
<a href="#servicios" class="btn btn-secondary">Ver servicios</a>
|
<a href="#galeria" class="btn btn-secondary">Ver nuestro trabajo</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- SERVICIOS -->
|
<!-- PROFESIONAL (Mayra) -->
|
||||||
<section id="servicios" class="section">
|
<section id="profesional" class="section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="section-title">Nuestros servicios</h2>
|
<div class="profesional-grid">
|
||||||
<p class="section-subtitle">Cuidamos de tu pelo con los mejores productos y técnicas</p>
|
<div class="profesional-img">
|
||||||
<div class="services-grid" id="services-grid">
|
<img src="/assets/may.jpg" alt="Mayra, peluquera profesional en MAY STUDIO">
|
||||||
<p class="services-loading">Cargando servicios…</p>
|
</div>
|
||||||
|
<div class="profesional-info">
|
||||||
|
<h3>Mayra</h3>
|
||||||
|
<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>Estoy especializada en color, mechas y cortes, buscando siempre resultados naturales, equilibrados y elegantes.</p>
|
||||||
|
<p>Cada cliente recibe una atención individualizada, analizando su cabello y su estilo para crear un resultado que sea bonito, favorecedor y fácil de mantener día a día.</p>
|
||||||
|
<p>MAY STUDIO nace con la idea de ofrecer un ambiente tranquilo donde el cuidado del cabello y la atención al detalle son lo más importante.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- GALERÍA -->
|
<!-- GALERÍA / Nuestro trabajo -->
|
||||||
<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">Algunos de los looks que hemos creado para nuestras clientas</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>
|
||||||
@@ -69,26 +79,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- PROFESIONAL -->
|
<!-- RESERVAR -->
|
||||||
<section id="profesional" class="section">
|
<section id="reservar" class="section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="profesional-grid">
|
<h2 class="section-title">Reservar cita</h2>
|
||||||
<div class="profesional-img">
|
<p class="section-subtitle">Elige uno o varios servicios, día y hora para tu cita</p>
|
||||||
<img src="/assets/may.jpg" alt="May, peluquera profesional en May HairCare">
|
|
||||||
</div>
|
|
||||||
<div class="profesional-info">
|
|
||||||
<h3>May</h3>
|
|
||||||
<p class="profesional-role">Peluquera & fundadora</p>
|
|
||||||
<p>Con más de 15 años de experiencia en peluquería profesional, May ha hecho del cuidado del cabello su vocación. Especializada en coloración, corte y tratamientos capilares, trabaja con técnicas actuales y productos de alta calidad para ofrecer resultados duraderos. Su trato cercano y la atención personalizada a cada cliente son la base de May HairCare.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- HORARIO -->
|
<!-- Horario integrado -->
|
||||||
<section id="horario" class="section section-alt">
|
<div style="max-width: 500px; margin: 0 auto 32px;">
|
||||||
<div class="container">
|
<h3 style="font-family: 'DM Serif Display', serif; font-size: 1.1rem; text-align: center; margin-bottom: 12px;">Horario</h3>
|
||||||
<h2 class="section-title">Horario</h2>
|
|
||||||
<div class="schedule">
|
<div class="schedule">
|
||||||
<div class="schedule-row">
|
<div class="schedule-row">
|
||||||
<span>Lunes a Viernes</span>
|
<span>Lunes a Viernes</span>
|
||||||
@@ -105,13 +104,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="schedule-note">Llámanos o reserva online. Te atendemos con o sin cita.</p>
|
<p class="schedule-note">Llámanos o reserva online. Te atendemos con o sin cita.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- RESERVAR -->
|
|
||||||
<section id="reservar" class="section">
|
|
||||||
<div class="container">
|
|
||||||
<h2 class="section-title">Reservar cita</h2>
|
|
||||||
<p class="section-subtitle">Elige uno o varios servicios, día y hora para tu cita</p>
|
|
||||||
|
|
||||||
<!-- Service selector -->
|
<!-- Service selector -->
|
||||||
<div class="service-selector" id="service-selector">
|
<div class="service-selector" id="service-selector">
|
||||||
@@ -138,7 +130,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Right: slots + contact form -->
|
<!-- Right: slots + contact form -->
|
||||||
<div class="cal-right">
|
<div class="cal-right">
|
||||||
<div id="slots-placeholder" class="slots-placeholder">Selecciona primero el servicio que deseas</div>
|
<div id="slots-placeholder" class="slots-placeholder">Selecciona primero el/los servicio(s) que deseas</div>
|
||||||
<div id="slots-container" style="display:none">
|
<div id="slots-container" style="display:none">
|
||||||
<h3 class="slots-title" id="slots-title"></h3>
|
<h3 class="slots-title" id="slots-title"></h3>
|
||||||
<div class="slots-grid" id="slots-grid"></div>
|
<div class="slots-grid" id="slots-grid"></div>
|
||||||
@@ -147,14 +139,18 @@
|
|||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="nombre">Nombre *</label>
|
<label for="nombre">Nombre *</label>
|
||||||
<input type="text" id="nombre" name="nombre" autocomplete="name" required>
|
<input type="text" id="nombre" name="nombre" autocomplete="given-name" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="email">Email *</label>
|
<label for="apellidos">Apellidos *</label>
|
||||||
<input type="email" id="email" name="email" autocomplete="email" required>
|
<input type="text" id="apellidos" name="apellidos" autocomplete="family-name" required>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email (opcional)</label>
|
||||||
|
<input type="email" id="email" name="email" autocomplete="email">
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="telefono">Teléfono *</label>
|
<label for="telefono">Teléfono *</label>
|
||||||
<input type="tel" id="telefono" name="telefono" autocomplete="tel" required>
|
<input type="tel" id="telefono" name="telefono" autocomplete="tel" required>
|
||||||
@@ -164,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>
|
||||||
@@ -180,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 HairCare">
|
loading="lazy" title="Mapa MAY STUDIO">
|
||||||
</iframe>
|
</iframe>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,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>HairCare</span></span>
|
<span class="logo">MAY <span>STUDIO</span></span>
|
||||||
<p>© 2026 May HairCare. Tu peluquería en Horta.</p>
|
<p>© 2026 MAY STUDIO. Tu peluquería en Horta.</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
@@ -216,62 +212,151 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// === EasyAppointments config ===
|
// === EasyAppointments config ===
|
||||||
// IMPORTANTE: Después de configurar EasyAppointments, ve al admin (http://localhost:8888)
|
// El id del provider lo resuelve el servidor (/api/config): autodescubierto
|
||||||
// y crea al menos un Provider. Anota su ID y ponlo aquí.
|
// en EasyAppointments o fijado con EA_PROVIDER_ID en .env. Aquí nunca va
|
||||||
const PROVIDER_ID = 1;
|
// hardcodeado, así el mismo HTML sirve en desarrollo y en producción.
|
||||||
|
let PROVIDER_ID = null;
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
const res = await fetch('/api/config');
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
|
PROVIDER_ID = (await res.json()).providerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Horario desde EasyAppointments (Settings → Business, company_working_plan) ----
|
||||||
|
// Si la petición falla se mantiene el horario estático del HTML.
|
||||||
|
async function loadSchedule() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/ea-api/v1/settings/company_working_plan');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const setting = await res.json();
|
||||||
|
const plan = JSON.parse(setting.value);
|
||||||
|
|
||||||
|
const DAYS = [
|
||||||
|
['monday', 'Lunes'], ['tuesday', 'Martes'], ['wednesday', 'Miércoles'],
|
||||||
|
['thursday', 'Jueves'], ['friday', 'Viernes'], ['saturday', 'Sábado'], ['sunday', 'Domingo']
|
||||||
|
];
|
||||||
|
const fmt = t => t.replace(/^0/, '');
|
||||||
|
// "09:00-18:00 con pausa 14:30-15:00" → "9:00 — 14:30 / 15:00 — 18:00"
|
||||||
|
const dayLabel = p => {
|
||||||
|
if (!p || !p.start || !p.end) return null; // cerrado
|
||||||
|
const breaks = (p.breaks || []).slice().sort((a, b) => a.start.localeCompare(b.start));
|
||||||
|
const segs = [];
|
||||||
|
let cur = p.start;
|
||||||
|
for (const b of breaks) {
|
||||||
|
if (b.start > cur) segs.push(fmt(cur) + ' — ' + fmt(b.start));
|
||||||
|
if (b.end > cur) cur = b.end;
|
||||||
|
}
|
||||||
|
if (cur < p.end) segs.push(fmt(cur) + ' — ' + fmt(p.end));
|
||||||
|
return segs.length ? segs.join(' / ') : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Agrupa días consecutivos con el mismo horario.
|
||||||
|
const groups = [];
|
||||||
|
for (const [key, nombre] of DAYS) {
|
||||||
|
const label = dayLabel(plan[key]);
|
||||||
|
const last = groups[groups.length - 1];
|
||||||
|
if (last && last.label === label) last.dias.push(nombre);
|
||||||
|
else groups.push({ label, dias: [nombre] });
|
||||||
|
}
|
||||||
|
const rows = groups.map(g => {
|
||||||
|
const dias = g.dias.length === 1 ? g.dias[0]
|
||||||
|
: g.dias.length === 2 ? g.dias[0] + ' y ' + g.dias[1]
|
||||||
|
: g.dias[0] + ' a ' + g.dias[g.dias.length - 1];
|
||||||
|
return g.label
|
||||||
|
? `<div class="schedule-row"><span>${esc(dias)}</span><span>${esc(g.label)}</span></div>`
|
||||||
|
: `<div class="schedule-row closed"><span>${esc(dias)}</span><span>Cerrado</span></div>`;
|
||||||
|
}).join('');
|
||||||
|
if (rows) document.querySelector('.schedule').innerHTML = rows;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('No se pudo cargar el horario desde EA; se muestra el estático.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Service selector state ----
|
// ---- Service selector state ----
|
||||||
let selectedCategory = null;
|
|
||||||
let selectedServices = []; // [{id, nombre, duracion}]
|
let selectedServices = []; // [{id, nombre, duracion}]
|
||||||
let totalDuration = 0;
|
let totalDuration = 0;
|
||||||
let allServices = []; // cache de servicios EA
|
|
||||||
|
|
||||||
// ---- Services (ahora desde EasyAppointments) ----
|
// ---- Categories and Services from EasyAppointments ----
|
||||||
async function loadServices() {
|
async function loadCategories() {
|
||||||
const grid = document.getElementById('services-grid');
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/ea-api/v1/services');
|
let res = await fetch('/ea-api/v1/categories');
|
||||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
if (!res.ok) {
|
||||||
const services = await res.json();
|
res = await fetch('/ea-api/v1/service_categories');
|
||||||
allServices = services;
|
}
|
||||||
|
if (!res.ok) return [];
|
||||||
|
return await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('No se pudieron cargar las categorías de EA');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Agrupamos en una categoría simple (puedes crear categorías en EA y extender esto)
|
async function loadServices() {
|
||||||
const fakeCategorias = [{
|
const selectorContainer = document.getElementById('service-selector');
|
||||||
nombre: "Servicios",
|
try {
|
||||||
imagen: "",
|
const [servicesRes, categories] = await Promise.all([
|
||||||
servicios: services.map(s => ({
|
fetch('/ea-api/v1/services'),
|
||||||
|
loadCategories()
|
||||||
|
]);
|
||||||
|
if (!servicesRes.ok) throw new Error('HTTP ' + servicesRes.status);
|
||||||
|
const services = await servicesRes.json();
|
||||||
|
|
||||||
|
// Filter services to those assigned to this provider
|
||||||
|
let displayServices = services;
|
||||||
|
try {
|
||||||
|
const provRes = await fetch(`/ea-api/v1/providers/${PROVIDER_ID}`);
|
||||||
|
if (provRes.ok) {
|
||||||
|
const prov = await provRes.json();
|
||||||
|
const allowed = (prov.services || prov.service_ids || []).map(id => parseInt(id));
|
||||||
|
if (allowed.length > 0) {
|
||||||
|
displayServices = services.filter(s => allowed.includes(parseInt(s.id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
const catMap = {};
|
||||||
|
(categories || []).forEach(cat => {
|
||||||
|
catMap[cat.id] = cat;
|
||||||
|
});
|
||||||
|
|
||||||
|
const grouped = {};
|
||||||
|
displayServices.forEach(s => {
|
||||||
|
const catId = s.categoryId ?? s.serviceCategoryId ?? 'uncat';
|
||||||
|
if (!grouped[catId]) {
|
||||||
|
const catInfo = catMap[catId] || {};
|
||||||
|
grouped[catId] = {
|
||||||
|
nombre: catInfo.name || catInfo.title || 'Otros servicios',
|
||||||
|
servicios: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
grouped[catId].servicios.push({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
nombre: s.name,
|
nombre: s.name,
|
||||||
precio: (parseFloat(s.price) || 0) + " €",
|
precio: (parseFloat(s.price) || 0) + " €",
|
||||||
duracion: s.duration || 30
|
duracion: s.duration || 30
|
||||||
}))
|
});
|
||||||
}];
|
});
|
||||||
|
|
||||||
renderServices(fakeCategorias, grid);
|
let categorias = Object.values(grouped);
|
||||||
} catch (e) {
|
// Sort categories alphabetically for consistent order
|
||||||
console.error(e);
|
categorias.sort((a, b) => a.nombre.localeCompare(b.nombre, 'es'));
|
||||||
grid.innerHTML = '<p class="services-loading">No se pudieron cargar los servicios desde EasyAppointments.</p>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderServices(categorias, grid) {
|
// Sort services inside each category
|
||||||
grid.innerHTML = categorias.map(cat => `
|
categorias.forEach(cat => {
|
||||||
<div class="service-card">
|
cat.servicios.sort((a, b) => a.nombre.localeCompare(b.nombre, 'es'));
|
||||||
<div class="service-img">
|
});
|
||||||
<img src="${esc(cat.imagen)}" alt="${esc(cat.nombre)}">
|
|
||||||
</div>
|
if (categorias.length === 0 || displayServices.length === 0) {
|
||||||
<div class="service-body">
|
selectorContainer.innerHTML = '<p class="services-loading">No hay servicios asignados para este proveedor o categorías.</p>';
|
||||||
<h3>${esc(cat.nombre)}</h3>
|
return;
|
||||||
<ul class="service-list">
|
}
|
||||||
${cat.servicios.map(s => `
|
|
||||||
<li><span>${esc(s.nombre)}</span><span class="price">${esc(s.precio)}</span></li>
|
|
||||||
`).join('')}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
renderServiceSelector(categorias);
|
renderServiceSelector(categorias);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
if (selectorContainer) selectorContainer.innerHTML = '<p class="services-loading">No se pudieron cargar los servicios. Inténtalo más tarde o llámanos.</p>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderServiceSelector(categorias) {
|
function renderServiceSelector(categorias) {
|
||||||
@@ -296,7 +381,10 @@
|
|||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
// Listener se añade una vez desde loadServices
|
// Listener se añade una vez desde loadServices
|
||||||
|
if (!container.dataset.listenerAttached) {
|
||||||
container.addEventListener('change', handleServiceChange);
|
container.addEventListener('change', handleServiceChange);
|
||||||
|
container.dataset.listenerAttached = 'true';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleServiceChange(e) {
|
function handleServiceChange(e) {
|
||||||
@@ -315,12 +403,9 @@
|
|||||||
totalDuration += parseInt(cb.dataset.duracion) || 30;
|
totalDuration += parseInt(cb.dataset.duracion) || 30;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Lock to one category
|
// No lock to category anymore - services from different categories can be selected
|
||||||
const first = document.querySelector('#service-selector input[type=checkbox]:checked');
|
|
||||||
selectedCategory = first ? first.dataset.cat : null;
|
|
||||||
|
|
||||||
updateServiceDisabledState();
|
|
||||||
updateServiceSummary();
|
updateServiceSummary();
|
||||||
|
loadMonthAvailability();
|
||||||
|
|
||||||
if (selectedDate) {
|
if (selectedDate) {
|
||||||
selectedHora = null;
|
selectedHora = null;
|
||||||
@@ -331,19 +416,11 @@
|
|||||||
} else {
|
} else {
|
||||||
const ph = document.getElementById('slots-placeholder');
|
const ph = document.getElementById('slots-placeholder');
|
||||||
ph.textContent = selectedServices.length === 0
|
ph.textContent = selectedServices.length === 0
|
||||||
? 'Selecciona primero el servicio que deseas'
|
? 'Selecciona primero el/los servicio(s) que deseas'
|
||||||
: 'Selecciona un día en el calendario';
|
: 'Selecciona un día en el calendario';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateServiceDisabledState() {
|
|
||||||
document.querySelectorAll('#service-selector .svc-cat-card').forEach(card => {
|
|
||||||
const locked = selectedCategory !== null && card.dataset.cat !== selectedCategory;
|
|
||||||
card.classList.toggle('svc-cat-locked', locked);
|
|
||||||
card.querySelectorAll('input[type=checkbox]').forEach(cb => { cb.disabled = locked; });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateServiceSummary() {
|
function updateServiceSummary() {
|
||||||
const bar = document.getElementById('service-summary');
|
const bar = document.getElementById('service-summary');
|
||||||
if (selectedServices.length === 0) { bar.style.display = 'none'; return; }
|
if (selectedServices.length === 0) { bar.style.display = 'none'; return; }
|
||||||
@@ -360,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'];
|
||||||
|
|
||||||
@@ -431,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;
|
||||||
@@ -531,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();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -556,106 +658,55 @@
|
|||||||
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 HairCare'
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
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) {
|
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; }
|
||||||
if (!selectedDate || !selectedHora) { alert('Selecciona día y hora'); return; }
|
if (!selectedDate || !selectedHora) { alert('Selecciona día y hora'); return; }
|
||||||
|
|
||||||
const email = form.email.value.trim();
|
const nombre = form.nombre.value.trim();
|
||||||
if (!email) { alert('El email es obligatorio'); return; }
|
const apellidos = form.apellidos.value.trim();
|
||||||
|
// EasyAppointments exige nombre y apellido al crear el cliente.
|
||||||
|
if (!nombre || !apellidos) { alert('Escribe tu nombre y tus apellidos'); return; }
|
||||||
|
|
||||||
|
const telefono = form.telefono.value.trim();
|
||||||
|
if (!telefono) { alert('El teléfono es obligatorio'); return; }
|
||||||
|
|
||||||
submit.disabled = true;
|
submit.disabled = true;
|
||||||
submit.textContent = 'Enviando…';
|
submit.textContent = 'Enviando…';
|
||||||
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: nombre,
|
||||||
form.telefono.value.trim()
|
apellidos: apellidos,
|
||||||
);
|
email: form.email.value.trim(),
|
||||||
|
telefono: telefono,
|
||||||
// 2. Preparar appointment (usamos el servicio principal - el de mayor duración - + notas con todos)
|
mensaje: form.mensaje.value.trim(),
|
||||||
const primaryServiceId = getPrimaryServiceId();
|
serviceIds: selectedServices.map(s => s.id).filter(Boolean),
|
||||||
const primaryService = selectedServices.find(s => s.id === primaryServiceId) || selectedServices[0];
|
date: selectedDate,
|
||||||
if (!primaryService || !primaryService.id) {
|
time: selectedHora
|
||||||
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,
|
|
||||||
customerId: customerId,
|
|
||||||
notes: notes,
|
|
||||||
status: 'booked'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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) {
|
||||||
@@ -667,26 +718,31 @@
|
|||||||
renderSlots(savedDate, savedDow);
|
renderSlots(savedDate, savedDow);
|
||||||
form.style.display = 'none';
|
form.style.display = 'none';
|
||||||
result.className = 'booking-result booking-success';
|
result.className = 'booking-result booking-success';
|
||||||
result.textContent = '¡Reserva confirmada! Te esperamos. Gestionada con EasyAppointments.';
|
result.textContent = '¡Reserva confirmada! Te esperamos.';
|
||||||
} 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);
|
||||||
result.className = 'booking-result booking-error';
|
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';
|
result.style.display = 'block';
|
||||||
submit.disabled = false;
|
|
||||||
submit.textContent = 'Confirmar reserva';
|
submit.textContent = 'Confirmar reserva';
|
||||||
|
updateSubmitState();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
loadServices();
|
loadSchedule();
|
||||||
|
// loadServices filtra por provider y las availabilities usan su id:
|
||||||
|
// primero la config. Si falla, los servicios se cargan igual (sin filtro).
|
||||||
|
loadConfig().catch(function(e) {
|
||||||
|
console.error('Error cargando config:', e);
|
||||||
|
}).finally(loadServices);
|
||||||
initCalendar();
|
initCalendar();
|
||||||
initBookingForm();
|
initBookingForm();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -108,15 +113,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 +186,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 +468,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 +546,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 +631,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);
|
||||||
}
|
}
|
||||||
|
|||||||