Turn-Anfrageformular: Feld „Geburtsdatum des Kindes" wieder da

Auf der alten Seite gab es das Feld, beim Neubau ist es untergegangen. Gegen
2024.tsv08kulmbach.de geprüft: dort hieß es input_alter_kind, war type="date",
OPTIONAL und trug das Label „Geburtsdatum des Kindes" — genau so wieder
eingebaut. Die Abteilung braucht es, weil die Gruppen nach Alter eingeteilt sind.

Nur in der Turn-Variante. In der allgemeinen wäre es sinnlos und würde ohne Not
personenbezogene Daten Minderjähriger erheben. Dafür drei Stellen:
- contact_variant(): Schalter ask_birthdate (true nur für turnen)
- contact-form.php: Feld definiert, bedingt gerendert, direkt nach der
  Gruppenauswahl wie auf der alten Seite
- contact-submit.php: Validierung UND Rücksetzen auf leer, wenn die Variante das
  Feld nicht anbietet — sonst könnte ein manipulierter POST es an der allgemeinen
  Variante vorbei einschmuggeln

Validierung serverseitig, nicht nur per type=date: echtes Datum, nicht in der
Zukunft, nicht vor 1900. createFromFormat mit Rückvergleich, weil PHP sonst
Scheindaten wie 2019-02-31 stillschweigend auf den 3. März verschiebt.
Geprüft mit ausreichend altem Token (die Time-Trap verlangt 3 s, meine ersten
Versuche liefen deshalb als Bot durch): 2019-02-31 abgewiesen, 2030-01-01
abgewiesen, gültiges Datum passiert. Ohne JS bleibt der Wert bei HTTP 422
erhalten (value="2019-03-12" im Re-Render nachgewiesen).

Kein neues Markup: form-field kann type=date und min/max längst, die Prop für
den Hilfetext heißt hint (nicht help, das war mein erster Fehlgriff). Docblock
der Komponente um date und min/max ergänzt.

Datenschutzerklärung mitgezogen, wie die Projektregel es für neue Formularfelder
verlangt — mit Zweckbindung (Zuordnung zur Altersgruppe) und dem ausdrücklichen
Hinweis, dass es keine Pflichtangabe ist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
This commit is contained in:
2026-07-31 21:10:47 +02:00
parent 75c06dbd04
commit c4ffe4a1f4
6 changed files with 54 additions and 5 deletions

View File

@@ -177,7 +177,11 @@ Funktion, sie ist die einzige Stelle, an der aus JSON HTML entsteht.
- **Varianten statt Zweitformular:** das Kontaktformular hat zwei Ausprägungen, gesteuert vom
Hidden-Feld `department` (`''` = allgemein, `'turnen'` = Turnabteilung auf `/turnen`).
`contact_variant()` (`helpers.php`) ist die **einzige** Quelle für Auswahlliste, Feldregeln
(Betreff-Feld, Pflichtfelder) und Empfänger; Komponente und Action lesen beide dort. Ein
(Betreff-Feld, Geburtsdatum-Feld, Pflichtfelder) und Empfänger; Komponente und Action lesen beide
dort. Ein variantenspezifisches Feld braucht **drei** Stellen: den Schalter in
`contact_variant()`, die bedingte Ausgabe in `contact-form.php` und in der Action beides —
Validierung **und** ein Zurücksetzen auf leer, wenn die Variante das Feld nicht anbietet (sonst
könnte ein manipulierter POST es an einer Variante vorbei einschmuggeln). Ein
unbekannter Wert fällt auf die allgemeine Variante zurück, ein manipuliertes Feld erreicht also
keinen fremden Empfänger. Die Turn-Variante geht an `club.email_turnen`, hat die Disziplinen aus
`data/turnen.json` als Auswahl (Pflicht, ersetzt den Betreff) und eine freiwillige Nachricht.

View File

