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:
@@ -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; ?>
|
||||
|
||||
@@ -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>
|
||||
|
||||
39
app/components/department-choice.php
Normal file
39
app/components/department-choice.php
Normal 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>
|
||||
29
app/components/fee-table.php
Normal file
29
app/components/fee-table.php
Normal 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>
|
||||
@@ -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>© <?= 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>
|
||||
|
||||
@@ -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'])): ?>
|
||||
|
||||
113
app/components/membership-form.php
Normal file
113
app/components/membership-form.php
Normal 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. 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user