/* ============================================================ * BOOKING MODAL — reserva de llamada con api.blackstock.es * * API: GET /api/public/call-slots?date=YYYY-MM-DD * POST /api/public/call-bookings * * Flujo: * 1. Form: nombre / email / teléfono / plan / experiencia / volumen / mensaje * 2. Calendario: 14 días + grid de slots 16:00–22:00 Europe/Madrid * 3. Confirmar: POST → 409 si slot ocupado, 200 si OK * 4. Pantalla "reservado" con resumen * * window.bsOpenBooking(planHint?) abre el modal con plan precargado. * ============================================================ */ const BS_BOOKING_API_BASE = 'https://api.blackstock.es'; const BookingModal = () => { const [open, setOpen] = React.useState(false); const [step, setStep] = React.useState(1); // 1 form, 2 slot, 3 done const [planHint, setPlanHint] = React.useState(null); // Form fields const [name, setName] = React.useState(''); const [email, setEmail] = React.useState(''); const [phone, setPhone] = React.useState(''); const [plan, setPlan] = React.useState('basic'); const [experience, setExperience] = React.useState(''); const [volume, setVolume] = React.useState(''); const [message, setMessage] = React.useState(''); // Slot picker const [slotsMeta, setSlotsMeta] = React.useState(null); const [selectedDate, setSelectedDate] = React.useState(''); const [slotsForDate, setSlotsForDate] = React.useState(null); const [selectedTime, setSelectedTime] = React.useState(''); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(''); const [confirmation, setConfirmation] = React.useState(null); React.useEffect(() => { window.bsOpenBooking = (hint) => { setOpen(true); setStep(1); setError(''); setConfirmation(null); const norm = String(hint || '').trim().toLowerCase(); if (norm === 'pro' || norm === 'basic') { setPlan(norm); setPlanHint(norm.toUpperCase()); } else { setPlanHint(null); } }; window.bsCloseBooking = () => setOpen(false); return () => { delete window.bsOpenBooking; delete window.bsCloseBooking; }; }, []); // ESC + body lock React.useEffect(() => { if (!open) return; const onKey = (e) => { if (e.key === 'Escape') setOpen(false); }; document.addEventListener('keydown', onKey); const prev = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.removeEventListener('keydown', onKey); document.body.style.overflow = prev; }; }, [open]); // Cargar disponibilidad cuando entras al paso 2 React.useEffect(() => { if (step !== 2) return; let cancelled = false; setSlotsMeta(null); fetch(`${BS_BOOKING_API_BASE}/api/public/call-slots`, { credentials: 'omit' }) .then((r) => r.json()) .then((j) => { if (cancelled) return; if (!j?.ok) throw new Error('slots_failed'); setSlotsMeta(j); const firstFree = (j.days || []).find((d) => d.freeCount > 0); if (firstFree && !selectedDate) setSelectedDate(firstFree.date); }) .catch(() => { if (!cancelled) setError('No pudimos cargar la disponibilidad. Inténtalo de nuevo.'); }); return () => { cancelled = true; }; }, [step]); React.useEffect(() => { if (step !== 2 || !selectedDate) return; let cancelled = false; setSlotsForDate(null); setSelectedTime(''); fetch(`${BS_BOOKING_API_BASE}/api/public/call-slots?date=${selectedDate}`, { credentials: 'omit' }) .then((r) => r.json()) .then((j) => { if (!cancelled && j?.ok) setSlotsForDate(j.slots || []); }) .catch(() => {}); return () => { cancelled = true; }; }, [step, selectedDate]); if (!open) return null; const validateStep1 = () => { if (name.trim().length < 2) return 'Tu nombre es obligatorio.'; if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return 'Email no válido.'; if (phone.replace(/\D/g, '').length < 6) return 'Introduce un teléfono válido.'; if (experience.trim().length < 4) return 'Cuéntanos un poco tu experiencia (1-2 frases).'; if (volume.trim().length < 1) return 'Indica un volumen aproximado.'; return ''; }; const handleNextFromForm = () => { const err = validateStep1(); if (err) { setError(err); return; } setError(''); setStep(2); }; const handleSubmit = async () => { if (!selectedDate || !selectedTime) { setError('Selecciona día y hora.'); return; } setLoading(true); setError(''); try { const res = await fetch(`${BS_BOOKING_API_BASE}/api/public/call-bookings`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'omit', body: JSON.stringify({ name: name.trim(), email: email.trim().toLowerCase(), phone: phone.trim(), plan, experience: experience.trim(), volume: volume.trim(), message: message.trim(), date: selectedDate, time: selectedTime, source: 'mobiles.blackstock.es', }), }); const data = await res.json().catch(() => ({})); if (!res.ok || !data?.ok) { if (data?.error === 'slot_taken') { setError('Esa hora ya se acaba de reservar. Elige otra disponible.'); // refrescar slots const j = await fetch(`${BS_BOOKING_API_BASE}/api/public/call-slots?date=${selectedDate}`).then((r) => r.json()); if (j?.ok) setSlotsForDate(j.slots || []); setSelectedTime(''); } else { setError('No pudimos completar la reserva. Revisa los datos o inténtalo en unos minutos.'); } setLoading(false); return; } setConfirmation(data.booking); setStep(3); setLoading(false); } catch (e) { setError('Error de red. Inténtalo de nuevo.'); setLoading(false); } }; const formatDayLabel = (ymd) => { if (!ymd) return ''; const [y, m, d] = ymd.split('-').map(Number); const date = new Date(Date.UTC(y, m - 1, d, 12, 0, 0)); return new Intl.DateTimeFormat('es-ES', { timeZone: 'Europe/Madrid', weekday: 'short', day: 'numeric', month: 'short', }).format(date); }; return (
{ if (e.target === e.currentTarget) setOpen(false); }}>
{/* Header */}
{planHint ? `Plan ${planHint}` : 'Acceso bajo llamada'}
Agenda una llamada con Blackstock
{/* Progress dots */}
{[1, 2, 3].map((n) => ( = n ? 'is-on' : ''} ${step === n ? 'is-current' : ''}`} /> ))}
{step === 1 && (
{/* Honeypot anti-spam */}
Plan que te interesa
{[ { c: 'basic', n: 'BASIC', s: 'Todo Blackstock · 22,90€/mes' }, { c: 'pro', n: 'PRO', s: 'BASIC + Bazooka · 149,90€/mes', badge: 'incluye Bazooka' }, ].map((p) => ( ))}