Unterseiten: Fußball-Übersicht, Mannschaften, Jugend, Verein, Rechtliches

Neue Seiten + Routen: fussball, fussball/{erste-mannschaft,zweite-mannschaft,
damen}, jugendfussball, verein, impressum, datenschutz. Dazu die Komponenten
team, team-cards/-roster/-photo/-bento, faq/faq-contact, key-facts, age-groups,
cta-band, training-location und der contact-slider.

Daten in teams/fussball/jugend/verein.json; club.json um Vorstand, Bankdaten,
Datenschutz-Kontakt erweitert. person-grid bekommt $names-Filter, partner-grid
einen Intro-Stil. Seiten setzen $meta['schema'] via team_/page_schema.
Styles für alle neuen Komponenten in components.css.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDQZ6FuchNUh2bxSW9PeNv
This commit is contained in:
2026-06-19 08:13:04 +02:00
parent a933b76c4f
commit 3b6f630a72
81 changed files with 2513 additions and 78 deletions

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
/**
* Jugend-Altersklassen als Karten-Grid. Props:
* $groups array [{code, name, photo?{base,widths,alt}, training?, note?}, …]
* $title string (optional, Default 'Unsere Jugendmannschaften')
* $anchor string (optional, Default 'mannschaften')
* Fehlt ein Foto, greift ein Platzhalter; fehlt die Trainingszeit, entfällt die Zeile.
*/
$title = $title ?? 'Unsere Jugendmannschaften';
$anchor = $anchor ?? 'mannschaften';
$groups = $groups ?? [];
if ($groups === []) {
return;
}
?>
<section class="section age-groups" id="<?= e($anchor) ?>">
<div class="container">
<h2><?= e($title) ?></h2>
<ul class="age-groups__grid">
<?php foreach ($groups as $g): ?>
<li class="age-group">
<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 else: ?>
<img class="age-group__img" src="<?= e(asset('img/persons/platzhalter.jpg')) ?>" alt="" width="400" height="300" loading="lazy" decoding="async">
<?php endif; ?>
<?php if (!empty($g['code'])): ?>
<span class="age-group__badge" aria-hidden="true"><?= e($g['code']) ?></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>
<?php elseif (!empty($g['note'])): ?>
<p class="age-group__training text-muted"><?= e($g['note']) ?></p>
<?php endif; ?>
</div>
</li>
<?php endforeach; ?>
</ul>
</div>
</section>

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/**
* Vollbreites Call-to-Action-Band. Props:
* $band array{title, text?, ctas: [{label, slug, style?}]}
* $anchor string (optional)
* Rendert nichts ohne CTAs. Nutzt das bestehende cta-/btn-Muster.
*/
$anchor = $anchor ?? null;
if (empty($band['ctas'])) {
return;
}
?>
<section class="section section--tinted cta-band"<?= $anchor ? ' id="' . e($anchor) . '"' : '' ?>>
<div class="container cta-band__inner">
<?php if (!empty($band['title'])): ?>
<h2 class="cta-band__title"><?= e($band['title']) ?></h2>
<?php endif; ?>
<?php if (!empty($band['text'])): ?>
<p class="cta-band__text"><?= e($band['text']) ?></p>
<?php endif; ?>
<div class="cta-band__actions">
<?php foreach ($band['ctas'] as $cta): ?>
<?php component('cta', ['cta' => $cta]); ?>
<?php endforeach; ?>
</div>
</div>
</section>

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
/**
* Kombi-Sektion: FAQ (2/3) + einzelner Ansprechpartner (1/3) nebeneinander.
* Sinnvoll, wenn es pro Mannschaft nur einen Kontakt gibt. Props:
* $faq array [{q, a}]
* $persons array — ein oder mehrere Ansprechpartner (person-card). Leer → FAQ volle Breite.
* Bei mehreren: Mini-Slider (ohne JS alle Karten gestapelt sichtbar).
* $faqTitle string (optional, Default 'Häufige Fragen')
* $faqIntro string (optional) — gedämpfte Subline unter der Überschrift
* $contactTitle string (optional, Default 'Dein Ansprechpartner')
* $anchor string (optional, Default 'kontakt')
* Natives <details>/<summary> (ohne JS bedienbar); FAQPage-Schema liefert die Seite.
*/
$faqTitle = $faqTitle ?? 'Häufige Fragen';
$faqIntro = $faqIntro ?? '';
$contactTitle = $contactTitle ?? 'Dein Ansprechpartner';
$anchor = $anchor ?? 'kontakt';
$faq = array_values(array_filter(
$faq ?? [],
static fn (array $i): bool => !empty($i['q']) && !empty($i['a'])
));
$persons = array_values(array_filter($persons ?? [], static fn ($p): bool => !empty($p)));
$hasPerson = $persons !== [];
$isSlider = count($persons) > 1;
if ($faq === [] && !$hasPerson) {
return;
}
?>
<section class="section faq-contact<?= $hasPerson ? '' : ' faq-contact--solo' ?>" id="<?= e($anchor) ?>">
<div class="container">
<h2 class="faq-contact__title"><?= e($faqTitle) ?></h2>
<?php if ($faqIntro !== ''): ?>
<p class="faq-contact__intro text-muted"><?= e($faqIntro) ?></p>
<?php endif; ?>
<div class="faq-contact__inner">
<div class="faq-contact__main">
<div class="faq__list">
<?php foreach ($faq as $item): ?>
<details class="faq__item">
<summary class="faq__q"><span><?= e($item['q']) ?></span><?= icon('chevron-down', 'faq__chevron') ?></summary>
<div class="faq__a"><p><?= e($item['a']) ?></p></div>
</details>
<?php endforeach; ?>
</div>
</div>
<?php if ($hasPerson): ?>
<aside class="faq-contact__aside">
<?php if ($contactTitle !== ''): ?>
<h2><?= e($contactTitle) ?></h2>
<?php endif; ?>
<?php if ($isSlider): ?>
<div class="contact-slider" data-contact-slider>
<ul class="contact-slider__track">
<?php foreach ($persons as $p): ?>
<li class="contact-slider__slide" data-contact-slide>
<?php component('person-card', ['person' => $p]); ?>
</li>
<?php endforeach; ?>
</ul>
<div class="contact-slider__dots">
<?php foreach ($persons as $i => $p): ?>
<button type="button" class="contact-slider__dot" data-contact-dot<?= $i === 0 ? ' aria-current="true"' : '' ?> aria-label="Ansprechpartner <?= $i + 1 ?>: <?= e($p['name']) ?>"></button>
<?php endforeach; ?>
</div>
</div>
<?php else: ?>
<?php component('person-card', ['person' => $persons[0]]); ?>
<?php endif; ?>
</aside>
<?php endif; ?>
</div>
</div>
</section>

