/* Browsernatives ES-Modul; wird ohne Build-Schritt ausgeliefert. */ const dateFormatter = new Intl.DateTimeFormat('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); const monthFormatter = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' }); const timeFormatter = new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit' }); const weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; const sessionProfileKey = 'sufw-booking-profile-v1'; function parseDateTime(value) { return new Date(value.replace(' ', 'T')); } function parseDate(value) { return new Date(`${value}T12:00:00`); } export function formatDateKey(date) { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; } function eventDateRange(event) { return [event.startsAt.slice(0, 10), event.endsAt.slice(0, 10)]; } function occursOnDate(event, date) { const [startDate, endDate] = eventDateRange(event); return startDate <= date && endDate >= date; } export function getSlotsForDate(project, date, events = []) { return events.filter((event) => event.project === project && occursOnDate(event, date)); } export function requiresIntroductionDetails(event = {}) { return event.project === 'produktionsschule' && event.introductionRequired === true; } export function formatEventDuration(event) { const start = parseDateTime(event.startsAt); const end = parseDateTime(event.endsAt); const [startDate, endDate] = eventDateRange(event); if (startDate !== endDate) { const dayCount = Math.round((parseDate(endDate) - parseDate(startDate)) / 86400000) + 1; return `${dayCount} Tage`; } const hours = Math.round((end - start) / 3600000 * 10) / 10; return `${String(hours).replace('.', ',')} ${hours === 1 ? 'Stunde' : 'Stunden'}`; } function formatEventTime(event) { return `${timeFormatter.format(parseDateTime(event.startsAt))}–${timeFormatter.format(parseDateTime(event.endsAt))} Uhr`; } function formatEventFormat(event) { return event.format === 'telefonisch' ? 'Telefonisch' : 'Vor Ort'; } export function validateBookingField(fieldName, value) { const text = typeof value === 'string' ? value.trim() : ''; if (fieldName === 'name') return text && text.length <= 160 ? '' : 'Bitte geben Sie Ihren Namen ein.'; if (fieldName === 'email') return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text) && text.length <= 254 ? '' : 'Bitte geben Sie eine gültige E-Mail-Adresse ein.'; if (fieldName === 'phone') return /^[0-9+()\-/\s]{6,64}$/.test(text) ? '' : 'Bitte geben Sie eine gültige Telefonnummer ein.'; if (fieldName === 'introductionAttended') return value ? '' : 'Bitte bestätigen Sie die Teilnahme an einer Einführungsveranstaltung.'; if (fieldName === 'introductionDate') return /^\d{4}-\d{2}-\d{2}$/.test(text) ? '' : 'Bitte geben Sie das Datum der Einführungsveranstaltung ein.'; if (fieldName === 'privacy') return value ? '' : 'Bitte bestätigen Sie die Datenschutzhinweise.'; return ''; } export function validateBookingForm({ name = '', email = '', phone = '', privacyAccepted = false, introductionAttended = false, introductionDate = '', event = {} }) { const values = { name, email, phone, privacy: privacyAccepted }; if (requiresIntroductionDetails(event)) Object.assign(values, { introductionAttended, introductionDate }); return Object.fromEntries(Object.entries(values).map(([fieldName, value]) => [fieldName, validateBookingField(fieldName, value)]).filter(([, error]) => error)); } export function canSubmitBookingForm(privacyAccepted) { return privacyAccepted; } export function bookingConfirmationMessage(appointmentTitle) { return `Ihre Anfrage für ${appointmentTitle} wurde gespeichert. Bitte bestätigen Sie den Link aus der E-Mail innerhalb von 30 Minuten.`; } function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (character) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[character]); } function createMonthDates(month) { const first = new Date(month.getFullYear(), month.getMonth(), 1); const dates = new Array((first.getDay() + 6) % 7).fill(null); const lastDay = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate(); for (let day = 1; day <= lastDay; day += 1) dates.push(new Date(month.getFullYear(), month.getMonth(), day)); return dates; } function loadSessionProfile() { try { const profile = JSON.parse(sessionStorage.getItem(sessionProfileKey) ?? '{}'); return typeof profile === 'object' && profile ? profile : {}; } catch { return {}; } } function saveSessionProfile(profile) { try { sessionStorage.setItem(sessionProfileKey, JSON.stringify(profile)); } catch { /* Speicherung ist optional. */ } } function showFieldError(form, fieldName, error) { const field = form.elements.namedItem(fieldName); if (!field) return; const errorId = `sufw-booking-${fieldName}-error`; let message = form.querySelector(`#${errorId}`); if (!message) { message = document.createElement('span'); message.id = errorId; message.className = 'sufw-booking__field-error'; message.setAttribute('role', 'alert'); const host = fieldName === 'privacy' || fieldName === 'introductionAttended' ? field.closest('label') : field; host.insertAdjacentElement('afterend', message); field.setAttribute('aria-describedby', errorId); } message.textContent = error; message.hidden = !error; field.setAttribute('aria-invalid', error ? 'true' : 'false'); } function openConfirmationDialog(container, event) { const dialog = document.createElement('dialog'); dialog.className = 'sufw-booking__dialog'; dialog.setAttribute('aria-labelledby', 'sufw-booking-confirmation-title'); dialog.innerHTML = `

