Die Turnabteilung (Maxi) beantwortet laufend Anfragen nach einem Wartelistenplatz für „Tiger" und „Hühner", den es nicht gibt. Auf der alten Seite stand der Hinweis direkt über dem Anfrageformular, hier fehlte er. Schlimmer: an der Stelle stand das GEGENTEIL. Der bisherige Hinweis lautete "Aktuell sind alle Kinderturngruppen belegt. Bei Interesse setzen wir sie gerne auf die Warteliste." — für alle Kindergruppen, also auch für die beiden, die keine Liste führen. Der Satz hat einen Teil der Anfragen vermutlich erst erzeugt. Er ist nicht gelöscht, sondern auf die übrigen Gruppen eingegrenzt und aufs Du umgestellt (Haus-Stil; vorher stand dort klein "sie"). Der Text steht genau EINMAL, in data/turnen.json unter disciplines.eltern-kind-turnen.notice + notice_title. Zwei Stellen lesen ihn: die rote Kachel auf /turnen/eltern-kind-turnen (gab es schon) und neu ein Block direkt über dem Formular auf /turnen. Ändert die Abteilung die Lage, ist das eine Zeile in einer Datei. Umsetzung: - notice_title ist neu, weil eine rote Fläche ohne Überschrift wie eine Fehlermeldung liest. Abel statt Coolvetica, der Schnitt ist bei ~19px in Versalien unleserlich (CLAUDE.md, Design). - Der Block steht AUSSERHALB des <form> und damit außerhalb der role="status"-Region: ein dauerhafter Hinweis, keine Rückmeldung zum Absenden. In der Live-Region läse ihn ein Screenreader bei jeder Formularmeldung erneut vor. Kein role="alert", er steht schon beim Laden da. - Überschrift als h3 unter der h2 des Intro-Blocks, damit die Folge nicht springt. - .contact__notice übernimmt bewusst die Optik von .turnen-dash__notice, damit beide Stellen als derselbe Hinweis erkennbar sind. Weiß auf --clr-accent erreicht 4,92:1 und trägt damit Fließtext (WCAG 1.4.3). Kommt ein dritter Ort dazu, gehören die gemeinsamen Eigenschaften in eine geteilte .notice-Klasse; für zwei wäre das verfrüht. Gemessen: Hinweisbreite gleich Formularbreite (720px bei 1280, 347px bei 390), kein Überlauf. preflight ohne neue Meldungen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
143 lines
8.5 KiB
PHP
143 lines
8.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Kontaktformular (Felder wie auf der alten Seite). Props:
|
|
* $intro array{title, text}|null — optionaler Intro-Block (Überschrift, Teaser, Direkt-Mail).
|
|
* Weglassen → nur das Formular (z. B. auf /kontakt, wo der Hero den Kontext liefert).
|
|
* $return string optional — Slug der absendenden Seite (Default '' = Startseite). Die Action
|
|
* leitet nach PRG hierher zurück (whitelist-validiert), damit /kontakt nicht auf die
|
|
* Startseite springt.
|
|
* $notice string optional — hervorgehobener Hinweis DIREKT über dem Formular, plus
|
|
* $noticeTitle für die Überschrift dazu. Für Dinge, die man wissen muss, BEVOR
|
|
* man schreibt (Wartelisten-Status der Turnabteilung). Steht bewusst außerhalb
|
|
* des <form> und damit außerhalb der role="status"-Region: das ist ein
|
|
* dauerhafter Hinweis, keine Rückmeldung zum Absenden — in der Live-Region
|
|
* würde ihn ein Screenreader bei jeder Formularmeldung erneut vorlesen.
|
|
* Der Text selbst gehört in die data/*.json der aufrufenden Seite, damit er
|
|
* nicht zweimal existiert (auf /turnen ist es derselbe Hinweis, den
|
|
* group-schedule.php auf der Kinderturn-Unterseite als Kachel zeigt).
|
|
* $department string optional — '' (allgemein) oder 'turnen'. Steuert Auswahlliste,
|
|
* Empfänger und Feldregeln über contact_variant() (helpers.php, einzige Quelle).
|
|
* Die Turn-Variante geht an die Abteilung statt an info@ und braucht keinen
|
|
* Betreff, weil die Disziplin-Auswahl ihn ersetzt. Bewusst dieselbe Komponente
|
|
* und dieselbe Action: ein zweites Formular hieße ein zweiter Spam-Schutz.
|
|
* Felder rendern ausschließlich über component('form-field') — siehe CLAUDE.md.
|
|
* Liest Flash-Status aus Query-Params (?sent=1 / ?error=...) — PRG-Muster.
|
|
* Spam-Schutz: Honeypot "company_url" + signierter Timestamp "ft" (form_token()).
|
|
*/
|
|
$club = json_load('club');
|
|
$return = $return ?? '';
|
|
$intro = $intro ?? null;
|
|
$notice = $notice ?? '';
|
|
$noticeTitle = $noticeTitle ?? '';
|
|
|
|
$variant = contact_variant((string) ($department ?? ''));
|
|
$interests = $variant['interests'];
|
|
// Sichtbare Direkt-Adresse: bei einer Abteilungsanfrage die der Abteilung.
|
|
$directMail = $variant['to'] !== '' ? $variant['to'] : $club['email'];
|
|
|
|
// Fehlerquelle: PRG-Query (?error=…) oder — beim 422-Re-Render ohne JS — die
|
|
// serverseitigen Feldfehler aus der Action.
|
|
$sent = isset($_GET['sent']);
|
|
$error = isset($_GET['error']) ? (string) $_GET['error'] : (form_has_errors() ? 'validation' : null);
|
|
$errorMessages = [
|
|
'validation' => 'Bitte prüfe deine Eingaben: Die markierten Felder brauchen noch etwas Aufmerksamkeit.',
|
|
'ratelimit' => 'Zu viele Anfragen in kurzer Zeit. Bitte versuche es in ein paar Minuten erneut.',
|
|
'busy' => 'Bei uns gehen gerade ungewöhnlich viele Anfragen ein. Bitte versuche es später noch einmal oder schreib uns direkt an ' . $directMail . '.',
|
|
'mail' => 'Deine Nachricht konnte gerade nicht versendet werden. Bitte versuche es später erneut oder schreib uns direkt an ' . $directMail . '.',
|
|
];
|
|
|
|
$f = [
|
|
'name' => ['type' => 'text', 'name' => 'name', 'id' => 'contact-name', 'label' => 'Name', 'required' => true, 'autocomplete' => 'name', 'maxlength' => 200],
|
|
'email' => ['type' => 'email', 'name' => 'email', 'id' => 'contact-email', 'label' => 'E-Mail', 'required' => true, 'autocomplete' => 'email', 'maxlength' => 200],
|
|
'phone' => ['type' => 'tel', 'name' => 'phone', 'id' => 'contact-phone', 'label' => 'Telefonnummer', 'autocomplete' => 'tel', 'maxlength' => 50],
|
|
'interest' => ['type' => 'select', 'name' => 'interest', 'id' => 'contact-interest', 'label' => 'Ich interessiere mich für', 'options' => $interests, 'required' => $variant['interest_required']],
|
|
// Optional und nur in der Turn-Variante (siehe contact_variant). max/min
|
|
// begrenzen die Auswahl im Datepicker schon im Browser; die Action prüft es
|
|
// serverseitig noch einmal.
|
|
'birthdate' => [
|
|
'type' => 'date', 'name' => 'birthdate', 'id' => 'contact-birthdate',
|
|
'label' => 'Geburtsdatum des Kindes', 'hint' => 'Optional. Hilft uns, die passende Gruppe zu finden.',
|
|
'min' => '1900-01-01', 'max' => date('Y-m-d'),
|
|
],
|
|
'subject' => ['type' => 'text', 'name' => 'subject', 'id' => 'contact-subject', 'label' => 'Betreff', 'required' => true, 'maxlength' => 200],
|
|
'message' => ['type' => 'textarea', 'name' => 'message', 'id' => 'contact-message', 'label' => 'Nachricht', 'required' => $variant['message_required'], 'maxlength' => 5000, 'rows' => 6],
|
|
'privacy' => [
|
|
'type' => 'checkbox', 'name' => 'privacy', 'id' => 'contact-privacy', 'required' => true,
|
|
'label' => 'Ich stimme den Datenschutzbestimmungen zu.',
|
|
'label_html' => 'Ich stimme den <a href="' . e(url('datenschutz')) . '">Datenschutzbestimmungen</a> zu.',
|
|
],
|
|
];
|
|
?>
|
|
<section class="section contact" id="kontakt">
|
|
<div class="container contact__inner">
|
|
<?php if (!empty($intro)): ?>
|
|
<div class="contact__intro">
|
|
<h2><?= e($intro['title']) ?></h2>
|
|
<p><?= e($intro['text']) ?></p>
|
|
<p class="text-muted">Oder direkt per Mail an <a href="mailto:<?= e($directMail) ?>"><?= e($directMail) ?></a></p>
|
|
</div>
|
|
<?php endif; ?>
|
|
<?php if ($notice !== ''): ?>
|
|
<?php /* h3, weil der Intro-Block direkt darüber die h2 der Sektion trägt — so bleibt die
|
|
Überschriften-Folge ohne Sprung. Kein role="alert": der Hinweis steht schon beim
|
|
Laden da und ist keine Reaktion auf eine Eingabe. */ ?>
|
|
<aside class="contact__notice">
|
|
<?= icon('info-circle', 'contact__notice-icon') ?>
|
|
<div>
|
|
<?php if ($noticeTitle !== ''): ?>
|
|
<h3 class="contact__notice-title"><?= e($noticeTitle) ?></h3>
|
|
<?php endif; ?>
|
|
<p><?= e($notice) ?></p>
|
|
</div>
|
|
</aside>
|
|
<?php endif; ?>
|
|
<form class="form" method="post" action="<?= e(url('kontakt-senden')) ?>" novalidate>
|
|
<?php /* autofocus nur beim 422-Re-Render: bringt den Fokus auch ohne JS in die Fehlermeldung */ ?>
|
|
<div class="form__status" role="status" aria-live="polite" tabindex="-1" data-form-status<?= form_has_errors() ? ' autofocus' : '' ?>>
|
|
<?php if ($sent): ?>
|
|
<p class="form__success">Danke für deine Anfrage! Wir melden uns so schnell wie möglich bei dir.</p>
|
|
<?php elseif ($error !== null): ?>
|
|
<p class="form__error"><?= e($errorMessages[$error] ?? $errorMessages['mail']) ?></p>
|
|
<?php endif; ?>
|
|
</div>
|
|
|
|
<div class="form__row">
|
|
<?php component('form-field', ['field' => $f['name']]); ?>
|
|
<?php component('form-field', ['field' => $f['email']]); ?>
|
|
</div>
|
|
|
|
<div class="form__row">
|
|
<?php component('form-field', ['field' => $f['phone']]); ?>
|
|
<?php component('form-field', ['field' => $f['interest']]); ?>
|
|
<?php if ($variant['ask_birthdate']): ?>
|
|
<?php component('form-field', ['field' => $f['birthdate']]); ?>
|
|
<?php endif; ?>
|
|
</div>
|
|
|
|
<?php /* Kein Betreff in der Abteilungsvariante: die Disziplin-Auswahl sagt schon,
|
|
worum es geht, und die Action baut den Mail-Betreff daraus. */ ?>
|
|
<?php if ($variant['ask_subject']): ?>
|
|
<?php component('form-field', ['field' => $f['subject']]); ?>
|
|
<?php endif; ?>
|
|
<?php component('form-field', ['field' => $f['message']]); ?>
|
|
<?php component('form-field', ['field' => $f['privacy']]); ?>
|
|
|
|
<?php /* Honeypot — für Menschen unsichtbar, Bots füllen es aus */ ?>
|
|
<div class="visually-hidden" aria-hidden="true">
|
|
<label for="contact-company-url">Bitte dieses Feld leer lassen</label>
|
|
<input type="text" id="contact-company-url" name="company_url" tabindex="-1" autocomplete="off">
|
|
</div>
|
|
<input type="hidden" name="ft" value="<?= e(form_token()) ?>">
|
|
<input type="hidden" name="return" value="<?= e($return) ?>">
|
|
<input type="hidden" name="department" value="<?= e($variant['department']) ?>">
|
|
|
|
<p class="form__submit">
|
|
<button class="btn" type="submit">Nachricht senden</button>
|
|
</p>
|
|
</form>
|
|
</div>
|
|
</section>
|