35
app/components/faq.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
/**
* FAQ-Accordion. Props:
* $faq array [{q, a}, …]
* $title string (optional, Default 'Häufige Fragen')
* $anchor string (optional, Default 'faq')
* Natives <details>/<summary> → ohne JS bedienbar. Das FAQPage-Schema liefert
* die Seite über $meta['schema'] (helpers: faq_schema/team_schema), nicht diese Komponente.
*/
$title = $title ?? 'Häufige Fragen';
$anchor = $anchor ?? 'faq';
$faq = array_values(array_filter(
$faq ?? [],
static fn (array $i): bool => !empty($i['q']) && !empty($i['a'])
));
if ($faq === []) {
return;
}
?>
<section class="section faq" id="<?= e($anchor) ?>">
<div class="container faq__inner">
<h2><?= e($title) ?></h2>
<div class="faq__list">
<?php foreach ($faq as $item): ?>
<details class="faq__item">
<summary class="faq__q"><span><?= e($item['q']) ?></span><?= icon('chevron-down', 'faq__chevron') ?></summary>
<div class="faq__a"><p><?= e($item['a']) ?></p></div>
</details>
<?php endforeach; ?>
</div>
</div>
</section>

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/**
* Kompakte Faktenleiste (Icon + Label + Wert). Props:
* $facts array [{icon, label, value}, …] — Einträge mit leerem 'value' werden übersprungen.
* $anchor string (optional) — Sektions-ID.
* $tinted bool (optional, Default false) — aufgehelltes Band.
* GEO/SEO: kompakte, zitierfähige Fakten. Rendert nichts, wenn keine Werte vorliegen.
*/
$anchor = $anchor ?? null;
$tinted = $tinted ?? false;
$facts = array_values(array_filter(
$facts ?? [],
static fn (array $f): bool => isset($f['value']) && $f['value'] !== ''
));
if ($facts === []) {
return;
}
?>
<section class="section key-facts<?= $tinted ? ' section--tinted' : '' ?>"<?= $anchor ? ' id="' . e($anchor) . '"' : '' ?>>
<div class="container">
<ul class="key-facts__grid">
<?php foreach ($facts as $fact): ?>
<li class="key-facts__item">
<?php if (!empty($fact['icon'])): ?>
<span class="key-facts__icon"><?= icon($fact['icon']) ?></span>
<?php endif; ?>
<span class="key-facts__text">
<?php if (!empty($fact['label'])): ?>
<span class="key-facts__label"><?= e($fact['label']) ?></span>
<?php endif; ?>
<span class="key-facts__value"><?= e($fact['value']) ?></span>
</span>
</li>
<?php endforeach; ?>
</ul>
</div>
</section>

View File

@@ -12,7 +12,7 @@ $partners = json_load('partners')['partners'] ?? [];
<section class="section partners" id="partner">
<div class="container">
<h2><?= e($intro['title']) ?></h2>
<p><?= e($intro['text']) ?></p>
<p class="partners__intro text-muted"><?= e($intro['text']) ?></p>
<ul class="partners__grid">
<?php foreach ($partners as $partner): ?>
<li class="partners__item">

View File

