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.',
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],

View File

@@ -3,39 +3,79 @@
"intro": "Du hast Fragen zu einer Abteilung? Unsere Ansprechpartner helfen dir direkt weiter.",
"persons": [
{
"group": "Fußball",
"department": "Herrenfußball",
"name": "Benedikt Höfner",
"phone": "+49 160 97516404",
"whatsapp": true,
"image": "img/persons/benedikt-hoefner.jpg"
},
{
"group": "Fußball",
"department": "Herrenfußball",
"name": "Rustem Hajzeraj",
"phone": "+49 171 1886358",
"whatsapp": true,
"image": "img/persons/rustem-hajzeraj.jpg"
},
{
"group": "Fußball",
"department": "Damenfußball",
"name": "Sam Kern",
"phone": "+49 176 91371064",
"whatsapp": true,
"image": "img/persons/sam-kern.jpg"
},
{
"group": "Fußball",
"department": "Jugendfußball",
"name": "Fabian Früh",
"phone": "+49 160 97516797",
"whatsapp": true,
"image": "img/persons/fabian-frueh.jpg"
},
{
"group": "Fußball",
"department": "Jugendfußball",
"name": "Kerstin Tauber",
"phone": "+49 176 38957526",
"whatsapp": true,
"image": null
},
{
"department": "Turnen",
"group": "Turnen",
"department": "Erwachsenenturnen",
"name": "Maxi Roßberg",
"email": "turnabteilung.tsv08kulmbach@gmail.com",
"image": null
},
{
"group": "Turnen",
"department": "Eltern-Kind- & Kinderturnen",
"name": "Annelie Frisque",
"email": "turnabteilung.tsv08kulmbach@gmail.com",
"image": null
},
{
"group": "Turnen",
"department": "Step Aerobic, Fitness & Pilates",
"name": "Karola Lorz",
"email": "turnabteilung.tsv08kulmbach@gmail.com",
"image": null
},
{
"group": "Turnen",
"department": "Trampolinturnen",
"name": "Tina Kornatz",
"email": "turnabteilung.tsv08kulmbach@gmail.com",
"image": null
},
{
"group": "Turnen",
"department": "Wettkampfturnen",
"name": "Franziska Trotzke",
"email": "turnabteilung.tsv08kulmbach@gmail.com",
"image": null
}
]
}

View File

@@ -18,12 +18,14 @@
{
"label": "Jugendfußball",
"slug": "jugendfussball",
"style": "primary"
"style": "primary",
"size": "lg"
},
{
"label": "Mitglied werden",
"slug": "mitglied-werden",
"style": "outline"
"style": "outline",
"size": "lg"
}
],
"display": true,

View File

