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"> <section class="section partners" id="partner">
<div class="container"> <div class="container">
<h2><?= e($intro['title']) ?></h2> <h2><?= e($intro['title']) ?></h2>
<p><?= e($intro['text']) ?></p> <p class="partners__intro text-muted"><?= e($intro['text']) ?></p>
<ul class="partners__grid"> <ul class="partners__grid">
<?php foreach ($partners as $partner): ?> <?php foreach ($partners as $partner): ?>
<li class="partners__item"> <li class="partners__item">

View File

@@ -6,6 +6,7 @@ declare(strict_types=1);
* Ansprechpartner-Grid aus data/ansprechpartner.json (Single Source of Truth). * Ansprechpartner-Grid aus data/ansprechpartner.json (Single Source of Truth).
* Alle Props optional: * Alle Props optional:
* $departments array — nur diese Abteilungen zeigen (z. B. ['Herrenfußball','Damenfußball']). * $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). * $title string — Überschrift überschreiben (Default aus JSON).
* $intro string — Intro-Text überschreiben (Default aus JSON; '' = ausblenden). * $intro string — Intro-Text überschreiben (Default aus JSON; '' = ausblenden).
* $anchor string — Sektions-ID (Default 'ansprechpartner'). * $anchor string — Sektions-ID (Default 'ansprechpartner').
@@ -24,6 +25,12 @@ if (!empty($departments)) {
static fn (array $p): bool => in_array($p['department'] ?? '', $departments, true) 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 === []) { if ($persons === []) {
return; 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('stats', ['stats' => $home['stats']]);
component('partner-grid', ['intro' => $home['partners']]); component('partner-grid', ['intro' => $home['partners']]);
component('person-grid');
component('contact-form', ['intro' => $home['contact']]); 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

@@ -12,6 +12,13 @@ declare(strict_types=1);
return [ return [
'' => ['file' => 'home.php', 'sitemap' => true], '' => ['file' => 'home.php', 'sitemap' => true],
'fussball' => ['file' => 'fussball.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], 'historie' => ['file' => 'historie.php', 'sitemap' => true],
'impressum' => ['file' => 'impressum.php', 'sitemap' => true],
'datenschutz' => ['file' => 'datenschutz.php', 'sitemap' => true],
'404' => ['file' => '404.php', 'sitemap' => false], '404' => ['file' => '404.php', 'sitemap' => false],
]; ];

View File

@@ -8,6 +8,11 @@
"phone": "+49 160 97516404", "phone": "+49 160 97516404",
"image": "img/persons/benedikt-hoefner.jpg" "image": "img/persons/benedikt-hoefner.jpg"
}, },
{
"department": "Herrenfußball",
"name": "Rustem Hajzeraj",
"image": "img/persons/rustem-hajzeraj.jpg"
},
{ {
"department": "Damenfußball", "department": "Damenfußball",
"name": "Sam Kern", "name": "Sam Kern",

View File

@@ -13,6 +13,22 @@
}, },
"email": "info@tsv08kulmbach.de", "email": "info@tsv08kulmbach.de",
"email_turnen": "turnabteilung.tsv08kulmbach@gmail.com", "email_turnen": "turnabteilung.tsv08kulmbach@gmail.com",
"board": {
"note": "Vertretungsberechtigter Vorstand gemäß § 26 BGB",
"members": [
{ "name": "Kai van Avondt", "role": "1. Vorsitzender" },
{ "name": "Marco Früh", "role": "" },
{ "name": "Fabian Früh", "role": "" },
{ "name": "Matthias Korzonek", "role": "" },
{ "name": "Michael Senar", "role": "" }
]
},
"banking": [
{ "bank": "Sparkasse Kulmbach-Kronach", "iban": "DE11 7715 0000 0000 1542 52", "bic": "BYLADEM1KUB" },
{ "bank": "VR Bank Oberfranken Mitte", "iban": "DE90 7719 0000 0000 0362 18", "bic": "GENODEF1KU1" }
],
"data_protection_contact": "Fabian Früh",
"webdesign": "Felix Schneider",
"memberships": [ "memberships": [
"Bayerischer Landes-Sportverband", "Bayerischer Landes-Sportverband",
"Bayerischer Fußball-Verband", "Bayerischer Fußball-Verband",

23
data/fussball.json Normal file
View File

@@ -0,0 +1,23 @@
{
"hero": {
"align": "bottom-left",
"compact": true,
"title": "Fußball",
"text": "Zwei Herrenmannschaften (1. Mannschaft in der Kreisklasse), eine Damenmannschaft und Jugendteams von der G- bis zur C-Jugend alle zuhause auf der Hans-Rausch-Sportanlage im Katzbachtal in Kulmbach.",
"image": {
"base": "img/pages/fussball-hero",
"widths": [640, 1000, 1600],
"alt": "Fußballplatz des TSV 08 Kulmbach im Katzbachtal"
},
"ctas": []
},
"mannschaften_intro": "Vom Kreisklasse-Fußball der Herren über die Damenmannschaft bis zum Nachwuchs wähle deine Mannschaft.",
"faq": [
{ "q": "Wo kann ich in Kulmbach Fußball spielen?", "a": "Beim TSV 08 Kulmbach im Katzbachtal mit erster und zweiter Herrenmannschaft, einer Damenmannschaft und Jugendmannschaften von der G- bis zur C-Jugend." },
{ "q": "Nehmt ihr neue Spieler und Spielerinnen auf?", "a": "Ja, jederzeit. Komm unverbindlich zum Probetraining der passenden Mannschaft sprich einfach den jeweiligen Ansprechpartner an." },
{ "q": "Auf welchem Niveau spielt der Herrenbereich?", "a": "Die erste Herrenmannschaft spielt auf Kreisklasse-Niveau, dazu kommt die zweite Mannschaft für alle, die mit Spaß regelmäßig kicken." },
{ "q": "Wann ist Fußballtraining beim TSV 08 Kulmbach?", "a": "Die erste Herrenmannschaft trainiert dienstags und donnerstags um 18:30 Uhr auf der Hans-Rausch-Sportanlage. Die Trainingszeiten der zweiten Mannschaft, der Damen und der Jugend erfährst du direkt beim jeweiligen Ansprechpartner." },
{ "q": "Ab welchem Alter kann mein Kind bei euch Fußball spielen?", "a": "Schon die Kleinsten sind willkommen: Unsere Jugendabteilung reicht von der G-Jugend (Bambini) bis zur C-Jugend. Mehr dazu auf unserer Seite zum Jugendfußball." },
{ "q": "Wo ist der Sportplatz?", "a": "Auf der Hans-Rausch-Sportanlage in der Thurnauer Str. 51 in Kulmbach, mitten im Katzbachtal." }
]
}

32
data/jugend.json Normal file
View File

@@ -0,0 +1,32 @@
{
"hero": {
"compact": true,
"align": "bottom-left",
"title": "Jugendfußball in Kulmbach",
"tagline": "G- bis C-Jugend im Katzbachtal",
"text": "Entdecke unsere Jugendmannschaften, Spielerinnen und Spieler sowie engagierte Betreuer und lerne den Katzbachtaler Weg im Jugendfußball kennen.",
"image": {
"base": "img/pages/jugendfussball-hero",
"widths": [640, 1000, 1440],
"alt": "Kinder beim Jugendfußball-Training des TSV 08 Kulmbach"
},
"ctas": []
},
"about": {
"title": "Mitmachen leicht gemacht",
"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" } }
],
"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." },
{ "q": "Wie melde ich mein Kind an?", "a": "Ganz unkompliziert: Ruf unseren Ansprechpartner an oder schreib eine WhatsApp-Nachricht. Wir beantworten alle Fragen rund um den Jugendfußball und sagen euch, wann das nächste Training ist." },
{ "q": "Was kostet ein Schnuppertraining?", "a": "Das Schnuppertraining ist kostenlos und unverbindlich ihr schaut euch in Ruhe an, ob es passt." },
{ "q": "Wo trainiert die Jugend?", "a": "Auf der Hans-Rausch-Sportanlage in der Thurnauer Str. 51 in Kulmbach, mitten im Katzbachtal." }
]
}

