Neue Seiten: Mitglied/Partner werden, Sportheimbuchung & Ansprechpartner

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RZsKTL5kRSG4KkK8w2LKkE
This commit is contained in:
2026-06-19 11:16:10 +02:00
parent 185365dc79
commit 4b78285698
31 changed files with 977 additions and 116 deletions

View File

@@ -0,0 +1,131 @@
<?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=…)
* oder JSON bei fetch (form.js).
*/
use PHPMailer\PHPMailer\PHPMailer;
$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] ?? ''));
// --- 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);

View File

@@ -20,22 +20,25 @@ if ($groups === []) {
<div class="container">
<h2><?= e($title) ?></h2>
<ul class="age-groups__grid">
<?php foreach ($groups as $g): ?>
<li class="age-group">
<?php foreach ($groups as $g): $upcoming = !empty($g['upcoming']); ?>
<li class="age-group<?= $upcoming ? ' age-group--upcoming' : '' ?>">
<div class="age-group__media">
<?php if (!empty($g['photo']['base'])): ?>
<?php component('img', ['image' => $g['photo'], 'sizes' => '(max-width: 600px) 100vw, 320px', 'class' => 'age-group__img']); ?>
<?php component('img', ['image' => $g['photo'], 'sizes' => '(max-width: 600px) 100vw, (max-width: 992px) 50vw, 560px', 'class' => 'age-group__img']); ?>
<?php else: ?>
<img class="age-group__img" src="<?= e(asset('img/persons/platzhalter.jpg')) ?>" alt="" width="400" height="300" loading="lazy" decoding="async">
<img class="age-group__img" src="<?= e(asset('img/persons/platzhalter-spieler.jpg')) ?>" alt="" width="480" height="1047" loading="lazy" decoding="async">
<?php endif; ?>
<?php if (!empty($g['code'])): ?>
<span class="age-group__badge" aria-hidden="true"><?= e($g['code']) ?></span>
<?php if ($upcoming): ?>
<span class="age-group__soon">In Entstehung</span>
<?php endif; ?>
</div>
<div class="age-group__body">
<h3 class="age-group__name"><?= e($g['name']) ?></h3>
<?php if (!empty($g['training'])): ?>
<p class="age-group__training"><?= icon('clock', 'age-group__icon') ?><span><?= e($g['training']) ?></span></p>
<p class="age-group__training">
<?= icon('clock', 'age-group__icon') ?>
<span><?php foreach ((array) $g['training'] as $i => $t): ?><?= $i ? '<br>' : '' ?><?= e($t) ?><?php endforeach; ?></span>
</p>
<?php elseif (!empty($g['note'])): ?>
<p class="age-group__training text-muted"><?= e($g['note']) ?></p>
<?php endif; ?>

View File

@@ -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' : ''));
?>
<a class="btn<?= $style === 'outline' ? ' btn--outline' : '' ?>" href="<?= e(url($cta['slug'])) ?>"><?= e($cta['label']) ?></a>
<a class="<?= e($class) ?>" href="<?= e(url($cta['slug'])) ?>"><?= e($cta['label']) ?></a>

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
/**
* Abteilungs-Auswahl: gleichwertige Karten, die auf Anker derselben Seite springen.
* Props:
* $choice array{title?, intro?, items: [{kicker?, title, text, label, anchor}]}
* $anchor string (optional, Sektions-ID)
* Bewusst eigenes <a href="#…">-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;
}
?>
<section class="section department-choice"<?= $anchor ? ' id="' . e($anchor) . '"' : '' ?>>
<div class="container">
<?php if (!empty($choice['title'])): ?>
<h2 class="department-choice__title"><?= e($choice['title']) ?></h2>
<?php endif; ?>
<?php if (!empty($choice['intro'])): ?>
<p class="department-choice__intro"><?= e($choice['intro']) ?></p>
<?php endif; ?>
<ul class="department-choice__grid" role="list">
<?php foreach ($choice['items'] as $item): ?>
<li class="department-choice__card">
<h3 class="department-choice__heading"><?= e($item['title']) ?></h3>
<?php if (!empty($item['subtitle'])): ?>
<p class="department-choice__subtitle"><?= e($item['subtitle']) ?></p>
<?php endif; ?>
<p class="department-choice__text"><?= e($item['text']) ?></p>
<a class="btn department-choice__btn" href="#<?= e($item['anchor']) ?>"><?= e($item['label']) ?><?= icon('arrow-right', 'department-choice__arrow') ?></a>
</li>
<?php endforeach; ?>
</ul>
</div>
</section>

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
/**
* Beitrags-/Preistabelle. Props:
* $fees array{title?, rows: [{label, price}], note?}
* Semantische Definitionsliste (Begriff → Betrag), responsiv ohne <table>-Layout-Zwang.
*/
if (empty($fees['rows'])) {
return;
}
?>
<div class="fee-table">
<?php if (!empty($fees['title'])): ?>
<h3 class="fee-table__title"><?= e($fees['title']) ?></h3>
<?php endif; ?>
<dl class="fee-table__list">
<?php foreach ($fees['rows'] as $row): ?>
<div class="fee-table__row">
<dt class="fee-table__label"><?= e($row['label']) ?></dt>
<dd class="fee-table__price"><?= e($row['price']) ?></dd>
</div>
<?php endforeach; ?>
</dl>
<?php if (!empty($fees['note'])): ?>
<p class="fee-table__note"><?= e($fees['note']) ?></p>
<?php endif; ?>
</div>

View File

@@ -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'] ?? []);
?>
<footer class="site-footer">
<div class="container site-footer__grid">
@@ -45,14 +53,18 @@ $footerNav = array_merge($footerNav, $nav['ctas'] ?? []);
<?php endforeach; ?>
</ul>
</div>
<nav aria-label="Footer-Navigation">
<h2 class="site-footer__heading">Seiten</h2>
<?php foreach ($sections as $section): ?>
<nav aria-label="<?= e($section['label']) ?>">
<h2 class="site-footer__heading">
<a href="<?= e(url($section['slug'])) ?>"><?= e($section['label']) ?></a>
</h2>
<ul class="site-footer__list">
<?php foreach ($footerNav as $item): ?>
<?php foreach ($section['links'] as $item): ?>
<li><a href="<?= e(url($item['slug'])) ?>"><?= e($item['label']) ?></a></li>
<?php endforeach; ?>
</ul>
</nav>
<?php endforeach; ?>
<div>
<h2 class="site-footer__heading">Kontakt</h2>
<address class="site-footer__address">
@@ -67,19 +79,23 @@ $footerNav = array_merge($footerNav, $nav['ctas'] ?? []);
<a href="mailto:<?= e($club['email']) ?>"><?= e($club['email']) ?></a>
</p>
</address>
</div>
<div>
<h2 class="site-footer__heading">Rechtliches</h2>
<ul class="site-footer__list">
<?php foreach ($nav['legal'] as $item): ?>
<?php if ($extraLinks): ?>
<ul class="site-footer__list site-footer__list--extra">
<?php foreach ($extraLinks as $item): ?>
<li><a href="<?= e(url($item['slug'])) ?>"><?= e($item['label']) ?></a></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</div>
<div class="site-footer__bottom">
<div class="container site-footer__bottom-inner">
<p>&copy; <?= e(date('Y')) ?> <?= e($club['legal_name']) ?></p>
<ul class="site-footer__legal" aria-label="Rechtliches">
<?php foreach ($nav['legal'] as $item): ?>
<li><a href="<?= e(url($item['slug'])) ?>"><?= e($item['label']) ?></a></li>
<?php endforeach; ?>
</ul>
<p class="site-footer__memberships">Mitglied in: <?= e(implode(' · ', $club['memberships'])) ?></p>
</div>
</div>

View File

@@ -12,6 +12,7 @@ $nav = json_load('navigation');
<div class="container site-header__inner">
<a class="site-header__brand" href="<?= e(url()) ?>" aria-label="<?= e($club['name']) ?> Startseite">
<img class="site-header__logo" src="<?= e(asset('img/logo.svg')) ?>" alt="" width="75" height="97">
<span class="site-header__name" aria-hidden="true"><?= e($club['name']) ?></span>
</a>
<?php component('nav', ['current' => $current]); ?>
<?php if (!empty($nav['ctas'])): ?>

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
/**
* Mitglieds-Antrag Fußball (schlank, ohne Bankdaten — die kommen auf dem Papierantrag).
* Props:
* $fee_options array Beitragsklassen als Strings (Whitelist muss zur Action passen)
* 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 "website" + signierter Timestamp "ft" (form_token()).
*/
$club = json_load('club');
$fee_options = $fee_options ?? [];
$sent = isset($_GET['sent']);
$error = isset($_GET['error']) ? (string) $_GET['error'] : null;
$errorMessages = [
'validation' => 'Bitte prüfe deine Eingaben Pflichtfelder fehlen oder die E-Mail-Adresse ist ungültig.',
'mail' => 'Dein Antrag konnte gerade nicht versendet werden. Bitte versuche es später erneut oder schreib uns direkt an ' . $club['email'] . '.',
];
$f = [
'first_name' => ['type' => 'text', 'name' => 'first_name', 'id' => 'mm-first-name', 'label' => 'Vorname', 'required' => true, 'autocomplete' => 'given-name', 'maxlength' => 100],
'last_name' => ['type' => 'text', 'name' => 'last_name', 'id' => 'mm-last-name', 'label' => 'Nachname', 'required' => true, 'autocomplete' => 'family-name', 'maxlength' => 100],
'birth_date' => ['type' => 'date', 'name' => 'birth_date', 'id' => 'mm-birth-date', 'label' => 'Geboren am', 'required' => true, 'autocomplete' => 'bday'],
'phone' => ['type' => 'tel', 'name' => 'phone', 'id' => 'mm-phone', 'label' => 'Telefonnummer', 'required' => true, 'autocomplete' => 'tel', 'maxlength' => 50],
'street' => ['type' => 'text', 'name' => 'street', 'id' => 'mm-street', 'label' => 'Straße & Hausnummer', 'required' => true, 'autocomplete' => 'street-address', 'maxlength' => 200],
'zip' => ['type' => 'text', 'name' => 'zip', 'id' => 'mm-zip', 'label' => 'Postleitzahl', 'required' => true, 'autocomplete' => 'postal-code', 'maxlength' => 10],
'city' => ['type' => 'text', 'name' => 'city', 'id' => 'mm-city', 'label' => 'Wohnort', 'required' => true, 'autocomplete' => 'address-level2', 'maxlength' => 100],
'email' => ['type' => 'email', 'name' => 'email', 'id' => 'mm-email', 'label' => 'E-Mail', 'required' => true, 'autocomplete' => 'email', 'maxlength' => 200],
'fee_class' => ['type' => 'select', 'name' => 'fee_class', 'id' => 'mm-fee-class', 'label' => 'Beitragsklasse', 'required' => true, 'options' => $fee_options, 'placeholder' => 'Bitte wählen'],
'statute' => [
'type' => 'checkbox', 'name' => 'statute', 'id' => 'mm-statute', 'required' => true,
'label' => 'Mit der Beitrittserklärung erkenne ich die Satzung des TSV 1908 Kulmbach e. V. an. Bei Kindern unter 18 Jahren ist die Unterschrift eines Erziehungsberechtigten erforderlich.',
'label_html' => 'Mit der Beitrittserklärung erkenne ich die Satzung des TSV 1908 Kulmbach e.&nbsp;V. an. Bei Kindern unter 18 Jahren ist die Unterschrift eines Erziehungsberechtigten erforderlich.',
],
'privacy' => [
'type' => 'checkbox', 'name' => 'privacy', 'id' => 'mm-privacy', 'required' => true,
'label' => 'Ich stimme den Datenschutzbestimmungen zu.',
'label_html' => 'Ich stimme den <a href="' . e(url('datenschutz')) . '">Datenschutzbestimmungen</a> zu.',
],
];
?>
<form class="form" method="post" action="<?= e(url('mitglied-werden-senden')) ?>" novalidate>
<div class="form__status" role="status" aria-live="polite" tabindex="-1" data-form-status>
<?php if ($sent): ?>
<p class="form__success">Danke! Dein Mitgliedsantrag ist bei uns eingegangen wir melden uns und senden dir die Beitrittserklärung per Post zu.</p>
<?php elseif ($error !== null): ?>
<p class="form__error"><?= e($errorMessages[$error] ?? $errorMessages['mail']) ?></p>
<?php endif; ?>
</div>
<p class="form__note">Pflichtfelder sind mit <span class="form__required" aria-hidden="true">*</span><span class="visually-hidden">Stern</span> markiert.</p>
<fieldset class="form__group">
<legend class="form__legend">Zur Person</legend>
<div class="form__group-body">
<div class="form__row">
<?php component('form-field', ['field' => $f['first_name']]); ?>
<?php component('form-field', ['field' => $f['last_name']]); ?>
</div>
<?php component('form-field', ['field' => $f['birth_date']]); ?>
</div>
</fieldset>
<fieldset class="form__group">
<legend class="form__legend">Adresse</legend>
<div class="form__group-body">
<?php component('form-field', ['field' => $f['street']]); ?>
<div class="form__row">
<?php component('form-field', ['field' => $f['zip']]); ?>
<?php component('form-field', ['field' => $f['city']]); ?>
</div>
</div>
</fieldset>
<fieldset class="form__group">
<legend class="form__legend">Kontakt</legend>
<div class="form__group-body">
<div class="form__row">
<?php component('form-field', ['field' => $f['phone']]); ?>
<?php component('form-field', ['field' => $f['email']]); ?>
</div>
</div>
</fieldset>
<fieldset class="form__group">
<legend class="form__legend">Mitgliedschaft</legend>
<div class="form__group-body">
<?php component('form-field', ['field' => $f['fee_class']]); ?>
</div>
</fieldset>
<fieldset class="form__group">
<legend class="form__legend">Bestätigung</legend>
<div class="form__group-body">
<?php component('form-field', ['field' => $f['statute']]); ?>
<?php component('form-field', ['field' => $f['privacy']]); ?>
</div>
</fieldset>
<?php /* Honeypot — für Menschen unsichtbar, Bots füllen es aus */ ?>
<div class="visually-hidden" aria-hidden="true">
<label for="mm-website">Website (bitte leer lassen)</label>
<input type="text" id="mm-website" name="website" tabindex="-1" autocomplete="off">
</div>
<input type="hidden" name="ft" value="<?= e(form_token()) ?>">
<p class="form__submit">
<button class="btn" type="submit">Antrag absenden</button>
</p>
</form>

View File

@@ -4,9 +4,10 @@ declare(strict_types=1);
/**
* Visitenkarte für eine Person. Props:
* $person array{department, name, phone?, email?, image?}
* $person array{department, name, phone?, email?, image?, whatsapp?}
* Ohne image greift der Platzhalter-Avatar (img/persons/platzhalter.jpg).
* Kontakt-Link: phone → tel:, sonst email → mailto:.
* Optional zusätzlich WhatsApp (wa.me) bei phone + whatsapp=true (opt-in pro Person).
*/
$image = $person['image'] ?? null;
$src = $image ?: 'img/persons/platzhalter.jpg';
@@ -22,6 +23,10 @@ if (!empty($person['phone'])) {
} else {
$contactHref = null;
}
$waHref = (!empty($person['whatsapp']) && !empty($person['phone']))
? 'https://wa.me/' . preg_replace('/\D/', '', $person['phone'])
: null;
?>
<article class="person-card">
<div class="person-card__media">
@@ -36,6 +41,11 @@ if (!empty($person['phone'])) {
<a class="person-card__contact" href="<?= e($contactHref) ?>">
<?= icon($contactIcon) ?><span><?= e($contactLabel) ?></span>
</a>
<?php endif; ?>
<?php if ($waHref !== null): ?>
<a class="person-card__contact" href="<?= e($waHref) ?>" target="_blank" rel="noopener">
<?= icon('whatsapp') ?><span>WhatsApp</span>
</a>
<?php endif; ?>
</div>
</article>

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
/**
* Ansprechpartner — Hero (Vereinsgelände) → zwei zusammengefasste person-grid-
* Sektionen: Fußball (Herren/Damen/Jugend) und Turnen, aus data/ansprechpartner.json.
*/
$page = json_load('ansprechpartner');
// Sektionen aus dem "group"-Feld der Personen ableiten (Single Source): eine
// Sektion je Gruppe (Fußball, Turnen), darin die jeweiligen Abteilungen/Disziplinen
// als Karten-Überschrift. Reihenfolge = JSON-Reihenfolge.
$groups = [];
foreach ($page['persons'] ?? [] as $p) {
$dept = $p['department'] ?? '';
if ($dept === '') {
continue;
}
$label = $p['group'] ?? $dept;
if (!in_array($dept, $groups[$label] ?? [], true)) {
$groups[$label][] = $dept;
}
}
// Eindeutiger Abschnitts-Anker aus dem Bereichsnamen (kein Slug-Helper im Projekt).
$anchor_slug = static function (string $s): string {
$s = strtolower(strtr($s, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']));
return trim((string) preg_replace('/[^a-z0-9]+/', '-', $s), '-');
};
$meta = [
'title' => 'Ansprechpartner',
'description' => 'Ansprechpartner und Kontaktwege beim TSV 1908 Kulmbach Geschäftsstelle, Turnabteilung und Vorstand auf einen Blick.',
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Der Verein', 'slug' => 'verein'],
['name' => 'Ansprechpartner', 'slug' => 'ansprechpartner'],
]),
];
component('hero', ['hero' => [
'compact' => true,
'kicker' => 'Der Verein',
'title' => 'Ansprechpartner',
'text' => $page['intro'] ?? '',
'image' => [
'base' => 'img/pages/vereinsgelaende',
'widths' => [1000, 1600],
'alt' => '',
],
]]);
$i = 0;
foreach ($groups as $label => $depts):
component('person-grid', [
'departments' => $depts,
'title' => $label,
'intro' => '',
'anchor' => 'kontakt-' . $anchor_slug($label),
'tinted' => ($i % 2 === 0),
]);
$i++;
endforeach;

View File

@@ -26,5 +26,9 @@ foreach ($home['sections'] as $section) {
component('stats', ['stats' => $home['stats']]);
component('partner-grid', ['intro' => $home['partners']]);
component('person-grid');
// Auf der Startseite bewusst nur die Haupt-Ansprechpartner je Bereich; die
// vollständige Turn-Disziplin-Liste lebt auf /ansprechpartner.
component('person-grid', [
'departments' => ['Herrenfußball', 'Damenfußball', 'Jugendfußball', 'Erwachsenenturnen'],
]);
component('contact-form', ['intro' => $home['contact']]);

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
/**
* Jugendfußball-Übersichtsseite. Inhalt: data/jugend.json.
* Komposition: Hero → Intro-Bento (Bild, Mitmachen, Spielort, Altersklassen) →
* Altersklassen-Karten → Ansprechpartner → CTA → FAQ.
* Altersklassen-Karten → FAQ + Ansprechpartner (faq-contact, Slider bei mehreren).
*/
$page = json_load('jugend');
@@ -13,10 +13,17 @@ $club = json_load('club');
$addr = $club['address'] ?? [];
$mapsQuery = rawurlencode(trim(($addr['venue'] ?? '') . ', ' . ($addr['street'] ?? '') . ', ' . ($addr['zip'] ?? '') . ' ' . ($addr['city'] ?? ''), ', '));
// Ansprechpartner Jugendfußball aus der Single-Source (mehrere → Slider in faq-contact).
$contacts = array_values(array_filter(
json_load('ansprechpartner')['persons'] ?? [],
static fn (array $p): bool => ($p['department'] ?? '') === 'Jugendfußball'
));
$meta = [
'title' => 'Jugendfußball in Kulmbach',
'description' => 'Jugendfußball in Kulmbach entdecke unsere Mannschaften von der G- bis zur C-Jugend, Betreuer und Trainingszeiten. Probiere Fußball in familiärer Atmosphäre aus.',
'og_image' => 'img/pages/jugendfussball-hero-1440.jpg',
'scripts' => count($contacts) > 1 ? ['contact-slider.js'] : [],
'schema' => page_schema(
[
['name' => 'Startseite', 'slug' => ''],
@@ -61,20 +68,10 @@ component('hero', ['hero' => $page['hero']]);
<?php
component('age-groups', ['groups' => $page['groups'] ?? []]);
component('person-grid', [
'departments' => ['Jugendfußball'],
'title' => 'Ansprechpartner Jugendfußball',
'intro' => 'Fragen zum Schnuppertraining oder zur passenden Altersgruppe? Melde dich direkt.',
'anchor' => 'kontakt',
component('faq-contact', [
'faq' => $page['faq'] ?? [],
'faqIntro' => 'Ruf uns direkt an oder schreib uns unkompliziert per WhatsApp wir beantworten alle Fragen rund um Schnuppertraining und die passende Altersgruppe.',
'persons' => $contacts,
'contactTitle' => '',
'anchor' => 'kontakt',
]);
component('cta-band', ['band' => [
'title' => 'Neugierig geworden?',
'text' => 'Komm mit deinem Kind zum kostenlosen Schnuppertraining ganz unverbindlich.',
'ctas' => [
['label' => 'Zum Ansprechpartner', 'slug' => 'jugendfussball#kontakt', 'style' => 'primary'],
['label' => 'Mitglied werden', 'slug' => 'mitglied-werden', 'style' => 'outline'],
],
]]);
component('faq', ['faq' => $page['faq'] ?? []]);

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/**
* Mitglied werden (Hub). Inhalt: data/mitglied-werden.json + data/club.json.
* Struktur: Hero → Abteilungs-Auswahl → Info-Band → Fußball (Beiträge + Online-Formular)
* → Turnen (Hinweise + Beiträge + PDF) → FAQ.
*/
$page = json_load('mitglied-werden');
$club = json_load('club');
// Beitragsklassen für das Formular aus der Beitragstabelle ableiten (Single Source).
// Muss identisch in app/actions/membership-submit.php gebildet werden (Whitelist).
$feeOptions = array_map(
static fn (array $r): string => $r['label'] . ' — ' . $r['price'],
$page['fussball']['fees']['rows'] ?? []
);
$meta = [
'title' => 'Mitglied werden',
'description' => 'Mitglied werden beim TSV 08 Kulmbach: Fußball bequem online anmelden oder Turnen per Aufnahmeantrag. Alle Beiträge, Aufnahme und Infos für deinen Vereinsbeitritt in Kulmbach.',
'og_image' => 'img/hero/hero-1600.jpg',
'scripts' => ['form.js'],
'schema' => page_schema(
[
['name' => 'Startseite', 'slug' => ''],
['name' => 'Mitglied werden', 'slug' => 'mitglied-werden'],
],
$page['faq'] ?? []
),
];
component('hero', ['hero' => $page['hero']]);
component('department-choice', ['choice' => $page['choice'], 'anchor' => 'abteilung']);
?>
<section class="section membership-signup" id="anmeldung-fussball">
<div class="container membership-signup__inner">
<header class="membership-signup__head">
<h2><?= e($page['fussball']['title']) ?></h2>
<p class="membership-signup__intro"><?= e($page['fussball']['intro']) ?></p>
</header>
<div class="membership-card">
<?php component('fee-table', ['fees' => $page['fussball']['fees']]); ?>
<?php component('membership-form', ['fee_options' => $feeOptions]); ?>
</div>
</div>
</section>
<section class="section section--tinted membership-signup" id="anmeldung-turnen">
<div class="container membership-signup__inner">
<header class="membership-signup__head">
<h2><?= e($page['turnen']['title']) ?></h2>
<p class="membership-signup__intro"><?= e($page['turnen']['intro']) ?></p>
</header>
<?php if (!empty($page['turnen']['notes'])): ?>
<ul class="membership-notes" role="list">
<?php foreach ($page['turnen']['notes'] as $note): ?>
<li class="membership-notes__item">
<?php if (!empty($note['icon'])): ?>
<span class="membership-notes__icon"><?= icon($note['icon']) ?></span>
<?php endif; ?>
<div class="membership-notes__body">
<h3 class="membership-notes__title"><?= e($note['title']) ?></h3>
<p><?= e($note['text']) ?></p>
</div>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<div class="membership-download">
<a class="btn membership-download__btn" href="<?= e(asset($page['turnen']['download']['file'])) ?>" download>
<?= icon('download', 'membership-download__icon') ?><?= e($page['turnen']['download']['label']) ?>
</a>
<p class="membership-download__hint"><?= e($page['turnen']['download']['hint']) ?></p>
<p class="membership-download__contact">Fragen zur Turnabteilung? Schreib an <a href="mailto:<?= e($club['email_turnen']) ?>"><?= e($club['email_turnen']) ?></a>.</p>
</div>
</div>
</section>
<?php
component('faq', ['faq' => $page['faq'] ?? []]);

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/**
* Partner werden — Platzhalter/Stub für Sponsoring/Partnerschaft. Pakete und
* Konditionen folgen; Kontaktweg über data/club.json (Single Source of Truth).
*/
$club = json_load('club');
$meta = [
'title' => 'Partner werden',
'description' => 'Werde Partner oder Sponsor des TSV 1908 Kulmbach und unterstütze den Sport im Katzbachtal so kommst du mit uns ins Gespräch.',
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Der Verein', 'slug' => 'verein'],
['name' => 'Partner werden', 'slug' => 'partner-werden'],
]),
];
?>
<section class="section legal">
<div class="container">
<div class="legal__head">
<p class="legal__kicker">Der Verein</p>
<h1>Partner werden</h1>
<p class="legal__lead">Unterstütze den TSV 08 Kulmbach und werde sichtbar als Partner mitten im Vereinsleben.</p>
</div>
<div class="legal__body">
<section>
<h2>Sponsoring &amp; Partnerschaft</h2>
<p>Ob Bandenwerbung, Trikotsponsoring oder Unterstützung einzelner Abteilungen wir stellen dir gern die passenden Möglichkeiten vor. Eine Übersicht der Partnerpakete und Konditionen ist in Vorbereitung.</p>
</section>
<section>
<h2>Kontakt aufnehmen</h2>
<div class="legal__card">
<p>
<strong><?= e($club['name']) ?></strong><br>
E-Mail: <a href="mailto:<?= e($club['email']) ?>"><?= e($club['email']) ?></a>
</p>
</div>
</section>
</div>
</div>
</section>

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/**
* Sportheimbuchung — Platzhalter/Stub. Inhalt (Verfügbarkeiten, Buchungsablauf,
* Konditionen) folgt; Kontaktweg über data/club.json (Single Source of Truth).
*/
$club = json_load('club');
$meta = [
'title' => 'Sportheim mieten & buchen',
'description' => 'Das Sportheim des TSV 1908 Kulmbach für Feiern und Veranstaltungen mieten Ansprechpartner und Buchungsablauf.',
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Der Verein', 'slug' => 'verein'],
['name' => 'Sportheimbuchung', 'slug' => 'sportheimbuchung'],
]),
];
?>
<section class="section legal">
<div class="container">
<div class="legal__head">
<p class="legal__kicker">Der Verein</p>
<h1>Sportheim mieten &amp; buchen</h1>
<p class="legal__lead">Unser Sportheim auf der <?= e($club['address']['venue']) ?> lässt sich für Feiern und Veranstaltungen mieten.</p>
</div>
<div class="legal__body">
<section>
<h2>Buchung &amp; Verfügbarkeit</h2>
<p>Die Online-Buchung mit Belegungskalender und Konditionen befindet sich gerade in Vorbereitung. Bis dahin nimm bitte direkt Kontakt mit uns auf wir klären Termin, Ausstattung und Preise unkompliziert mit dir.</p>
</section>
<section>
<h2>Ansprechpartner</h2>
<div class="legal__card">
<p>
<strong><?= e($club['name']) ?></strong><br>
E-Mail: <a href="mailto:<?= e($club['email']) ?>"><?= e($club['email']) ?></a>
</p>
</div>
</section>
</div>
</div>
</section>

View File

@@ -25,6 +25,10 @@ return [
'turnen/wettkampfturnen' => ['file' => 'turnen-wettkampfturnen.php', 'sitemap' => true],
'verein' => ['file' => 'verein.php', 'sitemap' => true],
'historie' => ['file' => 'historie.php', 'sitemap' => true],
'sportheimbuchung' => ['file' => 'sportheimbuchung.php', 'sitemap' => true],
'ansprechpartner' => ['file' => 'ansprechpartner.php', 'sitemap' => true],
'partner-werden' => ['file' => 'partner-werden.php', 'sitemap' => true],
'mitglied-werden' => ['file' => 'mitglied-werden.php', 'sitemap' => true],
'impressum' => ['file' => 'impressum.php', 'sitemap' => true],
'datenschutz' => ['file' => 'datenschutz.php', 'sitemap' => true],
'404' => ['file' => '404.php', 'sitemap' => false],