From 4b78285698a9ae2f10af7cf0fa1598472a2ad401 Mon Sep 17 00:00:00 2001 From: fs Date: Fri, 19 Jun 2026 11:16:10 +0200 Subject: [PATCH] Neue Seiten: Mitglied/Partner werden, Sportheimbuchung & Ansprechpartner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mitglied werden: Hub-Seite mit Online-Anmeldung (membership-form + membership-submit-Action, Honeypot + Time-Trap), Abteilungswahl (department-choice), Beitragstabelle (fee-table) und Turn-Aufnahmeantrag als PDF-Download. Daten in data/mitglied-werden.json. Partner werden & Sportheimbuchung: neue Seiten samt Routing und Navigation. Ansprechpartner: echter Hero (Vereinsgelände), Bereiche aus dem group-Feld der Single Source (Fußball / Turnen) zu zwei Sektionen gebündelt, Disziplin als Karten-Überschrift. Fehlende Turn-Disziplin-Leads (Frisque, Lorz, Kornatz, Trotzke) aus den Turnen-Unterseiten extrahiert und in data/ansprechpartner.json ergänzt (allgemeine Turnabteilungs-Mail als Kontakt). WhatsApp für alle Personen mit Telefonnummer. person-grid: kompaktere Karten, Hairline unter der Bereichsüberschrift, 5 Spalten am Desktop. Startseite auf die Haupt-Ansprechpartner je Bereich begrenzt. Dazu: neue Icons (check-circle, download, file-earmark-text, whatsapp), Anpassungen an Header/Footer/CTA/age-groups sowie tokens/layout/utilities. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RZsKTL5kRSG4KkK8w2LKkE --- app/actions/membership-submit.php | 131 +++++++++++++++++ app/components/age-groups.php | 17 ++- app/components/cta.php | 9 +- app/components/department-choice.php | 39 ++++++ app/components/fee-table.php | 29 ++++ app/components/footer.php | 44 ++++-- app/components/header.php | 1 + app/components/membership-form.php | 113 +++++++++++++++ app/components/person-card.php | 12 +- app/pages/ansprechpartner.php | 63 +++++++++ app/pages/home.php | 6 +- app/pages/jugendfussball.php | 31 ++-- app/pages/mitglied-werden.php | 82 +++++++++++ app/pages/partner-werden.php | 46 ++++++ app/pages/sportheimbuchung.php | 46 ++++++ app/routes.php | 4 + data/ansprechpartner.json | 42 +++++- data/home.json | 6 +- data/jugend.json | 12 +- data/mitglied-werden.json | 84 +++++++++++ data/navigation.json | 5 +- public/assets/css/components.css | 132 ++++++++++++------ public/assets/css/layout.css | 86 ++++++++++-- public/assets/css/tokens.css | 11 +- public/assets/css/utilities.css | 31 +++- .../downloads/beitrittserklaerung-turnen.pdf | Bin 0 -> 383968 bytes public/assets/icons/check-circle.svg | 2 + public/assets/icons/download.svg | 2 + public/assets/icons/file-earmark-text.svg | 2 + public/assets/icons/whatsapp.svg | 1 + public/index.php | 4 + 31 files changed, 977 insertions(+), 116 deletions(-) create mode 100644 app/actions/membership-submit.php create mode 100644 app/components/department-choice.php create mode 100644 app/components/fee-table.php create mode 100644 app/components/membership-form.php create mode 100644 app/pages/ansprechpartner.php create mode 100644 app/pages/mitglied-werden.php create mode 100644 app/pages/partner-werden.php create mode 100644 app/pages/sportheimbuchung.php create mode 100644 data/mitglied-werden.json create mode 100644 public/assets/downloads/beitrittserklaerung-turnen.pdf create mode 100644 public/assets/icons/check-circle.svg create mode 100644 public/assets/icons/download.svg create mode 100644 public/assets/icons/file-earmark-text.svg create mode 100644 public/assets/icons/whatsapp.svg diff --git a/app/actions/membership-submit.php b/app/actions/membership-submit.php new file mode 100644 index 0000000..5764394 --- /dev/null +++ b/app/actions/membership-submit.php @@ -0,0 +1,131 @@ + $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] ?? '')); + +// --- Spam-Checks: Bots bekommen ein stilles "OK" (kein Feedback-Kanal) --- +if ($field('website') !== '' || !form_token_valid($field('ft'))) { + $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'); + +// 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'] ?? [] +); + +// --- Validierung (Whitelist-Prinzip) --- +$valid = $firstName !== '' && mb_strlen($firstName) <= 100 + && $lastName !== '' && mb_strlen($lastName) <= 100 + && preg_match('/^\d{4}-\d{2}-\d{2}$/', $birthDate) === 1 + && $phone !== '' && mb_strlen($phone) <= 50 + && $street !== '' && mb_strlen($street) <= 200 + && $zip !== '' && mb_strlen($zip) <= 10 + && $city !== '' && mb_strlen($city) <= 100 + && filter_var($email, FILTER_VALIDATE_EMAIL) !== false + && in_array($feeClass, $feeOptions, true) + && ($_POST['statute'] ?? '') === '1' + && ($_POST['privacy'] ?? '') === '1' + && !preg_match('/[\r\n]/', $firstName . $lastName . $city . $street); + +if (!$valid) { + $respond(false, 'validation'); +} + +// --- Versand --- +$smtp = config('smtp'); + +$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"; + +$buildMailer = static function () use ($smtp): PHPMailer { + $mail = new PHPMailer(true); + $mail->isSMTP(); + $mail->Host = $smtp['host']; + $mail->Port = (int) $smtp['port']; + $mail->SMTPAuth = true; + $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; + $mail->Username = $smtp['username']; + $mail->Password = $smtp['password']; + $mail->CharSet = PHPMailer::CHARSET_UTF8; + return $mail; +}; + +// Mail 1 (intern) — erfolgskritisch. +try { + $mail = $buildMailer(); + $mail->setFrom($smtp['from'], $smtp['from_name']); + $mail->addAddress($smtp['to']); + $mail->addReplyTo($email, $firstName . ' ' . $lastName); + $mail->Subject = 'Mitgliedsantrag Fußball: ' . $firstName . ' ' . $lastName; + $mail->Body = $body; + $mail->send(); +} catch (Throwable $e) { + error_log('[' . date('c') . '] Mitgliedsantrag-Versand fehlgeschlagen: ' . $e->getMessage() . "\n", 3, STORAGE_PATH . '/logs/mail.log'); + $respond(false, 'mail'); +} + +// Mail 2 (Eingangsbestätigung an Antragsteller) — optional, Fehler nicht an Besucher leaken. +$welcome = $data['welcome_mail'] ?? null; +if (is_array($welcome) && !empty($welcome['subject']) && !empty($welcome['body'])) { + try { + $confirm = $buildMailer(); + $confirm->setFrom($smtp['from'], $smtp['from_name']); + $confirm->addAddress($email, $firstName . ' ' . $lastName); + $confirm->Subject = $welcome['subject']; + $confirm->Body = $welcome['body']; + $confirm->send(); + } catch (Throwable $e) { + error_log('[' . date('c') . '] Bestätigungsmail fehlgeschlagen: ' . $e->getMessage() . "\n", 3, STORAGE_PATH . '/logs/mail.log'); + } +} + +$respond(true); diff --git a/app/components/age-groups.php b/app/components/age-groups.php index 4c1f033..bb5d404 100644 --- a/app/components/age-groups.php +++ b/app/components/age-groups.php @@ -20,22 +20,25 @@ if ($groups === []) {

    - -
  • + +
  • - $g['photo'], 'sizes' => '(max-width: 600px) 100vw, 320px', 'class' => 'age-group__img']); ?> + $g['photo'], 'sizes' => '(max-width: 600px) 100vw, (max-width: 992px) 50vw, 560px', 'class' => 'age-group__img']); ?> - + - - + + In Entstehung

    -

    +

    + + $t): ?>' : '' ?> +

    diff --git a/app/components/cta.php b/app/components/cta.php index 0c532ea..0cfe7d3 100644 --- a/app/components/cta.php +++ b/app/components/cta.php @@ -3,8 +3,13 @@ declare(strict_types=1); /** - * CTA-Button. Props: $cta array{label: string, slug: string, style?: 'primary'|'outline'} + * CTA-Button. Props: $cta array{label: string, slug: string, + * style?: 'primary'|'outline', size?: 'sm'|'lg'} */ $style = $cta['style'] ?? 'primary'; +$size = $cta['size'] ?? null; +$class = 'btn' + . ($style === 'outline' ? ' btn--outline' : '') + . ($size === 'lg' ? ' btn--lg' : ($size === 'sm' ? ' btn--sm' : '')); ?> - + diff --git a/app/components/department-choice.php b/app/components/department-choice.php new file mode 100644 index 0000000..b7ff112 --- /dev/null +++ b/app/components/department-choice.php @@ -0,0 +1,39 @@ +-Markup (nicht die cta-Komponente): cta() schickt + * jeden Slug durch url() und würde aus "#anker" ein "/#anker" (Startseite) machen. + */ +$anchor = $anchor ?? null; +if (empty($choice['items'])) { + return; +} +?> +
    > +
    + +

    + + +

    + +
      + +
    • +

      + +

      + +

      + +
    • + +
    +
    +
    diff --git a/app/components/fee-table.php b/app/components/fee-table.php new file mode 100644 index 0000000..d18e2c5 --- /dev/null +++ b/app/components/fee-table.php @@ -0,0 +1,29 @@ +-Layout-Zwang. + */ +if (empty($fees['rows'])) { + return; +} +?> +
    + +

    + +
    + +
    +
    +
    +
    + +
    + +

    + +
    diff --git a/app/components/footer.php b/app/components/footer.php index 5b2afd9..a78259f 100644 --- a/app/components/footer.php +++ b/app/components/footer.php @@ -13,20 +13,28 @@ $nav = json_load('navigation'); $socialLabels = ['instagram' => 'Instagram', 'facebook' => 'Facebook', 'youtube' => 'YouTube']; $socialIcons = ['instagram' => 'instagram', 'facebook' => 'facebook', 'youtube' => 'youtube']; -// Hauptnav flach ausrollen (Unterpunkte wie Matchcenter einreihen), Anker-Links raus. -$footerNav = []; +// Hauptnav in Spalten gruppieren: jeder Hauptpunkt mit Unterpunkten wird eine +// Footer-Spalte (Heading verlinkt die Übersicht, darunter die Kinder). Standalone- +// Hauptpunkte (ohne Kinder) und CTAs sammeln sich als „Mehr“-Spalte. Anker-Links raus. +$sections = []; +$extraLinks = []; foreach ($nav['main'] as $item) { if (str_starts_with($item['slug'], '#')) { continue; } - $footerNav[] = ['label' => $item['label'], 'slug' => $item['slug']]; + $children = []; foreach ($item['children'] ?? [] as $child) { - if ($child['slug'] !== $item['slug']) { // „Übersicht“ dupliziert nur den Eltern-Slug - $footerNav[] = $child; + if ($child['slug'] !== $item['slug']) { // „Übersicht“ dupliziert nur den Eltern-Slug = Heading + $children[] = $child; } } + if ($children) { + $sections[] = ['label' => $item['label'], 'slug' => $item['slug'], 'links' => $children]; + } else { + $extraLinks[] = ['label' => $item['label'], 'slug' => $item['slug']]; + } } -$footerNav = array_merge($footerNav, $nav['ctas'] ?? []); +$extraLinks = array_merge($extraLinks, $nav['ctas'] ?? []); ?>
-