Files
tsv08kulmbach-website/app/actions/membership-submit.php
fs 8304095dc3 Design-System: Token-Ebene für Spacing, Fokus und Tap-Targets
Vorbereitung für die Konsolidierung. tokens.css war für Farben und Typo schon
die Single Source of Truth, für Abstände aber nur mit --space-section und
--container-pad besetzt — dem standen ~450 Spacing-Deklarationen mit 39
verschiedenen rem-Werten gegenüber.

Neu: 14-stufige Spacing-Skala auf 4px-Raster, aus dem Ist-Bestand geclustert
statt frei erfunden. Die neun häufigsten Werte der Codebase (1, 1.5, 1.25, 2.5,
0.75, 0.5, 1.75, 2, 3rem — zusammen ~60% aller Deklarationen) liegen bereits
exakt auf Stufen, die Migration ist dort verlustfrei. Nach oben wird das Raster
gröber (3 → 4 → 5 → 7rem), weil große Abstände das vertragen. --space-4xs (2px)
ist ausdrücklich nur für optische Korrekturen an Icon-/Baseline-Nudges.

Dazu --tap-min (44px, WCAG 2.5.5) und --focus-ring/--focus-offset. Der Fokus
wird auf Weiß festgeschrieben: 15,9:1 auf --clr-bg gegenüber 3,2:1 für Rot,
das WCAG 1.4.11 nur knapp schafft und als einziger Indikator zu schwach ist.

--container-pad und --space-section verweisen jetzt auf die Skala statt eigene
Werte zu führen. Entfernt: --clr-bg-light und --clr-surface-raised, beide
projektweit 0× verwendet.

Consumer folgen in den nächsten Commits — dieser Schritt ändert die Darstellung
noch nicht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:07:31 +02:00