@@ -17,11 +17,13 @@
"text": "Wenn deine Kinder Lust haben, bei uns Fußball auszuprobieren oder mitzuspielen, meldet euch gerne ganz unkompliziert. Wir freuen uns über jedes neue Gesicht und bieten Fußball in familiärer Atmosphäre vom ersten Ballkontakt bis zum echten Mannschaftsspiel."
},
"groups": [
{ "code": "G", "name": "G-Jugend (Bambini)", "training": "Freitag 16:30 Uhr", "photo": { "base": "img/teams/jugend-g", "widths": [400, 800], "alt": "G-Jugend des TSV 08 Kulmbach" } },
{ "code": "F", "name": "F-Jugend", "training": "Freitag 16:30 Uhr", "photo": { "base": "img/teams/jugend-f", "widths": [400, 800], "alt": "F-Jugend des TSV 08 Kulmbach" } },
{ "code": "E", "name": "E-Jugend", "note": "Trainingszeit auf Anfrage", "photo": { "base": "img/teams/jugend-e", "widths": [400, 800], "alt": "E-Jugend des TSV 08 Kulmbach" } },
{ "code": "D", "name": "D-Jugend", "note": "Trainingszeit auf Anfrage", "photo": { "base": "img/teams/jugend-d", "widths": [400, 800], "alt": "D-Jugend des TSV 08 Kulmbach" } },
{ "code": "C", "name": "C-Jugend", "note": "Trainingszeit auf Anfrage", "photo": { "base": "img/teams/jugend-c", "widths": [400, 800], "alt": "C-Jugend des TSV 08 Kulmbach" } }
{ "code": "G", "name": "G-Jugend (Bambini)", "training": ["Freitag 16:3017:30 Uhr"], "photo": { "base": "img/teams/jugend-g", "widths": [400, 800], "alt": "G-Jugend des TSV 08 Kulmbach" } },
{ "code": "F", "name": "F-Jugend", "training": ["Montag 17:0018:30 Uhr", "Freitag 16:3017:30 Uhr"], "photo": { "base": "img/teams/jugend-f", "widths": [400, 800], "alt": "F-Jugend des TSV 08 Kulmbach" } },
{ "code": "E", "name": "E-Jugend", "training": ["Montag 17:3018:30 Uhr", "Mittwoch 17:3019:00 Uhr"], "photo": { "base": "img/teams/jugend-e", "widths": [400, 800], "alt": "E-Jugend des TSV 08 Kulmbach" } },
{ "code": "D", "name": "D-Jugend", "training": ["Dienstag 17:3018:30 Uhr", "Freitag 17:3018:30 Uhr"], "photo": { "base": "img/teams/jugend-d", "widths": [400, 800], "alt": "D-Jugend des TSV 08 Kulmbach" } },
{ "code": "C", "name": "C-Jugend", "training": ["Montag 18:3019:30 Uhr", "Freitag 17:3019:00 Uhr"], "photo": { "base": "img/teams/jugend-c", "widths": [400, 800], "alt": "C-Jugend des TSV 08 Kulmbach" } },
{ "code": "B", "name": "B-Jugend", "upcoming": true, "note": "Mannschaft im Aufbau Infos folgen.", "photo": { "base": "img/pages/fussball-hero", "widths": [640, 1000, 1600], "alt": "" } },
{ "code": "A", "name": "A-Jugend", "upcoming": true, "note": "Mannschaft im Aufbau Infos folgen.", "photo": { "base": "img/pages/fussball-hero", "widths": [640, 1000, 1600], "alt": "" } }
],
"faq": [
{ "q": "Ab welchem Alter können Kinder mitspielen?", "a": "Vom Bambini-Alter (G-Jugend) bis zur C-Jugend ist bei uns alles dabei. Am besten kommt ihr einfach zum Schnuppertraining der passenden Altersgruppe vorbei." },

84
data/mitglied-werden.json Normal file
View File

@@ -0,0 +1,84 @@
{
"hero": {
"compact": true,
"align": "bottom-left",
"kicker": "TSV 1908 Kulmbach e. V.",
"title": "Mitglied werden",
"text": "Werde Teil der über 100-jährigen rot-schwarzen Tradition. Dein Mitgliedsbeitrag hält den Verein lebendig egal ob du auf dem Platz im Katzbachtal kickst oder in der Turnhalle aktiv wirst. Wähle deine Abteilung und mach den ersten Schritt.",
"image": {
"base": "img/hero/hero",
"widths": [640, 1000, 1600],
"alt": "Mitglieder des TSV 08 Kulmbach auf der Hans-Rausch-Sportanlage im Katzbachtal"
}
},
"choice": {
"items": [
{
"title": "Abteilung Fußball",
"subtitle": "Online-Anmeldung",
"text": "Schnelle Anmeldung direkt über unser Online-Formular Herren, Damen und Jugend.",
"label": "Zur Online-Anmeldung",
"anchor": "anmeldung-fussball"
},
{
"title": "Abteilung Turnen",
"subtitle": "PDF-Antrag beim Übungsleiter",
"text": "Aufnahmeantrag herunterladen, ausfüllen und beim Übungsleiter abgeben.",
"label": "Infos & Download",
"anchor": "anmeldung-turnen"
}
]
},
"fussball": {
"title": "Anmeldung Fußball",
"intro": "Fülle das Formular aus wir senden dir anschließend die Beitrittserklärung per Post zu, die du unterschrieben zurückschickst.",
"fees": {
"title": "Jahresbeiträge Fußball",
"rows": [
{ "label": "Kinder & Jugendliche (bis 18 Jahre)", "price": "60 €" },
{ "label": "Erwachsene", "price": "70 €" },
{ "label": "Senioren (ab 65 Jahre)", "price": "60 €" },
{ "label": "Familien (ab 3 Personen)", "price": "136 €" }
],
"note": "Pro Jahr, inkl. Vereins- und Abteilungsbeitrag, Verbandsabgaben und Sportunfall-Versicherung. Keine Aufnahmegebühr."
}
},
"turnen": {
"title": "Anmeldung Turnabteilung",
"intro": "Die Anmeldung läuft über den Aufnahmeantrag als PDF: herunterladen, ausfüllen und beim Übungsleiter abgeben.",
"notes": [
{
"icon": "check-circle",
"title": "Platz vorab prüfen",
"text": "Frag vorab beim Übungsleiter, ob in deiner Wunschgruppe ein Platz frei ist eine Anmeldung garantiert keinen Platz."
},
{
"icon": "file-earmark-text",
"title": "Pro Person ein Antrag",
"text": "Für jede Person bitte ein eigenes, vollständig ausgefülltes PDF einreichen."
},
{
"icon": "info-circle",
"title": "Auch passiv möglich",
"text": "Eine passive Mitgliedschaft („stilles Mitglied“) ist jederzeit möglich auch ohne Platz in einer aktiven Gruppe."
}
],
"download": {
"label": "Aufnahmeantrag Turnen (PDF) herunterladen",
"file": "downloads/beitrittserklaerung-turnen.pdf",
"hint": "Den Antrag bitte ausdrucken und beim Übungsleiter abgeben."
}
},
"faq": [
{ "q": "Was kostet die Mitgliedschaft beim TSV 08 Kulmbach?", "a": "Der Jahresbeitrag für die Abteilung Fußball beträgt 70 € für Erwachsene, 60 € für Kinder, Jugendliche und Senioren (ab 65) sowie 136 € für Familien ab drei Personen. Den aktuellen Beitrag der Turnabteilung erfährst du beim Übungsleiter bzw. im Aufnahmeantrag." },
{ "q": "Gibt es eine Aufnahmegebühr?", "a": "Nein. Eine Aufnahmegebühr erheben wir nicht. Im Jahresbeitrag sind Vereins- und Abteilungsbeitrag, Verbandsabgaben und die Sportunfall-Versicherung bereits enthalten." },
{ "q": "Wie melde ich mich für Fußball an?", "a": "Über das Online-Formular auf dieser Seite. Nach Eingang senden wir dir die unterschriftsreife Beitrittserklärung per Post zu, die du unterschrieben an uns zurückschickst." },
{ "q": "Wie melde ich mich für Turnen an?", "a": "Für die Turnabteilung lädst du den Aufnahmeantrag als PDF herunter, füllst ihn aus und gibst ihn beim jeweiligen Übungsleiter ab. Bitte prüfe vorab beim Übungsleiter, ob in deiner Wunschgruppe ein Platz frei ist." },
{ "q": "Kann ich unverbindlich zum Probetraining kommen?", "a": "Ja. Komm gern zum Probetraining der passenden Mannschaft oder Turngruppe vorbei und sprich einfach den jeweiligen Ansprechpartner an, bevor du Mitglied wirst." },
{ "q": "Wie kann ich meine Mitgliedschaft kündigen?", "a": "Der Austritt ist nur zum Ende eines Kalenderjahres möglich und muss bis spätestens 30. September schriftlich erklärt werden. Der Beitrag ist in jedem Fall bis zum Jahresende zu entrichten." }
],
"welcome_mail": {
"subject": "Dein Mitgliedsantrag beim TSV 08 Kulmbach",
"body": "Herzlich willkommen in der 08er-Familie!\n\nDein Mitgliedsantrag ist erfolgreich bei uns eingegangen. Wir bearbeiten ihn so schnell wie möglich und senden dir die Beitrittserklärung anschließend per Post zu.\n\nBitte schicke uns die Erklärung dann unterschrieben zurück.\n\nWir freuen uns auf dich!\n\nSportliche Grüße\nTSV 08 Kulmbach"
}
}

View File

@@ -30,7 +30,10 @@
"slug": "verein",
"children": [
{ "label": "Übersicht", "slug": "verein" },
{ "label": "Historie", "slug": "historie" }
{ "label": "Historie", "slug": "historie" },
{ "label": "Ansprechpartner", "slug": "ansprechpartner" },
{ "label": "Sportheimbuchung", "slug": "sportheimbuchung" },
{ "label": "Partner werden", "slug": "partner-werden" }
]
}
],