209
data/teams.json Normal file
View File

@@ -0,0 +1,209 @@
{
"erste-mannschaft": {
"name": "1. Mannschaft",
"department": "Herrenfußball",
"category": "Herren",
"league": "Kreisklasse",
"hero": {
"compact": true,
"align": "bottom-left",
"title": "1. Mannschaft",
"tagline": "Herrenfußball · Kreisklasse",
"text": "Unsere erste Herrenmannschaft spielt auf Kreisklasse-Niveau im Katzbachtal ambitioniert, aber mit echtem Vereinsgeist. Komm vorbei und spiel mit.",
"image": {
"base": "img/teams/herren-1",
"widths": [640, 1000, 1600],
"alt": "Spielszene der 1. Mannschaft des TSV 08 Kulmbach"
},
"ctas": []
},
"facts": [
{ "icon": "people-fill", "label": "Bereich", "value": "Herren" },
{ "icon": "trophy", "label": "Liga", "value": "Kreisklasse" },
{ "icon": "geo-alt", "label": "Heimat", "value": "Katzbachtal, Kulmbach" }
],
"about": {
"title": "Über uns",
"text": "Bei der ersten Mannschaft treffen Ehrgeiz und Spaß am Spiel aufeinander. Wir trainieren regelmäßig im Katzbachtal und freuen uns über ambitionierte Hobbyfußballer, die Lust auf Kreisklasse-Fußball in einer eingeschworenen Truppe haben."
},
"team_photo": {
"image": {
"base": "img/teams/herren-1-team",
"widths": [640, 1000, 1600],
"alt": "Mannschaftsfoto der 1. Mannschaft des TSV 08 Kulmbach"
},
"caption": "Die 1. Mannschaft des TSV 08 Kulmbach"
},
"roster": {
"title": "Mannschaft & Trainerstab",
"intro": "Trainerstab und Kader der ersten Herrenmannschaft in der Saison.",
"variant": "overlay",
"groups": [
{
"title": "Trainer & Betreuer",
"members": [
{ "name": "Ali Kilic", "role": "Trainer", "image": "img/persons/ali-kilic.jpg" },
{ "name": "Benedikt Höfner", "role": "Sportlicher Leiter", "image": "img/persons/benedikt-hoefner.jpg" },
{ "name": "Bastian Werner", "role": "Spielleiter", "image": "img/persons/bastian-werner.jpg" }
]
},
{
"title": "Torwart",
"members": [
{ "name": "Janick Martin", "image": "img/persons/janick-martin.jpg" },
{ "name": "Sascha Zimmerer", "image": "img/persons/sascha-zimmerer.jpg" }
]
},
{
"title": "Abwehr",
"members": [
{ "name": "Ali Kilic", "image": "img/persons/ali-kilic-spieler.jpg" },
{ "name": "Alexander Hatter", "image": "img/persons/alexander-hatter.jpg" },
{ "name": "Alex Schütz", "image": "img/persons/alex-schuetz.jpg" },
{ "name": "Rustem Hajzeraj", "image": "img/persons/rustem-hajzeraj.jpg" },
{ "name": "Güray Kosuva" },
{ "name": "Maximilian Koenen" }
]
},
{
"title": "Mittelfeld",
"members": [
{ "name": "Fabian Früh", "image": "img/persons/fabian-frueh.jpg" },
{ "name": "Christian Kästner", "image": "img/persons/christian-kaestner.jpg" },
{ "name": "Sebastiano Gross", "image": "img/persons/sebastiano-gross.jpg" },
{ "name": "Tim Hofmann", "image": "img/persons/tim-hofmann.jpg" },
{ "name": "Felix Schneider", "image": "img/persons/felix-schneider.jpg" },
{ "name": "Alexander Vogel", "image": "img/persons/alexander-vogel.jpg" },
{ "name": "Hannes Buchmeier" },
{ "name": "Hakan Yilmaz", "image": "img/persons/hakan-yilmaz.jpg" },
{ "name": "Dominik Schulz", "image": "img/persons/dominik-schulz.jpg" }
]
},
{
"title": "Angriff",
"members": [
{ "name": "Christian Ohnemüller", "image": "img/persons/christian-ohnemueller.jpg" },
{ "name": "Marek Szabelski", "image": "img/persons/marek-szabelski.jpg" }
]
}
]
},
"training": {
"venue_from_club": true,
"sessions": [
{ "day": "Dienstag", "time": "18:30 Uhr" },
{ "day": "Donnerstag", "time": "18:30 Uhr" }
]
},
"contact_department": "Herrenfußball",
"contact_name": "Benedikt Höfner",
"faq": [
{ "q": "Kann ich zum Probetraining kommen?", "a": "Klar melde dich kurz bei unserem Ansprechpartner und komm einfach zum Training vorbei. Ein Probetraining ist jederzeit unverbindlich möglich." },
{ "q": "Auf welchem Niveau spielt die 1. Mannschaft?", "a": "Die erste Herrenmannschaft des TSV 08 Kulmbach spielt auf Kreisklasse-Niveau." },
{ "q": "Wann trainiert die 1. Mannschaft?", "a": "Die erste Mannschaft trainiert dienstags und donnerstags um 18:30 Uhr auf der Hans-Rausch-Sportanlage im Katzbachtal." },
{ "q": "Wer ist der Trainer der 1. Mannschaft?", "a": "Trainer ist Ali Kilic. Sportlicher Leiter ist Benedikt Höfner, Spielleiter Bastian Werner." },
{ "q": "Wo trainiert und spielt die Mannschaft?", "a": "Heimat ist die Hans-Rausch-Sportanlage in der Thurnauer Str. 51 in Kulmbach, mitten im Katzbachtal." }
]
},
"zweite-mannschaft": {
"name": "2. Mannschaft",
"department": "Herrenfußball",
"category": "Herren",
"league": null,
"hero": {
"compact": true,
"align": "bottom-left",
"title": "2. Mannschaft",
"tagline": "Herrenfußball · SG mit TSV Melkendorf",
"text": "Unsere zweite Herrenmannschaft spielt in einer Spielgemeinschaft mit dem TSV Melkendorf. Hier steht der Spaß am Fußball im Vordergrund ideal für alle, die regelmäßig kicken und Teil der TSV-Familie werden wollen.",
"image": {
"base": "img/teams/herren-2",
"widths": [640, 1000, 1600],
"alt": "Einlaufen der Mannschaften beim TSV 08 Kulmbach"
},
"ctas": []
},
"facts": [
{ "icon": "people-fill", "label": "Bereich", "value": "Herren" },
{ "icon": "geo-alt", "label": "Heimat", "value": "Katzbachtal, Kulmbach" }
],
"about": {
"title": "Über uns",
"text": "Unsere zweite Mannschaft ist die ideale Anlaufstelle für alle, die mit Freude am Spiel regelmäßig auf dem Platz stehen wollen vom Wiedereinsteiger bis zum ambitionierten Hobbyfußballer. Gemeinsam mit dem TSV Melkendorf bilden wir eine Spielgemeinschaft: Breiter Kader, gute Stimmung, echtes Teamgefühl über beide Vereine hinweg."
},
"team_photo": {
"image": {
"base": "img/teams/herren-2",
"widths": [640, 1000, 1600],
"alt": "Die 2. Mannschaft des TSV 08 Kulmbach"
},
"caption": "Die 2. Mannschaft des TSV 08 Kulmbach"
},
"training": {
"venue_from_club": true,
"venue": {
"name": "Sportgelände TSV Melkendorf",
"locality": "Melkendorf · 95326 Kulmbach",
"maps_query": "Sportgelände TSV Melkendorf Kulmbach"
},
"sessions": [],
"note": "Als Spielgemeinschaft mit dem TSV Melkendorf finden die Heimspiele meistens in Melkendorf statt; trainiert wird abwechselnd in Melkendorf und bei uns im Katzbachtal."
},
"contact_department": "Herrenfußball",
"contact_names": ["Benedikt Höfner", "Rustem Hajzeraj"],
"faq": [
{ "q": "Kann ich zum Probetraining kommen?", "a": "Jederzeit sprich kurz unseren Ansprechpartner an und komm unverbindlich zum Training vorbei." },
{ "q": "Für wen ist die 2. Mannschaft geeignet?", "a": "Für alle, die mit Freude am Spiel regelmäßig auf dem Platz stehen wollen vom Wiedereinsteiger bis zum ambitionierten Hobbyfußballer. Vereinserfahrung ist keine Voraussetzung." },
{ "q": "Bildet die 2. Mannschaft eine Spielgemeinschaft?", "a": "Ja, die zweite Mannschaft ist eine Spielgemeinschaft mit dem TSV Melkendorf. Trainiert wird abwechselnd auf der Hans-Rausch-Sportanlage im Katzbachtal und in Melkendorf." },
{ "q": "Wer ist Ansprechpartner der 2. Mannschaft?", "a": "Benedikt Höfner sprich ihn einfach direkt an, wenn du mitspielen oder zum Probetraining kommen möchtest." },
{ "q": "Wo trainiert und spielt die 2. Mannschaft?", "a": "Heimspiele und Training finden auf der Hans-Rausch-Sportanlage in der Thurnauer Str. 51 in Kulmbach im Katzbachtal sowie im Rahmen der Spielgemeinschaft beim TSV Melkendorf statt." }
]
},
"damen": {
"name": "Damenmannschaft",
"department": "Damenfußball",
"category": "Damen",
"league": null,
"hero": {
"compact": true,
"align": "bottom-left",
"title": "Damenmannschaft",
"tagline": "Damenfußball",
"text": "Frauen- und Mädchenfußball hat beim TSV 08 Kulmbach einen festen Platz. Ob erfahren oder Wiedereinsteigerin bei uns findest du dein Team im Katzbachtal.",
"image": {
"base": "img/teams/damen",
"widths": [640, 1000, 1600],
"alt": "Damenmannschaft des TSV 08 Kulmbach"
},
"ctas": []
},
"facts": [
{ "icon": "people-fill", "label": "Bereich", "value": "Damen" },
{ "icon": "geo-alt", "label": "Heimat", "value": "Katzbachtal, Kulmbach" }
],
"about": {
"title": "Über uns",
"text": "Unsere Damenmannschaft lebt vom Zusammenhalt und der Freude am Fußball. Wir freuen uns über jede neue Spielerin egal ob mit Vereinserfahrung oder als Wiedereinsteigerin. Trau dich und komm zum Probetraining."
},
"team_photo": {
"image": {
"base": "img/teams/damen",
"widths": [640, 1000, 1600],
"alt": "Die Damenmannschaft des TSV 08 Kulmbach"
},
"caption": "Die Damenmannschaft des TSV 08 Kulmbach"
},
"training": {
"venue_from_club": true,
"sessions": []
},
"contact_department": "Damenfußball",
"contact_name": "Sam Kern",
"faq": [
{ "q": "Kann ich als Wiedereinsteigerin mitmachen?", "a": "Unbedingt bei uns sind Spielerinnen mit und ohne Vereinserfahrung willkommen. Komm einfach zum Probetraining." },
{ "q": "Für wen ist die Damenmannschaft?", "a": "Für Frauen und Mädchen, die Fußball spielen wollen ob ambitioniert oder als Wiedereinsteigerin, mit oder ohne Vereinserfahrung." },
{ "q": "Wer ist Ansprechpartner für die Damenmannschaft?", "a": "Sam Kern melde dich einfach direkt für Fragen oder ein unverbindliches Probetraining." },
{ "q": "Wo trainiert und spielt die Damenmannschaft?", "a": "Heimat ist die Hans-Rausch-Sportanlage in der Thurnauer Str. 51 in Kulmbach, mitten im Katzbachtal." }
]
}
}