E-Mail bestätigen

Fast geschafft

${escapeHtml(bookingConfirmationMessage(event.title))}

${escapeHtml(event.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule')}
${escapeHtml(dateFormatter.format(parseDate(event.startsAt.slice(0, 10))))} · ${formatEventTime(event)} · ${escapeHtml(event.title)}

Bitte prüfen Sie Ihr E-Mail-Postfach und bestätigen Sie dort Ihre Anfrage.

`; dialog.querySelector('[data-close-confirmation]').addEventListener('click', () => dialog.close()); dialog.addEventListener('close', () => dialog.remove()); container.append(dialog); if (typeof dialog.showModal === 'function') dialog.showModal(); else { dialog.setAttribute('open', ''); dialog.querySelector('[data-close-confirmation]').focus(); } } function openBookingDialog(container, event, state) { const profile = loadSessionProfile(); const requiresIntroduction = requiresIntroductionDetails(event); const dialog = document.createElement('dialog'); dialog.className = 'sufw-booking__dialog'; dialog.setAttribute('aria-labelledby', 'sufw-booking-form-title'); dialog.innerHTML = `

Termin verbindlich anmelden

Ihre Angaben

${escapeHtml(event.title)}
${escapeHtml(dateFormatter.format(parseDate(event.startsAt.slice(0, 10))))} · ${formatEventTime(event)} · ${escapeHtml(formatEventDuration(event))} · ${escapeHtml(formatEventFormat(event))}

Pflichtfeld

${requiresIntroduction ? '
Voraussetzung für Probearbeiten
' : ''}
`; const form = dialog.querySelector('form'); const submitButton = dialog.querySelector('[data-submit-booking]'); const privacy = form.elements.namedItem('privacy'); const fieldNames = ['name', 'email', 'phone', 'privacy', ...(requiresIntroduction ? ['introductionAttended', 'introductionDate'] : [])]; const formError = form.querySelector('.sufw-booking__form-errors'); const validateField = (fieldName) => { const field = form.elements.namedItem(fieldName); const value = fieldName === 'privacy' || fieldName === 'introductionAttended' ? field.checked : field.value; const error = validateBookingField(fieldName, value); showFieldError(form, fieldName, error); return error; }; fieldNames.forEach((fieldName) => { const field = form.elements.namedItem(fieldName); field.addEventListener(field.type === 'checkbox' ? 'change' : 'input', () => validateField(fieldName)); if (field.type !== 'checkbox') field.addEventListener('blur', () => validateField(fieldName)); }); privacy.addEventListener('change', () => { submitButton.disabled = !canSubmitBookingForm(privacy.checked); }); form.addEventListener('submit', async (submitEvent) => { submitEvent.preventDefault(); const values = Object.fromEntries(new FormData(form).entries()); const booking = { name: values.name ?? '', email: values.email ?? '', phone: values.phone ?? '', privacyAccepted: privacy.checked, introductionAttended: form.elements.namedItem('introductionAttended')?.checked ?? false, introductionDate: values.introductionDate ?? '', event }; const errors = validateBookingForm(booking); fieldNames.forEach((fieldName) => showFieldError(form, fieldName, errors[fieldName] ?? '')); if (Object.keys(errors).length) { form.elements.namedItem(Object.keys(errors)[0]).focus(); return; } submitButton.disabled = true; formError.hidden = true; try { const response = await fetch(state.apiUrl, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-SUFW-Booking-CSRF': state.csrfToken }, body: JSON.stringify({ project: event.project, eventId: event.id, name: booking.name, email: booking.email, phone: booking.phone, privacyAccepted: true, introductionAttended: booking.introductionAttended, introductionDate: booking.introductionDate }) }); const result = await response.json(); if (!response.ok) throw new Error(result.error ?? 'Die Anmeldung konnte nicht gespeichert werden.'); saveSessionProfile({ name: booking.name, email: booking.email, phone: booking.phone }); dialog.close(); await state.reload(); openConfirmationDialog(container, event); } catch (error) { formError.textContent = error instanceof Error ? error.message : 'Die Anmeldung konnte nicht gespeichert werden.'; formError.hidden = false; submitButton.disabled = false; } }); dialog.querySelector('[data-close-dialog]').addEventListener('click', () => dialog.close()); dialog.addEventListener('close', () => dialog.remove()); container.append(dialog); if (typeof dialog.showModal === 'function') dialog.showModal(); else { dialog.setAttribute('open', ''); form.elements.namedItem('name').focus(); } } function renderComponent(container) { const project = container.dataset.project === 'produktionsschule' ? 'produktionsschule' : 'perspektivwerk'; const now = new Date(); const state = { apiUrl: container.dataset.apiUrl || 'booking/api.php', csrfToken: '', events: [], currentMonth: new Date(now.getFullYear(), now.getMonth(), 1), selectedDate: '' }; const fetchEvents = async () => { const from = formatDateKey(state.currentMonth); const to = formatDateKey(new Date(state.currentMonth.getFullYear(), state.currentMonth.getMonth() + 1, 1)); const response = await fetch(`${state.apiUrl}?${new URLSearchParams({ project, from, to })}`, { credentials: 'same-origin' }); const result = await response.json(); if (!response.ok) throw new Error(result.error ?? 'Der Kalender konnte nicht geladen werden.'); state.events = result.events; state.csrfToken = result.csrfToken; if (!state.selectedDate && state.events.length) state.selectedDate = state.events[0].startsAt.slice(0, 10); }; const render = () => { const slots = state.selectedDate ? getSlotsForDate(project, state.selectedDate, state.events) : []; const selectedLabel = state.selectedDate ? dateFormatter.format(parseDate(state.selectedDate)) : 'einen Tag'; container.innerHTML = `

Terminbuchung

${project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule'}

Wählen Sie einen Tag. Termine zeigen Dauer, Format und freie Plätze.

Buchbar Ausgebucht

${monthFormatter.format(state.currentMonth)}

${createMonthDates(state.currentMonth).map((date) => { if (!date) return ''; const key = formatDateKey(date); const daySlots = getSlotsForDate(project, key, state.events); const available = daySlots.some((event) => event.status === 'available'); const full = daySlots.length > 0 && !available; const selected = key === state.selectedDate; const label = daySlots.length ? `${dateFormatter.format(date)}: ${available ? 'Termine buchbar' : 'Termine ausgebucht'}` : dateFormatter.format(date); return ``; }).join('')}

Termine am ${escapeHtml(selectedLabel)}

${slots.length ? `` : '

Für diesen Tag liegen keine Termine vor.

'}

`; container.querySelector('[data-action="previous"]').addEventListener('click', async () => { state.currentMonth = new Date(state.currentMonth.getFullYear(), state.currentMonth.getMonth() - 1, 1); state.selectedDate = ''; await loadAndRender(); }); container.querySelector('[data-action="next"]').addEventListener('click', async () => { state.currentMonth = new Date(state.currentMonth.getFullYear(), state.currentMonth.getMonth() + 1, 1); state.selectedDate = ''; await loadAndRender(); }); container.querySelectorAll('[data-date]').forEach((button) => button.addEventListener('click', () => { state.selectedDate = button.dataset.date ?? ''; render(); })); container.querySelectorAll('[data-event-id]').forEach((button) => button.addEventListener('click', () => { const event = state.events.find((entry) => entry.id === button.dataset.eventId); if (event) openBookingDialog(container, event, state); })); }; const loadAndRender = async () => { container.setAttribute('aria-busy', 'true'); try { await fetchEvents(); render(); } catch (error) { container.innerHTML = ``; } finally { container.removeAttribute('aria-busy'); } }; state.reload = loadAndRender; loadAndRender(); } export function initializeBookingComponents(root = document) { root.querySelectorAll('[data-sufw-booking]').forEach(renderComponent); } if (typeof document !== 'undefined') { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => initializeBookingComponents()); else initializeBookingComponents(); }