Files
sufw-webseite-terminplanung/development-system/public/perspektivwerk/js/booking.js
T

314 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Browsernatives ES-Modul; wird ohne TypeScript-Compiler ausgeliefert. */
const demoSlots = [
{ id: 'pw-2026-08-24-0900', project: 'perspektivwerk', date: '2026-08-24', start: '09:00', title: 'Erstgespräch', status: 'available' },
{ id: 'pw-2026-08-24-1100', project: 'perspektivwerk', date: '2026-08-24', start: '11:00', title: 'Erstgespräch', status: 'available' },
{ id: 'pw-2026-08-24-1400', project: 'perspektivwerk', date: '2026-08-24', start: '14:00', title: 'Soz. Päd. Modul', status: 'booked' },
{ id: 'pw-2026-08-18-1000', project: 'perspektivwerk', date: '2026-08-18', start: '10:00', title: 'Erstgespräch', status: 'available' },
{ id: 'pw-2026-08-18-1300', project: 'perspektivwerk', date: '2026-08-18', start: '13:00', title: 'Soz. Päd. Modul', status: 'booked' },
{ id: 'pw-2026-08-18-1500', project: 'perspektivwerk', date: '2026-08-18', start: '15:00', title: 'Erstgespräch', status: 'available' },
{ id: 'pw-2026-08-26-0900', project: 'perspektivwerk', date: '2026-08-26', start: '09:00', title: 'Erstgespräch', status: 'available' },
{ id: 'pw-2026-08-26-1100', project: 'perspektivwerk', date: '2026-08-26', start: '11:00', title: 'Psy Modul', status: 'booked' },
{ id: 'pw-2026-08-26-1400', project: 'perspektivwerk', date: '2026-08-26', start: '14:00', title: 'Soz. Päd. Modul', status: 'available' },
{ id: 'pw-2026-08-20-0900', project: 'perspektivwerk', date: '2026-08-20', start: '09:00', title: 'Psy Modul', status: 'available' },
{ id: 'pw-2026-08-20-1100', project: 'perspektivwerk', date: '2026-08-20', start: '11:00', title: 'Erstgespräch', status: 'booked' },
{ id: 'pw-2026-08-20-1500', project: 'perspektivwerk', date: '2026-08-20', start: '15:00', title: 'Psy Modul', status: 'available' },
{ id: 'pw-2026-08-28-0900', project: 'perspektivwerk', date: '2026-08-28', start: '09:00', title: 'Erstgespräch', status: 'available' },
{ id: 'pw-2026-08-28-1100', project: 'perspektivwerk', date: '2026-08-28', start: '11:00', title: 'Soz. Päd. Modul', status: 'available' },
{ id: 'pw-2026-08-28-1400', project: 'perspektivwerk', date: '2026-08-28', start: '14:00', title: 'Erstgespräch', status: 'booked' },
{ id: 'pw-2026-08-31-0800', project: 'perspektivwerk', date: '2026-08-31', start: '08:00', title: 'Praxis Modul', status: 'available' },
{ id: 'pw-2026-09-02-0900', project: 'perspektivwerk', date: '2026-09-02', start: '09:00', title: 'Erstgespräch', status: 'available' },
{ id: 'pw-2026-09-02-1100', project: 'perspektivwerk', date: '2026-09-02', start: '11:00', title: 'Soz. Päd. Modul', status: 'booked' },
{ id: 'pw-2026-09-02-1400', project: 'perspektivwerk', date: '2026-09-02', start: '14:00', title: 'Psy Modul', status: 'available' },
{ id: 'pw-2026-09-07-0900', project: 'perspektivwerk', date: '2026-09-07', start: '09:00', title: 'Erstgespräch', status: 'available' },
{ id: 'pw-2026-09-07-1300', project: 'perspektivwerk', date: '2026-09-07', start: '13:00', title: 'Soz. Päd. Modul', status: 'available' },
{ id: 'pw-2026-09-10-0900', project: 'perspektivwerk', date: '2026-09-10', start: '09:00', title: 'Psy Modul', status: 'available' },
{ id: 'pw-2026-09-10-1100', project: 'perspektivwerk', date: '2026-09-10', start: '11:00', title: 'Erstgespräch', status: 'booked' },
{ id: 'pw-2026-09-10-1500', project: 'perspektivwerk', date: '2026-09-10', start: '15:00', title: 'Erstgespräch', status: 'available' },
{ id: 'pw-2026-09-15-0900', project: 'perspektivwerk', date: '2026-09-15', start: '09:00', title: 'Erstgespräch', status: 'available' },
{ id: 'pw-2026-09-15-1100', project: 'perspektivwerk', date: '2026-09-15', start: '11:00', title: 'Soz. Päd. Modul', status: 'available' },
{ id: 'pw-2026-09-15-1400', project: 'perspektivwerk', date: '2026-09-15', start: '14:00', title: 'Psy Modul', status: 'booked' },
{ id: 'pw-2026-09-18-0900', project: 'perspektivwerk', date: '2026-09-18', start: '09:00', title: 'Erstgespräch', status: 'available' },
{ id: 'pw-2026-09-18-1300', project: 'perspektivwerk', date: '2026-09-18', start: '13:00', title: 'Soz. Päd. Modul', status: 'booked' },
{ id: 'pw-2026-09-22-1000', project: 'perspektivwerk', date: '2026-09-22', start: '10:00', title: 'Erstgespräch', status: 'available' },
{ id: 'pw-2026-09-22-1400', project: 'perspektivwerk', date: '2026-09-22', start: '14:00', title: 'Psy Modul', status: 'available' },
{ id: 'pw-2026-09-25-0900', project: 'perspektivwerk', date: '2026-09-25', start: '09:00', title: 'Soz. Päd. Modul', status: 'available' },
{ id: 'pw-2026-09-25-1100', project: 'perspektivwerk', date: '2026-09-25', start: '11:00', title: 'Erstgespräch', status: 'booked' },
{ id: 'pw-2026-09-29-0900', project: 'perspektivwerk', date: '2026-09-29', start: '09:00', title: 'Erstgespräch', status: 'available' },
{ id: 'pw-2026-09-29-1100', project: 'perspektivwerk', date: '2026-09-29', start: '11:00', title: 'Psy Modul', status: 'available' },
{ id: 'pw-2026-09-29-1400', project: 'perspektivwerk', date: '2026-09-29', start: '14:00', title: 'Soz. Päd. Modul', status: 'booked' },
{ id: 'ps-2026-08-25-1000', project: 'produktionsschule', date: '2026-08-25', start: '10:00', title: 'Kennenlerntermin', status: 'available' },
{ id: 'ps-2026-08-25-1300', project: 'produktionsschule', date: '2026-08-25', start: '13:00', title: 'Kennenlerntermin', status: 'booked' },
{ id: 'ps-2026-08-31-0800', project: 'produktionsschule', date: '2026-08-31', start: '08:00', title: 'Probearbeiten', status: 'available' }
];
export function getSlotsForDate(project, date) {
return demoSlots.filter((slot) => slot.project === project && slot.date === date);
}
export function validateBookingField(fieldName, value) {
const text = typeof value === 'string' ? value.trim() : '';
if (fieldName === 'name')
return text ? '' : 'Bitte geben Sie Ihren Namen ein.';
if (fieldName === 'email')
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text) ? '' : 'Bitte geben Sie eine gültige E-Mail-Adresse ein.';
if (fieldName === 'phone')
return !text || /^[0-9+()\-/\s]{6,}$/.test(text) ? '' : 'Bitte geben Sie eine gültige Telefonnummer ein.';
if (fieldName === 'age')
return !text || (/^\d+$/.test(text) && Number(text) >= 12 && Number(text) <= 112) ? '' : 'Bitte geben Sie ein Alter zwischen 12 und 112 Jahren ein.';
if (fieldName === 'message')
return text.length <= 2000 ? '' : 'Die Nachricht darf höchstens 2.000 Zeichen enthalten.';
if (fieldName === 'caseWorker')
return text.length <= 160 ? '' : 'Die Angabe darf höchstens 160 Zeichen enthalten.';
if (fieldName === 'privacy')
return value ? '' : 'Bitte bestätigen Sie die Datenschutzhinweise.';
return '';
}
export function validateBookingForm({ name = '', email = '', phone = '', age = '', message = '', caseWorker = '', privacyAccepted = false }) {
const values = { name, email, phone, age, message, caseWorker, privacy: privacyAccepted };
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 Terminanfrage für ${appointmentTitle} wurde erfolgreich übermittelt.`;
}
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 weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
function parseDate(value) {
return new Date(`${value}T12:00:00`);
}
export function formatDateKey(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function createMonthDates(month) {
const first = new Date(month.getFullYear(), month.getMonth(), 1);
const padding = (first.getDay() + 6) % 7;
const days = new Array(padding).fill(null);
const lastDay = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate();
for (let day = 1; day <= lastDay; day += 1) {
days.push(new Date(month.getFullYear(), month.getMonth(), day));
}
return days;
}
function monthForProject(project) {
const firstSlot = demoSlots.find((slot) => slot.project === project);
return firstSlot ? new Date(parseDate(firstSlot.date).getFullYear(), parseDate(firstSlot.date).getMonth(), 1) : new Date();
}
function escapeHtml(value) {
return value.replace(/[&<>'"]/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' })[character]);
}
function showFieldError(form, fieldName, error) {
const field = form.elements.namedItem(fieldName);
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');
message.style.display = 'block';
message.style.marginTop = '.35rem';
message.style.color = '#6c0808';
message.style.fontWeight = '400';
const host = fieldName === 'privacy' ? 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, slot) {
const projectTitle = slot.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule';
const dialog = document.createElement('dialog');
dialog.className = 'sufw-booking__dialog';
dialog.setAttribute('aria-labelledby', 'sufw-booking-confirmation-title');
dialog.innerHTML = `
<section class="sufw-booking__form">
<header class="sufw-booking__form-header">
<p class="sufw-booking__eyebrow">Termin angefragt</p>
<h2 id="sufw-booking-confirmation-title">Vielen Dank für Ihre Anfrage</h2>
<p>${escapeHtml(bookingConfirmationMessage(slot.title))}</p>
</header>
<p class="sufw-booking__appointment"><strong>${escapeHtml(projectTitle)}</strong><br>${escapeHtml(dateFormatter.format(parseDate(slot.date)))} · ${escapeHtml(slot.start)} Uhr · ${escapeHtml(slot.title)}</p>
<p>Wir senden Ihnen eine E-Mail zur Bestätigung Ihres Termins.</p>
<div class="sufw-booking__form-actions"><button type="button" data-close-confirmation>Schließen</button></div>
</section>`;
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, slot) {
const projectTitle = slot.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule';
const dialog = document.createElement('dialog');
dialog.className = 'sufw-booking__dialog';
dialog.setAttribute('aria-labelledby', 'sufw-booking-form-title');
dialog.innerHTML = `
<form class="sufw-booking__form" novalidate>
<header class="sufw-booking__form-header">
<p class="sufw-booking__eyebrow">Termin anfragen</p>
<h2 id="sufw-booking-form-title">Ihre Angaben</h2>
<p class="sufw-booking__appointment"><strong>${escapeHtml(projectTitle)}</strong><br>${escapeHtml(dateFormatter.format(parseDate(slot.date)))} · ${escapeHtml(slot.start)} Uhr · ${escapeHtml(slot.title)}</p>
</header>
<p class="sufw-booking__required"><span aria-hidden="true">*</span> Pflichtfeld</p>
<div class="sufw-booking__form-grid">
<label>Vor- und Nachname <span aria-hidden="true">*</span><input name="name" type="text" autocomplete="name" required></label>
<label>E-Mail-Adresse <span aria-hidden="true">*</span><input name="email" type="email" autocomplete="email" required></label>
<label>Telefonnummer <span class="sufw-booking__optional">freiwillig</span><input name="phone" type="tel" autocomplete="tel"></label>
<label>Alter <span class="sufw-booking__optional">freiwillig</span><input name="age" type="number" min="12" max="112" inputmode="numeric"></label>
</div>
<label>Nachricht <span class="sufw-booking__optional">freiwillig</span><textarea name="message" rows="4"></textarea></label>
<label>Ansprechperson bei Agentur für Arbeit oder Jobcenter <span class="sufw-booking__optional">freiwillig</span><input name="caseWorker" type="text"></label>
<label class="sufw-booking__consent"><input name="privacy" type="checkbox" value="accepted" required><span>Ich habe die Datenschutzhinweise nach Art. 13 DSGVO gelesen und stimme der Verarbeitung meiner Angaben zur Terminanfrage zu. <span aria-hidden="true">*</span></span></label>
<div class="sufw-booking__form-actions"><button type="button" class="sufw-booking__secondary" data-close-dialog>Abbrechen</button><button type="submit" data-submit-booking disabled>Terminanfrage absenden</button></div>
</form>`;
const form = dialog.querySelector('form');
const privacy = form.elements.namedItem('privacy');
const submitButton = dialog.querySelector('[data-submit-booking]');
const actions = dialog.querySelector('.sufw-booking__form-actions');
dialog.style.overflowY = 'auto';
actions.style.position = 'sticky';
actions.style.bottom = '0';
actions.style.zIndex = '2';
actions.style.display = 'flex';
actions.style.visibility = 'visible';
actions.style.opacity = '1';
actions.style.backgroundColor = '#fff';
actions.style.paddingTop = '.75rem';
const fieldNames = ['name', 'email', 'phone', 'age', 'message', 'caseWorker', 'privacy'];
const validateField = (fieldName) => {
const field = form.elements.namedItem(fieldName);
const value = fieldName === 'privacy' ? field.checked : field.value;
const error = validateBookingField(fieldName, value);
showFieldError(form, fieldName, error);
return error;
};
const updateSubmitButton = () => {
submitButton.disabled = !canSubmitBookingForm(privacy.checked);
submitButton.hidden = false;
submitButton.style.display = 'inline-block';
submitButton.style.visibility = 'visible';
submitButton.style.opacity = '1';
submitButton.style.position = 'relative';
submitButton.style.zIndex = '3';
submitButton.style.minHeight = '2.75rem';
};
fieldNames.forEach((fieldName) => {
const field = form.elements.namedItem(fieldName);
const eventName = fieldName === 'privacy' ? 'change' : 'input';
field.addEventListener(eventName, () => validateField(fieldName));
if (fieldName !== 'privacy')
field.addEventListener('blur', () => validateField(fieldName));
});
privacy.addEventListener('change', updateSubmitButton);
updateSubmitButton();
form.addEventListener('submit', (event) => {
event.preventDefault();
const errors = Object.fromEntries(fieldNames
.map((fieldName) => [fieldName, validateField(fieldName)])
.filter(([, error]) => error));
if (Object.keys(errors).length) {
const firstInvalid = form.elements.namedItem(Object.keys(errors)[0]);
firstInvalid.focus();
return;
}
dialog.close();
openConfirmationDialog(container, slot);
});
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', '');
dialog.querySelector('input[name="name"]').focus();
}
}
function renderComponent(container) {
const configuredProject = container.dataset.project;
const project = configuredProject === 'produktionsschule' ? 'produktionsschule' : 'perspektivwerk';
let currentMonth = monthForProject(project);
let selectedDate = demoSlots.find((slot) => slot.project === project)?.date ?? '';
const render = () => {
const slots = selectedDate ? getSlotsForDate(project, selectedDate) : [];
const selectedLabel = selectedDate ? dateFormatter.format(parseDate(selectedDate)) : 'einen Tag';
container.innerHTML = `
<section class="sufw-booking" aria-labelledby="sufw-booking-title">
<header class="sufw-booking__header">
<p class="sufw-booking__eyebrow">Terminbuchung</p>
<h2 id="sufw-booking-title">${project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule'}</h2>
<p>Wählen Sie einen Tag. Freie Termine sind mit „frei“ gekennzeichnet.</p>
</header>
<div class="sufw-booking__legend" aria-label="Legende"><span><i class="sufw-booking__marker sufw-booking__marker--available"></i> Termin verfügbar</span><span><i class="sufw-booking__marker sufw-booking__marker--booked"></i> Termin belegt</span></div>
<div class="sufw-booking__month-nav">
<button class="sufw-booking__nav" type="button" data-action="previous" aria-label="Vorheriger Monat">‹</button>
<h3 aria-live="polite">${monthFormatter.format(currentMonth)}</h3>
<button class="sufw-booking__nav" type="button" data-action="next" aria-label="Nächster Monat">›</button>
</div>
<div class="sufw-booking__weekdays" aria-hidden="true">${weekdays.map((weekday) => `<span>${weekday}</span>`).join('')}</div>
<div class="sufw-booking__calendar" role="grid" aria-label="Kalender ${monthFormatter.format(currentMonth)}">
${createMonthDates(currentMonth).map((date) => {
if (!date)
return '<span class="sufw-booking__empty" aria-hidden="true"></span>';
const key = formatDateKey(date);
const daySlots = getSlotsForDate(project, key);
const available = daySlots.some((slot) => slot.status === 'available');
const booked = daySlots.length > 0 && !available;
const selected = key === selectedDate;
const state = available ? 'available' : booked ? 'booked' : 'none';
const label = daySlots.length ? `${dateFormatter.format(date)}: ${available ? 'freie Termine verfügbar' : 'nur belegte Termine'}` : dateFormatter.format(date);
return `<button type="button" role="gridcell" class="sufw-booking__day sufw-booking__day--${state}${selected ? ' is-selected' : ''}" data-date="${key}" aria-pressed="${selected}" aria-label="${label}"><time datetime="${key}">${date.getDate()}</time>${daySlots.length ? `<small>${available ? 'frei' : 'belegt'}</small>` : ''}</button>`;
}).join('')}
</div>
<section class="sufw-booking__slots" aria-labelledby="sufw-slot-title">
<h3 id="sufw-slot-title">Termine am ${selectedLabel}</h3>
${slots.length ? `<ul>${slots.map((slot) => `<li><button type="button" class="sufw-booking__slot" data-slot-id="${slot.id}" ${slot.status === 'booked' ? 'disabled aria-disabled="true"' : ''}><strong>${slot.start} Uhr</strong><span>${slot.title}</span><em>${slot.status === 'available' ? 'Verfügbar' : 'Belegt'}</em></button></li>`).join('')}</ul>` : '<p class="sufw-booking__empty-state">Für diesen Tag liegen keine Termine vor.</p>'}
<p class="sufw-booking__status" role="status" aria-live="polite"></p>
</section>
</section>`;
container.querySelector('[data-action="previous"]')?.addEventListener('click', () => {
currentMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1);
render();
});
container.querySelector('[data-action="next"]')?.addEventListener('click', () => {
currentMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1);
render();
});
container.querySelectorAll('[data-date]').forEach((button) => button.addEventListener('click', () => {
selectedDate = button.dataset.date ?? '';
render();
}));
container.querySelectorAll('[data-slot-id]').forEach((button) => button.addEventListener('click', () => {
const slot = demoSlots.find((entry) => entry.id === button.dataset.slotId);
if (slot)
openBookingDialog(container, slot);
}));
};
render();
}
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();
}
}