cambios profundos
@@ -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**.
|
||||||
|
|
||||||
@@ -64,11 +64,13 @@ 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: ...
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8234:8080"
|
- "8234:8080"
|
||||||
environment:
|
environment:
|
||||||
- EA_API_USERNAME=admin # Usuario admin de EA (o mejor usa EA_API_KEY)
|
- EA_API_USERNAME=may # Usuario de EA (ver ea_user_settings.username del admin). Usa tu password real.
|
||||||
- EA_API_PASSWORD=yourpassword
|
# EA_API_PASSWORD=yourpassword
|
||||||
# - EA_API_KEY=tu-api-key # Recomendado: crea API Key en Settings de EA
|
- EA_API_KEY=t9T6IdUkAVGRoB1Shm8MgLcoIuMvgkWJSFGde4trAUEUIOC4iRtc57TvFO6gXMVr # Recomendado: en el panel EA ve a Settings y configura "API Token", luego usa esta variable (mejor que user/pass)
|
||||||
depends_on:
|
depends_on:
|
||||||
- easyappointments
|
- easyappointments
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"embed"
|
"embed"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -37,6 +38,29 @@ func main() {
|
|||||||
}
|
}
|
||||||
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) {
|
||||||
// /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)
|
||||||
|
|||||||
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
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 |
|
After Width: | Height: | Size: 868 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 98 KiB |
@@ -3,8 +3,8 @@
|
|||||||
<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="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">
|
||||||
@@ -14,12 +14,10 @@
|
|||||||
<!-- 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>
|
||||||
@@ -30,34 +28,42 @@
|
|||||||
<!-- 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 +75,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 +100,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 +126,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>
|
||||||
@@ -195,7 +183,7 @@
|
|||||||
<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.1550%2C41.4380%2C2.1650%2C41.4430&layer=mapnik&marker=41.4405%2C2.1600"
|
||||||
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 +193,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>
|
||||||
|
|
||||||
@@ -221,57 +209,92 @@
|
|||||||
const PROVIDER_ID = 1;
|
const PROVIDER_ID = 1;
|
||||||
|
|
||||||
// ---- 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
|
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');
|
||||||
|
}
|
||||||
|
if (!res.ok) return [];
|
||||||
|
return await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('No se pudieron cargar las categorías de EA');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadServices() {
|
||||||
|
const selectorContainer = document.getElementById('service-selector');
|
||||||
|
try {
|
||||||
|
const [servicesRes, categories] = await Promise.all([
|
||||||
|
fetch('/ea-api/v1/services'),
|
||||||
|
loadCategories()
|
||||||
|
]);
|
||||||
|
if (!servicesRes.ok) throw new Error('HTTP ' + servicesRes.status);
|
||||||
|
const services = await servicesRes.json();
|
||||||
allServices = services;
|
allServices = services;
|
||||||
|
|
||||||
// Agrupamos en una categoría simple (puedes crear categorías en EA y extender esto)
|
// Filter services to those assigned to this provider
|
||||||
const fakeCategorias = [{
|
let displayServices = services;
|
||||||
nombre: "Servicios",
|
try {
|
||||||
imagen: "",
|
const provRes = await fetch(`/ea-api/v1/providers/${PROVIDER_ID}`);
|
||||||
servicios: services.map(s => ({
|
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',
|
||||||
|
imagen: '',
|
||||||
|
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 desde EasyAppointments.</p>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderServiceSelector(categorias) {
|
function renderServiceSelector(categorias) {
|
||||||
@@ -296,7 +319,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,11 +341,7 @@
|
|||||||
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();
|
||||||
|
|
||||||
if (selectedDate) {
|
if (selectedDate) {
|
||||||
@@ -331,19 +353,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; }
|
||||||
@@ -582,7 +596,7 @@
|
|||||||
lastName: lastName,
|
lastName: lastName,
|
||||||
email: email,
|
email: email,
|
||||||
phone: telefono || '',
|
phone: telefono || '',
|
||||||
notes: 'Creado desde web May HairCare'
|
notes: 'Creado desde web MAY Studio'
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||