@@ -6,6 +6,7 @@ declare(strict_types=1);
* Ansprechpartner-Grid aus data/ansprechpartner.json (Single Source of Truth).
* Alle Props optional:
* $departments array — nur diese Abteilungen zeigen (z. B. ['Herrenfußball','Damenfußball']).
* $names array — zusätzlich auf diese Namen einschränken (z. B. ['Benedikt Höfner']).
* $title string — Überschrift überschreiben (Default aus JSON).
* $intro string — Intro-Text überschreiben (Default aus JSON; '' = ausblenden).
* $anchor string — Sektions-ID (Default 'ansprechpartner').
@@ -24,6 +25,12 @@ if (!empty($departments)) {
static fn (array $p): bool => in_array($p['department'] ?? '', $departments, true)
));
}
if (!empty($names)) {
$persons = array_values(array_filter(
$persons,
static fn (array $p): bool => in_array($p['name'] ?? '', $names, true)
));
}
if ($persons === []) {
return;

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/**
* Team-Dashboard als Bento-Grid: kombiniert Bild, Einleitung, Trainingszeiten,
* Spielklasse und Spielort in einem Kachel-Layout. Props:
* $team array — Eintrag aus data/teams.json.
* $anchor string (optional, Default 'team-info')
* Spielort kommt aus data/club.json (NAP-Single-Source). Deko-Icons sind dekorativ.
*/
$anchor = $anchor ?? 'team-info';
$about = $team['about'] ?? [];
$sessions = $team['training']['sessions'] ?? [];
$venueOverride = $team['training']['venue'] ?? null; // mannschaftsspezifischer Spielort (z. B. SG-Heimat)
$showVenue = !empty($team['training']['venue_from_club']) || !empty($venueOverride);
$highlight = !empty($team['league'])
? ['label' => 'Spielklasse', 'value' => $team['league']]
: ['label' => 'Bereich', 'value' => $team['category'] ?? ''];
$club = json_load('club');
$addr = $club['address'] ?? [];
$mapsQuery = !empty($venueOverride['maps_query'])
? rawurlencode($venueOverride['maps_query'])
: rawurlencode(trim(($addr['venue'] ?? '') . ', ' . ($addr['street'] ?? '') . ', ' . ($addr['zip'] ?? '') . ' ' . ($addr['city'] ?? ''), ', '));
?>
<section class="section team-bento-section" id="<?= e($anchor) ?>">
<div class="container">
<div class="team-bento">
<article class="bento__tile team-bento__about">
<h2><?= e($about['title'] ?? 'Über die Mannschaft') ?></h2>
<p><?= e($about['text'] ?? '') ?></p>
</article>
<article class="bento__tile team-bento__training">
<h2 class="bento__heading"><?= icon('clock', 'bento__hicon') ?>Trainingszeiten</h2>
<?php if ($sessions !== []): ?>
<ul class="bento__sessions">
<?php foreach ($sessions as $s): ?>
<li>
<span class="bento__day"><?= e($s['day']) ?></span>
<span class="bento__time"><?= e($s['time']) ?></span>
</li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<p class="text-muted">Die genauen Trainingszeiten erfährst du direkt bei unserem Ansprechpartner.</p>
<?php endif; ?>
<?php if (!empty($team['training']['note'])): ?>
<p class="bento__note text-muted"><?= e($team['training']['note']) ?></p>
<?php endif; ?>
<span class="bento__deco" aria-hidden="true"><?= icon('clock') ?></span>
</article>
<?php if (($highlight['value'] ?? '') !== ''): ?>
<article class="bento__tile bento__tile--accent team-bento__klasse">
<span class="bento__stat-label"><?= e($highlight['label']) ?></span>
<span class="bento__stat-value"><?= e($highlight['value']) ?></span>
</article>
<?php endif; ?>
<?php if ($showVenue && ($venueOverride || $addr !== [])): ?>
<article class="bento__tile team-bento__venue">
<div class="team-bento__venue-main">
<h2 class="bento__heading"><?= icon('geo-alt', 'bento__hicon') ?>Spielort</h2>
<?php if ($venueOverride): ?>
<p class="bento__address">
<?php if (!empty($venueOverride['name'])): ?><strong><?= e($venueOverride['name']) ?></strong><?php endif; ?><?php if (!empty($venueOverride['locality'])): ?> · <?= e($venueOverride['locality']) ?><?php endif; ?>
</p>
<?php else: ?>
<p class="bento__address">
<?php if (!empty($addr['venue'])): ?><strong><?= e($addr['venue']) ?></strong> · <?php endif; ?><?= e($addr['street']) ?>, <?= e($addr['zip']) ?> <?= e($addr['city']) ?>
</p>
<?php endif; ?>
</div>
<a class="btn btn--outline team-bento__route" href="https://www.google.com/maps/search/?api=1&amp;query=<?= e($mapsQuery) ?>" target="_blank" rel="noopener">Route planen<span class="visually-hidden"> (öffnet Google Maps in neuem Tab)</span></a>
<span class="bento__deco" aria-hidden="true"><?= icon('geo-alt') ?></span>
</article>
<?php endif; ?>
</div>
</div>
</section>

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/**
* Mannschafts-Karten, die auf die Team-Unterseiten verlinken. Props:
* $teams array [{name, slug, category, league?, image?{base,widths,alt}}, …]
* $title string (optional, Default 'Unsere Mannschaften')
* $title_hidden bool (optional) — H2 für SEO/A11y im DOM, aber visuell versteckt
* $intro string (optional)
* $intro_below bool (optional) — Intro als „Klärtext" UNTER die Kacheln statt darüber
* $anchor string (optional, Default 'mannschaften')
* Ganze Karte ist ein Link. Ohne Bild greift ein Platzhalter.
*/
$title = $title ?? 'Unsere Mannschaften';
$title_hidden = $title_hidden ?? false;
$anchor = $anchor ?? 'mannschaften';
$intro = $intro ?? '';
$intro_below = $intro_below ?? false;
$teams = $teams ?? [];
if ($teams === []) {
return;
}
?>
<section class="section team-cards" id="<?= e($anchor) ?>">
<div class="container">
<h2<?= $title_hidden ? ' class="visually-hidden"' : '' ?>><?= e($title) ?></h2>
<?php if ($intro !== '' && !$intro_below): ?>
<p class="team-cards__intro text-muted"><?= e($intro) ?></p>
<?php endif; ?>
<ul class="team-cards__grid">
<?php foreach ($teams as $t): ?>
<li>
<a class="team-card" href="<?= e(url($t['slug'])) ?>">
<div class="team-card__media">
<?php if (!empty($t['image']['base'])): ?>
<?php component('img', ['image' => $t['image'], 'sizes' => '(max-width: 600px) 100vw, 360px', 'class' => 'team-card__img']); ?>
<?php else: ?>
<img class="team-card__img" src="<?= e(asset('img/persons/platzhalter.jpg')) ?>" alt="" width="400" height="300" loading="lazy" decoding="async">
<?php endif; ?>
</div>
<div class="team-card__body">
<h3 class="team-card__name"><?= e($t['name']) ?></h3>
<p class="team-card__meta"><?= e(trim($t['category'] . (!empty($t['league']) ? ' · ' . $t['league'] : ''))) ?></p>
<span class="team-card__cta">Mehr erfahren <?= icon('arrow-right', 'team-card__arrow') ?></span>
</div>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php if ($intro !== '' && $intro_below): ?>
<p class="team-cards__intro team-cards__intro--below text-muted"><?= e($intro) ?></p>
<?php endif; ?>
</div>
</section>

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
/**
* Mannschaftsfoto in voller Breite (Querformat-Gruppenbild). Props:
* $photo array{ image: {base,widths,alt} | {src,width,height,alt}, caption? }
* $anchor string (optional, Default 'mannschaftsfoto')
* Zeigt das Bild im natürlichen Seitenverhältnis, abgerundet; optionale Bildunterschrift.
*/
$anchor = $anchor ?? 'mannschaftsfoto';
$image = $photo['image'] ?? null;
if (empty($image)) {
return;
}
$caption = $photo['caption'] ?? '';
?>
<section class="section team-photo" id="<?= e($anchor) ?>">
<div class="container">
<figure class="team-photo__figure">
<?php component('img', ['image' => $image, 'sizes' => '(max-width: 1100px) 100vw, 1040px', 'class' => 'team-photo__img']); ?>
<?php if ($caption !== ''): ?>
<figcaption class="team-photo__caption"><?= e($caption) ?></figcaption>
<?php endif; ?>
</figure>
</div>
</section>

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
/**
* Kader-/Trainerstab-Raster einer Mannschaft. Props:
* $roster array{
* title?, intro?,
* variant? : 'below' (Name unter dem Foto, Default) | 'overlay' (Name im Foto, Coolvetica),
* groups? : [{ title?, members:[…] }, …] ODER
* members? : [{ name, role?, image? }, …] (eine Gruppe ohne Überschrift)
* }
* $anchor string (optional, Default 'kader')
* Gruppen mit Titel bekommen eine Zwischenüberschrift (h3); leere Gruppen entfallen.
* Ohne Foto greift der Platzhalter-Avatar.
*/
$anchor = $anchor ?? 'kader';
// Gruppen normalisieren: entweder explizite groups oder flache members als eine Gruppe.
$groups = $roster['groups'] ?? null;
if ($groups === null) {
$flat = $roster['members'] ?? [];
$groups = $flat === [] ? [] : [['title' => '', 'members' => $flat]];
}
// Leere Gruppen rauswerfen.
$groups = array_values(array_filter($groups, static fn ($g): bool => !empty($g['members'])));
if ($groups === []) {
return;
}
$variant = ($roster['variant'] ?? 'below') === 'overlay' ? 'overlay' : 'below';
$title = $roster['title'] ?? 'Kader';
$intro = $roster['intro'] ?? '';
$cardMod = $variant === 'overlay' ? ' roster-card--overlay' : '';
?>
<section class="section team-roster" id="<?= e($anchor) ?>">
<div class="container">
<h2><?= e($title) ?></h2>
<?php if ($intro !== ''): ?>
<p class="team-roster__intro text-muted"><?= e($intro) ?></p>
<?php endif; ?>
<?php foreach ($groups as $group): ?>
<?php if (!empty($group['title'])): ?>
<h3 class="team-roster__group"><?= e($group['title']) ?></h3>
<?php endif; ?>
<ul class="team-roster__grid">
<?php foreach ($group['members'] as $m): ?>
<?php $src = !empty($m['image']) ? $m['image'] : 'img/persons/platzhalter-spieler.jpg'; ?>
<?php $alt = !empty($m['image']) ? e($m['name']) : ''; ?>
<?php $staffMod = !empty($m['role']) ? ' roster-card--staff' : ''; ?>
<li class="roster-card<?= $cardMod . $staffMod ?>">
<?php if ($variant === 'overlay'): ?>
<img class="roster-card__img" src="<?= e(asset($src)) ?>" alt="<?= $alt ?>"
width="480" height="600" loading="lazy" decoding="async">
<div class="roster-card__caption">
<?php if (!empty($m['role'])): ?>
<span class="roster-card__role"><?= e($m['role']) ?></span>
<?php endif; ?>
<p class="roster-card__name"><?= e($m['name']) ?></p>
</div>
<?php else: ?>
<div class="roster-card__media">
<img src="<?= e(asset($src)) ?>" alt="<?= $alt ?>"
width="480" height="600" loading="lazy" decoding="async">
</div>
<div class="roster-card__body">
<?php if (!empty($m['role'])): ?>
<span class="roster-card__role"><?= e($m['role']) ?></span>
<?php endif; ?>
<p class="roster-card__name"><?= e($m['name']) ?></p>
</div>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endforeach; ?>
</div>
</section>

46
app/components/team.php Normal file
View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/**
* Mannschafts-Seite (DRY-Template für 1./2. Herren + Damen). Props:
* $team array — Eintrag aus data/teams.json.
* $slug string — voller Seiten-Slug (für Anker-Links).
* Aufbau: Hero → Team-Bento (Bild, Einleitung, Training, Spielort) → FAQ + Ansprechpartner.
* Leere Datenblöcke (Training, FAQ, fehlender Kontakt) blenden sich selbst aus.
*/
$slug = $slug ?? '';
// Ansprechpartner aus der Single-Source auflösen (ein oder mehrere, Reihenfolge wie angegeben).
$allPersons = json_load('ansprechpartner')['persons'] ?? [];
$wantDept = $team['contact_department'] ?? '';
$wantNames = $team['contact_names'] ?? (isset($team['contact_name']) ? [$team['contact_name']] : []);
$contacts = [];
foreach ($wantNames as $wn) {
foreach ($allPersons as $p) {
if (($p['department'] ?? '') === $wantDept && ($p['name'] ?? '') === $wn) {
$contacts[] = $p;
break;
}
}
}
component('hero', ['hero' => $team['hero']]);
if (!empty($team['team_photo']['image'])) {
component('team-photo', ['photo' => $team['team_photo'], 'anchor' => 'mannschaftsfoto']);
}
component('team-bento', ['team' => $team, 'anchor' => 'team-info']);
if (!empty($team['roster']['members']) || !empty($team['roster']['groups'])) {
component('team-roster', ['roster' => $team['roster'], 'anchor' => 'kader']);
}
component('faq-contact', [
'faq' => $team['faq'] ?? [],
'faqIntro' => 'Die wichtigsten Fragen rund um die ' . ($team['name'] ?? 'Mannschaft') . ' kurz beantwortet.',
'persons' => $contacts,
'contactTitle' => '',
'anchor' => 'kontakt',
]);

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/**
* Trainings-/Spielort-„Dashboard" als Bento-Grid. Props:
* $training array{sessions: [{day, time}], venue_from_club?: bool, note?: string}
* $highlight array{label, value} (optional) — kleiner Akzent-Tile (z. B. Spielklasse).
* $anchor string (optional, Default 'training')
* Spielort kommt aus data/club.json (NAP-Single-Source). Ohne Daten: nichts.
*/
$anchor = $anchor ?? 'training';
$sessions = $training['sessions'] ?? [];
$showVenue = !empty($training['venue_from_club']);
$highlight = $highlight ?? null;
if ($sessions === [] && !$showVenue) {
return;
}
$club = json_load('club');
$addr = $club['address'] ?? [];
$mapsQuery = rawurlencode(trim(($addr['venue'] ?? '') . ', ' . ($addr['street'] ?? '') . ', ' . ($addr['zip'] ?? '') . ' ' . ($addr['city'] ?? ''), ', '));
?>
<section class="section training" id="<?= e($anchor) ?>">
<div class="container">
<div class="bento">
<article class="bento__tile bento__tile--times">
<h2 class="bento__heading"><?= icon('clock', 'bento__hicon') ?>Trainingszeiten</h2>
<?php if ($sessions !== []): ?>
<ul class="bento__sessions">
<?php foreach ($sessions as $s): ?>
<li>
<span class="bento__day"><?= e($s['day']) ?></span>
<span class="bento__time"><?= e($s['time']) ?></span>
</li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<p class="text-muted">Die genauen Trainingszeiten erfährst du direkt bei unserem Ansprechpartner.</p>
<?php endif; ?>
<?php if (!empty($training['note'])): ?>
<p class="bento__note text-muted"><?= e($training['note']) ?></p>
<?php endif; ?>
<span class="bento__deco" aria-hidden="true"><?= icon('clock') ?></span>
</article>
<?php if ($showVenue && $addr !== []): ?>
<article class="bento__tile bento__tile--venue">
<h2 class="bento__heading"><?= icon('geo-alt', 'bento__hicon') ?>Spielort</h2>
<p class="bento__address">
<?php if (!empty($addr['venue'])): ?><strong><?= e($addr['venue']) ?></strong><br><?php endif; ?>
<?= e($addr['street']) ?><br>
<?= e($addr['zip']) ?> <?= e($addr['city']) ?>
</p>
<span class="bento__deco" aria-hidden="true"><?= icon('geo-alt') ?></span>
</article>
<article class="bento__tile bento__tile--route">
<span class="bento__route-label">Anfahrt</span>
<a class="btn btn--outline" href="https://www.google.com/maps/search/?api=1&amp;query=<?= e($mapsQuery) ?>" target="_blank" rel="noopener">Route planen<span class="visually-hidden"> (öffnet Google Maps in neuem Tab)</span></a>
</article>
<?php endif; ?>
<?php if ($highlight !== null && ($highlight['value'] ?? '') !== ''): ?>
<article class="bento__tile bento__tile--accent">
<span class="bento__stat-label"><?= e($highlight['label'] ?? '') ?></span>
<span class="bento__stat-value"><?= e($highlight['value']) ?></span>
</article>
<?php endif; ?>
</div>
</div>
</section>

261
app/pages/datenschutz.php Normal file
View File

@@ -0,0 +1,261 @@
<?php
declare(strict_types=1);
/**
* Datenschutzerklärung — zugeschnitten auf den tatsächlichen Stack:
* komplett self-hosted, keine Tracker/Analyse/Werbe-Dienste, keine externen
* Fonts/Maps/Embeds. Einzige Verarbeitungen: Server-Logs (Hoster),
* Kontaktformular (Versand via Brevo SMTP), server-seitig gecachter
* Instagram-Feed (Bilder von unserer Domain). Vereinsdaten aus club.json.
*/
$club = json_load('club');
$addr = $club['address'] ?? [];
$dpo = $club['data_protection_contact'] ?? '';
$meta = [
'title' => 'Datenschutzerklärung',
'description' => 'Datenschutzerklärung des TSV 1908 Kulmbach e. V. welche Daten beim Besuch unserer Website verarbeitet werden. Komplett self-hosted, ohne Tracking und ohne Drittanbieter-Dienste im Browser.',
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Datenschutz', 'slug' => 'datenschutz'],
]),
];
?>
<section class="section legal">
<div class="container">
<div class="legal__head">
<p class="legal__kicker">Rechtliches</p>
<h1>Datenschutzerklärung</h1>
<p class="legal__lead">
Der Schutz deiner persönlichen Daten ist uns wichtig. Diese Website ist
bewusst datensparsam gebaut: Sie lädt keine externen Schriften, Karten,
Skripte oder Tracking-Dienste dein Browser kontaktiert ausschließlich
unseren eigenen Server.
</p>
</div>
<div class="legal__body">
<section>
<h2>Verantwortlicher</h2>
<p>Verantwortlich für die Datenverarbeitung auf dieser Website ist:</p>
<div class="legal__card">
<address>
<strong><?= e($club['legal_name']) ?></strong><br>
<?= e($addr['street']) ?><br>
<?= e($addr['zip']) ?> <?= e($addr['city']) ?><br>
<br>
E-Mail: <a href="mailto:<?= e($club['email']) ?>"><?= e($club['email']) ?></a>
</address>
</div>
<?php if ($dpo !== ''): ?>
<p class="legal__muted">
Ansprechpartner für Fragen zum Datenschutz: <?= e($dpo) ?>,
erreichbar unter <a href="mailto:<?= e($club['email']) ?>"><?= e($club['email']) ?></a>.
</p>
<?php endif; ?>
</section>
<section>
<h2>Grundsätzliches</h2>
<p>
Wir verarbeiten personenbezogene Daten nur im Rahmen der geltenden
Datenschutzgesetze, insbesondere der Datenschutz-Grundverordnung (DSGVO)
und des Bundesdatenschutzgesetzes (BDSG). „Personenbezogene Daten“ sind
alle Informationen, die sich auf eine identifizierte oder identifizierbare
Person beziehen. Eine Nutzung unserer Website ist grundsätzlich ohne Angabe
personenbezogener Daten möglich.
</p>
</section>
<section>
<h2>Aufruf der Website &amp; Server-Logfiles</h2>
<p>
Unsere Website wird bei folgendem Dienstleister gehostet:
</p>
<div class="legal__card">
<address>
<strong>ALL-INKL.COM Neue Medien Münnich</strong><br>
Inhaber: René Münnich<br>
Hauptstraße 68<br>
02742 Friedersdorf
</address>
</div>
<p>
Bei jedem Aufruf unserer Website werden durch unseren Webhoster automatisch
Informationen erfasst, die dein Browser übermittelt und die technisch
erforderlich sind, um die Seite auszuliefern. Erfasst werden:
</p>
<ul>
<li>die aufgerufene Adresse (URL) und die zuvor besuchte Seite (Referrer)</li>
<li>Datum und Uhrzeit des Zugriffs</li>
<li>verwendeter Browser und Betriebssystem</li>
<li>die IP-Adresse des anfragenden Geräts</li>
</ul>
<p>
Diese Daten dienen ausschließlich dem technischen Betrieb, der Sicherheit
und der Stabilität der Website und werden nicht mit anderen Datenquellen
zusammengeführt. Rechtsgrundlage ist unser berechtigtes Interesse an einer
sicheren und funktionsfähigen Website (Art. 6 Abs. 1 lit. f DSGVO). Die
Logdaten werden nach kurzer Zeit automatisch gelöscht.
</p>
<p class="legal__muted">
Unser Webhoster verarbeitet diese Daten in unserem Auftrag auf Grundlage
eines Vertrags zur Auftragsverarbeitung (Art. 28 DSGVO).
</p>
</section>
<section>
<h2>Verschlüsselung (SSL/TLS)</h2>
<p>
Diese Website nutzt aus Sicherheitsgründen eine SSL- bzw. TLS-Verschlüsselung.
Eine verschlüsselte Verbindung erkennst du am „https://“ in der Adresszeile
deines Browsers. Daten, die du an uns übermittelst, können dadurch nicht ohne
Weiteres von Dritten mitgelesen werden.
</p>
</section>
<section>
<h2>Cookies</h2>
<p>
Diese Website setzt <strong>keine Cookies zu Analyse-, Marketing- oder
Tracking-Zwecken</strong> und bindet keine Dienste ein, die solche Cookies
setzen würden. Aus diesem Grund benötigen wir auch kein Cookie-Banner.
</p>
</section>
<section>
<h2>Kontaktformular &amp; Kontaktaufnahme</h2>
<p>
Wenn du uns über das Kontaktformular oder per E-Mail kontaktierst, verarbeiten
wir die von dir angegebenen Daten, um deine Anfrage zu bearbeiten. Über das
Formular sind das:
</p>
<ul>
<li>Name (Pflichtangabe)</li>
<li>E-Mail-Adresse (Pflichtangabe)</li>
<li>Telefonnummer (freiwillig)</li>
<li>dein Interessensgebiet, Betreff und deine Nachricht</li>
</ul>
<p>
Rechtsgrundlage ist deine Einwilligung (Art. 6 Abs. 1 lit. a DSGVO), die du
mit dem Absenden erteilst, sowie unser berechtigtes Interesse bzw. die
Anbahnung eines (Mitgliedschafts-)Verhältnisses an der Beantwortung deiner
Anfrage (Art. 6 Abs. 1 lit. f und b DSGVO). Die Daten werden gespeichert, bis
deine Anfrage abschließend bearbeitet ist und keine gesetzlichen
Aufbewahrungspflichten entgegenstehen.
</p>
<p>
Für den technischen Versand der Formular-Nachrichten als E-Mail nutzen wir den
Dienstleister Brevo (Sendinblue GmbH, Köln) als Auftragsverarbeiter. Dabei
werden die im Formular angegebenen Daten an die Mailserver von Brevo
übermittelt. Die Übertragung erfolgt verschlüsselt.
</p>
<p class="legal__muted">
Hinweis: Zum Schutz vor automatisiertem Spam verwenden wir ein verstecktes
Pflichtfeld sowie einen signierten Zeitstempel. Dabei werden keine
zusätzlichen personenbezogenen Daten erhoben und keine externen Dienste
(z.&nbsp;B. reCAPTCHA) eingebunden.
</p>
</section>
<section>
<h2>Keine Analyse-, Tracking- oder Werbedienste</h2>
<p>
Wir verzichten bewusst vollständig auf Dienste, die dein Verhalten
auswerten oder Daten an Dritte weitergeben. Insbesondere setzen wir
<strong>nicht</strong> ein:
</p>
<ul>
<li>keine Webanalyse (z.&nbsp;B. Google Analytics, Matomo, Hotjar)</li>
<li>keine externen Schriftarten alle Fonts liegen auf unserem Server</li>
<li>keine eingebetteten Karten (z.&nbsp;B. Google Maps) und keine Video-Embeds</li>
<li>keine Social-Media-Plugins, „Like“-Buttons oder Tracking-Pixel</li>
<li>keine Werbenetzwerke oder Conversion-Tracking</li>
</ul>
</section>
<section>
<h2>Instagram-Inhalte auf dieser Website</h2>
<p>
Auf unserer Website zeigen wir aktuelle Beiträge unseres öffentlichen
Instagram-Profils. Diese Inhalte werden <strong>nicht</strong> über ein
Instagram-Plugin oder einen externen Einbettungscode geladen. Stattdessen
rufen wir die Beiträge in regelmäßigen Abständen serverseitig ab, speichern
sie auf unserem eigenen Server und liefern Bilder und Texte ausschließlich von
unserer Domain aus. Beim Ansehen unserer Website wird dadurch
<strong>keine Verbindung zu Instagram oder Meta hergestellt</strong> und es
werden keine Daten an diese Anbieter übertragen. Erst wenn du aktiv einen
Link zu Instagram anklickst, verlässt du unsere Website (siehe nächster
Abschnitt).
</p>
</section>
<section>
<h2>Externe Links &amp; Social Media</h2>
<p>
Unsere Website enthält Links zu externen Angeboten, etwa zu unseren Profilen
bei Instagram und Facebook oder zu einer Routenplanung über Google Maps. Wenn
du einen solchen Link anklickst, verlässt du unsere Website und es gelten die
Datenschutzbestimmungen des jeweiligen Anbieters. Auf deren Datenverarbeitung
haben wir keinen Einfluss.
</p>
</section>
<section>
<h2>Foto- und Videoaufnahmen</h2>
<p>
Im Rahmen unseres Vereinslebens, von Spielen und Veranstaltungen entstehen
Foto- und Videoaufnahmen, die wir zur Darstellung des Vereins auf dieser
Website, in unseren Social-Media-Kanälen und in Printmedien verwenden. Die
Veröffentlichung erfolgt auf Grundlage einer Einwilligung
(Art. 6 Abs. 1 lit. a DSGVO) bzw. unseres berechtigten Interesses an der
Vereinsöffentlichkeitsarbeit (Art. 6 Abs. 1 lit. f DSGVO). Wenn du mit der
Veröffentlichung einer dich zeigenden Aufnahme nicht einverstanden bist,
genügt eine formlose Nachricht an
<a href="mailto:<?= e($club['email']) ?>"><?= e($club['email']) ?></a> wir
entfernen die entsprechende Aufnahme dann zeitnah.
</p>
</section>
<section>
<h2>Deine Rechte</h2>
<p>Dir stehen hinsichtlich deiner personenbezogenen Daten folgende Rechte zu:</p>
<ul>
<li>Recht auf Auskunft (Art. 15 DSGVO)</li>
<li>Recht auf Berichtigung (Art. 16 DSGVO)</li>
<li>Recht auf Löschung (Art. 17 DSGVO)</li>
<li>Recht auf Einschränkung der Verarbeitung (Art. 18 DSGVO)</li>
<li>Recht auf Datenübertragbarkeit (Art. 20 DSGVO)</li>
<li>Recht auf Widerspruch gegen die Verarbeitung (Art. 21 DSGVO)</li>
</ul>
<p>
Eine erteilte Einwilligung kannst du jederzeit mit Wirkung für die Zukunft
widerrufen. Zur Ausübung deiner Rechte genügt eine Nachricht an die oben
genannten Kontaktdaten.
</p>
<p>
Außerdem hast du das Recht, dich bei einer Datenschutz-Aufsichtsbehörde zu
beschweren. Die für uns zuständige Behörde ist:
</p>
<div class="legal__card">
<address>
<strong>Bayerisches Landesamt für Datenschutzaufsicht (BayLDA)</strong><br>
Promenade 18<br>
91522 Ansbach
</address>
</div>
</section>
<section>
<h2>Aktualität dieser Datenschutzerklärung</h2>
<p class="legal__muted">
Stand: <?= e(date('m/Y')) ?>. Durch die Weiterentwicklung unserer Website
oder geänderte gesetzliche Vorgaben kann es nötig werden, diese
Datenschutzerklärung anzupassen. Es gilt jeweils die hier veröffentlichte
aktuelle Fassung.
</p>
</section>
</div>
</div>
</section>

