first commit
@@ -0,0 +1,11 @@
|
||||
FROM golang:1.24-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
COPY main.go ./
|
||||
COPY static/ ./static/
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /server .
|
||||
|
||||
FROM scratch
|
||||
COPY --from=build /server /server
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/server"]
|
||||
@@ -0,0 +1,84 @@
|
||||
# May Hair Care — Landing Page + EasyAppointments
|
||||
|
||||
Landing page bonita para May HairCare (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**.
|
||||
|
||||
## Stack actual
|
||||
|
||||
- **Web (estático)**: Go + proxy reverso a EasyAppointments API
|
||||
- **Gestión de citas, clientes y servicios**: EasyAppointments (PHP + MySQL)
|
||||
- **Base de datos**: MySQL (para EA)
|
||||
- **Docker Compose**: todo junto (web + easyappointments + mysql)
|
||||
|
||||
## Cómo ejecutar (Desarrollo y Producción)
|
||||
|
||||
### 1. Levantar todo
|
||||
|
||||
```sh
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
- Landing + reservas: **http://localhost:8234**
|
||||
- Panel de administración EasyAppointments: **http://localhost:8888**
|
||||
|
||||
### 2. Primer arranque (importante)
|
||||
|
||||
1. Abre http://localhost:8888
|
||||
2. Completa el asistente de instalación de EasyAppointments.
|
||||
3. Crea un **usuario administrador**.
|
||||
4. Ve a **Providers** → crea al menos un proveedor (ej. "May").
|
||||
5. Ve a **Services** → crea tus servicios (puedes usar la lista anterior de servicios como referencia).
|
||||
6. Asigna los servicios al proveedor.
|
||||
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;`
|
||||
|
||||
### 3. Variables importantes en compose
|
||||
|
||||
Edita `docker-compose.yml` antes de levantar:
|
||||
|
||||
- `EA_API_USERNAME` + `EA_API_PASSWORD` (o mejor: configura **API Key** en Settings de EA y usa `EA_API_KEY`)
|
||||
- `BASE_URL` del servicio `easyappointments`
|
||||
- Contraseña de MySQL
|
||||
|
||||
### Actualizar
|
||||
|
||||
```sh
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## Notas sobre la integración
|
||||
|
||||
- Los servicios y la disponibilidad los obtiene el frontend directamente de la API de EasyAppointments.
|
||||
- Al reservar:
|
||||
- Busca o crea automáticamente el cliente usando el **email**.
|
||||
- Crea la cita en EasyAppointments (con todos los servicios seleccionados en las notas).
|
||||
- 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.
|
||||
|
||||
## Comportamiento multiservicio
|
||||
|
||||
El formulario permite seleccionar **varios servicios** (multi-checkbox).
|
||||
|
||||
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.
|
||||
- **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.
|
||||
- **Al crear la reserva**:
|
||||
- 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 calcula la hora de fin sumando la **duración total** de todos los servicios seleccionados.
|
||||
- En el campo `notes` se guarda el detalle completo:
|
||||
```
|
||||
Nombre: ...
|
||||
Email: ...
|
||||
Teléfono: ...
|
||||
Servicios: Corte + Tinte + ...
|
||||
Duración total: 120 min
|
||||
Mensaje: ...
|
||||
```
|
||||
|
||||
**Limitación**: EasyAppointments modela una cita = un servicio principal. Los servicios adicionales quedan documentados en las notas del cliente y de la cita. No se crean múltiples registros de cita.
|
||||
|
||||
Recomendación: Si usas combinaciones frecuentes, crea "servicios combinados" directamente en EasyAppointments (ej. "Corte + Tinte Señora - 120min").
|
||||
@@ -0,0 +1,43 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
container_name: may
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8234:8080"
|
||||
environment:
|
||||
- EA_API_USERNAME=admin # Usuario admin de EA (o mejor usa EA_API_KEY)
|
||||
- EA_API_PASSWORD=yourpassword
|
||||
# - EA_API_KEY=tu-api-key # Recomendado: crea API Key en Settings de EA
|
||||
depends_on:
|
||||
- easyappointments
|
||||
|
||||
# EasyAppointments - gestión de citas, clientes y servicios
|
||||
easyappointments:
|
||||
image: alextselegidis/easyappointments:latest
|
||||
container_name: may-easyappointments
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8888:80" # Panel admin: http://localhost:8888
|
||||
depends_on:
|
||||
- mysql
|
||||
environment:
|
||||
- BASE_URL=http://localhost:8888 # Cambia en producción
|
||||
- DEBUG_MODE=FALSE
|
||||
- DB_HOST=mysql
|
||||
- DB_NAME=easyappointments
|
||||
- DB_USERNAME=root
|
||||
- DB_PASSWORD=secret # Cambia por contraseña fuerte
|
||||
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: may-ea-mysql
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- MYSQL_ROOT_PASSWORD=secret
|
||||
- MYSQL_DATABASE=easyappointments
|
||||
volumes:
|
||||
- ea-mysql-data:/var/lib/mysql
|
||||
|
||||
volumes:
|
||||
ea-mysql-data:
|
||||
@@ -0,0 +1,59 @@
|
||||
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
||||
|
||||
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||
|
||||
## 1. Think Before Coding
|
||||
|
||||
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||
|
||||
Before implementing:
|
||||
- State your assumptions explicitly. If uncertain, ask.
|
||||
- If multiple interpretations exist, present them - don't pick silently.
|
||||
- If a simpler approach exists, say so. Push back when warranted.
|
||||
- If something is unclear, stop. Name what's confusing. Ask.
|
||||
|
||||
## 2. Simplicity First
|
||||
|
||||
**Minimum code that solves the problem. Nothing speculative.**
|
||||
|
||||
- No features beyond what was asked.
|
||||
- No abstractions for single-use code.
|
||||
- No "flexibility" or "configurability" that wasn't requested.
|
||||
- No error handling for impossible scenarios.
|
||||
- If you write 200 lines and it could be 50, rewrite it.
|
||||
|
||||
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||
|
||||
## 3. Surgical Changes
|
||||
|
||||
**Touch only what you must. Clean up only your own mess.**
|
||||
|
||||
When editing existing code:
|
||||
- Don't "improve" adjacent code, comments, or formatting.
|
||||
- Don't refactor things that aren't broken.
|
||||
- Match existing style, even if you'd do it differently.
|
||||
- If you notice unrelated dead code, mention it - don't delete it.
|
||||
|
||||
When your changes create orphans:
|
||||
- Remove imports/variables/functions that YOUR changes made unused.
|
||||
- Don't remove pre-existing dead code unless asked.
|
||||
|
||||
The test: Every changed line should trace directly to the user's request.
|
||||
|
||||
## 4. Goal-Driven Execution
|
||||
|
||||
**Define success criteria. Loop until verified.**
|
||||
|
||||
Transform tasks into verifiable goals:
|
||||
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||
- "Refactor X" → "Ensure tests pass before and after"
|
||||
|
||||
For multi-step tasks, state a brief plan:
|
||||
```
|
||||
1. [Step] → verify: [check]
|
||||
2. [Step] → verify: [check]
|
||||
3. [Step] → verify: [check]
|
||||
```
|
||||
|
||||
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed all:static
|
||||
var static embed.FS
|
||||
|
||||
func main() {
|
||||
port := flag.Int("port", 8080, "puerto del servidor")
|
||||
flag.Parse()
|
||||
|
||||
staticFS, err := fs.Sub(static, "static")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Proxy a EasyAppointments API
|
||||
// El frontend llama a /ea-api/v1/... y aquí se reescribe y se añade auth
|
||||
eaTarget := "http://easyappointments:80"
|
||||
if t := os.Getenv("EA_TARGET"); t != "" {
|
||||
eaTarget = t
|
||||
}
|
||||
eaURL, err := url.Parse(eaTarget)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
eaProxy := httputil.NewSingleHostReverseProxy(eaURL)
|
||||
|
||||
http.HandleFunc("/ea-api/", func(w http.ResponseWriter, r *http.Request) {
|
||||
// /ea-api/v1/services → /index.php/api/v1/services
|
||||
r.URL.Path = strings.Replace(r.URL.Path, "/ea-api", "/index.php/api", 1)
|
||||
|
||||
// Auth (server-side, nunca se expone al navegador)
|
||||
if apiKey := os.Getenv("EA_API_KEY"); apiKey != "" {
|
||||
r.Header.Set("Authorization", "Bearer " + apiKey)
|
||||
} else if user := os.Getenv("EA_API_USERNAME"); user != "" {
|
||||
if pass := os.Getenv("EA_API_PASSWORD"); pass != "" {
|
||||
r.SetBasicAuth(user, pass)
|
||||
}
|
||||
}
|
||||
|
||||
eaProxy.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
http.Handle("/", http.FileServer(http.FS(staticFS)))
|
||||
|
||||
addr := fmt.Sprintf(":%d", *port)
|
||||
log.Printf("Servidor iniciado en http://localhost%s", addr)
|
||||
log.Fatal(http.ListenAndServe(addr, nil))
|
||||
}
|
||||
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,696 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>May HairCare — 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.">
|
||||
<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 rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- NAV -->
|
||||
<nav class="nav">
|
||||
<div class="container nav-inner">
|
||||
<a href="#" class="logo">May <span>HairCare</span></a>
|
||||
<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="#horario">Horario</a></li>
|
||||
<li><a href="#reservar">Reservar</a></li>
|
||||
<li><a href="#contacto">Contacto</a></li>
|
||||
</ul>
|
||||
<a href="tel:+34933333333" class="btn btn-primary btn-nav">Llamar</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- HERO -->
|
||||
<header class="hero">
|
||||
<div class="hero-bg">
|
||||
<img src="/assets/hero.jpg" alt="Interior del salón May HairCare">
|
||||
</div>
|
||||
<div class="container hero-content">
|
||||
<h1>May HairCare<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>
|
||||
<div class="hero-actions">
|
||||
<a href="#reservar" class="btn btn-primary">Reservar cita</a>
|
||||
<a href="#servicios" class="btn btn-secondary">Ver servicios</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- SERVICIOS -->
|
||||
<section id="servicios" class="section">
|
||||
<div class="container">
|
||||
<h2 class="section-title">Nuestros servicios</h2>
|
||||
<p class="section-subtitle">Cuidamos de tu pelo con los mejores productos y técnicas</p>
|
||||
<div class="services-grid" id="services-grid">
|
||||
<p class="services-loading">Cargando servicios…</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- GALERÍA -->
|
||||
<section id="galeria" class="section section-alt">
|
||||
<div class="container">
|
||||
<h2 class="section-title">Nuestro trabajo</h2>
|
||||
<p class="section-subtitle">Algunos de los looks que hemos creado para nuestras clientas</p>
|
||||
<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-2.jpg" alt="Trabajo de peluquería"></div>
|
||||
<div class="gallery-item"><img src="/assets/galeria-3.jpg" alt="Trabajo de peluquería"></div>
|
||||
<div class="gallery-item"><img src="/assets/galeria-4.jpg" alt="Trabajo de peluquería"></div>
|
||||
<div class="gallery-item"><img src="/assets/galeria-5.jpg" alt="Trabajo de peluquería"></div>
|
||||
<div class="gallery-item"><img src="/assets/galeria-6.jpg" alt="Trabajo de peluquería"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PROFESIONAL -->
|
||||
<section id="profesional" class="section">
|
||||
<div class="container">
|
||||
<div class="profesional-grid">
|
||||
<div class="profesional-img">
|
||||
<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 -->
|
||||
<section id="horario" class="section section-alt">
|
||||
<div class="container">
|
||||
<h2 class="section-title">Horario</h2>
|
||||
<div class="schedule">
|
||||
<div class="schedule-row">
|
||||
<span>Lunes a Viernes</span>
|
||||
<span>9:30 — 13:30 / 16:30 — 20:30</span>
|
||||
</div>
|
||||
<div class="schedule-row">
|
||||
<span>Sábados</span>
|
||||
<span>9:00 — 14:00</span>
|
||||
</div>
|
||||
<div class="schedule-row closed">
|
||||
<span>Domingos y festivos</span>
|
||||
<span>Cerrado</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="schedule-note">Llámanos o reserva online. Te atendemos con o sin cita.</p>
|
||||
</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 -->
|
||||
<div class="service-selector" id="service-selector">
|
||||
<p class="services-loading">Cargando servicios…</p>
|
||||
</div>
|
||||
<div class="service-summary-bar" id="service-summary">
|
||||
<span id="summary-names"></span>
|
||||
<span class="summary-sep">·</span>
|
||||
<span>Duración: <strong id="summary-dur"></strong></span>
|
||||
</div>
|
||||
|
||||
<div class="booking-calendar">
|
||||
<!-- Left: calendar -->
|
||||
<div class="cal-left">
|
||||
<div class="cal-header">
|
||||
<button type="button" class="cal-nav" id="cal-prev">←</button>
|
||||
<span class="cal-month" id="cal-month"></span>
|
||||
<button type="button" class="cal-nav" id="cal-next">→</button>
|
||||
</div>
|
||||
<div class="cal-weekdays">
|
||||
<span>Lu</span><span>Ma</span><span>Mi</span><span>Ju</span><span>Vi</span><span>Sá</span><span>Do</span>
|
||||
</div>
|
||||
<div class="cal-grid" id="cal-grid"></div>
|
||||
</div>
|
||||
<!-- Right: slots + contact form -->
|
||||
<div class="cal-right">
|
||||
<div id="slots-placeholder" class="slots-placeholder">Selecciona primero el servicio que deseas</div>
|
||||
<div id="slots-container" style="display:none">
|
||||
<h3 class="slots-title" id="slots-title"></h3>
|
||||
<div class="slots-grid" id="slots-grid"></div>
|
||||
</div>
|
||||
<form id="booking-form" class="booking-mini-form" style="display:none" novalidate>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="nombre">Nombre *</label>
|
||||
<input type="text" id="nombre" name="nombre" autocomplete="name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email *</label>
|
||||
<input type="email" id="email" name="email" autocomplete="email" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="telefono">Teléfono *</label>
|
||||
<input type="tel" id="telefono" name="telefono" autocomplete="tel" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="mensaje">Mensaje (opcional)</label>
|
||||
<textarea id="mensaje" name="mensaje" rows="2" placeholder="Algo que debamos saber…"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" id="booking-submit">Confirmar reserva</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div id="booking-result" class="booking-result" style="display:none"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CONTACTO -->
|
||||
<section id="contacto" class="section section-alt">
|
||||
<div class="container">
|
||||
<h2 class="section-title">Encuéntranos</h2>
|
||||
<div class="contact-grid">
|
||||
<div class="contact-info">
|
||||
<div class="contact-item">
|
||||
<strong>Dirección</strong>
|
||||
<p>Carrer d'Horta, 12<br>08032 Barcelona (Horta)</p>
|
||||
</div>
|
||||
<div class="contact-item">
|
||||
<strong>Teléfono</strong>
|
||||
<p><a href="tel:+34933333333">933 333 333</a></p>
|
||||
</div>
|
||||
<div class="contact-item">
|
||||
<strong>Cómo llegar</strong>
|
||||
<p>Metro L5 — Horta<br>Bus V19, 45, 102</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contact-map">
|
||||
<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"
|
||||
width="100%" height="300" style="border:0; border-radius: 12px;"
|
||||
loading="lazy" title="Mapa May HairCare">
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer class="footer">
|
||||
<div class="container footer-inner">
|
||||
<span class="logo">May <span>HairCare</span></span>
|
||||
<p>© 2026 May HairCare. Tu peluquería en Horta.</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// === EasyAppointments config ===
|
||||
// IMPORTANTE: Después de configurar EasyAppointments, ve al admin (http://localhost:8888)
|
||||
// y crea al menos un Provider. Anota su ID y ponlo aquí.
|
||||
const PROVIDER_ID = 1;
|
||||
|
||||
// ---- Service selector state ----
|
||||
let selectedCategory = null;
|
||||
let selectedServices = []; // [{id, nombre, duracion}]
|
||||
let totalDuration = 0;
|
||||
let allServices = []; // cache de servicios EA
|
||||
|
||||
// ---- Services (ahora desde EasyAppointments) ----
|
||||
async function loadServices() {
|
||||
const grid = document.getElementById('services-grid');
|
||||
try {
|
||||
const res = await fetch('/ea-api/v1/services');
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const services = await res.json();
|
||||
allServices = services;
|
||||
|
||||
// Agrupamos en una categoría simple (puedes crear categorías en EA y extender esto)
|
||||
const fakeCategorias = [{
|
||||
nombre: "Servicios",
|
||||
imagen: "",
|
||||
servicios: services.map(s => ({
|
||||
id: s.id,
|
||||
nombre: s.name,
|
||||
precio: (parseFloat(s.price) || 0) + " €",
|
||||
duracion: s.duration || 30
|
||||
}))
|
||||
}];
|
||||
|
||||
renderServices(fakeCategorias, grid);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
grid.innerHTML = '<p class="services-loading">No se pudieron cargar los servicios desde EasyAppointments.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderServices(categorias, grid) {
|
||||
grid.innerHTML = categorias.map(cat => `
|
||||
<div class="service-card">
|
||||
<div class="service-img">
|
||||
<img src="${esc(cat.imagen)}" alt="${esc(cat.nombre)}">
|
||||
</div>
|
||||
<div class="service-body">
|
||||
<h3>${esc(cat.nombre)}</h3>
|
||||
<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);
|
||||
}
|
||||
|
||||
function renderServiceSelector(categorias) {
|
||||
const container = document.getElementById('service-selector');
|
||||
container.innerHTML = categorias.map(cat => `
|
||||
<div class="svc-cat-card" data-cat="${esc(cat.nombre)}">
|
||||
<h4 class="svc-cat-title">${esc(cat.nombre)}</h4>
|
||||
<div class="svc-options">
|
||||
${cat.servicios.map(s => `
|
||||
<label class="svc-option">
|
||||
<input type="checkbox"
|
||||
value="${esc(s.nombre)}"
|
||||
data-cat="${esc(cat.nombre)}"
|
||||
data-duracion="${s.duracion || 30}"
|
||||
data-id="${s.id || ''}">
|
||||
<span class="svc-name">${esc(s.nombre)}</span>
|
||||
<span class="svc-meta">${s.duracion || 30} min · ${esc(s.precio)}</span>
|
||||
</label>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Listener se añade una vez desde loadServices
|
||||
container.addEventListener('change', handleServiceChange);
|
||||
}
|
||||
|
||||
function handleServiceChange(e) {
|
||||
if (e.target.type !== 'checkbox') return;
|
||||
|
||||
// Rebuild selected services from all checked boxes (incluye id de EA).
|
||||
const checked = document.querySelectorAll('#service-selector input[type=checkbox]:checked');
|
||||
selectedServices = [];
|
||||
totalDuration = 0;
|
||||
checked.forEach(cb => {
|
||||
selectedServices.push({
|
||||
id: parseInt(cb.dataset.id) || null,
|
||||
nombre: cb.value,
|
||||
duracion: parseInt(cb.dataset.duracion) || 30
|
||||
});
|
||||
totalDuration += parseInt(cb.dataset.duracion) || 30;
|
||||
});
|
||||
|
||||
// Lock to one category
|
||||
const first = document.querySelector('#service-selector input[type=checkbox]:checked');
|
||||
selectedCategory = first ? first.dataset.cat : null;
|
||||
|
||||
updateServiceDisabledState();
|
||||
updateServiceSummary();
|
||||
|
||||
if (selectedDate) {
|
||||
selectedHora = null;
|
||||
document.getElementById('booking-form').style.display = 'none';
|
||||
const parts = selectedDate.split('-');
|
||||
const dow = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2])).getDay();
|
||||
renderSlots(selectedDate, dow);
|
||||
} else {
|
||||
const ph = document.getElementById('slots-placeholder');
|
||||
ph.textContent = selectedServices.length === 0
|
||||
? 'Selecciona primero el servicio que deseas'
|
||||
: '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() {
|
||||
const bar = document.getElementById('service-summary');
|
||||
if (selectedServices.length === 0) { bar.style.display = 'none'; return; }
|
||||
bar.style.display = 'flex';
|
||||
document.getElementById('summary-names').textContent = selectedServices.map(s => s.nombre).join(' + ');
|
||||
const h = Math.floor(totalDuration / 60);
|
||||
const m = totalDuration % 60;
|
||||
document.getElementById('summary-dur').textContent =
|
||||
h > 0 ? (m > 0 ? h + ' h ' + m + ' min' : h + ' h') : m + ' min';
|
||||
}
|
||||
|
||||
// ---- Calendar state (usando availabilities de EasyAppointments) ----
|
||||
let calYear, calMonth;
|
||||
let selectedDate = null;
|
||||
let selectedHora = null;
|
||||
let availableSlotsForDate = []; // array de "HH:mm" devueltos por EA
|
||||
|
||||
const MONTH_NAMES = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'];
|
||||
|
||||
function pad(n) { return n < 10 ? '0' + n : '' + n; }
|
||||
function fmtDate(y, m, d) { return y + '-' + pad(m + 1) + '-' + pad(d); }
|
||||
|
||||
function timeToMinutes(hora) {
|
||||
const [h, m] = hora.split(':').map(Number);
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
function minutesToTime(mins) {
|
||||
const h = Math.floor(mins / 60);
|
||||
const m = mins % 60;
|
||||
return pad(h) + ':' + pad(m);
|
||||
}
|
||||
|
||||
// Comprueba si desde 'startHora' hay un bloque consecutivo libre de 'neededMinutes'
|
||||
// usando la lista de starts devueltos por EA.
|
||||
function canFitFullBlock(startHora, neededMinutes, availableList) {
|
||||
if (!startHora || neededMinutes <= 0 || !availableList || availableList.length === 0) return false;
|
||||
const startM = timeToMinutes(startHora);
|
||||
const step = 30;
|
||||
const numSteps = Math.max(1, Math.ceil(neededMinutes / step));
|
||||
for (let i = 0; i < numSteps; i++) {
|
||||
const checkM = startM + i * step;
|
||||
const checkStr = minutesToTime(checkM);
|
||||
if (!availableList.includes(checkStr)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Obtiene el serviceId principal para consultar disponibilidad (el de mayor duración o el primero)
|
||||
function getPrimaryServiceId() {
|
||||
if (!selectedServices.length) return null;
|
||||
// Prioriza el de mayor duración
|
||||
const sorted = [...selectedServices].sort((a, b) => (b.duracion || 0) - (a.duracion || 0));
|
||||
return sorted[0].id;
|
||||
}
|
||||
|
||||
function renderCalendar() {
|
||||
document.getElementById('cal-month').textContent = MONTH_NAMES[calMonth] + ' ' + calYear;
|
||||
|
||||
const grid = document.getElementById('cal-grid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
const firstDay = new Date(calYear, calMonth, 1);
|
||||
let startDow = firstDay.getDay();
|
||||
startDow = startDow === 0 ? 6 : startDow - 1;
|
||||
|
||||
const daysInMonth = new Date(calYear, calMonth + 1, 0).getDate();
|
||||
const today = new Date();
|
||||
today.setHours(0,0,0,0);
|
||||
|
||||
for (let i = 0; i < startDow; i++) {
|
||||
const empty = document.createElement('span');
|
||||
empty.className = 'cal-day cal-empty';
|
||||
grid.appendChild(empty);
|
||||
}
|
||||
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const date = new Date(calYear, calMonth, d);
|
||||
const dateStr = fmtDate(calYear, calMonth, d);
|
||||
const dow = date.getDay();
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = d;
|
||||
btn.className = 'cal-day';
|
||||
if (date < today || dow === 0) { btn.disabled = true; btn.classList.add('cal-disabled'); }
|
||||
if (dateStr === selectedDate) btn.classList.add('cal-selected');
|
||||
btn.addEventListener('click', () => selectDay(dateStr, dow));
|
||||
grid.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function selectDay(dateStr, dow) {
|
||||
selectedDate = dateStr;
|
||||
selectedHora = null;
|
||||
document.getElementById('booking-form').style.display = 'none';
|
||||
renderCalendar();
|
||||
renderSlots(dateStr, dow);
|
||||
}
|
||||
|
||||
// Versión simplificada usando EasyAppointments
|
||||
async function renderSlots(dateStr, dow) {
|
||||
const container = document.getElementById('slots-container');
|
||||
const placeholder = document.getElementById('slots-placeholder');
|
||||
const grid = document.getElementById('slots-grid');
|
||||
const title = document.getElementById('slots-title');
|
||||
|
||||
if (selectedServices.length === 0) {
|
||||
placeholder.textContent = 'Selecciona primero el servicio que deseas';
|
||||
placeholder.style.display = '';
|
||||
container.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const primaryServiceId = getPrimaryServiceId();
|
||||
if (!primaryServiceId) {
|
||||
placeholder.textContent = 'Error seleccionando servicio principal';
|
||||
placeholder.style.display = '';
|
||||
container.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
placeholder.style.display = 'none';
|
||||
container.style.display = '';
|
||||
|
||||
const [y, m, d] = dateStr.split('-');
|
||||
const dayNames = ['Domingo','Lunes','Martes','Miércoles','Jueves','Viernes','Sábado'];
|
||||
const dateObj = new Date(parseInt(y), parseInt(m) - 1, parseInt(d));
|
||||
title.textContent = dayNames[dateObj.getDay()] + ' ' + parseInt(d) + ' de ' + MONTH_NAMES[parseInt(m) - 1];
|
||||
|
||||
try {
|
||||
const url = `/ea-api/v1/availabilities?providerId=${PROVIDER_ID}&serviceId=${primaryServiceId}&date=${dateStr}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
availableSlotsForDate = await res.json();
|
||||
} catch (e) {
|
||||
console.error('Error al obtener availabilities de EA:', e);
|
||||
availableSlotsForDate = [];
|
||||
}
|
||||
|
||||
if (!availableSlotsForDate || availableSlotsForDate.length === 0) {
|
||||
placeholder.textContent = 'No hay disponibilidad este día para el servicio seleccionado';
|
||||
placeholder.style.display = '';
|
||||
container.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Filtrado clave para multiservicio:
|
||||
// Usamos la lista de EA (basada en el servicio más largo) pero solo mostramos
|
||||
// aquellos starts desde los que cabe un bloque completo de 'totalDuration' minutos.
|
||||
const filteredSlots = availableSlotsForDate.filter(hora =>
|
||||
canFitFullBlock(hora, totalDuration, availableSlotsForDate)
|
||||
);
|
||||
|
||||
if (filteredSlots.length === 0) {
|
||||
placeholder.textContent = 'No hay bloques libres de ' + totalDuration + ' minutos';
|
||||
placeholder.style.display = '';
|
||||
container.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = filteredSlots.map(hora => {
|
||||
const isSel = hora === selectedHora;
|
||||
const cls = 'slot-btn' + (isSel ? ' slot-selected' : '');
|
||||
return `<button type="button" class="${cls}" data-hora="${esc(hora)}">${esc(hora)}</button>`;
|
||||
}).join('');
|
||||
|
||||
grid.querySelectorAll('.slot-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
selectedHora = btn.dataset.hora;
|
||||
renderSlots(dateStr, dow);
|
||||
document.getElementById('booking-form').style.display = '';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initCalendar() {
|
||||
const now = new Date();
|
||||
calYear = now.getFullYear();
|
||||
calMonth = now.getMonth();
|
||||
|
||||
document.getElementById('cal-prev').addEventListener('click', () => {
|
||||
const now = new Date();
|
||||
if (calYear === now.getFullYear() && calMonth === now.getMonth()) return;
|
||||
calMonth--;
|
||||
if (calMonth < 0) { calMonth = 11; calYear--; }
|
||||
renderCalendar();
|
||||
hideSlots();
|
||||
});
|
||||
|
||||
document.getElementById('cal-next').addEventListener('click', () => {
|
||||
calMonth++;
|
||||
if (calMonth > 11) { calMonth = 0; calYear++; }
|
||||
renderCalendar();
|
||||
hideSlots();
|
||||
});
|
||||
|
||||
renderCalendar();
|
||||
}
|
||||
|
||||
function hideSlots() {
|
||||
selectedDate = null;
|
||||
selectedHora = null;
|
||||
document.getElementById('slots-container').style.display = 'none';
|
||||
document.getElementById('slots-placeholder').style.display = '';
|
||||
document.getElementById('slots-placeholder').textContent = selectedServices.length === 0
|
||||
? 'Selecciona primero el servicio que deseas'
|
||||
: 'Selecciona un día en el calendario';
|
||||
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() {
|
||||
document.getElementById('booking-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const submit = document.getElementById('booking-submit');
|
||||
const result = document.getElementById('booking-result');
|
||||
|
||||
if (selectedServices.length === 0) { alert('Selecciona al menos un servicio'); return; }
|
||||
if (!selectedDate || !selectedHora) { alert('Selecciona día y hora'); return; }
|
||||
|
||||
const email = form.email.value.trim();
|
||||
if (!email) { alert('El email es obligatorio'); return; }
|
||||
|
||||
submit.disabled = true;
|
||||
submit.textContent = 'Enviando…';
|
||||
result.style.display = 'none';
|
||||
|
||||
try {
|
||||
// 1. Cliente (por email)
|
||||
const customerId = await findOrCreateCustomer(
|
||||
form.nombre.value.trim(),
|
||||
email,
|
||||
form.telefono.value.trim()
|
||||
);
|
||||
|
||||
// 2. Preparar appointment (usamos el servicio principal - el de mayor duración - + notas con todos)
|
||||
const primaryServiceId = getPrimaryServiceId();
|
||||
const primaryService = selectedServices.find(s => s.id === primaryServiceId) || selectedServices[0];
|
||||
if (!primaryService || !primaryService.id) {
|
||||
throw new Error('No se encontró ID de servicio válido');
|
||||
}
|
||||
|
||||
const start = `${selectedDate} ${selectedHora}:00`;
|
||||
// Duración total (EA usará la del servicio, pero ponemos nota)
|
||||
const endDate = new Date(`${selectedDate}T${selectedHora}`);
|
||||
endDate.setMinutes(endDate.getMinutes() + totalDuration);
|
||||
const endHour = pad(endDate.getHours()) + ':' + pad(endDate.getMinutes());
|
||||
|
||||
const notes = [
|
||||
`Nombre: ${form.nombre.value.trim()}`,
|
||||
`Email: ${email}`,
|
||||
`Teléfono: ${form.telefono.value.trim()}`,
|
||||
`Servicios: ${selectedServices.map(s => s.nombre).join(' + ')}`,
|
||||
`Duración total: ${totalDuration} min`,
|
||||
form.mensaje.value.trim() ? `Mensaje: ${form.mensaje.value.trim()}` : ''
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
const appointment = {
|
||||
start: start,
|
||||
end: `${selectedDate} ${endHour}:00`,
|
||||
serviceId: primaryService.id,
|
||||
providerId: PROVIDER_ID,
|
||||
customerId: customerId,
|
||||
notes: notes,
|
||||
status: 'booked'
|
||||
};
|
||||
|
||||
const res = await fetch('/ea-api/v1/appointments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(appointment),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
form.reset();
|
||||
selectedHora = null;
|
||||
// Refrescar slots
|
||||
const savedDate = selectedDate;
|
||||
const savedDow = new Date(parseInt(savedDate.split('-')[0]), parseInt(savedDate.split('-')[1])-1, parseInt(savedDate.split('-')[2])).getDay();
|
||||
renderSlots(savedDate, savedDow);
|
||||
form.style.display = 'none';
|
||||
result.className = 'booking-result booking-success';
|
||||
result.textContent = '¡Reserva confirmada! Te esperamos. Gestionada con EasyAppointments.';
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
result.className = 'booking-result booking-error';
|
||||
result.textContent = (err.message || err.error || JSON.stringify(err)) || 'Error creando la cita en EasyAppointments.';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
result.className = 'booking-result booking-error';
|
||||
result.textContent = err.message || 'No se pudo conectar con EasyAppointments.';
|
||||
}
|
||||
|
||||
result.style.display = 'block';
|
||||
submit.disabled = false;
|
||||
submit.textContent = 'Confirmar reserva';
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadServices();
|
||||
initCalendar();
|
||||
initBookingForm();
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,760 @@
|
||||
/* ============ RESET & BASE ============ */
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
--bg: #faf9f7;
|
||||
--bg-alt: #f3ede6;
|
||||
--text: #2c2c2c;
|
||||
--text-mid: #6b6b6b;
|
||||
--accent: #a67c52;
|
||||
--accent-dk:#8c6740;
|
||||
--white: #fff;
|
||||
--radius: 12px;
|
||||
}
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
img { max-width: 100%; display: block; }
|
||||
|
||||
.container {
|
||||
max-width: 1060px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
/* ============ NAV ============ */
|
||||
.nav {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0;
|
||||
background: rgba(250, 249, 247, 0.92);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 100;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.nav-inner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-family: 'DM Serif Display', serif;
|
||||
font-size: 1.3rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.logo span { color: var(--accent); }
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 28px;
|
||||
list-style: none;
|
||||
}
|
||||
.nav-links a {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-mid);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.nav-links a:hover { color: var(--accent); }
|
||||
|
||||
.btn-nav {
|
||||
padding: 8px 20px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ============ HERO ============ */
|
||||
.hero {
|
||||
position: relative;
|
||||
min-height: 520px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 140px 0 80px;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.hero-bg img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.hero-bg::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(250, 249, 247, 0.75) 0%,
|
||||
rgba(250, 249, 247, 0.55) 50%,
|
||||
rgba(250, 249, 247, 0.85) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-family: 'DM Serif Display', serif;
|
||||
font-size: clamp(2.4rem, 5vw, 3.6rem);
|
||||
line-height: 1.15;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
color: var(--text);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
max-width: 500px;
|
||||
margin: 0 auto 32px;
|
||||
text-shadow: 0 1px 8px rgba(250, 249, 247, 0.9);
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ============ BUTTONS ============ */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 12px 28px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--white);
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-dk); }
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 2px solid rgba(0,0,0,0.12);
|
||||
}
|
||||
.btn-secondary:hover { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
/* ============ SECTIONS ============ */
|
||||
.section { padding: 80px 0; }
|
||||
.section-alt { background: var(--bg-alt); }
|
||||
|
||||
.section-title {
|
||||
font-family: 'DM Serif Display', serif;
|
||||
font-size: 2rem;
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
text-align: center;
|
||||
color: var(--text-mid);
|
||||
font-size: 1rem;
|
||||
margin-bottom: 48px;
|
||||
max-width: 500px;
|
||||
margin-left: 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-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.gallery-item {
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
aspect-ratio: 1;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.gallery-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.gallery-item:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* ============ SCHEDULE ============ */
|
||||
.schedule {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.schedule-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.08);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.schedule-row.closed span:last-child { color: #c0392b; font-weight: 500; }
|
||||
|
||||
.schedule-note {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* ============ CONTACT ============ */
|
||||
.contact-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 40px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.contact-item { margin-bottom: 20px; }
|
||||
.contact-item strong {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--accent);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.contact-item p { font-size: 0.95rem; color: var(--text-mid); }
|
||||
.contact-item a:hover { color: var(--accent); }
|
||||
|
||||
/* ============ BOOKING FORM ============ */
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-row .form-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid rgba(0,0,0,0.14);
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
background: var(--white);
|
||||
color: var(--text);
|
||||
transition: border-color 0.2s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.booking-result {
|
||||
max-width: 820px;
|
||||
margin: 16px auto 0;
|
||||
padding: 14px 18px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.booking-success {
|
||||
background: #edf7ed;
|
||||
color: #2e7d32;
|
||||
border: 1px solid #a5d6a7;
|
||||
}
|
||||
|
||||
.booking-error {
|
||||
background: #fdecea;
|
||||
color: #c62828;
|
||||
border: 1px solid #ef9a9a;
|
||||
}
|
||||
|
||||
/* ============ CALENDAR ============ */
|
||||
.booking-calendar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 40px;
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.cal-left {
|
||||
background: var(--white);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.cal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 20px 0 16px;
|
||||
}
|
||||
|
||||
.cal-month {
|
||||
font-family: 'DM Serif Display', serif;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.cal-nav {
|
||||
background: none;
|
||||
border: 1px solid rgba(0,0,0,0.12);
|
||||
border-radius: 8px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: border-color 0.2s, color 0.2s;
|
||||
padding: 0;
|
||||
}
|
||||
.cal-nav:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: none;
|
||||
}
|
||||
|
||||
.cal-weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-mid);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.cal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.cal-day {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
.cal-day:hover:not(:disabled) {
|
||||
background: var(--bg-alt);
|
||||
}
|
||||
|
||||
.cal-empty {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.cal-disabled {
|
||||
color: rgba(0,0,0,0.2);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.cal-selected {
|
||||
background: var(--accent) !important;
|
||||
color: var(--white) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ============ SLOTS ============ */
|
||||
.cal-right {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.slots-placeholder {
|
||||
color: var(--text-mid);
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.slots-title {
|
||||
font-family: 'DM Serif Display', serif;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.slots-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.slot-btn {
|
||||
padding: 10px 4px;
|
||||
border: 1px solid rgba(0,0,0,0.12);
|
||||
border-radius: 8px;
|
||||
background: var(--white);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
}
|
||||
.slot-btn:hover:not(:disabled) {
|
||||
border-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 {
|
||||
background: var(--accent) !important;
|
||||
color: var(--white) !important;
|
||||
border-color: var(--accent) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.booking-mini-form {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.services-loading {
|
||||
color: var(--text-mid);
|
||||
text-align: center;
|
||||
grid-column: 1 / -1;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
/* ============ PROFESIONAL ============ */
|
||||
.profesional-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
gap: 60px;
|
||||
align-items: center;
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.profesional-img {
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
aspect-ratio: 3 / 4;
|
||||
}
|
||||
|
||||
.profesional-img img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.profesional-info h3 {
|
||||
font-family: 'DM Serif Display', serif;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.profesional-role {
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.profesional-info p {
|
||||
color: var(--text-mid);
|
||||
font-size: 0.97rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
/* ============ SERVICE SELECTOR (booking) ============ */
|
||||
.service-selector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
max-width: 820px;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
.svc-cat-card {
|
||||
background: var(--white);
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.svc-cat-card.svc-cat-locked {
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.svc-cat-title {
|
||||
font-family: 'DM Serif Display', serif;
|
||||
font-size: 1rem;
|
||||
color: var(--accent);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.svc-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.svc-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.svc-option:hover {
|
||||
background: var(--bg-alt);
|
||||
}
|
||||
|
||||
.svc-option input[type=checkbox] {
|
||||
accent-color: var(--accent);
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.svc-name {
|
||||
flex: 1;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.svc-meta {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-mid);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.service-summary-bar {
|
||||
display: none;
|
||||
max-width: 820px;
|
||||
margin: 0 auto 28px;
|
||||
padding: 12px 20px;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius);
|
||||
color: var(--white);
|
||||
font-size: 0.9rem;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.service-summary-bar #summary-names {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.service-summary-bar .summary-sep {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ============ FOOTER ============ */
|
||||
.footer {
|
||||
padding: 32px 0;
|
||||
border-top: 1px solid rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.footer-inner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.footer p { font-size: 0.82rem; color: var(--text-mid); }
|
||||
|
||||
/* ============ RESPONSIVE ============ */
|
||||
@media (max-width: 768px) {
|
||||
.services-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.gallery-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.booking-calendar {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
.slots-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.profesional-grid {
|
||||
grid-template-columns: 200px 1fr;
|
||||
gap: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.nav-links { display: none; }
|
||||
.btn-nav { display: none; }
|
||||
.contact-grid { grid-template-columns: 1fr; }
|
||||
.footer-inner { flex-direction: column; gap: 8px; text-align: center; }
|
||||
.gallery-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.profesional-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
.profesional-img {
|
||||
max-width: 200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.service-selector {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||