cambios profundos

This commit is contained in:
2026-06-20 20:01:40 +02:00
parent 2f5f37c7b4
commit 98fd85226b
21 changed files with 158 additions and 118 deletions
+6 -4
View File
@@ -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: ...
+3 -3
View File
@@ -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=***REMOVED*** # 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
+24
View File
@@ -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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 868 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

+111 -97
View File
@@ -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 &amp; 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 &amp; 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>&copy; 2026 May HairCare. Tu peluquería en Horta.</p> <p>&copy; 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'
}) })
}); });