@@ -60,6 +60,7 @@ $name = $field('name');
$email = $field('email');
$phone = $field('phone');
$interest = $field('interest');
$birthdate = $field('birthdate');
$subject = $field('subject');
$message = $field('message');
$privacy = ($_POST['privacy'] ?? '') === '1';
@@ -74,6 +75,9 @@ if (!$variant['interest_required']) {
if (!$variant['ask_subject']) {
$subject = ''; // Feld gibt es dort nicht, ein mitgeschicktes ignorieren
}
if (!$variant['ask_birthdate']) {
$birthdate = ''; // dito — nur die Turn-Variante fragt danach
}
// --- Validierung (Whitelist-Prinzip, Fehler pro Feld) ---
$errors = [];
@@ -86,6 +90,21 @@ if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
if (mb_strlen($phone) > 50) {
$errors['contact-phone'] = 'Die Telefonnummer ist zu lang (max. 50 Zeichen).';
}
// Geburtsdatum: optional, aber wenn angegeben, dann ein echtes Datum in einem
// plausiblen Bereich. type=date liefert YYYY-MM-DD; createFromFormat mit
// anschließendem Vergleich weist auch Scheindaten wie 2019-02-31 ab (PHP würde
// die sonst stillschweigend auf den 3. März verschieben).
if ($birthdate !== '') {
$d = DateTimeImmutable::createFromFormat('!Y-m-d', $birthdate);
$heute = new DateTimeImmutable('today');
if ($d === false || $d->format('Y-m-d') !== $birthdate) {
$errors['contact-birthdate'] = 'Bitte gib das Geburtsdatum im Format TT.MM.JJJJ an.';
} elseif ($d > $heute) {
$errors['contact-birthdate'] = 'Das Geburtsdatum kann nicht in der Zukunft liegen.';
} elseif ($d < new DateTimeImmutable('1900-01-01')) {
$errors['contact-birthdate'] = 'Bitte prüfe das Geburtsdatum.';
}
}
if (!in_array($interest, $interests, true)) {
$errors['contact-interest'] = $variant['interest_required']
? 'Bitte wähle aus, für welche Gruppe du dich interessierst.'
@@ -110,7 +129,8 @@ if ($errors !== []) {
// Ohne JS: Seite mit Eingaben und Feldfehlern neu rendern statt PRG (Werte bleiben erhalten).
render_form_invalid($return, [
'name' => $name, 'email' => $email, 'phone' => $phone, 'interest' => $interest,
'subject' => $subject, 'message' => $message, 'privacy' => $privacy ? '1' : '',
'birthdate' => $birthdate, 'subject' => $subject, 'message' => $message,
'privacy' => $privacy ? '1' : '',
], $errors);
}
@@ -148,6 +168,7 @@ $body = $variant['context'] . " über tsv08kulmbach.de\n"
. "E-Mail: {$email}\n"
. ($phone !== '' ? "Telefon: {$phone}\n" : '')
. ($interest !== '' ? "Interesse: {$interest}\n" : '')
. ($birthdate !== '' ? 'Geburtstag des Kindes: ' . german_date($birthdate) . "\n" : '')
. ($subject !== '' ? "Betreff: {$subject}\n" : '')
. str_repeat('-', 40) . "\n\n"
. ($message !== '' ? $message . "\n\n" : "(keine Nachricht angegeben)\n\n")

View File

@@ -43,6 +43,14 @@ $f = [
'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' => [
@@ -79,6 +87,9 @@ $f = [
<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,

View File

@@ -6,11 +6,11 @@ declare(strict_types=1);
* Einheitliches Formularfeld — EINZIGE Quelle für Feld-Markup aller Formulare.
*
* Props: $field (array):
* type text | email | tel | select | textarea | checkbox (Default: text)
* type text | email | tel | date | select | textarea | checkbox (Default: text)
* name, label Pflicht
* id Default: field-<name>
* required bool
* autocomplete, maxlength, rows (textarea)
* autocomplete, maxlength, rows (textarea), min/max (date)
* options[] + placeholder (select)
* hint optionaler Hilfetext (aria-describedby)
* label_html NUR für vertrauenswürdiges Markup aus Komponenten-Code

View File

@@ -1370,6 +1370,7 @@ function contact_variant(string $department): array
'interests' => ['Herrenfußball', 'Frauenfußball', 'Jugendfußball', 'Turnen', 'Ehrenamtlich mithelfen', 'Sonstiges'],
'interest_required' => false,
'ask_subject' => true,
'ask_birthdate' => false,
'message_required' => true,
'to' => '',
'context' => 'Kontaktanfrage',
@@ -1395,6 +1396,13 @@ function contact_variant(string $department): array
// Kern der Anfrage, und sie ersetzt hier das Betreff-Feld.
'interest_required' => true,
'ask_subject' => false,
// Geburtsdatum nur hier: die Turnabteilung braucht bei Anfragen für Kinder
// die Altersgruppe, um die passende Gruppe zuzuordnen (Mäuse, Tiger,
// Hühner …). Gab es auf der alten Seite als optionales Feld
// „Geburtsdatum des Kindes" und ist beim Neubau untergegangen.
// In der allgemeinen Variante wäre es sinnlos und würde unnötig
// personenbezogene Daten von Minderjährigen erheben.
'ask_birthdate' => true,
// Bewusst optional (wie auf der alten Seite): „Interessiere mich für
// Trampolin, ruft mich an" ist eine vollständige Anfrage. Name, Mail und
// Disziplin stehen ja schon da.

View File

@@ -168,7 +168,12 @@ $meta = [
Das <strong>Anfrageformular der Turnabteilung</strong> auf
<a href="<?= e(url('turnen')) ?>">/turnen</a> erhebt dieselben Angaben wie das
Kontaktformular, statt eines Betreffs jedoch die Turngruppe, für die du dich
interessierst; eine Nachricht ist dort freiwillig. Diese Anfragen gehen an das
interessierst; eine Nachricht ist dort freiwillig. Zusätzlich kannst du
<strong>freiwillig das Geburtsdatum des Kindes</strong> angeben, um das es bei der
Anfrage geht. Wir brauchen es nur, um die passende Turngruppe zuzuordnen, weil die
Gruppen nach Alter eingeteilt sind. Das Feld ist keine Pflichtangabe: ohne Datum
bearbeiten wir die Anfrage genauso und fragen gegebenenfalls nach dem Alter nach.
Diese Anfragen gehen an das
Postfach der Turnabteilung
(<a href="mailto:<?= e($club['email_turnen']) ?>"><?= e($club['email_turnen']) ?></a>),
weil die Abteilung sie selbst beantwortet.