53
app/pages/fussball.php Normal file
View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
/**
* Fußball-Übersichtsseite (Hub). Inhalt: data/fussball.json + data/teams.json.
* Struktur: Hero → Mannschafts-Karten → Ansprechpartner → CTA → FAQ.
*/
$page = json_load('fussball');
$teams = json_load('teams');
// Karten verlinken auf die vier Unterseiten (3 Senior + Jugend).
$cards = [
['name' => '1. Mannschaft', 'slug' => 'fussball/erste-mannschaft', 'category' => 'Herren', 'league' => 'Kreisklasse', 'image' => $teams['erste-mannschaft']['team_photo']['image'] ?? $teams['erste-mannschaft']['hero']['image']],
['name' => '2. Mannschaft', 'slug' => 'fussball/zweite-mannschaft', 'category' => 'Herren', 'image' => $teams['zweite-mannschaft']['hero']['image']],
['name' => 'Damenmannschaft', 'slug' => 'fussball/damen', 'category' => 'Damen', 'image' => $teams['damen']['hero']['image']],
['name' => 'Jugendfußball', 'slug' => 'jugendfussball', 'category' => 'G- bis C-Jugend', 'image' => [
'base' => 'img/pages/jugendfussball-hero', 'widths' => [640, 1000, 1440], 'alt' => 'Jugendfußball des TSV 08 Kulmbach',
]],
];
$meta = [
'title' => 'Fußball in Kulmbach',
'description' => 'Herren-, Damen- und Jugendfußball beim TSV 08 Kulmbach: erste und zweite Mannschaft auf Kreisklasse-Niveau, Damenmannschaft und Nachwuchs aktiv werden im Katzbachtal.',
'og_image' => 'img/pages/fussball-hero-1600.jpg',
'schema' => page_schema(
[
['name' => 'Startseite', 'slug' => ''],
['name' => 'Fußball', 'slug' => 'fussball'],
],
$page['faq'] ?? []
),
];
component('hero', ['hero' => $page['hero']]);
component('team-cards', [
'teams' => $cards,
'intro' => $page['mannschaften_intro'] ?? '',
'title_hidden' => true,
'intro_below' => true,
]);
component('person-grid', [
'departments' => ['Herrenfußball', 'Damenfußball'],
// Auf dem Hub bewusst nur die beiden Haupt-Ansprechpartner (mannschaftsspezifische
// Zusatzkontakte wie Rustem erscheinen auf der jeweiligen Team-Seite).
'names' => ['Benedikt Höfner', 'Sam Kern'],
'title' => 'Ansprechpartner Fußball',
'intro' => 'Fragen zu Training, Probetraining oder einem Wechsel? Sprich direkt unsere Verantwortlichen an.',
'anchor' => 'kontakt',
]);
component('faq', ['faq' => $page['faq'] ?? []]);