49
data/verein.json Normal file
View File

@@ -0,0 +1,49 @@
{
"hero": {
"align": "bottom-left",
"compact": true,
"title": "Der Verein",
"text": "Seit über 100 Jahren dein Sportverein im Katzbachtal Fußball und Turnen unter einem Dach, getragen vom Ehrenamt. Made in Kulmbach.",
"image": {
"base": "img/pages/vereinsgelaende",
"widths": [1000, 1600],
"alt": "Luftaufnahme der Hans-Rausch-Sportanlage des TSV 08 Kulmbach im Katzbachtal"
},
"ctas": []
},
"facts": [
{ "icon": "clock", "label": "Gegründet", "value": "1908" },
{ "icon": "people-fill", "label": "Mitglieder", "value": "650+" },
{ "icon": "trophy", "label": "Mannschaften", "value": "10+" },
{ "icon": "geo-alt", "label": "Zuhause", "value": "Katzbachtal" }
],
"about": {
"id": "ueber-uns",
"title": "Im Katzbachtal zuhause",
"text": "Im Katzbachtal zuhause sind wir dein Verein für Turnen und Fußball. Schwerpunkt ist dabei die kulturelle und sportliche Förderung von Jugendlichen. Bei uns wird das Ehrenamt leidenschaftlich gelebt so entstehen Jahr für Jahr neue erfolgreiche Sportgeschichten, made in Kulmbach.",
"tinted": true,
"image": {
"base": "img/pages/sportheim",
"widths": [640, 1000],
"alt": "Sportheim des TSV 08 Kulmbach mit Pokalen und Vereinswappen"
}
},
"abteilungen_intro": "Zwei Abteilungen, ein Verein wähle deinen Sport.",
"gelaende": {
"title": "Unser Gelände im Katzbachtal",
"text": "Mach dir einen Eindruck von unseren Gegebenheiten: Auf der Hans-Rausch-Sportanlage liegen Rasenplatz, Hartplatz, Turnhalle und Sportheim direkt beieinander mitten im Katzbachtal in Kulmbach. Wir freuen uns, dich schon bald hier begrüßen zu dürfen.",
"caption": "Hans-Rausch-Sportanlage aus der Luft Rasenplatz, Hartplatz, Turnhalle und Sportheim auf einen Blick.",
"image": {
"base": "img/pages/verein",
"widths": [640, 1000],
"alt": "Beschriftete Luftaufnahme des Vereinsgeländes: Rasenplatz, Hartplatz, Turnhalle und Sportheim"
}
},
"faq": [
{ "q": "Welche Abteilungen hat der TSV 08 Kulmbach?", "a": "Der TSV 1908 Kulmbach e. V. hat zwei Abteilungen: Fußball (Herren, Damen und Jugend) sowie Turnen, zu dem auch das Trampolinturnen gehört." },
{ "q": "Seit wann gibt es den TSV 08 Kulmbach?", "a": "Der Verein wurde 1908 gegründet und ist damit seit über 100 Jahren in Kulmbach aktiv." },
{ "q": "Wo ist der TSV 08 Kulmbach zuhause?", "a": "Auf der Hans-Rausch-Sportanlage in der Thurnauer Str. 51 in Kulmbach, mitten im Katzbachtal." },
{ "q": "Wie viele Mitglieder hat der Verein?", "a": "Der TSV 08 Kulmbach zählt über 650 Mitglieder in mehr als zehn Mannschaften." },
{ "q": "Wie kann ich Mitglied werden?", "a": "Komm unverbindlich vorbei oder nimm über das Kontaktformular Kontakt mit uns auf wir helfen dir, die passende Abteilung und Gruppe zu finden." }
]
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

View File

@@ -0,0 +1,75 @@
/* Ansprechpartner-Mini-Slider: zeigt bei mehreren Kontakten nur eine Karte und
wechselt automatisch durch. Progressive Enhancement:
- ohne JS bleiben alle Karten gestapelt sichtbar (kein Info-Verlust),
- bei prefers-reduced-motion findet keine Enhancement/Auto-Rotation statt
(alle Karten bleiben sichtbar),
- Pause bei Hover/Fokus, Punkte zur manuellen Auswahl. */
(function () {
'use strict';
var root = document.querySelector('[data-contact-slider]');
if (!root) {
return;
}
var slides = Array.prototype.slice.call(root.querySelectorAll('[data-contact-slide]'));
if (slides.length < 2) {
return;
}
// Bewegungsreduzierung: nicht enhancen → alle Karten bleiben sichtbar.
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
return;
}
var dots = Array.prototype.slice.call(root.querySelectorAll('[data-contact-dot]'));
var INTERVAL = 5000;
var current = 0;
var timer = null;
function show(n) {
current = (n + slides.length) % slides.length;
slides.forEach(function (slide, idx) {
var active = idx === current;
slide.hidden = !active;
slide.classList.toggle('is-active', active);
});
dots.forEach(function (dot, idx) {
if (idx === current) {
dot.setAttribute('aria-current', 'true');
} else {
dot.removeAttribute('aria-current');
}
});
}
function stop() {
if (timer) {
window.clearInterval(timer);
timer = null;
}
}
function start() {
stop();
timer = window.setInterval(function () {
show(current + 1);
}, INTERVAL);
}
dots.forEach(function (dot, idx) {
dot.addEventListener('click', function () {
show(idx);
start();
});
});
root.addEventListener('mouseenter', stop);
root.addEventListener('mouseleave', start);
root.addEventListener('focusin', stop);
root.addEventListener('focusout', start);
root.classList.add('contact-slider--ready');
show(0);
start();
})();