187 lines
7.2 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* POST /mitglied-werden-senden — Mitgliedsantrag Fußball (schlank, ohne Bankdaten).
* Versendet eine interne Vereins-Mail (erfolgskritisch) und eine Eingangsbestätigung
* an den Antragsteller (optional, Fehler dort wird nicht an den Besucher geleakt).
* Antwort: PRG-Redirect zu /mitglied-werden#anmeldung-fussball (?sent=1 | ?error=…),
* JSON bei fetch (form.js) oder — bei Feldfehlern ohne JS — die Seite direkt
* mit HTTP 422, erhaltenen Eingaben und Feldfehlern (render_form_invalid()).
*/
$wantsJson = str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json');
$respond = static function (bool $ok, string $error = '') use ($wantsJson): never {
if ($wantsJson) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => $ok, 'error' => $error ?: null]);
} else {
header('Location: ' . url('mitglied-werden') . ($ok ? '?sent=1' : '?error=' . $error) . '#anmeldung-fussball', true, 303);
}
exit;
};
$field = static fn (string $key): string => trim((string) ($_POST[$key] ?? ''));
$route = 'mitglied-werden';
$token = $field('ft');
// Ohne vollständige config/config.php liefe die Seite auf der Vorlage (öffentliches
// app_secret, kein SMTP-Passwort) — dann gar nicht erst verarbeiten.
if (config_problems() !== []) {
error_log('[' . date('c') . "] {$route}: Formular abgewiesen, Konfiguration unvollständig (php bin/preflight.php).\n", 3, STORAGE_PATH . '/logs/mail.log');
$respond(false, 'mail');
}
// --- Spam-Checks: Bots bekommen ein stilles "OK" (kein Feedback-Kanal) ---
if ($field('company_url') !== '') {
log_spam($route, 'honeypot');
$respond(true);
}
if (!form_token_valid($token)) {
log_spam($route, 'token');
$respond(true);
}
// --- Eingaben ---
$firstName = $field('first_name');
$lastName = $field('last_name');
$birthDate = $field('birth_date');
$phone = $field('phone');
$street = $field('street');
$zip = $field('zip');
$city = $field('city');
$email = $field('email');
$feeClass = $field('fee_class');
$statute = ($_POST['statute'] ?? '') === '1';
$privacy = ($_POST['privacy'] ?? '') === '1';
// Beitragsklassen-Whitelist identisch zur Seite aus data/mitglied-werden.json ableiten.
$data = json_load('mitglied-werden');
$feeOptions = array_map(
static fn (array $r): string => $r['label'] . ' — ' . $r['price'],
$data['fussball']['fees']['rows'] ?? []
);
// Geburtsdatum: Format, echter Kalendertag und plausibler Zeitraum.
$birthValid = false;
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $birthDate, $m) === 1) {
$birthValid = checkdate((int) $m[2], (int) $m[3], (int) $m[1])
&& $birthDate <= date('Y-m-d')
&& (int) $m[1] >= (int) date('Y') - 120;
}
// --- Validierung (Whitelist-Prinzip, Fehler pro Feld) ---
$errors = [];
if ($firstName === '' || mb_strlen($firstName) > 100 || preg_match('/[\r\n]/', $firstName)) {
$errors['mm-first-name'] = 'Bitte gib den Vornamen an (max. 100 Zeichen).';
}
if ($lastName === '' || mb_strlen($lastName) > 100 || preg_match('/[\r\n]/', $lastName)) {
$errors['mm-last-name'] = 'Bitte gib den Nachnamen an (max. 100 Zeichen).';
}
if (!$birthValid) {
$errors['mm-birth-date'] = 'Bitte gib ein gültiges Geburtsdatum an.';
}
if ($phone === '' || mb_strlen($phone) > 50) {
$errors['mm-phone'] = 'Bitte gib eine Telefonnummer an (max. 50 Zeichen).';
}
if ($street === '' || mb_strlen($street) > 200 || preg_match('/[\r\n]/', $street)) {
$errors['mm-street'] = 'Bitte gib Straße und Hausnummer an.';
}
if ($zip === '' || mb_strlen($zip) > 10 || preg_match('/[^\p{L}\d \-]/u', $zip)) {
$errors['mm-zip'] = 'Bitte gib eine gültige Postleitzahl an.';
}
if ($city === '' || mb_strlen($city) > 100 || preg_match('/[\r\n]/', $city)) {
$errors['mm-city'] = 'Bitte gib den Wohnort an (max. 100 Zeichen).';
}
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
$errors['mm-email'] = 'Bitte gib eine gültige E-Mail-Adresse an.';
}
if (!in_array($feeClass, $feeOptions, true)) {
$errors['mm-fee-class'] = 'Bitte wähle eine Beitragsklasse aus der Liste.';
}
if (!$statute) {
$errors['mm-statute'] = 'Ohne Anerkennung der Satzung ist keine Mitgliedschaft möglich.';
}
if (!$privacy) {
$errors['mm-privacy'] = 'Ohne Zustimmung zum Datenschutz können wir den Antrag nicht entgegennehmen.';
}
if ($errors !== []) {
if ($wantsJson) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => 'validation', 'fields' => $errors]);
exit;
}
// Ohne JS: Seite mit Eingaben und Feldfehlern neu rendern statt PRG (Werte bleiben erhalten).
render_form_invalid($route, [
'first_name' => $firstName, 'last_name' => $lastName, 'birth_date' => $birthDate,
'phone' => $phone, 'street' => $street, 'zip' => $zip, 'city' => $city,
'email' => $email, 'fee_class' => $feeClass,
'statute' => $statute ? '1' : '', 'privacy' => $privacy ? '1' : '',
], $errors);
}
// --- Volumen-Grenzen: erst ab hier, damit Tippfehler kein Kontingent verbrauchen.
// Sie antworten ehrlich — stilles "Danke" ohne Versand verschluckt echte Anträge. ---
if (!rate_limit_ok('submit|' . $route . '|' . client_ip(), 5, 600)) {
log_spam($route, 'ratelimit');
$respond(false, 'ratelimit');
}
// Token-Mehrfachnutzung begrenzen (Replay-Schutz). IP im Schlüssel, weil form_token()
// sekundengenau ist: sonst teilen sich alle Besucher derselben Sekunde ein Kontingent.
if (!rate_limit_ok('token|' . $token . '|' . client_ip(), 3, 86400)) {
log_spam($route, 'replay');
$respond(false, 'ratelimit');
}
// Globaler Tages-Cap über alle Formulare: schützt Postfach und Absender-Reputation.
if (!rate_limit_ok('daily', 200, 86400)) {
log_spam($route, 'daily-cap');
$respond(false, 'busy');
}
// --- Versand ---
$body = "Neuer Mitgliedsantrag (Fußball) über tsv08kulmbach.de\n"
. str_repeat('-', 40) . "\n"
. "Vorname: {$firstName}\n"
. "Nachname: {$lastName}\n"
. "Geboren am: {$birthDate}\n"
. "Adresse: {$street}, {$zip} {$city}\n"
. "Telefon: {$phone}\n"
. "E-Mail: {$email}\n"
. "Beitragsklasse: {$feeClass}\n"
. str_repeat('-', 40) . "\n"
. "Satzung anerkannt: ja\n"
. "Datenschutz: ja\n"
. 'Eingegangen: ' . date('d.m.Y H:i') . "\n\n"
. "Hinweis: Bankdaten werden online nicht erhoben Beitrittserklärung\n"
. "per Post zur Unterschrift zusenden.\n";
// Mail 1 (intern) — erfolgskritisch.
$sent = send_mail([
'context' => 'Mitgliedsantrag',
'subject' => 'Mitgliedsantrag Fußball: ' . $firstName . ' ' . $lastName,
'body' => $body,
'reply_to' => ['email' => $email, 'name' => $firstName . ' ' . $lastName],
]);
if (!$sent) {
$respond(false, 'mail');
}
// Mail 2 (Eingangsbestätigung an den Antragsteller) — optional, Fehler nur ins Log.
$welcome = $data['welcome_mail'] ?? null;
if (is_array($welcome) && !empty($welcome['subject']) && !empty($welcome['body'])) {
send_mail([
'context' => 'Bestätigungsmail Mitgliedsantrag',
'to' => $email,
'to_name' => $firstName . ' ' . $lastName,
'subject' => (string) $welcome['subject'],
'body' => (string) $welcome['body'],
]);
}
$respond(true);