View File

@@ -26,4 +26,5 @@ foreach ($home['sections'] as $section) {
component('stats', ['stats' => $home['stats']]);
component('partner-grid', ['intro' => $home['partners']]);
component('person-grid');
component('contact-form', ['intro' => $home['contact']]);

139
app/pages/impressum.php Normal file
View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
/**
* Impressum — Angaben gemäß § 5 DDG, Vertretungsberechtigter Vorstand,
* Bankverbindung, Haftungs- und Urheberrechtshinweise.
* Alle Vereinsdaten aus data/club.json (Single Source of Truth).
*/
$club = json_load('club');
$addr = $club['address'] ?? [];
$chair = $club['board']['members'][0]['name'] ?? '';
$meta = [
'title' => 'Impressum',
'description' => 'Impressum des TSV 1908 Kulmbach e. V. Anbieterkennzeichnung, vertretungsberechtigter Vorstand und Kontaktdaten.',
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Impressum', 'slug' => 'impressum'],
]),
];
?>
<section class="section legal">
<div class="container">
<div class="legal__head">
<p class="legal__kicker">Rechtliches</p>
<h1>Impressum</h1>
<p class="legal__lead">Angaben gemäß § 5 Digitale-Dienste-Gesetz (DDG).</p>
</div>
<div class="legal__body">
<section>
<h2>Anbieter</h2>
<div class="legal__card">
<address>
<strong><?= e($club['legal_name']) ?></strong><br>
<?= e($addr['street']) ?><br>
<?= e($addr['zip']) ?> <?= e($addr['city']) ?><br>
<br>
E-Mail: <a href="mailto:<?= e($club['email']) ?>"><?= e($club['email']) ?></a><br>
Web: <a href="<?= e(config('base_url')) ?>"><?= e(preg_replace('#^https?://#', '', (string) config('base_url'))) ?></a>
</address>
</div>
</section>
<section>
<h2>Vertretungsberechtigter Vorstand</h2>
<p><?= e($club['board']['note']) ?>:</p>
<ul>
<?php foreach ($club['board']['members'] as $member): ?>
<li><?= e($member['name']) ?><?= $member['role'] !== '' ? ' ' . e($member['role']) : '' ?></li>
<?php endforeach; ?>
</ul>
</section>
<section>
<h2>Bankverbindung</h2>
<div class="legal__cards legal__cards--2">
<?php foreach ($club['banking'] as $bank): ?>
<div class="legal__card">
<p>
<strong><?= e($bank['bank']) ?></strong><br>
IBAN: <?= e($bank['iban']) ?><br>
BIC: <?= e($bank['bic']) ?>
</p>
</div>
<?php endforeach; ?>
</div>
</section>
<section>
<h2>Mitgliedschaften</h2>
<ul>
<?php foreach ($club['memberships'] as $membership): ?>
<li><?= e($membership) ?></li>
<?php endforeach; ?>
</ul>
</section>
<section>
<h2>Verantwortlich für den Inhalt</h2>
<p>Verantwortlich für den Inhalt nach § 18 Abs. 2 Medienstaatsvertrag (MStV):</p>
<div class="legal__card">
<address>
<?php if ($chair !== ''): ?>
<strong><?= e($chair) ?></strong><br>
<?php endif; ?>
<?= e($club['legal_name']) ?><br>
<?= e($addr['street']) ?>, <?= e($addr['zip']) ?> <?= e($addr['city']) ?>
</address>
</div>
</section>
<section>
<h2>Urheberrecht</h2>
<p>
Alle Rechte vorbehalten. Sämtliche auf dieser Website veröffentlichten Bilder,
Texte und sonstigen Dateien unterliegen dem Urheberrecht. Eine Verwendung,
Vervielfältigung oder Weitergabe auch auszugsweise ist nur mit ausdrücklicher,
vorheriger Zustimmung des TSV 1908 Kulmbach e. V. gestattet. Soweit Inhalte auf
dieser Seite nicht vom Betreiber erstellt wurden, werden die Urheberrechte Dritter
beachtet und entsprechend gekennzeichnet. Sollte dir dennoch eine
Urheberrechtsverletzung auffallen, bitten wir um einen Hinweis bei Bekanntwerden
entfernen wir die betreffenden Inhalte umgehend.
</p>
</section>
<section>
<h2>Haftung für Inhalte</h2>
<p>
Die Inhalte dieser Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit,
Vollständigkeit und Aktualität der Inhalte können wir jedoch keine Gewähr übernehmen.
Als Diensteanbieter sind wir gemäß § 7 Abs. 1 DDG für eigene Inhalte auf diesen Seiten
nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 DDG sind wir als
Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde
Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige
Tätigkeit hinweisen.
</p>
</section>
<section>
<h2>Haftung für Links</h2>
<p>
Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen
Einfluss haben. Für die Gestaltung und die Inhalte der gelinkten Seiten sind
ausschließlich deren Betreiber verantwortlich. Zum Zeitpunkt der Verlinkung waren keine
rechtswidrigen Inhalte erkennbar. Sollten uns Rechtsverletzungen bekannt werden,
entfernen wir die entsprechenden Links umgehend. Hiermit distanzieren wir uns
ausdrücklich von allen Inhalten sämtlicher verlinkter Seiten.
</p>
</section>
<section>
<h2>Gestaltung &amp; Umsetzung</h2>
<p class="legal__muted">Webdesign und Layout: <?= e($club['webdesign']) ?>.</p>
</section>
</div>
</div>
</section>

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
/**
* Jugendfußball-Übersichtsseite. Inhalt: data/jugend.json.
* Komposition: Hero → Intro-Bento (Bild, Mitmachen, Spielort, Altersklassen) →
* Altersklassen-Karten → Ansprechpartner → CTA → FAQ.
*/
$page = json_load('jugend');
$club = json_load('club');
$addr = $club['address'] ?? [];
$mapsQuery = rawurlencode(trim(($addr['venue'] ?? '') . ', ' . ($addr['street'] ?? '') . ', ' . ($addr['zip'] ?? '') . ' ' . ($addr['city'] ?? ''), ', '));
$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',
'schema' => page_schema(
[
['name' => 'Startseite', 'slug' => ''],
['name' => 'Fußball', 'slug' => 'fussball'],
['name' => 'Jugendfußball', 'slug' => 'jugendfussball'],
],
$page['faq'] ?? []
),
];
component('hero', ['hero' => $page['hero']]);
?>
<section class="section" id="ueber-uns">
<div class="container">
<div class="intro-bento">
<div class="bento__tile intro-bento__image">
<?php component('img', ['image' => $page['hero']['image'], 'sizes' => '(max-width: 900px) 100vw, 40vw', 'class' => 'intro-bento__img']); ?>
</div>
<article class="bento__tile intro-bento__about">
<h2><?= e($page['about']['title']) ?></h2>
<p><?= e($page['about']['text']) ?></p>
</article>
<article class="bento__tile intro-bento__venue">
<h2 class="bento__heading"><?= icon('geo-alt', 'bento__hicon') ?>Spielort</h2>
<p class="bento__address">
<?php if (!empty($addr['venue'])): ?><strong><?= e($addr['venue']) ?></strong><br><?php endif; ?>
<?= e($addr['street']) ?>, <?= e($addr['zip']) ?> <?= e($addr['city']) ?>
</p>
<a class="btn btn--outline intro-bento__route" href="https://www.google.com/maps/search/?api=1&amp;query=<?= e($mapsQuery) ?>" target="_blank" rel="noopener">Route planen<span class="visually-hidden"> (öffnet Google Maps in neuem Tab)</span></a>
<span class="bento__deco" aria-hidden="true"><?= icon('geo-alt') ?></span>
</article>
<article class="bento__tile bento__tile--accent intro-bento__stat">
<span class="bento__stat-label">Altersklassen</span>
<span class="bento__stat-value">G&nbsp;&nbsp;C</span>
</article>
</div>
</div>
</section>
<?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('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'] ?? []]);

