Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
037ea11de5 | ||
|
|
6a9d31ca5e | ||
|
|
6dcc315d25 | ||
|
|
76e45ac9ad | ||
|
|
e96c73000d |
No files matched your search
@@ -1,14 +1,99 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
## Guidlines
|
## Guidelines
|
||||||
|
|
||||||
Ich bin Softwareentwickler, Full-Stack Webentwickler und Netzwerkadministrator. Ich möchte bei allen Software und Netzwerk-Design-Entscheidungen und beim Erstellen von Code eingebunden werden.
|
Die Fragende Person/der Nutzer ist Softwareentwickler, Full-Stack Webentwickler und Netzwerkadministrator.
|
||||||
|
Der Fragende/der Nutzer wird bei allen Software und Netzwerk-Design-Entscheidungen und beim Erstellen von Code durch Nachfrage eingebunden!
|
||||||
Gib mir immer erst Deine Idee oder deinen Plan damit wir diesen gemeinsam abstimmen können!
|
Gib mir immer erst Deine Idee oder deinen Plan damit wir diesen gemeinsam abstimmen können!
|
||||||
Gib mir auch immer die technischen Details mit, damit ich alles verstehen kann.
|
Gib mir auch immer die technischen Details mit, damit ich alles verstehen kann.
|
||||||
|
|
||||||
## Ziel
|
## Ziel der zu entwicklenden Anwendung
|
||||||
|
|
||||||
|
Entwicklung einer abgeschlossenen Komponente für das CMS Contao, um Terminbuchungs-Funktionen in der Website des Perspektivwerks (PW) und der Produktionsschule (PS) zu implementieren.
|
||||||
|
Ziel ist eine nutzerfreundliche und schöne Buchungsoberfläche, die den genannten Terminarten entspricht, mobil nutzbar ist, sicher umgesetzt wird und später mittels ICS Addresse in Kalenderprogramme integriert werden kann.
|
||||||
|
|
||||||
|
### Technik
|
||||||
|
|
||||||
|
Contao ist ein in PHP geschriebenes CMS, das ansonsten einfach Webtechnologien verwendet.
|
||||||
|
In dieses soll eine WebComponente integriert werden, die einen Terminbuchungskalender anzeigt.
|
||||||
|
Termininformationen kommen aus der Datenbank entweder mittels Contao oder direkt als MySQL Query.
|
||||||
|
Es gibt zwei Kalender (je für PW/PS) und jeder Kalender enthält eigene Termine die von Besuchern gebucht werden können.
|
||||||
|
|
||||||
|
### Auslieferungswege der Terminbuchungskomponente
|
||||||
|
|
||||||
|
Die Terminbuchungskomponente muss in zwei gleichwertigen Varianten funktionieren:
|
||||||
|
|
||||||
|
- **Apache-Vorschau:** als Bestandteil der statischen Seite im Webpfad `public/perspektivwerk/`, erreichbar unter `http://localhost:8080/perspektivwerk/`.
|
||||||
|
- **Contao-Einbindung:** als einbettbare Komponente in einem Contao-Inhaltselement vom Typ HTML.
|
||||||
|
|
||||||
|
Contao verwendet die browsernativen JavaScript- und CSS-Assets aus `public/booking/`: `booking.js` und `booking.css`, eingebunden über `/booking/booking.js` und `/booking/booking.css`. Für die Apache-Vorschau liegen identische, statisch übertragbare Kopien unter `public/perspektivwerk/js/`, die relativ über `js/booking.js` und `js/booking.css` eingebunden werden. Für Contao wird das kombinierte Snippet aus `integration/contao-html-element.html` in ein neues Inhaltselement vom Typ **HTML** eingefügt. Es muss als Roh-HTML im Feld „HTML-Code“ gespeichert werden, nicht als Textinhalt eines TinyMCE-Text-Elements. Ein zweites Kopieren in den Contao-Dateibereich `files/` ist nicht erforderlich.
|
||||||
|
|
||||||
|
### Die Oberfläche / UI
|
||||||
|
|
||||||
|
- ein Kalender-view aller Tage des aktuellen Monats
|
||||||
|
- der Monat kann vor und zurück gewechselt werden
|
||||||
|
- Tage mit freien Terminen werden farblich dargestellt
|
||||||
|
- wird auf einen Tag geklickt sind alle slots an diesem Tag zu sehen (farblich unterschieden verfügbar/schon belegt)
|
||||||
|
- wird auf einen freien Slot geklickt kann dieser durch eine E-Mail-Bestätigung (Datenschutzbedingung akzeptiert, Art. 13 DSGVO) verbindlich gebucht werden
|
||||||
|
- is barrierefrei. Alle Komponenten haben WAI-ARIA tags, sinnvolle Focus-Reihenfolge, Bildbeschreibungen, gute Kontraste, Farbenblindheit ist berücksichtigt
|
||||||
|
|
||||||
|
### Besucher der Webseite können
|
||||||
|
|
||||||
|
- in der Komponente die verfügbaren Termine sehen (offen / schon belegt)
|
||||||
|
- einen der verfügbaren Termine für sich buchen (Angabe einer E-Mail Adresse, Bestätigung des Termins via Email)
|
||||||
|
- Nach der Bestätigung via E-Mail kommt zweite Mail zur Bestätigung. Diese enthält ein *.ics file mit dem Termin (Kalenderintegration des Klienten)
|
||||||
|
- Nicht bestätigte Buchungen laufen nach 30 Minuten ab, der Slot wird wieder frei
|
||||||
|
|
||||||
|
### Mitarbeiter von PS oder PW können
|
||||||
|
|
||||||
|
- alle gebuchten Termine mittels einer Kalenderaddresse abonnieren und in ihren eigenen Kalender (Google & Outlook) integrieren
|
||||||
|
- wann abonnierte Kalender synchronisiert werden unklar, Mitarbeiter müssen im Zweifel bei kurz bevorstehenden Terminen im tool nachsehen
|
||||||
|
- soweit vorgesehen: Ihre Verfügbarkeit für Termin-Slots im eintragen (via. Contao-Backend)
|
||||||
|
- soweit vorgesehen: Ihre Verfügbarkeit für Termin-Slots bearbeiten (via. Contao-Backend)
|
||||||
|
|
||||||
|
|
||||||
|
### Fachliche Fragen
|
||||||
|
|
||||||
|
1. Sind die buchbaren Zeiten immer gleich (z.B.. Mo. & Mit. 13, 14 und 15Uhr) oder ständig neu (Feiertage, Schließzeiten, etc.)?
|
||||||
|
1. Wer darf alles die Zeiten anpassen (müssen leicht anzupassen sein),
|
||||||
|
2. Mit wieviel Vorlaufzeit sind offene Terminslots bekannt?
|
||||||
|
3. Wie sollen Terminslots geändert werden?
|
||||||
|
1. per Contao-Backend?
|
||||||
|
2. ein eigenes Backend des Tools?
|
||||||
|
3. per Mail an das Tool?
|
||||||
|
4. per eigenem Kalendereintrag?
|
||||||
|
2. Dürfen Besucher auch belegte (anonyme) Termine sehen (um über die Terminlage generell informiert zu sein)?
|
||||||
|
3. Sollen Besucher eine Bestätigungsmail bekommen und in dieser den Termin auch bestätigen?
|
||||||
|
4. Welche Informationen sollen Besucher der Seite für den Termin eingeben?
|
||||||
|
1. Name, Telefon, Alter, Nachricht, Ansprechpartner bei AfA/Jobcenter?
|
||||||
|
2. Wer soll die Informationen bekommen? (eine Koordinierungstelle, die konkreten verschiedenen Sachbarbeitenden, andere?)
|
||||||
|
5. Wer oder wie viele Menschen/Kontakte werden informiert? Gibt es nur ein Stelle die je für PW/PS involviert , oder müssen wie bei deiner Arztreferenz auch verschiedene Personen/Themen/Schwerpunkte o.ä. koordiniert werden?
|
||||||
|
6. Gibt es mehrere Räume die z.B. abhängig genutzt und in die Modellierung eingebaut werden müssen?
|
||||||
|
7. Gibt es irgendwas zu beachten bei Minderjährigen und E-Mails oder Zeitbuchungen?
|
||||||
|
8. Wenn E-Mail nicht klappt, Fallback auf Telefon (WhatsApp, Signal whatever?)
|
||||||
|
9. Hinweis: Rücksprache mit Datenschutzbeauftragtem bzgl. E-Mail Validierung!
|
||||||
|
10. Sollen die Mitarbeiter direkten E-Mail Kontakt zu den Klienten aufnehmen können, z.B. um Termin zu verschieben, stornieren, Rückfragen?
|
||||||
|
11. Müssen stornierte Termine gespeichert werden? Wegen Kosten oder Rückverfolgung?
|
||||||
|
12. Soll das tool Klienten einen Tag vor dem Termin noch mal per Mail informieren o.ä.?
|
||||||
|
|
||||||
|
|
||||||
|
### Technische Fragen
|
||||||
|
|
||||||
|
1. Die verwendete Contao-Version hat bekannte Sicherheitslücken, wann wird aktualisiert?
|
||||||
|
2. Zum einbinden der Terminbuchung muss in den Sicherheitseinstellungen von Contao <link> und <script> Element + deren Attribute frei gegeben werden.
|
||||||
|
3. Ich benötige eigene Datenbanken (oder Speicher direkt in Contao) für die Termine und E-Mails
|
||||||
|
4. E-Mail Validierung bei Buchung:
|
||||||
|
1. Terminbuchungstool muss E-Mails schicken können (IMAP-Konto)
|
||||||
|
2. Wann sollen Nutzerdaten gelöscht werden? (Fachlich und aus DSGVO Sicht)
|
||||||
|
5. Art.13 DSGVO: festgelegte Rechtsgrundlage:
|
||||||
|
1. Nutzer die sich anmelden haben das Recht auf Dateneinsicht und Datenlöschung, wie damit am besten umgehen?
|
||||||
|
2. Ein Löschkonzept sowie geregelte Zugriffe der Mitarbeitenden.
|
||||||
|
3. Datenminimierung und Speicherbegrenzung verlangen, nur notwendige Daten und nur so lange wie erforderlich zu speichern.
|
||||||
|
6. Hat der Mailserver SPF, DKIM und DMARC für die Absenderdomain eingerichtet?
|
||||||
|
7. Serverseitige Input-Validierung, CSRF-Schutz, Rate Limits, Bot-Schutz mit Datenschutzfreundlicher Eskalation und keine preisgebenden Fehlermeldungen?
|
||||||
|
8. Gibt es Staging und Development Systeme für die Entwicklung?
|
||||||
|
9. Wer verantwortet Betrieb, Updates, Backups, Monitoring und Datenschutzanfragen?
|
||||||
|
|
||||||
Diese Aufgabe betrifft die Implementierung einer Terminbuchungs-Funktion für die Website des Perspektivwerks und der Produktionsschule. Ziel ist eine nutzerfreundliche Buchungsoberfläche, die den genannten Terminarten entspricht, mobil nutzbar ist, sicher umgesetzt wird und später in einen Google-Kalender integriert werden kann.
|
|
||||||
|
|
||||||
## Projektkontext
|
## Projektkontext
|
||||||
|
|
||||||
@@ -16,7 +101,6 @@ Diese Aufgabe betrifft die Implementierung einer Terminbuchungs-Funktion für di
|
|||||||
- Website: https://www.sufw.de/produktionsschule
|
- Website: https://www.sufw.de/produktionsschule
|
||||||
- Referenzstil: https://webtermin.medatixx.de/#/affe7e7b-bb18-46b6-aaf4-6ff75152606c/search
|
- Referenzstil: https://webtermin.medatixx.de/#/affe7e7b-bb18-46b6-aaf4-6ff75152606c/search
|
||||||
- Zugangsdaten für die Terminbuchung werden nachgereicht.
|
- Zugangsdaten für die Terminbuchung werden nachgereicht.
|
||||||
- Der aktuelle Stand ist noch unbekannt; deshalb wird zuerst ein Mockup basierend auf der bestehenden Seite erstellt, bevor Backend- und Integrationslogik angebunden wird.
|
|
||||||
|
|
||||||
## Technische Bestandsaufnahme der Website
|
## Technische Bestandsaufnahme der Website
|
||||||
|
|
||||||
@@ -26,8 +110,7 @@ Die folgenden Punkte sind aus einer passiven, öffentlich sichtbaren Analyse abg
|
|||||||
- Als CMS ist sehr wahrscheinlich Contao im Einsatz.
|
- Als CMS ist sehr wahrscheinlich Contao im Einsatz.
|
||||||
- Der Webserver antwortet mit `Apache`.
|
- Der Webserver antwortet mit `Apache`.
|
||||||
- Die Anwendung läuft sehr wahrscheinlich auf einem PHP-Stack; Contao spricht dabei typischerweise über Symfony/Doctrine mit einer MySQL- oder MariaDB-Datenbank.
|
- Die Anwendung läuft sehr wahrscheinlich auf einem PHP-Stack; Contao spricht dabei typischerweise über Symfony/Doctrine mit einer MySQL- oder MariaDB-Datenbank.
|
||||||
- Im Frontend sind Bootstrap-5-Elemente sowie jQuery erkennbar.
|
- Zusätzliche Frontend-Bausteine wie Slick-Slider und eigene CSS-Bundles sind auf der Bestandswebsite eingebunden.
|
||||||
- Zusätzliche Frontend-Bausteine wie Slick-Slider und eigene CSS-Bundles sind eingebunden.
|
|
||||||
- Ein selbst gehostetes Matomo-Tracking ist sichtbar.
|
- Ein selbst gehostetes Matomo-Tracking ist sichtbar.
|
||||||
- Der genaue Contao- und PHP-Versionsstand ist von außen nicht belastbar bestimmbar und sollte für die Implementierung als offen behandelt werden.
|
- Der genaue Contao- und PHP-Versionsstand ist von außen nicht belastbar bestimmbar und sollte für die Implementierung als offen behandelt werden.
|
||||||
- Der Host wirkt nach der Infrastruktur sehr wahrscheinlich wie eine IONOS-Umgebung, auch das ist aber nur eine Wahrscheinlichkeitsannahme.
|
- Der Host wirkt nach der Infrastruktur sehr wahrscheinlich wie eine IONOS-Umgebung, auch das ist aber nur eine Wahrscheinlichkeitsannahme.
|
||||||
@@ -35,13 +118,13 @@ Die folgenden Punkte sind aus einer passiven, öffentlich sichtbaren Analyse abg
|
|||||||
Für die weitere Umsetzung bedeutet das:
|
Für die weitere Umsetzung bedeutet das:
|
||||||
|
|
||||||
- Die Terminbuchung sollte sich sauber in eine klassische CMS-Seite einbetten lassen.
|
- Die Terminbuchung sollte sich sauber in eine klassische CMS-Seite einbetten lassen.
|
||||||
- Ein kleines, eigenständiges TypeScript-Frontend ist hier voraussichtlich passender als eine große neue Framework-Schicht.
|
- Ein kleines, eigenständiges JavaScript-Frontend ist hier voraussichtlich passender als eine große neue Framework-Schicht.
|
||||||
- Backend- und Kalenderanbindung sollten entkoppelt geplant werden, damit ein späterer Wechsel von Google Kalender zu Exchange möglich bleibt, ohne das UI neu bauen zu müssen.
|
- Backend- und Kalenderanbindung sollten entkoppelt geplant werden, damit ein späterer Wechsel von Google Kalender zu Exchange möglich bleibt, ohne das UI neu bauen zu müssen.
|
||||||
|
|
||||||
## Lokales Entwicklungssystem
|
## Lokales Entwicklungssystem
|
||||||
|
|
||||||
- Das lokale Setup bildet die vermutete Komponentenfamilie mit Docker Compose nach: Apache/PHP 8.3, Contao Managed Edition 5.3, MariaDB 10.11 und Adminer.
|
- Das lokale Setup bildet die vermutete Komponentenfamilie mit Docker Compose nach: Apache/PHP 8.3, Contao Managed Edition 5.3, MariaDB 10.11 und Adminer.
|
||||||
- Frontend-Abhängigkeiten für die angenommene Originalseite sind in `package.json` vorgesehen: Bootstrap 5, jQuery und Slick Carousel; die eigene Logik bleibt in TypeScript.
|
- Für die Buchungskomponente wird eigene Logik als browsernatives JavaScript ohne Build-Schritt ausgeliefert.
|
||||||
- Start über `docker compose up --build`; CMS auf Port 8080, Adminer auf Port 8081, MariaDB zusätzlich auf Host-Port 3307.
|
- Start über `docker compose up --build`; CMS auf Port 8080, Adminer auf Port 8081, MariaDB zusätzlich auf Host-Port 3307.
|
||||||
- Das Setup ist eine reproduzierbare Entwicklungsbasis und keine Kopie der Produktivinstallation. Exakte Contao-/PHP-Versionen, Templates, Erweiterungen und Hosting-Einstellungen der Originalseite müssen bei Zugriff auf die Produktivumgebung abgeglichen werden.
|
- Das Setup ist eine reproduzierbare Entwicklungsbasis und keine Kopie der Produktivinstallation. Exakte Contao-/PHP-Versionen, Templates, Erweiterungen und Hosting-Einstellungen der Originalseite müssen bei Zugriff auf die Produktivumgebung abgeglichen werden.
|
||||||
|
|
||||||
@@ -62,29 +145,27 @@ Für die weitere Umsetzung bedeutet das:
|
|||||||
## Grundprinzipien
|
## Grundprinzipien
|
||||||
|
|
||||||
- Mobile-first und barrierearm entwickeln.
|
- Mobile-first und barrierearm entwickeln.
|
||||||
- Die Frontend-Logik in TypeScript umsetzen.
|
- Eine in Contao CMS funkionierende Lösung sein.
|
||||||
- Für die Oberfläche möglichst einfache, weit verbreitete Technologien nutzen: HTML, CSS und TypeScript ohne unnötige Framework-Schicht.
|
- Die Frontend-Logik in browsernativem JavaScript (ES-Module) umsetzen.
|
||||||
|
- Für die Oberfläche möglichst einfache, weit verbreitete Technologien nutzen: HTML, CSS und JavaScript ohne unnötige Framework-Schicht oder verpflichtenden Build-Schritt.
|
||||||
- Performance hat Priorität: geringe Dateigrößen, wenig JavaScript, keine unnötigen Abhängigkeiten, schnelle Rendering- und Ladezeiten.
|
- Performance hat Priorität: geringe Dateigrößen, wenig JavaScript, keine unnötigen Abhängigkeiten, schnelle Rendering- und Ladezeiten.
|
||||||
- Die Lösung soll modular, testbar und leicht später mit Backend oder Kalender-API verbunden werden können.
|
- Die Lösung soll modular, testbar und leicht ins Contao Backend integriert werden können.
|
||||||
|
|
||||||
## Technische Anforderungen
|
## Technische Anforderungen
|
||||||
|
|
||||||
- Die Lösung muss auf der bestehenden Website sauber einbindbar sein.
|
- Die Lösung muss im Contao CMS sauber einbindbar sein.
|
||||||
|
- Die Lösung muss zusätzlich als Apache-Vorschau unter `/perspektivwerk/` funktionieren.
|
||||||
- Die Terminliste soll klar, verständlich und mobil kompatibel sein.
|
- Die Terminliste soll klar, verständlich und mobil kompatibel sein.
|
||||||
- Das Frontend soll nach einem „mockup-first“-Ansatz entstehen: zunächst ein realistisches, klickbares UI-Prototyping basierend auf der aktuellen Seite, dann später Anbindung an echte Daten und Kalender.
|
|
||||||
- Zugriffsdaten und Zugangsspezifika sind als Umgebungsvariablen bzw. sichere Konfiguration zu behandeln.
|
- Zugriffsdaten und Zugangsspezifika sind als Umgebungsvariablen bzw. sichere Konfiguration zu behandeln.
|
||||||
- Die Terminbuchung soll zunächst in einen Google-Kalender integriert werden.
|
- Die technische Architektur soll die Integration ins Contao Backend sauber abbilden, ohne dass das Frontend später neu geschrieben werden muss falls die Kommunikation zu Datenbank angepasst werden muss. (Separation of concerns)
|
||||||
- Ein späterer Wechsel auf Exchange-Kalender muss technisch berücksichtigt werden.
|
|
||||||
- Die technische Architektur soll die spätere Integration von Backend, API und Kalender sauber abbilden, ohne dass das Frontend später neu geschrieben werden muss.
|
|
||||||
|
|
||||||
## Sicherheitsanforderungen
|
## Sicherheitsanforderungen
|
||||||
|
|
||||||
- Keine geheimen Zugangsdaten, Tokens, Keys oder Secrets im Repository speichern.
|
- Keine geheimen Zugangsdaten, Tokens, Keys oder Secrets im Repository speichern.
|
||||||
- Eingaben immer validieren und bereinigen; XSS- und Injection-Angriffe vermeiden.
|
- Eingaben immer validieren und bereinigen; XSS- und Injection-Angriffe vermeiden.
|
||||||
- Wenn Sicherheitsrelevanter Code geschrieben wurde, soll gefragt werden ob eine gesonderete tiefe Sicherheitsanalyse dafür durchgeführt werden soll.
|
- Wenn Sicherheitsrelevanter Code geschrieben wurde, soll gefragt werden ob eine gesonderete tiefe Sicherheitsanalyse dafür durchgeführt werden soll.
|
||||||
- Wenn Formulare oder API-Calls später eingesetzt werden, nur mit abgesicherten, validierten Requests arbeiten.
|
- Wenn Formulare oder API-Calls eingesetzt werden, nur mit abgesicherten, validierten Requests arbeiten.
|
||||||
- Bei späterem Backend- und API-Setup: sichere Authentifizierung, autorisierte Zugriffe und minimal notwendige Berechtigungen verwenden.
|
- Bei Backend- und API-Setup: sichere Authentifizierung, autorisierte Zugriffe und minimal notwendige Berechtigungen verwenden.
|
||||||
- Keine sensiblen Daten im Client- oder Mockup-Code sichtbar machen.
|
|
||||||
|
|
||||||
## Qualitätsmaßstäbe
|
## Qualitätsmaßstäbe
|
||||||
|
|
||||||
@@ -106,31 +187,27 @@ Für die weitere Umsetzung bedeutet das:
|
|||||||
## Dokumentationsanforderungen
|
## Dokumentationsanforderungen
|
||||||
|
|
||||||
- Eine README mit Installations- und Startanweisungen erstellen.
|
- Eine README mit Installations- und Startanweisungen erstellen.
|
||||||
- Eine kurze Architektur- und Integrationsdokumentation für Frontend, Mockup, späteres Backend und Kalender-API ergänzen.
|
- Eine kurze Architektur- und Integrationsdokumentation für Frontend und Backend ergänzen.
|
||||||
- Konfigurationswerte und Umgebungsvariablen sauber dokumentieren, aber keine geheimen Daten enthalten.
|
- Konfigurationswerte und Umgebungsvariablen sauber dokumentieren, aber keine geheimen Daten enthalten.
|
||||||
- Hinweise zur späteren Google-Calendar- und Exchange-Anbindung dokumentieren.
|
- Hinweise zur späteren Google-Calendar- und Exchange-Anbindung dokumentieren.
|
||||||
- Frontend-Entscheidungen, nicht trivialen Implementierungsdetails und potenzielle Risiken ebenfalls kurz dokumentieren.
|
- Frontend-Entscheidungen, nicht trivialen Implementierungsdetails und potenzielle Risiken ebenfalls kurz dokumentieren.
|
||||||
|
|
||||||
## Arbeitsweise
|
## Arbeitsweise
|
||||||
|
|
||||||
1. Zuerst die bestehende Website-Struktur bzw. vorhandene technische Basis analysieren.
|
1. Das Design und die Nutzerführung für Booking-Flow definieren.
|
||||||
2. Das Design und die Nutzerführung für ein mockup-basiertes Booking-Flow definieren.
|
2. Die geeignete Architektur für die Terminbuchung bestimmen.
|
||||||
3. Die geeignete Architektur für die Terminbuchung bestimmen.
|
3. Die Buchungs-UI mit den erforderlichen Projekten und Terminarten umsetzen.
|
||||||
4. Die Buchungs-UI mit den erforderlichen Projekten und Terminarten umsetzen.
|
4. Testfälle für Kernfunktionen schreiben und anschließend die Umsetzung validieren.
|
||||||
5. Testfälle für Kernfunktionen schreiben und anschließend die Umsetzung validieren.
|
|
||||||
6. Die Kalender-Integration vorbereiten und auf Google-Kalender ausrichten.
|
|
||||||
7. Das Ergebnis lokalisieren, validieren und möglichst mit Beispiel- oder Testdaten absichern.
|
|
||||||
|
|
||||||
## Abnahme
|
## Abnahme
|
||||||
|
|
||||||
Die Aufgabe gilt als abgeschlossen, wenn:
|
Die Aufgabe gilt als abgeschlossen, wenn:
|
||||||
|
|
||||||
- die Angebotsseite für beide Projekte korrekt funktioniert,
|
- wenn eine Contao-Komponente erstellt wurde die im Contao-Backend eingebunden werden kann
|
||||||
|
- auf der Contao-Webseite die Terminbuchung angezeigt wird.
|
||||||
- die Terminarten auswählbar sind,
|
- die Terminarten auswählbar sind,
|
||||||
- die Oberfläche mobil-first, performant und barrierearm ist,
|
- die Oberfläche mobil-first, performant und barrierearm ist,
|
||||||
- das Frontend in TypeScript umgesetzt wurde,
|
- das Frontend in browsernativem JavaScript umgesetzt wurde,
|
||||||
- die Lösung als Mockup auf der aktuellen Seite verankert ist und später backendfähig bleibt,
|
|
||||||
- die Integration zur Kalender-API bzw. zum Google-Kalender vorbereitet ist,
|
|
||||||
- die Konfiguration sicher, dokumentiert und nachvollziehbar ist,
|
- die Konfiguration sicher, dokumentiert und nachvollziehbar ist,
|
||||||
- die Kernfunktionen durch Tests abgesichert sind.
|
- die Kernfunktionen durch Tests abgesichert sind.
|
||||||
|
|
||||||
@@ -139,4 +216,3 @@ Die Aufgabe gilt als abgeschlossen, wenn:
|
|||||||
- Zugangsdaten sind vertraulich und dürfen nicht in die Versionskontrolle eingegeben werden.
|
- Zugangsdaten sind vertraulich und dürfen nicht in die Versionskontrolle eingegeben werden.
|
||||||
- Wenn die Umgebung oder der vorhandene Stack unklar ist, zuerst die technische Basis prüfen und dann gezielt implementieren.
|
- Wenn die Umgebung oder der vorhandene Stack unklar ist, zuerst die technische Basis prüfen und dann gezielt implementieren.
|
||||||
- Das Projekt ist zunächst als funktionierende Terminbuchung mit klarer Erweiterbarkeit zu sehen; keine Über-Engineering-Phase ohne Notwendigkeit.
|
- Das Projekt ist zunächst als funktionierende Terminbuchung mit klarer Erweiterbarkeit zu sehen; keine Über-Engineering-Phase ohne Notwendigkeit.
|
||||||
- Das Frontend muss zunächst als robustes, realistisches Mockup funktionieren, auch wenn das Backend noch nicht definiert ist.
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Perspektivwerk Terminbuchung – lokales Entwicklungssystem
|
# Perspektivwerk Terminbuchung – lokales Entwicklungssystem
|
||||||
|
|
||||||
Dieser Ordner enthält eine lokale Contao-4.13.58-Entwicklungsumgebung mit Apache, MariaDB sowie die im Frontend verbreiteten Komponenten Bootstrap 5, jQuery und Slick Carousel.
|
Dieser Ordner enthält eine lokale Contao-4.13.58-Entwicklungsumgebung mit Apache, MariaDB und Bootstrap 5. Die Buchungskomponente verwendet browsernative DOM-APIs und keine jQuery-Abhängigkeit.
|
||||||
|
|
||||||
## Contao-Version und Sicherheitsstatus
|
## Contao-Version und Sicherheitsstatus
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ Contao 4.13.58 ist ausschließlich als lokale Kompatibilitätsumgebung vorgesehe
|
|||||||
|
|
||||||
## Start
|
## Start
|
||||||
|
|
||||||
Voraussetzungen: Docker Desktop (Compose v2) und optional Node.js 20+ für die TypeScript-Assets.
|
Voraussetzungen: Docker Desktop (Compose v2) und optional Node.js 20+ zum Ausführen der JavaScript-Regressionstests.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
@@ -39,11 +39,11 @@ Danach den versionierten Seitenzustand importieren:
|
|||||||
- Contao-Login: <http://localhost:8080/contao/login>
|
- Contao-Login: <http://localhost:8080/contao/login>
|
||||||
- Datenbankinspektor: <http://localhost:8081> (Server `db`, Benutzer `perspektivwerk`, Passwort `perspektivwerk`, Datenbank `perspektivwerk`)
|
- Datenbankinspektor: <http://localhost:8081> (Server `db`, Benutzer `perspektivwerk`, Passwort `perspektivwerk`, Datenbank `perspektivwerk`)
|
||||||
|
|
||||||
Für die Frontend-Logik:
|
Für die Frontend-Regressionstests:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
**Seite öffnen:**
|
**Seite öffnen:**
|
||||||
@@ -58,7 +58,54 @@ http://localhost:8080/perspektivwerk/
|
|||||||
|
|
||||||
Die genaue Contao-, PHP- und Template-Version der öffentlichen Seite ist nicht verlässlich aus dem ausgelieferten HTML ableitbar. Das Setup bildet daher die wahrscheinlichste Komponentenfamilie nach; es ist kein Dump der Produktivinstallation. IONOS/Hosting, Matomo-Konfiguration, Inhalte, Templates und Erweiterungen müssen bei Bedarf separat ergänzt werden.
|
Die genaue Contao-, PHP- und Template-Version der öffentlichen Seite ist nicht verlässlich aus dem ausgelieferten HTML ableitbar. Das Setup bildet daher die wahrscheinlichste Komponentenfamilie nach; es ist kein Dump der Produktivinstallation. IONOS/Hosting, Matomo-Konfiguration, Inhalte, Templates und Erweiterungen müssen bei Bedarf separat ergänzt werden.
|
||||||
|
|
||||||
Die Terminlogik liegt zunächst in `src/booking.ts`. Eine spätere Google-Calendar- oder Exchange-Anbindung sollte über eine Backend-API erfolgen, damit keine Secrets im Browser landen.
|
Die Terminlogik liegt in `public/booking/booking.js` als browsernatives ES-Modul. Für die statische Perspektivwerk-Vorschau liegen identische Kopien unter `public/perspektivwerk/js/`.
|
||||||
|
|
||||||
|
## Zwei Einbindungswege
|
||||||
|
|
||||||
|
Die Terminbuchungskomponente hat eine Vorschau auf einem Webseiten-Nachbau der Orginalseite:
|
||||||
|
|
||||||
|
**Apache-Vorschau:** auf der statischen Perspektivwerk-Seite unter <http://localhost:8080/perspektivwerk/>. Die Seite liegt in `public/perspektivwerk/`; ihre Referenzen auf die Komponente verwenden die relativen Pfade `js/booking.css` und `js/booking.js`.
|
||||||
|
2. **Contao:** als eigenständiges Inhaltselement vom Typ **HTML** auf einer Contao-Seite. Es enthält das kombinierte Snippet mit CSS, Komponentenplatzhalter und JavaScript.
|
||||||
|
|
||||||
|
`public/` ist der Apache-Webroot der lokalen Entwicklungsumgebung. Daher ist kein Kopieren der Komponentenassets nach `files/terminbuchung/` erforderlich.
|
||||||
|
|
||||||
|
## Statische Buchungsvorschau in Contao einbinden
|
||||||
|
|
||||||
|
Die erste Komponente ist absichtlich datenbankfrei. Sie zeigt Test-Slots, rendert einen Monatskalender und unterscheidet freie von belegten Terminen. Ein Klick auf einen freien Slot öffnet eine modale Eingabemaske für Name, E-Mail, Telefon, Alter, Nachricht, Ansprechperson bei AfA/Jobcenter sowie die DSGVO-Einwilligung. Die Eingaben werden derzeit ausschließlich im Browser validiert und weder gespeichert noch versendet.
|
||||||
|
|
||||||
|
Die Komponente benötigt keinen TypeScript-Compiler und keinen Build-Schritt. Regressionstests ausführen:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run test
|
||||||
|
```
|
||||||
|
### Einbinden in Contao
|
||||||
|
|
||||||
|
Ein **neues** Inhaltselement vom Typ **HTML** anlegen. Im Feld **HTML-Code** den vollständigen Inhalt aus [integration/contao-html-element.html](integration/contao-html-element.html) einfügen und speichern. Nicht das Inhaltselement **Text** und keinen TinyMCE-Editor verwenden: Diese können `link`- und `script`-Tags als Text maskieren. Für die Produktionsschule wird `data-project="produktionsschule"` gesetzt.
|
||||||
|
|
||||||
|
In der Seitenvorschau muss der HTML-Quelltext die Tags als `<link ...>` und `<script ...>` enthalten. Erscheinen stattdessen `<link` oder `<script`, wurde nicht das unmaskierte Feld **HTML-Code** verwendet und die Komponente kann nicht starten.
|
||||||
|
|
||||||
|
### Erforderliche Contao-Sicherheitseinstellung
|
||||||
|
|
||||||
|
Contao maskiert nicht freigegebene HTML-Tags auch im Inhaltselement **HTML**. Vor dem Einfügen der Komponente unter **System → Einstellungen → Sicherheit** die folgenden Einträge ergänzen:
|
||||||
|
|
||||||
|
- **Erlaubte HTML-Tags:** `script`, `link`
|
||||||
|
- **Erlaubte HTML-Attribute:** für `script` die Attribute `src`, `type`; für `link` die Attribute `href`, `rel`; für den Komponentenplatzhalter auf `div` entweder `data-*` oder mindestens `data-sufw-booking`, `data-project`.
|
||||||
|
|
||||||
|
Danach den Contao-Cache über **Systemwartung** leeren und das Inhaltselement erneut speichern. Keine globale Freigabe mit `*` verwenden. Wer ein HTML-Inhaltselement bearbeiten darf, kann mit dem freigegebenen `script`-Tag JavaScript auf der Website ausführen; diese Bearbeitungsrechte müssen daher auf vertrauenswürdige Backend-Nutzer beschränkt bleiben.
|
||||||
|
|
||||||
|
Die Beispieltermine stehen in `public/booking/booking.js`; `public/perspektivwerk/js/booking.js` ist die für den statischen Upload bestimmte Kopie. Ein Test stellt sicher, dass beide Dateien gleich bleiben. Der spätere serverseitige Endpunkt muss sie ersetzen; Datenbankzugänge oder SQL gehören niemals in das HTML oder das Browser-JavaScript.
|
||||||
|
|
||||||
|
## Statische Perspektivwerk-Seite übertragen
|
||||||
|
|
||||||
|
Das Skript [scripts/Deploy-Perspektivwerk.ps1](scripts/Deploy-Perspektivwerk.ps1) überträgt ausschließlich den Inhalt von `public/perspektivwerk/` per SCP und korrigiert anschließend die Lesbarkeit auf dem Server per SSH. Dieser Ordner enthält auch `js/booking.js` und `js/booking.css`. Es lädt weder Contao noch die Datenbank oder andere Projektdateien hoch.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\Deploy-Perspektivwerk.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Hostname `boogie` wird aus der lokalen SSH-Konfiguration verwendet. Der vollständige Inhalt wird per SCP-Aufruf nach `/home/boogie/html/perspektivwerk/` übertragen. Anschließend führt ein separater SSH-Aufruf `chmod -R a+rX /home/boogie/html/perspektivwerk` aus. Die Passphrase des SSH-Schlüssels wird daher in der Regel zweimal abgefragt.
|
||||||
|
|
||||||
|
Vor dem Upload gibt das Skript den vollständigen Zielpfad aus. Damit lässt sich direkt prüfen, ob `booking.js` unter `/home/boogie/html/perspektivwerk/js/booking.js` landet. Das große `X` in `a+rX` setzt das Ausführungsrecht nur auf Verzeichnissen und auf bereits ausführbaren Dateien; normale HTML-, CSS- und JavaScript-Dateien erhalten kein unnötiges Ausführungsbit.
|
||||||
|
|
||||||
## Versionierter Contao-Seitenzustand
|
## Versionierter Contao-Seitenzustand
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<!--
|
||||||
|
Contao: Neues Inhaltselement vom Typ "HTML" anlegen.
|
||||||
|
Diesen vollständigen Block unverändert in das Feld "HTML-Code" einfügen.
|
||||||
|
Nicht in ein Text-/TinyMCE-Element einfügen.
|
||||||
|
Für Produktionsschule data-project="produktionsschule" setzen.
|
||||||
|
-->
|
||||||
|
<link rel="stylesheet" href="/booking/booking.css">
|
||||||
|
<div data-sufw-booking data-project="perspektivwerk"></div>
|
||||||
|
<script type="module" src="/booking/booking.js"></script>
|
||||||
Generated
+1
-53
@@ -6,13 +6,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "perspektivwerk-booking-frontend",
|
"name": "perspektivwerk-booking-frontend",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3"
|
||||||
"jquery": "^3.7.1",
|
|
||||||
"slick-carousel": "^1.8.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/jquery": "^3.5.32",
|
|
||||||
"typescript": "^5.7.2"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@popperjs/core": {
|
"node_modules/@popperjs/core": {
|
||||||
@@ -26,23 +20,6 @@
|
|||||||
"url": "https://opencollective.com/popperjs"
|
"url": "https://opencollective.com/popperjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/jquery": {
|
|
||||||
"version": "3.5.34",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.34.tgz",
|
|
||||||
"integrity": "sha512-3m3939S3erqmTLJANS/uy0B6V7BorKx7RorcGZVjZ62dF5PAGbKEDZK1CuLtKombJkFA2T1jl8LAIIs7IV6gBQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/sizzle": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/sizzle": {
|
|
||||||
"version": "2.3.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.10.tgz",
|
|
||||||
"integrity": "sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/bootstrap": {
|
"node_modules/bootstrap": {
|
||||||
"version": "5.3.8",
|
"version": "5.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
|
||||||
@@ -61,35 +38,6 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@popperjs/core": "^2.11.8"
|
"@popperjs/core": "^2.11.8"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"node_modules/jquery": {
|
|
||||||
"version": "3.7.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
|
|
||||||
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/slick-carousel": {
|
|
||||||
"version": "1.8.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/slick-carousel/-/slick-carousel-1.8.1.tgz",
|
|
||||||
"integrity": "sha512-XB9Ftrf2EEKfzoQXt3Nitrt/IPbT+f1fgqBdoxO3W/+JYvtEOW6EgxnWfr9GH6nmULv7Y2tPmEX3koxThVmebA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"jquery": ">=1.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript": {
|
|
||||||
"version": "5.9.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"tsc": "bin/tsc",
|
|
||||||
"tsserver": "bin/tsserver"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.17"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,16 +3,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"test": "node --test tests/booking.test.mjs"
|
||||||
"dev": "tsc --watch"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3"
|
||||||
"jquery": "^3.7.1",
|
|
||||||
"slick-carousel": "^1.8.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/jquery": "^3.5.32",
|
|
||||||
"typescript": "^5.7.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
.sufw-booking {
|
||||||
|
--sufw-booking-green: #08752a;
|
||||||
|
--sufw-booking-text: #202020;
|
||||||
|
--sufw-booking-muted: #5a5a5a;
|
||||||
|
--sufw-booking-line: #c7c7c7;
|
||||||
|
--sufw-booking-pale: #f3f6f3;
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 1.25rem;
|
||||||
|
color: var(--sufw-booking-text);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--sufw-booking-line);
|
||||||
|
font: 1rem/1.5 Arial, "Helvetica Neue", sans-serif;
|
||||||
|
}
|
||||||
|
.sufw-booking * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.sufw-booking__header h2,
|
||||||
|
.sufw-booking__slots h3 {
|
||||||
|
margin: 0.15rem 0 0.5rem;
|
||||||
|
color: var(--sufw-booking-green);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__header p {
|
||||||
|
margin: 0.3rem 0;
|
||||||
|
}
|
||||||
|
.sufw-booking__eyebrow {
|
||||||
|
color: var(--sufw-booking-green);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.sufw-booking__legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__marker {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.8rem;
|
||||||
|
height: 0.8rem;
|
||||||
|
margin-right: 0.35rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.sufw-booking__marker--available {
|
||||||
|
background: var(--sufw-booking-green);
|
||||||
|
}
|
||||||
|
.sufw-booking__marker--booked {
|
||||||
|
background: #535353;
|
||||||
|
}
|
||||||
|
.sufw-booking__month-nav {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 3rem 1fr 3rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.sufw-booking__month-nav h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
.sufw-booking__nav,
|
||||||
|
.sufw-booking__day,
|
||||||
|
.sufw-booking__slot {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.sufw-booking__nav {
|
||||||
|
min-width: 2.75rem;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
border: 1px solid var(--sufw-booking-green);
|
||||||
|
color: var(--sufw-booking-green);
|
||||||
|
background: #fff;
|
||||||
|
font-size: 1.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sufw-booking__nav:hover,
|
||||||
|
.sufw-booking__nav:focus-visible {
|
||||||
|
background: var(--sufw-booking-pale);
|
||||||
|
}
|
||||||
|
.sufw-booking__weekdays,
|
||||||
|
.sufw-booking__calendar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__weekdays {
|
||||||
|
margin-top: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__day {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 0.1rem;
|
||||||
|
min-height: 3.8rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--sufw-booking-text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sufw-booking__day:hover {
|
||||||
|
border-color: var(--sufw-booking-green);
|
||||||
|
background: var(--sufw-booking-pale);
|
||||||
|
}
|
||||||
|
.sufw-booking__day--available {
|
||||||
|
background: #dff3e4;
|
||||||
|
border-color: var(--sufw-booking-green);
|
||||||
|
}
|
||||||
|
.sufw-booking__day--booked {
|
||||||
|
background: #ececec;
|
||||||
|
border-color: #777;
|
||||||
|
}
|
||||||
|
.sufw-booking__day.is-selected {
|
||||||
|
outline: 3px solid #1f4fbc;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.sufw-booking__day small {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__empty {
|
||||||
|
min-height: 3.8rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__slots {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
border-top: 1px solid var(--sufw-booking-line);
|
||||||
|
}
|
||||||
|
.sufw-booking__slots ul {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.sufw-booking__slot {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 5rem 1fr auto;
|
||||||
|
gap: 0.7rem;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0.45rem 0;
|
||||||
|
padding: 0.8rem;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--sufw-booking-text);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--sufw-booking-green);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sufw-booking__slot:hover,
|
||||||
|
.sufw-booking__slot:focus-visible {
|
||||||
|
background: var(--sufw-booking-pale);
|
||||||
|
}
|
||||||
|
.sufw-booking__slot em {
|
||||||
|
color: var(--sufw-booking-green);
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.sufw-booking__slot:disabled {
|
||||||
|
border-color: #777;
|
||||||
|
background: #eee;
|
||||||
|
color: #555;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.sufw-booking__slot:disabled em {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.sufw-booking__empty-state,
|
||||||
|
.sufw-booking__status {
|
||||||
|
margin: 0.7rem 0;
|
||||||
|
}
|
||||||
|
.sufw-booking__status {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.sufw-booking__dialog {
|
||||||
|
width: min(42rem, calc(100% - 2rem));
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
color: var(--sufw-booking-text);
|
||||||
|
box-shadow: 0 0.75rem 2.5rem rgba(0, 0, 0, 0.35);
|
||||||
|
font: 1rem/1.5 Arial, "Helvetica Neue", sans-serif;
|
||||||
|
}
|
||||||
|
.sufw-booking__dialog::backdrop {
|
||||||
|
background: rgba(0, 0, 0, 0.62);
|
||||||
|
}
|
||||||
|
.sufw-booking__form {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__form-header {
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--sufw-booking-line);
|
||||||
|
}
|
||||||
|
.sufw-booking__form-header h2 {
|
||||||
|
margin: 0.15rem 0 0.5rem;
|
||||||
|
color: var(--sufw-booking-green);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__appointment,
|
||||||
|
.sufw-booking__required,
|
||||||
|
.sufw-booking__prototype-note {
|
||||||
|
margin: 0.35rem 0;
|
||||||
|
}
|
||||||
|
.sufw-booking__required {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__optional {
|
||||||
|
color: var(--sufw-booking-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.sufw-booking__form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.sufw-booking__form input,
|
||||||
|
.sufw-booking__form textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.65rem;
|
||||||
|
border: 1px solid #767676;
|
||||||
|
color: var(--sufw-booking-text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.sufw-booking__form input:focus-visible,
|
||||||
|
.sufw-booking__form textarea:focus-visible,
|
||||||
|
.sufw-booking__form button:focus-visible {
|
||||||
|
outline: 3px solid #1f4fbc;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.sufw-booking__consent {
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
align-items: start;
|
||||||
|
font-weight: 400 !important;
|
||||||
|
}
|
||||||
|
.sufw-booking__consent input {
|
||||||
|
width: 1.1rem !important;
|
||||||
|
height: 1.1rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__form-errors:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.sufw-booking__form-errors {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-left: 4px solid #a31111;
|
||||||
|
background: #fff1f1;
|
||||||
|
color: #6c0808;
|
||||||
|
}
|
||||||
|
.sufw-booking__form-errors ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__prototype-note {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--sufw-booking-pale);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__form-status {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--sufw-booking-green);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.sufw-booking__form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.sufw-booking__form-actions button {
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
border: 1px solid var(--sufw-booking-green);
|
||||||
|
background-color: #08752a;
|
||||||
|
color: #fff;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sufw-booking__form-actions button:hover:not(:disabled) {
|
||||||
|
border-color: #05571f;
|
||||||
|
background-color: #05571f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.sufw-booking__form-actions button:disabled {
|
||||||
|
border-color: #777;
|
||||||
|
background-color: #d8d8d8;
|
||||||
|
color: #555;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.sufw-booking__form-actions .sufw-booking__secondary {
|
||||||
|
background-color: #fff;
|
||||||
|
color: var(--sufw-booking-green);
|
||||||
|
}
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.sufw-booking {
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__weekdays {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__day {
|
||||||
|
min-height: 3.4rem;
|
||||||
|
padding: 0.15rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__slot {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__slot strong {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.sufw-booking__form-actions button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
/* 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) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
<meta name="description" content="PerspektivWerk – Brücke zwischen Diagnostik, Begleitung und betrieblicher Praxis.">
|
<meta name="description" content="PerspektivWerk – Brücke zwischen Diagnostik, Begleitung und betrieblicher Praxis.">
|
||||||
<title>PerspektivWerk | SUFW Dresden</title>
|
<title>PerspektivWerk | SUFW Dresden</title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<link rel="stylesheet" href="js/booking.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<a class="skip-link" href="#main">Navigation überspringen</a>
|
<a class="skip-link" href="#main">Navigation überspringen</a>
|
||||||
@@ -19,8 +20,12 @@
|
|||||||
<section class="intro page-width"><h1>PerspektivWerk – Brücke zwischen Diagnostik, Begleitung und betrieblicher Praxis</h1><img class="project-logo" src="media/logo-perspektivwerk.jpg" alt="PerspektivWerk"><div class="intro-copy"><p>Unser Fachpersonal arbeitet mit Ihnen gemeinsam daran, <strong>Ihre aktuelle Lebenssituation zu verstehen.</strong> Ziel ist es, aus den Informationen individuelle und zu Ihnen <strong>passende Wege ins Arbeitsleben</strong> sichtbar zu machen.</p><p>Dazu gehört es, herauszufinden, welche <strong>Herausforderungen Sie im Alltag</strong> bestreiten müssen und wo es Unterstützungsbedarf gibt. Vor allem aber auch, wo Ihre Fähigkeiten, Interessen und Entwicklungsmöglichkeiten liegen. Dabei arbeiten wir eng mit den beratenden Fachkräften der Agentur für Arbeit und des Jobcenters zusammen.</p></div></section>
|
<section class="intro page-width"><h1>PerspektivWerk – Brücke zwischen Diagnostik, Begleitung und betrieblicher Praxis</h1><img class="project-logo" src="media/logo-perspektivwerk.jpg" alt="PerspektivWerk"><div class="intro-copy"><p>Unser Fachpersonal arbeitet mit Ihnen gemeinsam daran, <strong>Ihre aktuelle Lebenssituation zu verstehen.</strong> Ziel ist es, aus den Informationen individuelle und zu Ihnen <strong>passende Wege ins Arbeitsleben</strong> sichtbar zu machen.</p><p>Dazu gehört es, herauszufinden, welche <strong>Herausforderungen Sie im Alltag</strong> bestreiten müssen und wo es Unterstützungsbedarf gibt. Vor allem aber auch, wo Ihre Fähigkeiten, Interessen und Entwicklungsmöglichkeiten liegen. Dabei arbeiten wir eng mit den beratenden Fachkräften der Agentur für Arbeit und des Jobcenters zusammen.</p></div></section>
|
||||||
<section class="team-section"><div class="team-copy"><img src="media/wir-sind-fuer-sie-da.svg" alt="Wir sind für Sie da"><p>Unser Team, bestehend aus sozialpädagogischen, pädagogischen und psychologischen Mitarbeiterinnen und Mitarbeitern, <strong>unterstützt Ihren Prozess der beruflichen Orientierung.</strong> Wir arbeiten eng mit den Bearbeiterinnen und Beratern der Agentur für Arbeit, dem Jobcenter und weiteren Beratungsstellen zusammen.</p><p>Die Teilnahme am <strong>PerspektivWerk ist kostenlos.</strong> Sie bestimmen, an welchen Angeboten Sie teilnehmen.</p></div><div class="team-image"><img src="media/perspektivwerk-psych.jpg" alt="Beratungsgespräch im PerspektivWerk"></div></section>
|
<section class="team-section"><div class="team-copy"><img src="media/wir-sind-fuer-sie-da.svg" alt="Wir sind für Sie da"><p>Unser Team, bestehend aus sozialpädagogischen, pädagogischen und psychologischen Mitarbeiterinnen und Mitarbeitern, <strong>unterstützt Ihren Prozess der beruflichen Orientierung.</strong> Wir arbeiten eng mit den Bearbeiterinnen und Beratern der Agentur für Arbeit, dem Jobcenter und weiteren Beratungsstellen zusammen.</p><p>Die Teilnahme am <strong>PerspektivWerk ist kostenlos.</strong> Sie bestimmen, an welchen Angeboten Sie teilnehmen.</p></div><div class="team-image"><img src="media/perspektivwerk-psych.jpg" alt="Beratungsgespräch im PerspektivWerk"></div></section>
|
||||||
<section class="content page-width" id="projekte"><div class="content-grid"><article><h2>Wer kann zu uns kommen?</h2><p>Menschen zwischen 15 und 65 Jahren, die sich beruflich neu orientieren möchten, beim Einstieg Unterstützung suchen oder ihre Möglichkeiten gemeinsam klären wollen.</p></article><article><h2>1. Orientierung & Ersteinschätzung</h2><p>Sozialpädagogische und psychologische Module helfen dabei, persönliche Situation, Motivation, Belastbarkeit und Stärken einzuschätzen.</p></article><article><h2>2. Praktische Erprobung & Belastungstest</h2><p>Arbeitsproben und praktische Erprobungen machen fachliche, soziale und arbeitsbezogene Kompetenzen sichtbar.</p></article><article><h2>3. Auswertung & Perspektiventwicklung</h2><p>Wir führen die Ergebnisse zusammen und entwickeln konkrete, realistische nächste Schritte.</p></article></div></section>
|
<section class="content page-width" id="projekte"><div class="content-grid"><article><h2>Wer kann zu uns kommen?</h2><p>Menschen zwischen 15 und 65 Jahren, die sich beruflich neu orientieren möchten, beim Einstieg Unterstützung suchen oder ihre Möglichkeiten gemeinsam klären wollen.</p></article><article><h2>1. Orientierung & Ersteinschätzung</h2><p>Sozialpädagogische und psychologische Module helfen dabei, persönliche Situation, Motivation, Belastbarkeit und Stärken einzuschätzen.</p></article><article><h2>2. Praktische Erprobung & Belastungstest</h2><p>Arbeitsproben und praktische Erprobungen machen fachliche, soziale und arbeitsbezogene Kompetenzen sichtbar.</p></article><article><h2>3. Auswertung & Perspektiventwicklung</h2><p>Wir führen die Ergebnisse zusammen und entwickeln konkrete, realistische nächste Schritte.</p></article></div></section>
|
||||||
|
<section class="page-width" aria-label="Terminbuchung PerspektivWerk">
|
||||||
|
<div data-sufw-booking data-project="perspektivwerk"></div>
|
||||||
|
</section>
|
||||||
<section class="contact page-width" id="kontakt"><h2>Sie haben Fragen zu dieser Maßnahme?</h2><p>Haben Sie Interesse an unserem Angebot, melden Sie sich bei uns. Wir beraten Sie gern vorab zu den weiteren Schritten.</p><p><strong>Kontakt:</strong> <a href="tel:+4917660328929">(0176) 60328929</a> · <a href="mailto:perspektivwerk@sufw.de">perspektivwerk@sufw.de</a> · Dresdner Straße 16, 01326 Dresden</p></section>
|
<section class="contact page-width" id="kontakt"><h2>Sie haben Fragen zu dieser Maßnahme?</h2><p>Haben Sie Interesse an unserem Angebot, melden Sie sich bei uns. Wir beraten Sie gern vorab zu den weiteren Schritten.</p><p><strong>Kontakt:</strong> <a href="tel:+4917660328929">(0176) 60328929</a> · <a href="mailto:perspektivwerk@sufw.de">perspektivwerk@sufw.de</a> · Dresdner Straße 16, 01326 Dresden</p></section>
|
||||||
</main>
|
</main>
|
||||||
<footer><div class="page-width">Sächsisches Umschulungs- und Fortbildungswerk Dresden e. V.</div></footer>
|
<footer><div class="page-width">Sächsisches Umschulungs- und Fortbildungswerk Dresden e. V.</div></footer>
|
||||||
|
<script type="module" src="js/booking.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
.sufw-booking {
|
||||||
|
--sufw-booking-green: #08752a;
|
||||||
|
--sufw-booking-text: #202020;
|
||||||
|
--sufw-booking-muted: #5a5a5a;
|
||||||
|
--sufw-booking-line: #c7c7c7;
|
||||||
|
--sufw-booking-pale: #f3f6f3;
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 1.25rem;
|
||||||
|
color: var(--sufw-booking-text);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--sufw-booking-line);
|
||||||
|
font: 1rem/1.5 Arial, "Helvetica Neue", sans-serif;
|
||||||
|
}
|
||||||
|
.sufw-booking * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.sufw-booking__header h2,
|
||||||
|
.sufw-booking__slots h3 {
|
||||||
|
margin: 0.15rem 0 0.5rem;
|
||||||
|
color: var(--sufw-booking-green);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__header p {
|
||||||
|
margin: 0.3rem 0;
|
||||||
|
}
|
||||||
|
.sufw-booking__eyebrow {
|
||||||
|
color: var(--sufw-booking-green);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.sufw-booking__legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__marker {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.8rem;
|
||||||
|
height: 0.8rem;
|
||||||
|
margin-right: 0.35rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.sufw-booking__marker--available {
|
||||||
|
background: var(--sufw-booking-green);
|
||||||
|
}
|
||||||
|
.sufw-booking__marker--booked {
|
||||||
|
background: #535353;
|
||||||
|
}
|
||||||
|
.sufw-booking__month-nav {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 3rem 1fr 3rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.sufw-booking__month-nav h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
.sufw-booking__nav,
|
||||||
|
.sufw-booking__day,
|
||||||
|
.sufw-booking__slot {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.sufw-booking__nav {
|
||||||
|
min-width: 2.75rem;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
border: 1px solid var(--sufw-booking-green);
|
||||||
|
color: var(--sufw-booking-green);
|
||||||
|
background: #fff;
|
||||||
|
font-size: 1.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sufw-booking__nav:hover,
|
||||||
|
.sufw-booking__nav:focus-visible {
|
||||||
|
background: var(--sufw-booking-pale);
|
||||||
|
}
|
||||||
|
.sufw-booking__weekdays,
|
||||||
|
.sufw-booking__calendar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__weekdays {
|
||||||
|
margin-top: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__day {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 0.1rem;
|
||||||
|
min-height: 3.8rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--sufw-booking-text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sufw-booking__day:hover {
|
||||||
|
border-color: var(--sufw-booking-green);
|
||||||
|
background: var(--sufw-booking-pale);
|
||||||
|
}
|
||||||
|
.sufw-booking__day--available {
|
||||||
|
background: #dff3e4;
|
||||||
|
border-color: var(--sufw-booking-green);
|
||||||
|
}
|
||||||
|
.sufw-booking__day--booked {
|
||||||
|
background: #ececec;
|
||||||
|
border-color: #777;
|
||||||
|
}
|
||||||
|
.sufw-booking__day.is-selected {
|
||||||
|
outline: 3px solid #1f4fbc;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.sufw-booking__day small {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__empty {
|
||||||
|
min-height: 3.8rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__slots {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
border-top: 1px solid var(--sufw-booking-line);
|
||||||
|
}
|
||||||
|
.sufw-booking__slots ul {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.sufw-booking__slot {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 5rem 1fr auto;
|
||||||
|
gap: 0.7rem;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0.45rem 0;
|
||||||
|
padding: 0.8rem;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--sufw-booking-text);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--sufw-booking-green);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sufw-booking__slot:hover,
|
||||||
|
.sufw-booking__slot:focus-visible {
|
||||||
|
background: var(--sufw-booking-pale);
|
||||||
|
}
|
||||||
|
.sufw-booking__slot em {
|
||||||
|
color: var(--sufw-booking-green);
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.sufw-booking__slot:disabled {
|
||||||
|
border-color: #777;
|
||||||
|
background: #eee;
|
||||||
|
color: #555;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.sufw-booking__slot:disabled em {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.sufw-booking__empty-state,
|
||||||
|
.sufw-booking__status {
|
||||||
|
margin: 0.7rem 0;
|
||||||
|
}
|
||||||
|
.sufw-booking__status {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.sufw-booking__dialog {
|
||||||
|
width: min(42rem, calc(100% - 2rem));
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
color: var(--sufw-booking-text);
|
||||||
|
box-shadow: 0 0.75rem 2.5rem rgba(0, 0, 0, 0.35);
|
||||||
|
font: 1rem/1.5 Arial, "Helvetica Neue", sans-serif;
|
||||||
|
}
|
||||||
|
.sufw-booking__dialog::backdrop {
|
||||||
|
background: rgba(0, 0, 0, 0.62);
|
||||||
|
}
|
||||||
|
.sufw-booking__form {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__form-header {
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--sufw-booking-line);
|
||||||
|
}
|
||||||
|
.sufw-booking__form-header h2 {
|
||||||
|
margin: 0.15rem 0 0.5rem;
|
||||||
|
color: var(--sufw-booking-green);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__appointment,
|
||||||
|
.sufw-booking__required,
|
||||||
|
.sufw-booking__prototype-note {
|
||||||
|
margin: 0.35rem 0;
|
||||||
|
}
|
||||||
|
.sufw-booking__required {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__optional {
|
||||||
|
color: var(--sufw-booking-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.sufw-booking__form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.sufw-booking__form input,
|
||||||
|
.sufw-booking__form textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.65rem;
|
||||||
|
border: 1px solid #767676;
|
||||||
|
color: var(--sufw-booking-text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.sufw-booking__form input:focus-visible,
|
||||||
|
.sufw-booking__form textarea:focus-visible,
|
||||||
|
.sufw-booking__form button:focus-visible {
|
||||||
|
outline: 3px solid #1f4fbc;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.sufw-booking__consent {
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
align-items: start;
|
||||||
|
font-weight: 400 !important;
|
||||||
|
}
|
||||||
|
.sufw-booking__consent input {
|
||||||
|
width: 1.1rem !important;
|
||||||
|
height: 1.1rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__form-errors:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.sufw-booking__form-errors {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-left: 4px solid #a31111;
|
||||||
|
background: #fff1f1;
|
||||||
|
color: #6c0808;
|
||||||
|
}
|
||||||
|
.sufw-booking__form-errors ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__prototype-note {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--sufw-booking-pale);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__form-status {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--sufw-booking-green);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.sufw-booking__form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.sufw-booking__form-actions button {
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
border: 1px solid var(--sufw-booking-green);
|
||||||
|
background-color: #08752a;
|
||||||
|
color: #fff;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sufw-booking__form-actions button:hover:not(:disabled) {
|
||||||
|
border-color: #05571f;
|
||||||
|
background-color: #05571f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.sufw-booking__form-actions button:disabled {
|
||||||
|
border-color: #777;
|
||||||
|
background-color: #d8d8d8;
|
||||||
|
color: #555;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.sufw-booking__form-actions .sufw-booking__secondary {
|
||||||
|
background-color: #fff;
|
||||||
|
color: var(--sufw-booking-green);
|
||||||
|
}
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.sufw-booking {
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__weekdays {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__day {
|
||||||
|
min-height: 3.4rem;
|
||||||
|
padding: 0.15rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__slot {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__slot strong {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.sufw-booking__form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.sufw-booking__form-actions button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
/* 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) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param()
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||||
|
$perspektivwerkSource = Join-Path $projectRoot 'public/perspektivwerk'
|
||||||
|
$remoteTarget = 'boogie:/home/boogie/html/perspektivwerk/'
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $perspektivwerkSource -PathType Container)) {
|
||||||
|
throw "Der zu übertragende Ordner '$perspektivwerkSource' wurde nicht gefunden."
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceItems = @(Get-ChildItem -LiteralPath $perspektivwerkSource -Force)
|
||||||
|
if ($sourceItems.Count -eq 0) {
|
||||||
|
throw "Der zu übertragende Ordner '$perspektivwerkSource' enthält keine Dateien."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Get-Command scp -ErrorAction SilentlyContinue)) {
|
||||||
|
throw 'OpenSSH scp wurde nicht gefunden. Installiere den OpenSSH-Client oder füge ihn zum PATH hinzu.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Get-Command ssh -ErrorAction SilentlyContinue)) {
|
||||||
|
throw 'OpenSSH ssh wurde nicht gefunden. Installiere den OpenSSH-Client oder füge ihn zum PATH hinzu.'
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host 'Zielverzeichnisse auf dem Server:'
|
||||||
|
Write-Host " $perspektivwerkSource\* -> $remoteTarget"
|
||||||
|
|
||||||
|
Write-Host "Übertrage ausschließlich die statische Perspektivwerk-Seite nach $remoteTarget"
|
||||||
|
Write-Host 'SCP-Upload und anschließende Rechtekorrektur benötigen jeweils eine SSH-Verbindung.'
|
||||||
|
|
||||||
|
& scp -r @sourceItems $remoteTarget
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Der Upload nach '$remoteTarget' ist fehlgeschlagen (scp Exit-Code $LASTEXITCODE)."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host 'Setze Lese- und Durchsuchrechte auf dem hochgeladenen Ordner.'
|
||||||
|
& ssh boogie 'chmod -R a+rX /home/boogie/html/perspektivwerk'
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Der Upload war erfolgreich, aber die Rechtekorrektur ist fehlgeschlagen (ssh Exit-Code $LASTEXITCODE)."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host 'Upload und Rechtekorrektur abgeschlossen: /home/boogie/html/perspektivwerk/ (inklusive js/booking.js).'
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
/**
|
|
||||||
* Dünne, frameworkfreie Buchungslogik. Sie kann später gegen eine Backend-API
|
|
||||||
* oder eine Kalender-Integration ausgetauscht werden, ohne das UI neu zu bauen.
|
|
||||||
*/
|
|
||||||
export type BookingProject = 'perspektivwerk' | 'produktionsschule';
|
|
||||||
|
|
||||||
export interface BookingOption {
|
|
||||||
id: string;
|
|
||||||
project: BookingProject;
|
|
||||||
title: string;
|
|
||||||
duration: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const bookingOptions: BookingOption[] = [
|
|
||||||
{ id: 'pw-erstgespraech', project: 'perspektivwerk', title: 'Erstgespräch Perspektivwerk', duration: '1 Stunde' },
|
|
||||||
{ id: 'pw-sozpaed', project: 'perspektivwerk', title: 'Soz. Päd. Modul Perspektivwerk', duration: '4 Stunden' },
|
|
||||||
{ id: 'pw-psy', project: 'perspektivwerk', title: 'Psy Modul Perspektivwerk', duration: '4 Stunden' },
|
|
||||||
{ id: 'pw-praxis', project: 'perspektivwerk', title: 'Praxis Modul Perspektivwerk', duration: '1 Woche, ab Montag 08:00 Uhr' },
|
|
||||||
{ id: 'ps-kennenlernen', project: 'produktionsschule', title: 'Voranmeldung Kennenlerntermin Produktionsschule', duration: '1 Stunde, dienstags' },
|
|
||||||
{ id: 'ps-probearbeiten', project: 'produktionsschule', title: 'Probearbeiten', duration: '1 Woche, ab Montag 08:00 Uhr' }
|
|
||||||
];
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||||
|
$deployScriptPath = Join-Path $projectRoot 'scripts\Deploy-Perspektivwerk.ps1'
|
||||||
|
|
||||||
|
Describe 'Perspektivwerk deployment' {
|
||||||
|
It 'provides a deployment script for the static page' {
|
||||||
|
(Test-Path -LiteralPath $deployScriptPath) | Should Be $true
|
||||||
|
}
|
||||||
|
|
||||||
|
It 'uploads the static page and its nested booking assets with one scp invocation' {
|
||||||
|
$script = Get-Content -LiteralPath $deployScriptPath -Raw
|
||||||
|
|
||||||
|
$script | Should Match 'public[\\/]perspektivwerk'
|
||||||
|
$script | Should Not Match 'public[\\/]booking'
|
||||||
|
$script | Should Match 'boogie:/home/boogie/html/perspektivwerk/'
|
||||||
|
([regex]::Matches($script, '& scp')).Count | Should Be 1
|
||||||
|
$script | Should Not Match '& rsync'
|
||||||
|
}
|
||||||
|
|
||||||
|
It 'repairs read and traversal permissions with a separate SSH invocation' {
|
||||||
|
$script = Get-Content -LiteralPath $deployScriptPath -Raw
|
||||||
|
|
||||||
|
([regex]::Matches($script, '& ssh')).Count | Should Be 1
|
||||||
|
$script | Should Match 'chmod -R a\+rX /home/boogie/html/perspektivwerk'
|
||||||
|
}
|
||||||
|
|
||||||
|
It 'prints the complete static-page target directory' {
|
||||||
|
$script = Get-Content -LiteralPath $deployScriptPath -Raw
|
||||||
|
|
||||||
|
$script | Should Match 'Zielverzeichnisse auf dem Server'
|
||||||
|
$script | Should Match 'boogie:/home/boogie/html/perspektivwerk/'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||||
$mockupPath = Join-Path $projectRoot 'public\perspektivwerk\index.html'
|
$mockupPath = Join-Path $projectRoot 'public\perspektivwerk\index.html'
|
||||||
|
$bookingScriptPath = Join-Path $projectRoot 'public\perspektivwerk\js\booking.js'
|
||||||
|
$bookingStylesPath = Join-Path $projectRoot 'public\perspektivwerk\js\booking.css'
|
||||||
|
$contaoBookingScriptPath = Join-Path $projectRoot 'public\booking\booking.js'
|
||||||
|
$contaoBookingStylesPath = Join-Path $projectRoot 'public\booking\booking.css'
|
||||||
$htaccessPath = Join-Path $projectRoot 'public\.htaccess'
|
$htaccessPath = Join-Path $projectRoot 'public\.htaccess'
|
||||||
|
|
||||||
Describe 'Perspektivwerk static route' {
|
Describe 'Perspektivwerk static route' {
|
||||||
@@ -7,6 +11,37 @@ Describe 'Perspektivwerk static route' {
|
|||||||
(Test-Path -LiteralPath $mockupPath) | Should Be $true
|
(Test-Path -LiteralPath $mockupPath) | Should Be $true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
It 'embeds the appointment booking component with its public assets' {
|
||||||
|
$mockup = Get-Content -LiteralPath $mockupPath -Raw
|
||||||
|
|
||||||
|
(Test-Path -LiteralPath $bookingScriptPath) | Should Be $true
|
||||||
|
(Test-Path -LiteralPath $bookingStylesPath) | Should Be $true
|
||||||
|
$mockup | Should Match 'href="js/booking\.css"'
|
||||||
|
$mockup | Should Match 'data-sufw-booking'
|
||||||
|
$mockup | Should Match 'src="js/booking\.js"'
|
||||||
|
}
|
||||||
|
|
||||||
|
It 'keeps the Contao and static-preview asset copies identical' {
|
||||||
|
(Test-Path -LiteralPath $contaoBookingScriptPath) | Should Be $true
|
||||||
|
(Test-Path -LiteralPath $contaoBookingStylesPath) | Should Be $true
|
||||||
|
(Get-FileHash -LiteralPath $bookingScriptPath).Hash | Should Be (Get-FileHash -LiteralPath $contaoBookingScriptPath).Hash
|
||||||
|
(Get-FileHash -LiteralPath $bookingStylesPath).Hash | Should Be (Get-FileHash -LiteralPath $contaoBookingStylesPath).Hash
|
||||||
|
}
|
||||||
|
|
||||||
|
It 'keeps the enabled submit button visibly rendered' {
|
||||||
|
$script = Get-Content -LiteralPath $contaoBookingScriptPath -Raw
|
||||||
|
|
||||||
|
$script | Should Match "submitButton\.style\.visibility = 'visible'"
|
||||||
|
$script | Should Match "actions\.style\.position = 'sticky'"
|
||||||
|
}
|
||||||
|
|
||||||
|
It 'defines colours for the booking form action buttons' {
|
||||||
|
$styles = Get-Content -LiteralPath $contaoBookingStylesPath -Raw
|
||||||
|
|
||||||
|
$styles | Should Match '\.sufw-booking__form-actions button\s*\{[^}]*background-color:\s*#08752a'
|
||||||
|
$styles | Should Match '\.sufw-booking__form-actions button\s*\{[^}]*color:\s*#fff'
|
||||||
|
}
|
||||||
|
|
||||||
It 'routes /perspektivwerk to the static entry point before the Contao fallback' {
|
It 'routes /perspektivwerk to the static entry point before the Contao fallback' {
|
||||||
$htaccess = Get-Content -LiteralPath $htaccessPath -Raw
|
$htaccess = Get-Content -LiteralPath $htaccessPath -Raw
|
||||||
$staticRouteIndex = $htaccess.IndexOf('RewriteRule ^perspektivwerk/?$ perspektivwerk/index.html [L]')
|
$staticRouteIndex = $htaccess.IndexOf('RewriteRule ^perspektivwerk/?$ perspektivwerk/index.html [L]')
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { bookingConfirmationMessage, canSubmitBookingForm, formatDateKey, getSlotsForDate, validateBookingField, validateBookingForm } from '../public/booking/booking.js';
|
||||||
|
|
||||||
|
test('liefert für einen Beispieltag verfügbare und belegte Slots', () => {
|
||||||
|
const slots = getSlotsForDate('perspektivwerk', '2026-08-25');
|
||||||
|
|
||||||
|
assert.equal(slots.length, 3);
|
||||||
|
assert.equal(slots.filter((slot) => slot.status === 'available').length, 2);
|
||||||
|
assert.equal(slots.filter((slot) => slot.status === 'booked').length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('liefert für einen Tag ohne Termin keine Slots', () => {
|
||||||
|
assert.deepEqual(getSlotsForDate('produktionsschule', '2026-08-20'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verschiebt Kalendertage in der Sommerzeit nicht auf den Vortag', () => {
|
||||||
|
process.env.TZ = 'Europe/Berlin';
|
||||||
|
|
||||||
|
assert.equal(formatDateKey(new Date(2026, 7, 24)), '2026-08-24');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('liefert Termine im aktuellen und folgenden Monat', () => {
|
||||||
|
assert.ok(getSlotsForDate('perspektivwerk', '2026-08-24').length >= 2);
|
||||||
|
assert.ok(getSlotsForDate('perspektivwerk', '2026-08-28').length >= 2);
|
||||||
|
assert.ok(getSlotsForDate('perspektivwerk', '2026-09-02').length >= 2);
|
||||||
|
assert.ok(getSlotsForDate('perspektivwerk', '2026-09-15').length >= 2);
|
||||||
|
assert.ok(getSlotsForDate('perspektivwerk', '2026-09-29').length >= 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validiert die Pflichtangaben der Buchungsmaske', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
validateBookingForm({ name: '', email: 'ungültig', privacyAccepted: false }),
|
||||||
|
{
|
||||||
|
name: 'Bitte geben Sie Ihren Namen ein.',
|
||||||
|
email: 'Bitte geben Sie eine gültige E-Mail-Adresse ein.',
|
||||||
|
privacy: 'Bitte bestätigen Sie die Datenschutzhinweise.'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('akzeptiert die vollständigen Pflichtangaben der Buchungsmaske', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
validateBookingForm({ name: 'Max Mustermann', email: 'max@example.org', privacyAccepted: true }),
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lehnt ein eingegebenes Alter außerhalb von 12 bis 112 ab', () => {
|
||||||
|
assert.equal(
|
||||||
|
validateBookingForm({ name: 'Max Mustermann', email: 'max@example.org', age: '11', privacyAccepted: true }).age,
|
||||||
|
'Bitte geben Sie ein Alter zwischen 12 und 112 Jahren ein.'
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
validateBookingForm({ name: 'Max Mustermann', email: 'max@example.org', age: '113', privacyAccepted: true }).age,
|
||||||
|
'Bitte geben Sie ein Alter zwischen 12 und 112 Jahren ein.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('akzeptiert ein leeres oder gültiges optionales Alter', () => {
|
||||||
|
assert.deepEqual(validateBookingForm({ name: 'Max Mustermann', email: 'max@example.org', age: '', privacyAccepted: true }), {});
|
||||||
|
assert.deepEqual(validateBookingForm({ name: 'Max Mustermann', email: 'max@example.org', age: '12', privacyAccepted: true }), {});
|
||||||
|
assert.deepEqual(validateBookingForm({ name: 'Max Mustermann', email: 'max@example.org', age: '112', privacyAccepted: true }), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validiert optionale Telefonnummern direkt am Feld', () => {
|
||||||
|
assert.equal(validateBookingField('phone', 'abc'), 'Bitte geben Sie eine gültige Telefonnummer ein.');
|
||||||
|
assert.equal(validateBookingField('phone', '+49 351 123456'), '');
|
||||||
|
assert.equal(validateBookingField('phone', ''), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gibt den Absenden-Button erst nach DSGVO-Einwilligung frei', () => {
|
||||||
|
assert.equal(canSubmitBookingForm(false), false);
|
||||||
|
assert.equal(canSubmitBookingForm(true), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zeigt nach dem Absenden eine Bestätigung ohne Mockup-Hinweis', () => {
|
||||||
|
const message = bookingConfirmationMessage('Erstgespräch');
|
||||||
|
|
||||||
|
assert.match(message, /erfolgreich übermittelt/);
|
||||||
|
assert.doesNotMatch(message, /Vorschau|Mockup/i);
|
||||||
|
});
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020",
|
|
||||||
"module": "ES2020",
|
|
||||||
"moduleResolution": "Bundler",
|
|
||||||
"strict": true,
|
|
||||||
"noEmitOnError": true,
|
|
||||||
"outDir": "public/perspektivwerk/js"
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts"]
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user