View File

@@ -607,20 +607,30 @@
color: var(--clr-text-muted);
}
/* Max. 3 pro Reihe; unvollständige Reihen (z. B. 3+2) zentrieren sich automatisch */
/* Kurze Hairline unter der Bereichs-Überschrift */
.persons h2::after {
content: "";
display: block;
width: 2.5rem;
height: 2px;
margin: 0.6rem auto 0;
background: var(--clr-accent);
}
/* Max. 5 pro Reihe; unvollständige Reihen zentrieren sich automatisch */
.persons__grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 1.5rem;
margin-top: 2.5rem;
gap: 1.25rem;
margin-top: 2.25rem;
text-align: left;
}
.persons__grid > li {
flex: 0 1 calc((100% - 3rem) / 3);
min-width: 220px;
max-width: 340px;
flex: 0 1 calc((100% - 5rem) / 5);
min-width: 170px;
max-width: 250px;
}
.person-card {
@@ -655,6 +665,15 @@
background: linear-gradient(180deg, rgb(20 20 20 / 0) 0%, rgb(20 20 20 / 0.35) 100%);
}
/* Kompaktere Kacheln im Ansprechpartner-Grid (faq-contact bleibt unberührt) */
.persons .person-card__media img {
aspect-ratio: 4 / 4.2;
}
.persons .person-card__body {
padding: 0.85rem 1rem 1rem;
}
.person-card__department {
font-size: var(--fs-650);
letter-spacing: 0.04em;
@@ -675,6 +694,11 @@
font-size: var(--fs-500);
}
/* Zweiter Kontakt-Link (z. B. WhatsApp) sitzt enger unter dem ersten */
.person-card__contact + .person-card__contact {
margin-top: 0;
}
.person-card__contact .icon {
color: var(--clr-accent);
flex: none;
@@ -867,28 +891,22 @@
margin: 0;
}
/* Gruppierte Abschnitte (fieldset/legend) — gliedert lange Formulare in ruhige
Blöcke, getrennt durch eine dezente Hairline. Reduziert die optische Last. */
/* Gruppierte Abschnitte (fieldset/legend) — jeder Block ein dezent umrandetes
Feld mit Titel auf der Rahmenlinie. Gliedert lange Formulare optisch klar. */
.form__group {
margin: 0;
padding: 0;
border: 0;
padding: 1.25rem 1.5rem 1.5rem;
border: 1px solid var(--clr-glass-border);
border-radius: var(--radius-card);
min-inline-size: 0;
}
.form__group + .form__group {
padding-top: 1.75rem;
border-top: 1px solid var(--clr-glass-border);
}
.form__legend {
width: 100%;
padding: 0;
margin-bottom: 1rem;
padding: 0 0.5rem;
font-size: var(--fs-500);
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--clr-accent);
letter-spacing: 0.08em;
color: var(--clr-text);
}
.form__group-body {
@@ -1908,14 +1926,20 @@
.age-groups__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.5rem;
grid-template-columns: repeat(2, 1fr);
gap: 1.75rem;
margin: 2.5rem 0 0;
padding: 0;
list-style: none;
text-align: left;
}
@media (max-width: 600px) {
.age-groups__grid {
grid-template-columns: 1fr;
}
}
.age-group {
display: grid;
grid-template-rows: auto 1fr;
@@ -1940,7 +1964,7 @@
display: block;
width: 100%;
height: auto;
aspect-ratio: 4 / 3;
aspect-ratio: 3 / 2;
object-fit: cover;
transition: transform var(--transition);
}
@@ -1949,50 +1973,58 @@
transform: scale(1.05);
}
/* Klassen-Badge (G/F/E/D/C) auf dem Bild. */
.age-group__badge {
position: absolute;
top: 0.75rem;
left: 0.75rem;
display: grid;
place-items: center;
width: 2.6rem;
height: 2.6rem;
font-family: var(--ff-heading);
font-size: var(--fs-650);
line-height: 1;
color: #fff;
background: var(--clr-accent);
border-radius: 50%;
box-shadow: 0 2px 10px rgb(0 0 0 / 0.35);
}
.age-group__body {
display: grid;
gap: 0.4rem;
gap: 0.6rem;
align-content: start;
padding: 1.1rem 1.3rem 1.3rem;
padding: 1.5rem 1.6rem 1.6rem;
}
.age-group__name {
font-size: var(--fs-650);
font-size: var(--fs-700);
line-height: var(--lh-heading);
}
.age-group__training {
display: inline-flex;
align-items: center;
align-items: flex-start;
gap: 0.5rem;
margin: 0;
font-size: var(--fs-500);
font-size: var(--fs-600);
line-height: 1.45;
color: var(--clr-text-muted);
}
.age-group__icon {
flex: none;
margin-top: 0.2em;
color: var(--clr-accent);
}
/* Platzhalter-Karten (B-/A-Jugend „In Entstehung"): Bild gedämpft, Label oben links. */
.age-group--upcoming .age-group__img {
opacity: 0.5;
filter: grayscale(0.5);
}
.age-group--upcoming:hover .age-group__img {
transform: none;
}
.age-group__soon {
position: absolute;
top: 0.9rem;
left: 0.9rem;
padding: 0.35rem 0.75rem;
font-family: var(--ff-body);
font-size: var(--fs-400);
letter-spacing: 0.12em;
text-transform: uppercase;
color: #fff;
background: var(--clr-accent);
border-radius: var(--radius-btn);
}
/* === team-cards (Mannschafts-Karten) === */
.team-cards > .container {
text-align: center;
@@ -2760,6 +2792,14 @@
color: var(--clr-text-muted);
}
/* Mehrere Beitragstabellen + Austritts-Hinweis gestapelt (Turn-Übersicht). */
.fee-stack {
display: flex;
flex-direction: column;
gap: 1.5rem;
margin-top: 1.5rem;
}
/* === membership (Mitglied werden) === */
.membership-signup__inner {
max-width: 720px;

View File

@@ -26,19 +26,18 @@
z-index: 10;
}
/* Desktop: Logo links | Nav exakt zentriert | CTAs rechts */
/* Desktop: Logo + Schriftzug links | Nav-Punkte und CTA rechtsbündig zusammen */
.site-header__inner {
display: grid;
grid-template-columns: 1fr auto 1fr;
display: flex;
align-items: center;
gap: 1.5rem;
min-height: var(--header-h);
}
.site-header__brand {
justify-self: start;
display: flex;
align-items: center;
gap: 0.75rem;
text-decoration: none;
color: var(--clr-text);
}
@@ -49,16 +48,25 @@
filter: drop-shadow(var(--shadow));
}
.site-header__name {
font-family: var(--ff-heading);
font-size: var(--fs-675);
line-height: 0.85;
text-transform: uppercase;
white-space: nowrap;
text-shadow: var(--shadow);
}
.site-header__actions {
justify-self: end;
display: flex;
align-items: center;
gap: 0.75rem;
}
/* --- Navigation --- */
/* margin-left:auto schiebt Nav + nachfolgende CTAs als Block nach rechts */
.site-nav {
justify-self: center;
margin-left: auto;
}
.site-nav__toggle {
@@ -208,6 +216,7 @@
.site-nav {
order: 3;
margin-left: 0;
}
.site-nav__toggle {
@@ -329,14 +338,18 @@
}
@media (max-width: 600px) {
/* Schmale Screens: CTA(s) kompakter neben dem Burger. */
/* Schmale Screens: Schriftzug ausblenden (Logo + aria-label bleiben). */
.site-header__name {
display: none;
}
/* CTA(s) kompakter neben dem Burger. */
.site-header__actions a:not(:last-child) {
display: none;
}
.site-header__actions .btn {
font-size: var(--fs-400);
padding: 8px 14px;
font-size: var(--btn-font-sm);
}
}
@@ -394,21 +407,31 @@
.site-footer__grid {
display: grid;
grid-template-columns: 1.4fr 1fr 1.2fr 1fr;
gap: 3rem;
grid-template-columns: 1.5fr repeat(4, minmax(0, 1fr));
gap: 2.5rem 3rem;
padding-block: 4.5rem 3.5rem;
}
@media (max-width: 992px) {
/* Brand-Block bleibt links stehen, Nav-Spalten rücken rechts daneben */
@media (max-width: 1024px) {
.site-footer__grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.site-footer__brand {
grid-column: 1 / -1;
}
}
@media (max-width: 768px) {
.site-footer__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 600px) {
@media (max-width: 500px) {
.site-footer__grid {
grid-template-columns: 1fr;
gap: 2.5rem;
gap: 2.25rem;
}
}
@@ -454,6 +477,16 @@
position: relative;
}
.site-footer__heading a {
color: inherit;
text-decoration: none;
transition: color var(--transition);
}
.site-footer__heading a:hover {
color: var(--clr-accent);
}
.site-footer__heading::after {
content: '';
position: absolute;
@@ -500,12 +533,20 @@
.site-footer__list a:hover,
.site-footer__address a:hover,
.site-footer__social a:hover {
.site-footer__social a:hover,
.site-footer__legal a:hover {
color: var(--clr-text);
text-decoration: underline;
text-underline-offset: 0.25em;
}
/* Zusatzlinks (Startseite, Mitglied werden) unter dem Kontakt-Block */
.site-footer__list--extra {
margin-top: 1.25rem;
padding-top: 1.25rem;
border-top: 1px solid var(--clr-glass-border);
}
/* Bottom-Bar */
.site-footer__bottom {
border-top: 1px solid var(--clr-glass-border);
@@ -525,6 +566,21 @@
margin: 0;
}
.site-footer__legal {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 1.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.site-footer__legal a {
color: var(--clr-text-muted);
text-decoration: none;
transition: color var(--transition);
}
/* Verbände-Zeile bleibt ab Tablet-Breite einzeilig (wrappt als Ganzes in die nächste Reihe) */
@media (min-width: 768px) {
.site-footer__memberships {

View File

@@ -30,6 +30,7 @@
--fs-500: 1.125rem; /* 18px Navigation */
--fs-600: 1.3125rem; /* 21px Body */
--fs-650: 1.75rem; /* 28px Zwischengröße (z.B. Footer-Headings) */
--fs-675: 2.125rem; /* 34px Header-Wortmarke */
--fs-700: clamp(2rem, 1.2rem + 3.5vw, 3.125rem); /* 50px h3 */
--fs-800: clamp(2.25rem, 1.3rem + 4.5vw, 3.75rem); /* 60px h2 */
--fs-900: clamp(3rem, 1.75rem + 5.5vw, 6rem); /* 96px h1 */
@@ -38,6 +39,14 @@
--fw-regular: 400;
--fw-bold: 700;
/* --- Buttons --- */
--btn-font: 1rem; /* 16px Default (moderat) */
--btn-font-sm: var(--fs-300); /* 13px dezente Buttons */
--btn-font-lg: var(--fs-500); /* 18px Hero-CTAs */
--btn-pad-y: 0.625em; /* skaliert mit font-size */
--btn-pad-x: 1.25em;
--btn-line: 1.2;
/* --- Form & Tiefe --- */
--radius-btn: 8px;
--radius-card: 15px;
@@ -49,7 +58,7 @@
--container-pad: 1rem;
--space-section: 5rem;
--header-h: 60px;
--logo-h: 97px;
--logo-h: 72px;
/* --- Bewegung --- */
--transition: 300ms ease;

View File

@@ -3,25 +3,45 @@
============================================================ */
.btn {
display: inline-block;
padding: 10px 18px;
padding: var(--btn-pad-y) var(--btn-pad-x);
white-space: nowrap;
background: var(--clr-accent);
color: var(--clr-text);
font-size: var(--fs-500);
font-size: var(--btn-font);
line-height: var(--btn-line);
text-decoration: none;
text-align: center;
border: 1px solid var(--clr-accent);
border-radius: var(--radius-btn);
cursor: pointer;
transition: background var(--transition), border-color var(--transition);
transition: background var(--transition), border-color var(--transition),
color var(--transition), transform var(--transition);
}
/* primary (Default) */
.btn:hover {
background: var(--clr-accent-dark);
border-color: var(--clr-accent-dark);
color: var(--clr-text);
}
.btn:focus-visible {
outline: 2px solid var(--clr-text);
outline-offset: 2px;
}
.btn:active {
transform: translateY(1px);
}
.btn[disabled],
.btn[aria-disabled="true"] {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
/* secondary */
.btn--outline {
background: transparent;
border-color: var(--clr-text);
@@ -30,8 +50,13 @@
.btn--outline:hover {
background: var(--clr-glass-bg);
border-color: var(--clr-text);
color: var(--clr-text);
}
/* Größenmodifier — Padding ist em-basiert, skaliert mit der font-size */
.btn--sm { font-size: var(--btn-font-sm); }
.btn--lg { font-size: var(--btn-font-lg); }
.visually-hidden {
position: absolute !important;
width: 1px;

Binary file not shown.

View File

@@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
<path d="m10.97 4.97-.02.022-3.473 4.425-2.093-2.094a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05"/></svg>

After

Width:  |  Height:  |  Size: 296 B

View File

@@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5"/>
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708z"/></svg>

After

Width:  |  Height:  |  Size: 353 B

View File

@@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M5.5 7a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1zM5 9.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5m0 2a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5"/>
<path d="M9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.5zm0 1v2A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1z"/></svg>

After

Width:  |  Height:  |  Size: 390 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M13.601 2.326A7.85 7.85 0 0 0 7.994 0C3.627 0 .068 3.558.064 7.926c0 1.399.366 2.76 1.057 3.965L0 16l4.204-1.102a7.9 7.9 0 0 0 3.79.965h.004c4.368 0 7.926-3.558 7.93-7.93A7.9 7.9 0 0 0 13.6 2.326zM7.994 14.521a6.6 6.6 0 0 1-3.356-.92l-.24-.144-2.494.654.666-2.433-.156-.251a6.56 6.56 0 0 1-1.007-3.505c0-3.626 2.957-6.584 6.591-6.584a6.56 6.56 0 0 1 4.66 1.931 6.56 6.56 0 0 1 1.928 4.66c-.004 3.639-2.961 6.592-6.592 6.592m3.615-4.934c-.197-.099-1.17-.578-1.353-.646-.182-.065-.315-.099-.445.099-.133.197-.513.646-.627.775-.114.133-.232.148-.43.05-.197-.1-.836-.308-1.592-.985-.59-.525-.985-1.175-1.103-1.372-.114-.198-.011-.304.088-.403.087-.088.197-.232.296-.346.1-.114.133-.198.198-.33.065-.134.034-.248-.015-.347-.05-.099-.445-1.076-.612-1.47-.16-.389-.323-.335-.445-.34-.114-.007-.247-.007-.38-.007a.73.73 0 0 0-.529.247c-.182.198-.691.677-.691 1.654s.71 1.916.81 2.049c.098.133 1.394 2.132 3.383 2.992.47.205.84.326 1.129.418.475.152.904.129 1.246.08.38-.058 1.171-.48 1.338-.943.164-.464.164-.86.114-.943-.049-.084-.182-.133-.38-.232"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -28,6 +28,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $slug === 'kontakt-senden') {
require APP_PATH . '/actions/contact-submit.php';
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $slug === 'mitglied-werden-senden') {
require APP_PATH . '/actions/membership-submit.php';
exit;
}
$routes = require APP_PATH . '/routes.php';