18
app/pages/team-damen.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
/**
* Damenmannschaft (Damenfußball). Inhalt: data/teams.json → damen.
*/
$slug = 'fussball/damen';
$team = json_load('teams')['damen'];
$meta = [
'title' => 'Damenmannschaft Damenfußball Kulmbach',
'description' => $team['hero']['text'],
'og_image' => $team['hero']['image']['base'] . '-1600.jpg',
'schema' => team_schema($team, $slug),
];
component('team', ['team' => $team, 'slug' => $slug]);

18
app/pages/team-erste.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
/**
* 1. Mannschaft (Herrenfußball). Inhalt: data/teams.json → erste-mannschaft.
*/
$slug = 'fussball/erste-mannschaft';
$team = json_load('teams')['erste-mannschaft'];
$meta = [
'title' => '1. Mannschaft Herrenfußball Kreisklasse Kulmbach',
'description' => $team['hero']['text'],
'og_image' => $team['hero']['image']['base'] . '-1600.jpg',
'schema' => team_schema($team, $slug),
];
component('team', ['team' => $team, 'slug' => $slug]);

19
app/pages/team-zwei.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
/**
* 2. Mannschaft (Herrenfußball). Inhalt: data/teams.json → zweite-mannschaft.
*/
$slug = 'fussball/zweite-mannschaft';
$team = json_load('teams')['zweite-mannschaft'];
$meta = [
'title' => '2. Mannschaft Herrenfußball Kulmbach',
'description' => $team['hero']['text'],
'og_image' => $team['hero']['image']['base'] . '-1600.jpg',
'scripts' => (count($team['contact_names'] ?? []) > 1) ? ['contact-slider.js'] : [],
'schema' => team_schema($team, $slug),
];
component('team', ['team' => $team, 'slug' => $slug]);

