/* global React, LR_IMAGES */ const BOOKING_DURATION_MINUTES = 60; const GOOGLE_HEALTH_CHECK_INTERVAL = 30000; const CITAS_LOOKRENDER_CALENDAR_ID = 'kinoprados@gmail.com'; const AGENDA_CALENDAR_ID = 'egs48vf2oj15pm7b59l4al8ngc@group.calendar.google.com'; const FAMILY_CALENDAR_ID = 'family09004856225076930200@group.calendar.google.com'; const BLOCKING_ICS_URLS = [ 'https://calendar.google.com/calendar/ical/kinoprados%40gmail.com/public/basic.ics', 'https://calendar.google.com/calendar/ical/family09004856225076930200%40group.calendar.google.com/public/basic.ics', 'https://calendar.google.com/calendar/ical/egs48vf2oj15pm7b59l4al8ngc%40group.calendar.google.com/public/basic.ics', ]; const BLOCKING_CALENDAR_IDS = [ CITAS_LOOKRENDER_CALENDAR_ID, FAMILY_CALENDAR_ID, AGENDA_CALENDAR_ID, ]; const DIRECT_GOOGLE_PROXY = 'http://lookrender.synology.me:3009'; function pad2(value) { return String(value).padStart(2, '0'); } function toDateKey(date) { return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`; } function fromDateKey(dateKey, hour) { const [year, month, day] = dateKey.split('-').map(Number); return new Date(year, month - 1, day, hour, 0, 0, 0); } function addMinutes(date, minutes) { return new Date(date.getTime() + minutes * 60 * 1000); } function buildBookingDays(count) { const today = new Date(); const days = []; for (let i = 0; days.length < count && i < 45; i += 1) { const date = new Date(today.getFullYear(), today.getMonth(), today.getDate() + i); const day = date.getDay(); if (day !== 0 && day !== 6) days.push(date); } return days; } function buildMonthDays(date) { const first = new Date(date.getFullYear(), date.getMonth(), 1); const last = new Date(date.getFullYear(), date.getMonth() + 1, 0); const offset = (first.getDay() + 6) % 7; const cells = []; for (let i = 0; i < offset; i += 1) cells.push(null); for (let day = 1; day <= last.getDate(); day += 1) { cells.push(new Date(date.getFullYear(), date.getMonth(), day)); } return cells; } const BOOKING_HOURS = [10, 11, 12, 13, 15, 16]; function isLocalHost() { return ['localhost', '127.0.0.1'].includes(window.location.hostname); } function unfoldIcsLines(text) { return text.replace(/\r\n/g, '\n').split('\n').reduce((lines, line) => { if (/^[ \t]/.test(line) && lines.length > 0) { lines[lines.length - 1] += line.slice(1); } else { lines.push(line); } return lines; }, []); } function parseIcsDate(value) { if (!value) return null; const clean = value.trim(); const match = clean.match(/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?)?(Z)?$/); if (!match) return null; const [, year, month, day, hour = '00', minute = '00', second = '00', utc] = match; if (utc) return new Date(Date.UTC(+year, +month - 1, +day, +hour, +minute, +(second || '0'))); return new Date(+year, +month - 1, +day, +hour, +minute, +(second || '0')); } function parseIcsBusy(text) { const lines = unfoldIcsLines(text); const busy = []; let event = null; for (const line of lines) { if (line === 'BEGIN:VEVENT') event = {}; if (line === 'END:VEVENT') { if (event?.start && event?.end) busy.push({ start: event.start.toISOString(), end: event.end.toISOString() }); event = null; continue; } if (!event) continue; const sep = line.indexOf(':'); if (sep < 0) continue; const key = line.slice(0, sep).split(';')[0]; const value = line.slice(sep + 1); if (key === 'DTSTART') event.start = parseIcsDate(value); if (key === 'DTEND') event.end = parseIcsDate(value); } return busy; } async function fetchIcsBusy(timeMin, timeMax, signal) { const min = new Date(timeMin); const max = new Date(timeMax); const lists = await Promise.all(BLOCKING_ICS_URLS.map(async (url) => { const res = await fetch(url, { signal }); if (!res.ok) throw new Error('ics'); return parseIcsBusy(await res.text()).filter((event) => { const start = new Date(event.start); const end = new Date(event.end); return start < max && end > min; }); })); return lists.flat(); } async function fetchDirectCalendarBusy(timeMin, timeMax, signal) { const lists = await Promise.all(BLOCKING_CALENDAR_IDS.map(async (calendarId) => { const params = new URLSearchParams({ calendarId, timeMin, timeMax }); const res = await fetch(`${DIRECT_GOOGLE_PROXY}/google/events?${params}`, { signal }); if (!res.ok) throw new Error('direct-calendar'); const events = await res.json(); return Array.isArray(events) ? events .filter((event) => event.status !== 'cancelled' && event.start && event.end) .map((event) => ({ start: event.start, end: event.end, calendarId })) : []; })); return lists.flat(); } async function fetchBookingAvailability(timeMin, timeMax, signal) { try { const params = new URLSearchParams({ action: 'availability', timeMin, timeMax, }); const endpoint = isLocalHost() ? `https://eidoraprojects.com/calendar.php?${params}` : `/calendar.php?${params}`; const res = await fetch(endpoint, { signal, credentials: isLocalHost() ? 'omit' : 'same-origin' }); if (!res.ok) throw new Error('calendar'); const data = await res.json(); return { busy: Array.isArray(data.busy) ? data.busy : [], source: data.source || 'unknown', }; } catch (err) { if (err.name === 'AbortError') throw err; return { busy: await fetchIcsBusy(timeMin, timeMax, signal), source: 'ics', }; } } async function fetchGoogleCalendarHealth(signal) { try { const endpoint = isLocalHost() ? 'https://eidoraprojects.com/calendar.php?action=health' : '/calendar.php?action=health'; const res = await fetch(endpoint, { signal, credentials: isLocalHost() ? 'omit' : 'same-origin' }); if (!res.ok) throw new Error('calendar-health'); const data = await res.json(); return data?.google?.connected === true; } catch (_) { return false; } } async function createBookingEvent(payload) { if (isLocalHost()) { try { const res = await fetch('https://eidoraprojects.com/calendar.php?action=book', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const data = await res.json().catch(() => ({})); if (res.ok) return { res, data }; } catch (_) {} return { res: { ok: true }, data: { fallbackSent: true, localMailto: buildBookingFallbackMailto(payload), }, }; } const res = await fetch('/calendar.php?action=book', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const data = await res.json().catch(() => ({})); return { res, data }; } function buildBookingFallbackMailto(payload) { const start = new Date(payload.start); const end = new Date(payload.end); const dateLabel = `${start.toLocaleDateString('es-ES', { day: 'numeric', month: 'long', year: 'numeric' })} · ${pad2(start.getHours())}:00-${pad2(end.getHours())}:00`; const subject = `Solicitud de cita EIDORA pendiente - ${payload.name}`; const body = [ 'Solicitud de cita recibida desde eidoraprojects.com', '', `Nombre: ${payload.name}`, `Email: ${payload.email}`, `Telefono: ${payload.phone}`, `Fecha: ${dateLabel} (Europe/Madrid)`, 'Google Meet: Joaquin enviara el enlace cuando confirme la cita.', '', `Info del proyecto: ${payload.notes || '-'}`, ].join('\n'); return `mailto:info@eidoraprojects.com?cc=${encodeURIComponent(payload.email)}&subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; } function Contact() { const t = window.useT(); const photo = LR_IMAGES.pick('1745358941246.jpg'); const [status, setStatus] = React.useState('idle'); // idle | sending | ok | error const [errorMsg, setErrorMsg] = React.useState(''); const [mode, setMode] = React.useState('meeting'); // meeting | brief const [bookingStep, setBookingStep] = React.useState('date'); // date | time | details | booked const [calendarMonth, setCalendarMonth] = React.useState(() => new Date()); const bookingDays = React.useMemo(() => buildBookingDays(30), []); const monthDays = React.useMemo(() => buildMonthDays(calendarMonth), [calendarMonth]); const [selectedDate, setSelectedDate] = React.useState(() => toDateKey(bookingDays[0])); const [selectedTime, setSelectedTime] = React.useState(''); const [calendarEvents, setCalendarEvents] = React.useState([]); const [calendarStatus, setCalendarStatus] = React.useState('idle'); // idle | loading | ok | error const [googleConnectionStatus, setGoogleConnectionStatus] = React.useState('unknown'); // unknown | loading | connected | fallback const [bookingStatus, setBookingStatus] = React.useState('idle'); // idle | sending | ok | error const [bookingError, setBookingError] = React.useState(''); const [bookedEvent, setBookedEvent] = React.useState(null); const [copiedMeetLink, setCopiedMeetLink] = React.useState(false); const mountedAt = React.useRef(Date.now()); React.useEffect(() => { if (!window.gsap) return; if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; const ctx = gsap.context(() => { gsap.from('.contact-section h2, .contact-section p, .contact-section ul, .contact-section form', { y: 24, opacity: 0, duration: 0.7, ease: 'expo.out', stagger: 0.08, scrollTrigger: { trigger: '.contact-section', start: 'top 75%', once: true }, }); }); return () => ctx.revert(); }, []); React.useEffect(() => { if (mode !== 'meeting') return; const controller = new AbortController(); const timeMin = new Date().toISOString(); const timeMax = addMinutes(bookingDays[bookingDays.length - 1], 24 * 60).toISOString(); setCalendarStatus('loading'); setGoogleConnectionStatus('loading'); fetchGoogleCalendarHealth(controller.signal) .then((connected) => { setGoogleConnectionStatus(connected ? 'connected' : 'fallback'); }); fetchBookingAvailability(timeMin, timeMax, controller.signal) .then(({ busy, source }) => { setCalendarEvents(busy); setCalendarStatus('ok'); if (source === 'oauth') setGoogleConnectionStatus('connected'); }) .catch((err) => { if (err.name === 'AbortError') return; setCalendarStatus('error'); setGoogleConnectionStatus('fallback'); }); return () => controller.abort(); }, [mode, bookingDays]); React.useEffect(() => { if (mode !== 'meeting') return; const controller = new AbortController(); let stopped = false; const checkHealth = () => { fetchGoogleCalendarHealth(controller.signal) .then((connected) => { if (stopped) return; setGoogleConnectionStatus(connected ? 'connected' : 'fallback'); }); }; const checkHealthIfVisible = () => { if (document.visibilityState !== 'hidden') checkHealth(); }; checkHealth(); const interval = window.setInterval(checkHealth, GOOGLE_HEALTH_CHECK_INTERVAL); window.addEventListener('focus', checkHealth); window.addEventListener('online', checkHealth); document.addEventListener('visibilitychange', checkHealthIfVisible); return () => { stopped = true; window.clearInterval(interval); window.removeEventListener('focus', checkHealth); window.removeEventListener('online', checkHealth); document.removeEventListener('visibilitychange', checkHealthIfVisible); controller.abort(); }; }, [mode]); const getSlotsForDate = React.useCallback((dateKey) => { const now = new Date(); return BOOKING_HOURS.map((hour) => { const start = fromDateKey(dateKey, hour); const end = addMinutes(start, BOOKING_DURATION_MINUTES); const isPast = start.getTime() <= now.getTime() + 2 * 60 * 60 * 1000; const busy = calendarEvents.some((event) => { const eventStart = new Date(event.start); const eventEnd = new Date(event.end); return start < eventEnd && end > eventStart; }); return { hour, label: `${pad2(hour)}:00`, disabled: isPast || busy }; }); }, [calendarEvents]); const availableDays = React.useMemo(() => { if (calendarStatus === 'loading') return []; const daysWithSlots = bookingDays.filter((date) => getSlotsForDate(toDateKey(date)).some((slot) => !slot.disabled) ); return daysWithSlots.length > 0 ? daysWithSlots : bookingDays; }, [bookingDays, calendarStatus, getSlotsForDate]); React.useEffect(() => { if (calendarStatus === 'loading' || availableDays.length === 0) return; if (!availableDays.some((date) => toDateKey(date) === selectedDate)) { setSelectedDate(toDateKey(availableDays[0])); setSelectedTime(''); } }, [availableDays, calendarStatus, selectedDate]); const availableTimes = React.useMemo(() => getSlotsForDate(selectedDate), [getSlotsForDate, selectedDate]); const onBookingSubmit = async (e) => { e.preventDefault(); if (bookingStatus === 'sending' || !selectedTime) return; setBookingStatus('sending'); setBookingError(''); const form = e.currentTarget; const fd = new FormData(form); const nombre = String(fd.get('booking_nombre') || '').trim(); const email = String(fd.get('booking_email') || '').trim(); const telefono = String(fd.get('booking_phone') || '').trim(); const notas = String(fd.get('booking_notes') || '').trim(); const start = fromDateKey(selectedDate, Number(selectedTime)); const end = addMinutes(start, BOOKING_DURATION_MINUTES); try { const { res, data } = await createBookingEvent({ name: nombre, email, phone: telefono, notes: notas, calendarId: CITAS_LOOKRENDER_CALENDAR_ID, start: start.toISOString(), end: end.toISOString(), }); if (!res.ok || (!data.id && !data.hangoutLink && !data.fallbackSent)) throw new Error('booking'); if (data.localMailto) window.location.href = data.localMailto; setBookedEvent(data); setBookingStatus('ok'); setBookingStep('booked'); form.reset(); } catch (_) { setBookingStatus('error'); setBookingError('No hemos podido reservar la cita. Escríbenos a info@eidoraprojects.com y lo cerramos manualmente.'); } }; const copyMeetingLink = async () => { if (!bookedEvent?.hangoutLink) return; try { await navigator.clipboard.writeText(bookedEvent.hangoutLink); } catch (_) { const input = document.createElement('input'); input.value = bookedEvent.hangoutLink; input.setAttribute('readonly', ''); input.style.position = 'fixed'; input.style.opacity = '0'; document.body.appendChild(input); input.select(); document.execCommand('copy'); document.body.removeChild(input); } setCopiedMeetLink(true); window.setTimeout(() => setCopiedMeetLink(false), 2200); }; const onSubmit = async (e) => { e.preventDefault(); if (status === 'sending') return; setStatus('sending'); setErrorMsg(''); const form = e.currentTarget; const fd = new FormData(form); fd.append('ttp', String(Date.now() - mountedAt.current)); try { const contactEndpoint = isLocalHost() ? 'https://eidoraprojects.com/contact.php' : '/contact.php'; const res = await fetch(contactEndpoint, { method: 'POST', body: new URLSearchParams(fd), headers: { 'Accept': 'application/json' }, credentials: isLocalHost() ? 'omit' : 'same-origin', }); const data = await res.json().catch(() => ({})); if (res.ok && data.ok) { setStatus('ok'); form.reset(); } else { setStatus('error'); const map = { rate: t('contact.form.error.rate'), validation: t('contact.form.error.validation'), injection: t('contact.form.error.injection'), method: t('contact.form.error.method'), origin: t('contact.form.error.origin'), referer: t('contact.form.error.origin'), payload: t('contact.form.error.payload'), send: t('contact.form.error.send'), }; setErrorMsg(map[data.error] || t('contact.form.error.default')); } } catch (_) { setStatus('error'); setErrorMsg(t('contact.form.error.network')); } }; return (
{t('contact.header.label')}

{t('contact.header.title').replace(t('contact.header.cta'), '').trimEnd()} {' '}{t('contact.header.cta')}

{t('contact.body.description')}

    {[ [t('contact.nap.email.label'), 'info@eidoraprojects.com', 'mailto:info@eidoraprojects.com'], [t('contact.nap.phone.label'), '+34 614 32 55 64', 'tel:+34614325564'], [t('contact.nap.studio.label'), t('contact.nap.studio.value'), null], ].map(([k, v, href], i) => (
  • {k} {href ? ( {v} ) : ( {v} )}
  • ))}
{/* Tabs: Agendar cita / Enviar briefing */}
{[ { key: 'meeting', label: t('contact.modes.meeting') }, { key: 'brief', label: t('contact.modes.brief') }, ].map((m) => { const active = mode === m.key; return ( ); })}
{[ ['date', '01', 'Fecha'], ['time', '02', 'Hora'], ['details', '03', 'Datos'], ].map(([key, number, label]) => ( ))}
{bookingStep === 'date' && (

Selecciona fecha

{calendarStatus === 'loading' &&

Cargando días disponibles...

}
{calendarMonth.toLocaleDateString('es-ES', { month: 'long', year: 'numeric' })}
{monthDays.map((date, index) => { if (!date) return ; const dateKey = toDateKey(date); const active = selectedDate === dateKey; const todayKey = toDateKey(new Date()); const isPast = dateKey < todayKey; const isWeekend = date.getDay() === 0 || date.getDay() === 6; const hasSlots = availableDays.some((availableDate) => toDateKey(availableDate) === dateKey); const disabled = isPast || isWeekend || !hasSlots; return ( ); })}
)} {bookingStep === 'time' && (

Selecciona hora

{fromDateKey(selectedDate, 12).toLocaleDateString('es-ES', { day: 'numeric', month: 'long', year: 'numeric' })} · Hora de Madrid

{calendarStatus === 'loading' &&

Consultando disponibilidad...

} {calendarStatus === 'error' &&

No se pudo consultar Google Calendar. Puedes elegir hora y la confirmaremos manualmente.

}
{availableTimes.map((slot) => ( ))}
)} {bookingStep === 'details' && (

Confirma tus datos

{fromDateKey(selectedDate, Number(selectedTime)).toLocaleDateString('es-ES', { day: 'numeric', month: 'long' })} · {pad2(selectedTime)}:00 · Google Meet