84
app/pages/verein.php Normal file
View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
/**
* Vereins-Übersichtsseite (Hub). Inhalt: data/verein.json + data/club.json.
* Struktur: Hero → Eckdaten (key-facts) → Leitbild (split) → Abteilungs-Kacheln →
* Gelände/Drohnenbild → CTA → FAQ.
*/
$page = json_load('verein');
$club = json_load('club');
$addr = $club['address'] ?? [];
$mapsQuery = rawurlencode(trim(($addr['venue'] ?? '') . ', ' . ($addr['street'] ?? '') . ', ' . ($addr['zip'] ?? '') . ' ' . ($addr['city'] ?? ''), ', '));
// Zwei Abteilungs-Kacheln. Turnen verlinkt bereits auf /turnen (Seite folgt).
$cards = [
[
'name' => 'Fußball',
'slug' => 'fussball',
'category' => 'Herren · Damen · Jugend',
'image' => ['base' => 'img/pages/fussball-hero', 'widths' => [640, 1000, 1600], 'alt' => 'Fußball beim TSV 08 Kulmbach'],
],
[
'name' => 'Turnabteilung',
'slug' => 'turnen',
'category' => 'Turnen · Trampolin',
'image' => ['base' => 'img/pages/turnen', 'widths' => [640, 1000], 'alt' => 'Turnabteilung des TSV 08 Kulmbach'],
],
];
$meta = [
'title' => 'Der Verein',
'description' => 'Der TSV 1908 Kulmbach e. V. seit 1908 dein Sportverein im Katzbachtal in Kulmbach. Über 650 Mitglieder, die Abteilungen Fußball und Turnen, zuhause auf der Hans-Rausch-Sportanlage.',
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
'schema' => page_schema(
[
['name' => 'Startseite', 'slug' => ''],
['name' => 'Der Verein', 'slug' => 'verein'],
],
$page['faq'] ?? []
),
];
component('hero', ['hero' => $page['hero']]);
component('key-facts', ['facts' => $page['facts'] ?? [], 'anchor' => 'eckdaten']);
component('section', ['section' => $page['about']]);
component('team-cards', [
'teams' => $cards,
'title' => 'Unsere Abteilungen',
'intro' => $page['abteilungen_intro'] ?? '',
'intro_below' => true,
'anchor' => 'abteilungen',
]);
?>
<section class="section facility" id="gelaende">
<div class="container">
<h2><?= e($page['gelaende']['title']) ?></h2>
<p class="facility__lead"><?= e($page['gelaende']['text']) ?></p>
<figure class="facility__figure">
<?php component('img', ['image' => $page['gelaende']['image'], 'sizes' => '(max-width: 1200px) 100vw, 1200px', 'class' => 'facility__img']); ?>
<?php if (!empty($page['gelaende']['caption'])): ?>
<figcaption class="facility__caption"><?= e($page['gelaende']['caption']) ?></figcaption>
<?php endif; ?>
</figure>
<p class="facility__actions">
<a class="btn btn--outline" href="https://www.google.com/maps/search/?api=1&amp;query=<?= e($mapsQuery) ?>" target="_blank" rel="noopener">Route planen<span class="visually-hidden"> (öffnet Google Maps in neuem Tab)</span></a>
</p>
</div>
</section>
<?php
component('cta-band', ['band' => [
'title' => 'Werde Teil des TSV 08',
'text' => 'Ob Fußball oder Turnen komm unverbindlich vorbei oder schreib uns. Wir finden gemeinsam die passende Gruppe für dich.',
'ctas' => [
['label' => 'Mitglied werden', 'slug' => 'mitglied-werden', 'style' => 'primary'],
['label' => 'Kontakt aufnehmen', 'slug' => '#kontakt', 'style' => 'outline'],
],
]]);
component('faq', ['faq' => $page['faq'] ?? []]);

View File

@@ -10,8 +10,15 @@ declare(strict_types=1);
* Canonical und Sitemap folgen automatisch.
*/
return [
'' => ['file' => 'home.php', 'sitemap' => true],
'fussball' => ['file' => 'fussball.php', 'sitemap' => true],
'historie' => ['file' => 'historie.php', 'sitemap' => true],
'404' => ['file' => '404.php', 'sitemap' => false],
'' => ['file' => 'home.php', 'sitemap' => true],
'fussball' => ['file' => 'fussball.php', 'sitemap' => true],
'fussball/erste-mannschaft' => ['file' => 'team-erste.php', 'sitemap' => true],
'fussball/zweite-mannschaft' => ['file' => 'team-zwei.php', 'sitemap' => true],
'fussball/damen' => ['file' => 'team-damen.php', 'sitemap' => true],
'jugendfussball' => ['file' => 'jugendfussball.php', 'sitemap' => true],
'verein' => ['file' => 'verein.php', 'sitemap' => true],
'historie' => ['file' => 'historie.php', 'sitemap' => true],
'impressum' => ['file' => 'impressum.php', 'sitemap' => true],
'datenschutz' => ['file' => 'datenschutz.php', 'sitemap' => true],
'404' => ['file' => '404.php', 'sitemap' => false],
];