Compare commits
71 Commits
17acfe7081
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e761231442 | |||
| 0a01134583 | |||
| 5854e452f7 | |||
| f2f193b223 | |||
| b4d2cde149 | |||
| 0d80f69a76 | |||
| 6313b98f9e | |||
| f88b5f1dd1 | |||
| 736a588a88 | |||
| 550616aacf | |||
| c688d155cc | |||
| 1839632b66 | |||
| d410c0bedf | |||
| 133c3f926e | |||
| 146471bae8 | |||
| f3f004d13e | |||
| 7bbf783562 | |||
| f21224ea84 | |||
| 592acf02b0 | |||
| 63a045835e | |||
| 06a18a2d19 | |||
| e7572b34d6 | |||
| 921a55635c | |||
| 25f36e5a53 | |||
| 6a9244d847 | |||
| 8c15b2584b | |||
| c4ffe4a1f4 | |||
| 75c06dbd04 | |||
| 6bc6b0d599 | |||
| 9244149bac | |||
| 97676e91ee | |||
| a975bed33c | |||
| e0b2184372 | |||
| c7b23b9f39 | |||
| f6efdfb4b4 | |||
| e24b63147d | |||
| 24a3d6234c | |||
| f678608140 | |||
| 8be0441341 | |||
| de5132c2ec | |||
| 7223122220 | |||
| 344727bed0 | |||
| a63822f3d4 | |||
| 827a717a02 | |||
| 05c64c8f12 | |||
| 964ffb932b | |||
| 913c058c93 | |||
| f1ba364922 | |||
| 748f029afb | |||
| 2066b43a57 | |||
| a3cfd4941d | |||
| e8296a465b | |||
| 1213fa4ca1 | |||
| 8ed5707d24 | |||
| 3cf8c2688b | |||
| dd18906348 | |||
| 4f1bfce8b2 | |||
| f0ec8614b2 | |||
| a495250db8 | |||
| aee8a3a901 | |||
| e6f72a0e4b | |||
| 2f8619e78d | |||
| 93b67bc9d3 | |||
| a101b0c3ec | |||
| 3fb480dd78 | |||
| 82cf21085d | |||
| eacf9449e0 | |||
| 3fa521eaf1 | |||
| 87055bd14b | |||
| 8304095dc3 | |||
| 96a698ebdc |
36
.gitignore
vendored
36
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# Secrets — nur config.example.php wird committed
|
||||
/config/config.php
|
||||
/config/config.production.php
|
||||
|
||||
# Composer
|
||||
/vendor/
|
||||
@@ -8,19 +9,40 @@
|
||||
/storage/logs/*
|
||||
/storage/cache/*
|
||||
/storage/ratelimit/*
|
||||
# Druck-QR-Codes (bin/qr.php) — Zwischenprodukte für Canva, kein Repo-Inhalt.
|
||||
# Wichtig: sie enthalten die base_url fest eingebacken, ein versehentlich
|
||||
# eingecheckter localhost-Code wäre eine Falle.
|
||||
/storage/qr/
|
||||
!/storage/logs/.gitkeep
|
||||
!/storage/cache/.gitkeep
|
||||
!/storage/ratelimit/.gitkeep
|
||||
|
||||
# Maschinenverwaltet (bin/instagram-sync.php)
|
||||
# Maschinenverwaltet: die JSON-Caches bleiben draußen, der Sync baut sie neu.
|
||||
#
|
||||
# Die BILDER dagegen sind versioniert, obwohl sie auch der Sync herunterlädt:
|
||||
# eine VERÖFFENTLICHTE Stadionzeitung friert Verweise auf genau diese Dateien
|
||||
# dauerhaft ein (Wappen in Tabellen/Ergebnissen/Vorschau, Instagram-Fotos auf der
|
||||
# Momente-Seite). Ein frischer Klon zeigte sonst 37 kaputte Bilder im Heft, und
|
||||
# der Instagram-Sync räumt alte Posts irgendwann weg — dann bräche eine
|
||||
# archivierte Ausgabe nachträglich. Maschinenverwaltet und flüchtig ist nicht
|
||||
# dasselbe wie maschinenverwaltet und für immer referenziert.
|
||||
/data/instagram.json
|
||||
/public/assets/img/instagram/*
|
||||
!/public/assets/img/instagram/.gitkeep
|
||||
|
||||
# Maschinenverwaltet (bin/matchcenter-sync.php)
|
||||
/data/matchcenter.json
|
||||
/public/assets/img/crests/*
|
||||
!/public/assets/img/crests/.gitkeep
|
||||
|
||||
# Dateiablage /dateien — Laufzeit-Kram der Files Gallery.
|
||||
# Committed bleiben nur system/users/*/config.php (Rechte, keine Passwörter) und
|
||||
# system/include/* (Skin). system/config/config.php generiert die App selbst und
|
||||
# überschreibt sie bei jedem Update — unsere Werte stehen in _filesconfig.php.
|
||||
#
|
||||
# uploads/ IST versioniert (Beschluss 31.07.2026) — das Material des Vereins ist
|
||||
# damit gesichert und lokal verfügbar. Zwei Bedingungen tragen das:
|
||||
# 1. Der Ordner ist ein Schreibziel für ANDERE. Deshalb gilt: erst auf dem
|
||||
# Server committen und pushen, dann lokal pullen, dann entwickeln. Nie
|
||||
# umgekehrt, sonst arbeiten zwei Stände gegeneinander.
|
||||
# 2. Git vergisst nichts. Eine gelöschte Datei bleibt in der Historie, das
|
||||
# Repo schrumpft nie wieder. Große Videos deshalb bewusst sparsam.
|
||||
/storage/dateien/system/cache/
|
||||
/storage/dateien/system/config/
|
||||
|
||||
# Referenz-Material der alten Seite — NIE committen
|
||||
# (.context_db enthält personenbezogene Daten: Mail-Logs, Hashes)
|
||||
|
||||
@@ -3,13 +3,18 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* POST /kontakt-senden — Kontaktformular validieren und via Brevo SMTP versenden.
|
||||
* Antwort: PRG-Redirect zurück zur absendenden Seite (#kontakt, ?sent=1 | ?error=…)
|
||||
* oder JSON bei fetch (form.js). Das Formular kommt von / wie auch von /kontakt.
|
||||
* POST /kontakt-senden — Kontaktformular validieren und über All-Inkl-SMTP versenden.
|
||||
* Antwort: PRG-Redirect zurück zur absendenden Seite (#kontakt, ?sent=1 | ?error=…),
|
||||
* JSON bei fetch (form.js) oder — bei Feldfehlern ohne JS — die Seite direkt
|
||||
* mit HTTP 422, erhaltenen Eingaben und Feldfehlern (render_form_invalid()).
|
||||
* Das Formular kommt von / wie auch von /kontakt und /turnen.
|
||||
*
|
||||
* Eine Action für alle Varianten: `department` (Hidden-Feld) wählt über
|
||||
* contact_variant() Auswahlliste, Feldregeln und Empfänger. Eine zweite Action
|
||||
* für die Turnabteilung hätte den kompletten Spam-Schutz verdoppelt — und damit
|
||||
* eine zweite Stelle, die man bei jeder Änderung mitziehen müsste.
|
||||
*/
|
||||
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
|
||||
$field = static fn (string $key): string => trim((string) ($_POST[$key] ?? ''));
|
||||
|
||||
// Rücksprungziel gegen die Slug-Whitelist (Single Source of Truth) prüfen — kein Open-Redirect.
|
||||
@@ -34,6 +39,12 @@ $respond = static function (bool $ok, string $error = '') use ($wantsJson, $retu
|
||||
$route = 'kontakt';
|
||||
$token = $field('ft');
|
||||
|
||||
// Ohne vollständige config/config.php liefe die Seite auf der Vorlage (öffentliches
|
||||
// app_secret, kein SMTP-Passwort) — dann gar nicht erst verarbeiten.
|
||||
if (!config_ready($route)) {
|
||||
$respond(false, 'mail');
|
||||
}
|
||||
|
||||
// --- Spam-Checks: Bots bekommen ein stilles "OK" (kein Feedback-Kanal) ---
|
||||
if ($field('company_url') !== '') {
|
||||
log_spam($route, 'honeypot');
|
||||
@@ -44,87 +55,133 @@ if (!form_token_valid($token)) {
|
||||
$respond(true);
|
||||
}
|
||||
|
||||
// Rate-Limit pro IP (sichtbarer Hinweis statt still — legitime NAT-Nutzer nicht im Dunkeln lassen).
|
||||
if (!rate_limit_ok('submit|' . $route . '|' . client_ip(), 5, 600)) {
|
||||
log_spam($route, 'ratelimit');
|
||||
$respond(false, 'ratelimit');
|
||||
}
|
||||
|
||||
// --- Validierung ---
|
||||
// --- Eingaben ---
|
||||
$name = $field('name');
|
||||
$email = $field('email');
|
||||
$phone = $field('phone');
|
||||
$interest = $field('interest');
|
||||
$birthdate = $field('birthdate');
|
||||
$subject = $field('subject');
|
||||
$message = $field('message');
|
||||
$privacy = ($_POST['privacy'] ?? '') === '1';
|
||||
|
||||
$interests = ['', 'Herrenfußball', 'Frauenfußball', 'Jugendfußball', 'Turnen', 'Sonstiges'];
|
||||
|
||||
$valid = $name !== '' && mb_strlen($name) <= 200
|
||||
&& filter_var($email, FILTER_VALIDATE_EMAIL) !== false
|
||||
&& mb_strlen($phone) <= 50
|
||||
&& in_array($interest, $interests, true)
|
||||
&& $subject !== '' && mb_strlen($subject) <= 200
|
||||
&& $message !== '' && mb_strlen($message) <= 5000
|
||||
&& ($_POST['privacy'] ?? '') === '1'
|
||||
&& !preg_match('/[\r\n]/', $name . $subject);
|
||||
|
||||
if (!$valid) {
|
||||
$respond(false, 'validation');
|
||||
// Variante bestimmen (unbekannter Wert fällt auf die allgemeine zurück, siehe
|
||||
// contact_variant()) — dieselbe Quelle, aus der die Komponente ihr Formular baut.
|
||||
$variant = contact_variant($field('department'));
|
||||
$interests = $variant['interests'];
|
||||
if (!$variant['interest_required']) {
|
||||
$interests[] = ''; // die allgemeine Variante darf das Feld leer lassen
|
||||
}
|
||||
if (!$variant['ask_subject']) {
|
||||
$subject = ''; // Feld gibt es dort nicht, ein mitgeschicktes ignorieren
|
||||
}
|
||||
if (!$variant['ask_birthdate']) {
|
||||
$birthdate = ''; // dito — nur die Turn-Variante fragt danach
|
||||
}
|
||||
|
||||
// --- Inhalts-/Aufkommens-Heuristiken (still abweisen, Bot-typisch) ---
|
||||
// --- Validierung (Whitelist-Prinzip, Fehler pro Feld) ---
|
||||
$errors = [];
|
||||
if ($name === '' || mb_strlen($name) > 200 || preg_match('/[\r\n]/', $name)) {
|
||||
$errors['contact-name'] = 'Bitte gib deinen Namen an (max. 200 Zeichen).';
|
||||
}
|
||||
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
|
||||
$errors['contact-email'] = 'Bitte gib eine gültige E-Mail-Adresse an.';
|
||||
}
|
||||
if (mb_strlen($phone) > 50) {
|
||||
$errors['contact-phone'] = 'Die Telefonnummer ist zu lang (max. 50 Zeichen).';
|
||||
}
|
||||
// Geburtsdatum: optional, aber wenn angegeben, dann ein echtes Datum in einem
|
||||
// plausiblen Bereich. type=date liefert YYYY-MM-DD; createFromFormat mit
|
||||
// anschließendem Vergleich weist auch Scheindaten wie 2019-02-31 ab (PHP würde
|
||||
// die sonst stillschweigend auf den 3. März verschieben).
|
||||
if ($birthdate !== '') {
|
||||
$d = DateTimeImmutable::createFromFormat('!Y-m-d', $birthdate);
|
||||
$heute = new DateTimeImmutable('today');
|
||||
if ($d === false || $d->format('Y-m-d') !== $birthdate) {
|
||||
$errors['contact-birthdate'] = 'Bitte gib das Geburtsdatum im Format TT.MM.JJJJ an.';
|
||||
} elseif ($d > $heute) {
|
||||
$errors['contact-birthdate'] = 'Das Geburtsdatum kann nicht in der Zukunft liegen.';
|
||||
} elseif ($d < new DateTimeImmutable('1900-01-01')) {
|
||||
$errors['contact-birthdate'] = 'Bitte prüfe das Geburtsdatum.';
|
||||
}
|
||||
}
|
||||
if (!in_array($interest, $interests, true)) {
|
||||
$errors['contact-interest'] = $variant['interest_required']
|
||||
? 'Bitte wähle aus, für welche Gruppe du dich interessierst.'
|
||||
: 'Bitte wähle einen der angebotenen Bereiche.';
|
||||
}
|
||||
if ($variant['ask_subject'] && ($subject === '' || mb_strlen($subject) > 200 || preg_match('/[\r\n]/', $subject))) {
|
||||
$errors['contact-subject'] = 'Bitte gib einen Betreff an (max. 200 Zeichen, einzeilig).';
|
||||
}
|
||||
if (($variant['message_required'] && $message === '') || mb_strlen($message) > 5000) {
|
||||
$errors['contact-message'] = 'Bitte schreib uns eine Nachricht (max. 5000 Zeichen).';
|
||||
}
|
||||
if (!$privacy) {
|
||||
$errors['contact-privacy'] = 'Ohne Zustimmung zum Datenschutz können wir die Anfrage nicht entgegennehmen.';
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
if ($wantsJson) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok' => false, 'error' => 'validation', 'fields' => $errors]);
|
||||
exit;
|
||||
}
|
||||
// Ohne JS: Seite mit Eingaben und Feldfehlern neu rendern statt PRG (Werte bleiben erhalten).
|
||||
render_form_invalid($return, [
|
||||
'name' => $name, 'email' => $email, 'phone' => $phone, 'interest' => $interest,
|
||||
'birthdate' => $birthdate, 'subject' => $subject, 'message' => $message,
|
||||
'privacy' => $privacy ? '1' : '',
|
||||
], $errors);
|
||||
}
|
||||
|
||||
// --- Inhalts-Heuristik (still abweisen, Bot-typisch) ---
|
||||
if (preg_match_all('~https?://~i', $subject . ' ' . $message) > 2) {
|
||||
log_spam($route, 'links');
|
||||
$respond(true);
|
||||
}
|
||||
// Globaler Tages-Cap als Brevo-Kosten-Backstop — die Flut soll ihn nicht bemerken.
|
||||
if (!rate_limit_ok('daily', 100, 86400)) {
|
||||
log_spam($route, 'daily-cap');
|
||||
$respond(true);
|
||||
|
||||
// --- Volumen-Grenzen: erst ab hier, damit Tippfehler kein Kontingent verbrauchen.
|
||||
// Sie antworten ehrlich — stilles "Danke" ohne Versand verschluckt echte Anfragen. ---
|
||||
if (!rate_limit_ok('submit|' . $route . '|' . client_ip(), 5, 600)) {
|
||||
log_spam($route, 'ratelimit');
|
||||
$respond(false, 'ratelimit');
|
||||
}
|
||||
// Token-Mehrfachnutzung begrenzen (Replay-Schutz, großzügig für menschliches Mehrfach-Senden).
|
||||
if (!rate_limit_ok('token|' . $token, 3, 86400)) {
|
||||
// Token-Mehrfachnutzung begrenzen (Replay-Schutz). IP im Schlüssel, weil form_token()
|
||||
// sekundengenau ist: sonst teilen sich alle Besucher derselben Sekunde ein Kontingent.
|
||||
if (!rate_limit_ok('token|' . $token . '|' . client_ip(), 3, 86400)) {
|
||||
log_spam($route, 'replay');
|
||||
$respond(true);
|
||||
$respond(false, 'ratelimit');
|
||||
}
|
||||
// Globaler Tages-Cap über alle Formulare: schützt Postfach und Absender-Reputation.
|
||||
if (!rate_limit_ok('daily', 200, 86400)) {
|
||||
log_spam($route, 'daily-cap');
|
||||
$respond(false, 'busy');
|
||||
}
|
||||
|
||||
// --- Versand ---
|
||||
$smtp = config('smtp');
|
||||
// Ohne Betreff-Feld trägt die Auswahl den Mail-Betreff.
|
||||
$mailSubject = $variant['subject_prefix'] . ': ' . ($subject !== '' ? $subject : $interest);
|
||||
|
||||
$body = "Kontaktanfrage über tsv08kulmbach.de\n"
|
||||
$body = $variant['context'] . " über tsv08kulmbach.de\n"
|
||||
. str_repeat('-', 40) . "\n"
|
||||
. "Name: {$name}\n"
|
||||
. "E-Mail: {$email}\n"
|
||||
. ($phone !== '' ? "Telefon: {$phone}\n" : '')
|
||||
. ($interest !== '' ? "Interesse: {$interest}\n" : '')
|
||||
. "Betreff: {$subject}\n"
|
||||
. ($birthdate !== '' ? 'Geburtstag des Kindes: ' . german_date($birthdate) . "\n" : '')
|
||||
. ($subject !== '' ? "Betreff: {$subject}\n" : '')
|
||||
. str_repeat('-', 40) . "\n\n"
|
||||
. $message . "\n\n"
|
||||
. ($message !== '' ? $message . "\n\n" : "(keine Nachricht angegeben)\n\n")
|
||||
. str_repeat('-', 40) . "\n"
|
||||
. 'Datenschutz zugestimmt: ja (' . date('d.m.Y H:i') . ")\n";
|
||||
|
||||
try {
|
||||
$mail = new PHPMailer(true);
|
||||
$mail->isSMTP();
|
||||
$mail->Host = $smtp['host'];
|
||||
$mail->Port = (int) $smtp['port'];
|
||||
$mail->SMTPAuth = true;
|
||||
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
|
||||
$mail->Username = $smtp['username'];
|
||||
$mail->Password = $smtp['password'];
|
||||
$mail->CharSet = PHPMailer::CHARSET_UTF8;
|
||||
$sent = send_mail([
|
||||
'context' => $variant['context'],
|
||||
'subject' => $mailSubject,
|
||||
'body' => $body,
|
||||
'reply_to' => ['email' => $email, 'name' => $name],
|
||||
// Leer heißt: Standardempfänger aus config('smtp.to') (send_mail()).
|
||||
'to' => $variant['to'] !== '' ? $variant['to'] : null,
|
||||
]);
|
||||
|
||||
$mail->setFrom($smtp['from'], $smtp['from_name']);
|
||||
$mail->addAddress($smtp['to']);
|
||||
$mail->addReplyTo($email, $name);
|
||||
$mail->Subject = 'Kontaktanfrage: ' . $subject;
|
||||
$mail->Body = $body;
|
||||
|
||||
$mail->send();
|
||||
} catch (Throwable $e) {
|
||||
error_log('[' . date('c') . '] Mailversand fehlgeschlagen: ' . $e->getMessage() . "\n", 3, STORAGE_PATH . '/logs/mail.log');
|
||||
$respond(false, 'mail');
|
||||
}
|
||||
|
||||
$respond(true);
|
||||
$respond($sent, $sent ? '' : 'mail');
|
||||
|
||||
@@ -6,12 +6,11 @@ declare(strict_types=1);
|
||||
* POST /mitglied-werden-senden — Mitgliedsantrag Fußball (schlank, ohne Bankdaten).
|
||||
* Versendet eine interne Vereins-Mail (erfolgskritisch) und eine Eingangsbestätigung
|
||||
* an den Antragsteller (optional, Fehler dort wird nicht an den Besucher geleakt).
|
||||
* Antwort: PRG-Redirect zu /mitglied-werden#anmeldung-fussball (?sent=1 | ?error=…)
|
||||
* oder JSON bei fetch (form.js).
|
||||
* Antwort: PRG-Redirect zu /mitglied-werden#anmeldung-fussball (?sent=1 | ?error=…),
|
||||
* JSON bei fetch (form.js) oder — bei Feldfehlern ohne JS — die Seite direkt
|
||||
* mit HTTP 422, erhaltenen Eingaben und Feldfehlern (render_form_invalid()).
|
||||
*/
|
||||
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
|
||||
$wantsJson = str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json');
|
||||
|
||||
$respond = static function (bool $ok, string $error = '') use ($wantsJson): never {
|
||||
@@ -29,6 +28,12 @@ $field = static fn (string $key): string => trim((string) ($_POST[$key] ?? ''));
|
||||
$route = 'mitglied-werden';
|
||||
$token = $field('ft');
|
||||
|
||||
// Ohne vollständige config/config.php liefe die Seite auf der Vorlage (öffentliches
|
||||
// app_secret, kein SMTP-Passwort) — dann gar nicht erst verarbeiten.
|
||||
if (!config_ready($route)) {
|
||||
$respond(false, 'mail');
|
||||
}
|
||||
|
||||
// --- Spam-Checks: Bots bekommen ein stilles "OK" (kein Feedback-Kanal) ---
|
||||
if ($field('company_url') !== '') {
|
||||
log_spam($route, 'honeypot');
|
||||
@@ -39,12 +44,6 @@ if (!form_token_valid($token)) {
|
||||
$respond(true);
|
||||
}
|
||||
|
||||
// Rate-Limit pro IP (sichtbarer Hinweis statt still — legitime NAT-Nutzer nicht im Dunkeln lassen).
|
||||
if (!rate_limit_ok('submit|' . $route . '|' . client_ip(), 5, 600)) {
|
||||
log_spam($route, 'ratelimit');
|
||||
$respond(false, 'ratelimit');
|
||||
}
|
||||
|
||||
// --- Eingaben ---
|
||||
$firstName = $field('first_name');
|
||||
$lastName = $field('last_name');
|
||||
@@ -55,6 +54,8 @@ $zip = $field('zip');
|
||||
$city = $field('city');
|
||||
$email = $field('email');
|
||||
$feeClass = $field('fee_class');
|
||||
$statute = ($_POST['statute'] ?? '') === '1';
|
||||
$privacy = ($_POST['privacy'] ?? '') === '1';
|
||||
|
||||
// Beitragsklassen-Whitelist identisch zur Seite aus data/mitglied-werden.json ableiten.
|
||||
$data = json_load('mitglied-werden');
|
||||
@@ -63,39 +64,84 @@ $feeOptions = array_map(
|
||||
$data['fussball']['fees']['rows'] ?? []
|
||||
);
|
||||
|
||||
// --- Validierung (Whitelist-Prinzip) ---
|
||||
$valid = $firstName !== '' && mb_strlen($firstName) <= 100
|
||||
&& $lastName !== '' && mb_strlen($lastName) <= 100
|
||||
&& preg_match('/^\d{4}-\d{2}-\d{2}$/', $birthDate) === 1
|
||||
&& $phone !== '' && mb_strlen($phone) <= 50
|
||||
&& $street !== '' && mb_strlen($street) <= 200
|
||||
&& $zip !== '' && mb_strlen($zip) <= 10
|
||||
&& $city !== '' && mb_strlen($city) <= 100
|
||||
&& filter_var($email, FILTER_VALIDATE_EMAIL) !== false
|
||||
&& in_array($feeClass, $feeOptions, true)
|
||||
&& ($_POST['statute'] ?? '') === '1'
|
||||
&& ($_POST['privacy'] ?? '') === '1'
|
||||
&& !preg_match('/[\r\n]/', $firstName . $lastName . $city . $street);
|
||||
|
||||
if (!$valid) {
|
||||
$respond(false, 'validation');
|
||||
// Geburtsdatum: Format, echter Kalendertag und plausibler Zeitraum.
|
||||
$birthValid = false;
|
||||
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $birthDate, $m) === 1) {
|
||||
$birthValid = checkdate((int) $m[2], (int) $m[3], (int) $m[1])
|
||||
&& $birthDate <= date('Y-m-d')
|
||||
&& (int) $m[1] >= (int) date('Y') - 120;
|
||||
}
|
||||
|
||||
// --- Aufkommens-Heuristiken (still abweisen) ---
|
||||
// Globaler Tages-Cap als Brevo-Kosten-Backstop — die Flut soll ihn nicht bemerken.
|
||||
if (!rate_limit_ok('daily', 100, 86400)) {
|
||||
log_spam($route, 'daily-cap');
|
||||
$respond(true);
|
||||
// --- Validierung (Whitelist-Prinzip, Fehler pro Feld) ---
|
||||
$errors = [];
|
||||
if ($firstName === '' || mb_strlen($firstName) > 100 || preg_match('/[\r\n]/', $firstName)) {
|
||||
$errors['mm-first-name'] = 'Bitte gib den Vornamen an (max. 100 Zeichen).';
|
||||
}
|
||||
// Token-Mehrfachnutzung begrenzen (Replay-Schutz, großzügig für menschliches Mehrfach-Senden).
|
||||
if (!rate_limit_ok('token|' . $token, 3, 86400)) {
|
||||
if ($lastName === '' || mb_strlen($lastName) > 100 || preg_match('/[\r\n]/', $lastName)) {
|
||||
$errors['mm-last-name'] = 'Bitte gib den Nachnamen an (max. 100 Zeichen).';
|
||||
}
|
||||
if (!$birthValid) {
|
||||
$errors['mm-birth-date'] = 'Bitte gib ein gültiges Geburtsdatum an.';
|
||||
}
|
||||
if ($phone === '' || mb_strlen($phone) > 50) {
|
||||
$errors['mm-phone'] = 'Bitte gib eine Telefonnummer an (max. 50 Zeichen).';
|
||||
}
|
||||
if ($street === '' || mb_strlen($street) > 200 || preg_match('/[\r\n]/', $street)) {
|
||||
$errors['mm-street'] = 'Bitte gib Straße und Hausnummer an.';
|
||||
}
|
||||
if ($zip === '' || mb_strlen($zip) > 10 || preg_match('/[^\p{L}\d \-]/u', $zip)) {
|
||||
$errors['mm-zip'] = 'Bitte gib eine gültige Postleitzahl an.';
|
||||
}
|
||||
if ($city === '' || mb_strlen($city) > 100 || preg_match('/[\r\n]/', $city)) {
|
||||
$errors['mm-city'] = 'Bitte gib den Wohnort an (max. 100 Zeichen).';
|
||||
}
|
||||
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
|
||||
$errors['mm-email'] = 'Bitte gib eine gültige E-Mail-Adresse an.';
|
||||
}
|
||||
if (!in_array($feeClass, $feeOptions, true)) {
|
||||
$errors['mm-fee-class'] = 'Bitte wähle eine Beitragsklasse aus der Liste.';
|
||||
}
|
||||
if (!$statute) {
|
||||
$errors['mm-statute'] = 'Ohne Anerkennung der Satzung ist keine Mitgliedschaft möglich.';
|
||||
}
|
||||
if (!$privacy) {
|
||||
$errors['mm-privacy'] = 'Ohne Zustimmung zum Datenschutz können wir den Antrag nicht entgegennehmen.';
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
if ($wantsJson) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok' => false, 'error' => 'validation', 'fields' => $errors]);
|
||||
exit;
|
||||
}
|
||||
// Ohne JS: Seite mit Eingaben und Feldfehlern neu rendern statt PRG (Werte bleiben erhalten).
|
||||
render_form_invalid($route, [
|
||||
'first_name' => $firstName, 'last_name' => $lastName, 'birth_date' => $birthDate,
|
||||
'phone' => $phone, 'street' => $street, 'zip' => $zip, 'city' => $city,
|
||||
'email' => $email, 'fee_class' => $feeClass,
|
||||
'statute' => $statute ? '1' : '', 'privacy' => $privacy ? '1' : '',
|
||||
], $errors);
|
||||
}
|
||||
|
||||
// --- Volumen-Grenzen: erst ab hier, damit Tippfehler kein Kontingent verbrauchen.
|
||||
// Sie antworten ehrlich — stilles "Danke" ohne Versand verschluckt echte Anträge. ---
|
||||
if (!rate_limit_ok('submit|' . $route . '|' . client_ip(), 5, 600)) {
|
||||
log_spam($route, 'ratelimit');
|
||||
$respond(false, 'ratelimit');
|
||||
}
|
||||
// Token-Mehrfachnutzung begrenzen (Replay-Schutz). IP im Schlüssel, weil form_token()
|
||||
// sekundengenau ist: sonst teilen sich alle Besucher derselben Sekunde ein Kontingent.
|
||||
if (!rate_limit_ok('token|' . $token . '|' . client_ip(), 3, 86400)) {
|
||||
log_spam($route, 'replay');
|
||||
$respond(true);
|
||||
$respond(false, 'ratelimit');
|
||||
}
|
||||
// Globaler Tages-Cap über alle Formulare: schützt Postfach und Absender-Reputation.
|
||||
if (!rate_limit_ok('daily', 200, 86400)) {
|
||||
log_spam($route, 'daily-cap');
|
||||
$respond(false, 'busy');
|
||||
}
|
||||
|
||||
// --- Versand ---
|
||||
$smtp = config('smtp');
|
||||
|
||||
$body = "Neuer Mitgliedsantrag (Fußball) über tsv08kulmbach.de\n"
|
||||
. str_repeat('-', 40) . "\n"
|
||||
. "Vorname: {$firstName}\n"
|
||||
@@ -109,49 +155,31 @@ $body = "Neuer Mitgliedsantrag (Fußball) über tsv08kulmbach.de\n"
|
||||
. "Satzung anerkannt: ja\n"
|
||||
. "Datenschutz: ja\n"
|
||||
. 'Eingegangen: ' . date('d.m.Y H:i') . "\n\n"
|
||||
. "Hinweis: Bankdaten werden online nicht erhoben – Beitrittserklärung\n"
|
||||
. "Hinweis: Bankdaten werden online nicht erhoben. Beitrittserklärung\n"
|
||||
. "per Post zur Unterschrift zusenden.\n";
|
||||
|
||||
$buildMailer = static function () use ($smtp): PHPMailer {
|
||||
$mail = new PHPMailer(true);
|
||||
$mail->isSMTP();
|
||||
$mail->Host = $smtp['host'];
|
||||
$mail->Port = (int) $smtp['port'];
|
||||
$mail->SMTPAuth = true;
|
||||
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
|
||||
$mail->Username = $smtp['username'];
|
||||
$mail->Password = $smtp['password'];
|
||||
$mail->CharSet = PHPMailer::CHARSET_UTF8;
|
||||
return $mail;
|
||||
};
|
||||
|
||||
// Mail 1 (intern) — erfolgskritisch.
|
||||
try {
|
||||
$mail = $buildMailer();
|
||||
$mail->setFrom($smtp['from'], $smtp['from_name']);
|
||||
$mail->addAddress($smtp['to']);
|
||||
$mail->addReplyTo($email, $firstName . ' ' . $lastName);
|
||||
$mail->Subject = 'Mitgliedsantrag Fußball: ' . $firstName . ' ' . $lastName;
|
||||
$mail->Body = $body;
|
||||
$mail->send();
|
||||
} catch (Throwable $e) {
|
||||
error_log('[' . date('c') . '] Mitgliedsantrag-Versand fehlgeschlagen: ' . $e->getMessage() . "\n", 3, STORAGE_PATH . '/logs/mail.log');
|
||||
$sent = send_mail([
|
||||
'context' => 'Mitgliedsantrag',
|
||||
'subject' => 'Mitgliedsantrag Fußball: ' . $firstName . ' ' . $lastName,
|
||||
'body' => $body,
|
||||
'reply_to' => ['email' => $email, 'name' => $firstName . ' ' . $lastName],
|
||||
]);
|
||||
|
||||
if (!$sent) {
|
||||
$respond(false, 'mail');
|
||||
}
|
||||
|
||||
// Mail 2 (Eingangsbestätigung an Antragsteller) — optional, Fehler nicht an Besucher leaken.
|
||||
// Mail 2 (Eingangsbestätigung an den Antragsteller) — optional, Fehler nur ins Log.
|
||||
$welcome = $data['welcome_mail'] ?? null;
|
||||
if (is_array($welcome) && !empty($welcome['subject']) && !empty($welcome['body'])) {
|
||||
try {
|
||||
$confirm = $buildMailer();
|
||||
$confirm->setFrom($smtp['from'], $smtp['from_name']);
|
||||
$confirm->addAddress($email, $firstName . ' ' . $lastName);
|
||||
$confirm->Subject = $welcome['subject'];
|
||||
$confirm->Body = $welcome['body'];
|
||||
$confirm->send();
|
||||
} catch (Throwable $e) {
|
||||
error_log('[' . date('c') . '] Bestätigungsmail fehlgeschlagen: ' . $e->getMessage() . "\n", 3, STORAGE_PATH . '/logs/mail.log');
|
||||
}
|
||||
send_mail([
|
||||
'context' => 'Bestätigungsmail Mitgliedsantrag',
|
||||
'to' => $email,
|
||||
'to_name' => $firstName . ' ' . $lastName,
|
||||
'subject' => (string) $welcome['subject'],
|
||||
'body' => (string) $welcome['body'],
|
||||
]);
|
||||
}
|
||||
|
||||
$respond(true);
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* POST /sportheimbuchung-senden — Sportheim-Buchungsanfrage validieren und via Brevo SMTP versenden.
|
||||
* Antwort: PRG-Redirect zu /sportheimbuchung#buchung (?sent=1 | ?error=…).
|
||||
* POST /sportheimbuchung-senden — Sportheim-Buchungsanfrage validieren und über
|
||||
* All-Inkl-SMTP versenden.
|
||||
* Antwort: PRG-Redirect zu /sportheimbuchung#buchung (?sent=1 | ?error=…),
|
||||
* JSON bei fetch (form.js) oder — bei Feldfehlern ohne JS — die Seite direkt
|
||||
* mit HTTP 422, erhaltenen Eingaben und Feldfehlern (render_form_invalid()).
|
||||
*/
|
||||
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
|
||||
$wantsJson = str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json');
|
||||
|
||||
$respond = static function (bool $ok, string $error = '') use ($wantsJson): never {
|
||||
@@ -26,6 +27,12 @@ $field = static fn (string $key): string => trim((string) ($_POST[$key] ?? ''));
|
||||
$route = 'sportheimbuchung';
|
||||
$token = $field('ft');
|
||||
|
||||
// Ohne vollständige config/config.php liefe die Seite auf der Vorlage (öffentliches
|
||||
// app_secret, kein SMTP-Passwort) — dann gar nicht erst verarbeiten.
|
||||
if (!config_ready($route)) {
|
||||
$respond(false, 'mail');
|
||||
}
|
||||
|
||||
// --- Spam-Checks: Bots bekommen ein stilles "OK" (kein Feedback-Kanal) ---
|
||||
if ($field('company_url') !== '') {
|
||||
log_spam($route, 'honeypot');
|
||||
@@ -36,13 +43,7 @@ if (!form_token_valid($token)) {
|
||||
$respond(true);
|
||||
}
|
||||
|
||||
// Rate-Limit pro IP (sichtbarer Hinweis statt still — legitime NAT-Nutzer nicht im Dunkeln lassen).
|
||||
if (!rate_limit_ok('submit|' . $route . '|' . client_ip(), 5, 600)) {
|
||||
log_spam($route, 'ratelimit');
|
||||
$respond(false, 'ratelimit');
|
||||
}
|
||||
|
||||
// --- Validierung ---
|
||||
// --- Eingaben ---
|
||||
$name = $field('name');
|
||||
$email = $field('email');
|
||||
$phone = $field('phone');
|
||||
@@ -50,43 +51,86 @@ $eventType = $field('event_type');
|
||||
$eventDate = $field('event_date');
|
||||
$attendees = $field('attendees');
|
||||
$message = $field('message');
|
||||
$privacy = ($_POST['privacy'] ?? '') === '1';
|
||||
|
||||
$validTypes = ['', 'Geburtstagsfeier', 'Party', 'Vereinsveranstaltung', 'Weihnachtsfeier', 'Firmenveranstaltung', 'Sonstiges'];
|
||||
// Veranstaltungsarten aus derselben Quelle wie das Formular (data/sportheimbuchung.json).
|
||||
$validTypes = json_load('sportheimbuchung')['form']['event_types'] ?? [];
|
||||
|
||||
$valid = $name !== '' && mb_strlen($name) <= 200
|
||||
&& filter_var($email, FILTER_VALIDATE_EMAIL) !== false
|
||||
&& mb_strlen($phone) <= 50
|
||||
&& in_array($eventType, $validTypes, true)
|
||||
&& ($eventDate === '' || preg_match('/^\d{4}-\d{2}-\d{2}$/', $eventDate))
|
||||
&& ($attendees === '' || (ctype_digit($attendees) && (int) $attendees >= 1 && (int) $attendees <= 500))
|
||||
&& mb_strlen($message) <= 3000
|
||||
&& ($_POST['privacy'] ?? '') === '1'
|
||||
&& !preg_match('/[\r\n]/', $name);
|
||||
|
||||
if (!$valid) {
|
||||
$respond(false, 'validation');
|
||||
// Wunschdatum: echter Kalendertag, nicht in der Vergangenheit, höchstens 3 Jahre voraus.
|
||||
$dateValid = false;
|
||||
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $eventDate, $m) === 1) {
|
||||
$dateValid = checkdate((int) $m[2], (int) $m[3], (int) $m[1])
|
||||
&& $eventDate >= date('Y-m-d')
|
||||
&& $eventDate <= date('Y-m-d', strtotime('+3 years'));
|
||||
}
|
||||
|
||||
// --- Inhalts-/Aufkommens-Heuristiken (still abweisen, Bot-typisch) ---
|
||||
// --- Validierung (Whitelist-Prinzip, Fehler pro Feld) ---
|
||||
$errors = [];
|
||||
if ($name === '' || mb_strlen($name) > 200 || preg_match('/[\r\n]/', $name)) {
|
||||
$errors['buch-name'] = 'Bitte gib deinen Namen an (max. 200 Zeichen).';
|
||||
}
|
||||
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
|
||||
$errors['buch-email'] = 'Bitte gib eine gültige E-Mail-Adresse an.';
|
||||
}
|
||||
if (mb_strlen($phone) > 50) {
|
||||
$errors['buch-phone'] = 'Die Telefonnummer ist zu lang (max. 50 Zeichen).';
|
||||
}
|
||||
if ($eventType !== '' && !in_array($eventType, $validTypes, true)) {
|
||||
$errors['buch-type'] = 'Bitte wähle eine der angebotenen Veranstaltungsarten.';
|
||||
}
|
||||
if (!$dateValid) {
|
||||
$errors['buch-date'] = 'Bitte gib ein Wunschdatum an (heute oder später).';
|
||||
}
|
||||
if (!ctype_digit($attendees) || (int) $attendees < 1 || (int) $attendees > 500) {
|
||||
$errors['buch-attendees'] = 'Bitte gib die ungefähre Personenzahl an (1 bis 500).';
|
||||
}
|
||||
if (mb_strlen($message) > 3000) {
|
||||
$errors['buch-message'] = 'Der Text ist zu lang (max. 3000 Zeichen).';
|
||||
}
|
||||
if (!$privacy) {
|
||||
$errors['buch-privacy'] = 'Ohne Zustimmung zum Datenschutz können wir die Anfrage nicht entgegennehmen.';
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
if ($wantsJson) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok' => false, 'error' => 'validation', 'fields' => $errors]);
|
||||
exit;
|
||||
}
|
||||
// Ohne JS: Seite mit Eingaben und Feldfehlern neu rendern statt PRG (Werte bleiben erhalten).
|
||||
render_form_invalid($route, [
|
||||
'name' => $name, 'email' => $email, 'phone' => $phone, 'event_type' => $eventType,
|
||||
'event_date' => $eventDate, 'attendees' => $attendees, 'message' => $message,
|
||||
'privacy' => $privacy ? '1' : '',
|
||||
], $errors);
|
||||
}
|
||||
|
||||
// --- Inhalts-Heuristik (still abweisen, Bot-typisch) ---
|
||||
if (preg_match_all('~https?://~i', $message) > 2) {
|
||||
log_spam($route, 'links');
|
||||
$respond(true);
|
||||
}
|
||||
// Globaler Tages-Cap als Brevo-Kosten-Backstop — die Flut soll ihn nicht bemerken.
|
||||
if (!rate_limit_ok('daily', 100, 86400)) {
|
||||
log_spam($route, 'daily-cap');
|
||||
$respond(true);
|
||||
|
||||
// --- Volumen-Grenzen: erst ab hier, damit Tippfehler kein Kontingent verbrauchen.
|
||||
// Sie antworten ehrlich — stilles "Danke" ohne Versand verschluckt echte Anfragen. ---
|
||||
if (!rate_limit_ok('submit|' . $route . '|' . client_ip(), 5, 600)) {
|
||||
log_spam($route, 'ratelimit');
|
||||
$respond(false, 'ratelimit');
|
||||
}
|
||||
// Token-Mehrfachnutzung begrenzen (Replay-Schutz, großzügig für menschliches Mehrfach-Senden).
|
||||
if (!rate_limit_ok('token|' . $token, 3, 86400)) {
|
||||
// Token-Mehrfachnutzung begrenzen (Replay-Schutz). IP im Schlüssel, weil form_token()
|
||||
// sekundengenau ist: sonst teilen sich alle Besucher derselben Sekunde ein Kontingent.
|
||||
if (!rate_limit_ok('token|' . $token . '|' . client_ip(), 3, 86400)) {
|
||||
log_spam($route, 'replay');
|
||||
$respond(true);
|
||||
$respond(false, 'ratelimit');
|
||||
}
|
||||
// Globaler Tages-Cap über alle Formulare: schützt Postfach und Absender-Reputation.
|
||||
if (!rate_limit_ok('daily', 200, 86400)) {
|
||||
log_spam($route, 'daily-cap');
|
||||
$respond(false, 'busy');
|
||||
}
|
||||
|
||||
// --- Versand ---
|
||||
$smtp = config('smtp');
|
||||
|
||||
$dateFormatted = $eventDate !== '' ? date('d.m.Y', strtotime($eventDate)) : '–';
|
||||
$dateFormatted = date('d.m.Y', (int) strtotime($eventDate));
|
||||
|
||||
$body = "Sportheimbuchung-Anfrage über tsv08kulmbach.de\n"
|
||||
. str_repeat('-', 40) . "\n"
|
||||
@@ -95,33 +139,17 @@ $body = "Sportheimbuchung-Anfrage über tsv08kulmbach.de\n"
|
||||
. ($phone !== '' ? "Telefon: {$phone}\n" : '')
|
||||
. ($eventType !== '' ? "Veranstaltungstyp: {$eventType}\n" : '')
|
||||
. "Gewünschtes Datum: {$dateFormatted}\n"
|
||||
. ($attendees !== '' ? "Personenzahl: {$attendees}\n" : '')
|
||||
. "Personenzahl: {$attendees}\n"
|
||||
. str_repeat('-', 40) . "\n\n"
|
||||
. ($message !== '' ? $message . "\n\n" : '')
|
||||
. str_repeat('-', 40) . "\n"
|
||||
. 'Datenschutz zugestimmt: ja (' . date('d.m.Y H:i') . ")\n";
|
||||
|
||||
try {
|
||||
$mail = new PHPMailer(true);
|
||||
$mail->isSMTP();
|
||||
$mail->Host = $smtp['host'];
|
||||
$mail->Port = (int) $smtp['port'];
|
||||
$mail->SMTPAuth = true;
|
||||
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
|
||||
$mail->Username = $smtp['username'];
|
||||
$mail->Password = $smtp['password'];
|
||||
$mail->CharSet = PHPMailer::CHARSET_UTF8;
|
||||
$sent = send_mail([
|
||||
'context' => 'Sportheimbuchung',
|
||||
'subject' => 'Sportheimbuchung: ' . ($eventType !== '' ? $eventType : 'Anfrage') . ', ' . $dateFormatted,
|
||||
'body' => $body,
|
||||
'reply_to' => ['email' => $email, 'name' => $name],
|
||||
]);
|
||||
|
||||
$mail->setFrom($smtp['from'], $smtp['from_name']);
|
||||
$mail->addAddress($smtp['to']);
|
||||
$mail->addReplyTo($email, $name);
|
||||
$mail->Subject = 'Sportheimbuchung: ' . ($eventType !== '' ? $eventType : 'Anfrage') . ', ' . $dateFormatted;
|
||||
$mail->Body = $body;
|
||||
|
||||
$mail->send();
|
||||
} catch (Throwable $e) {
|
||||
error_log('[' . date('c') . '] Sportheimbuchung-Mailversand fehlgeschlagen: ' . $e->getMessage() . "\n", 3, STORAGE_PATH . '/logs/mail.log');
|
||||
$respond(false, 'mail');
|
||||
}
|
||||
|
||||
$respond(true);
|
||||
$respond($sent, $sent ? '' : 'mail');
|
||||
|
||||
@@ -6,11 +6,23 @@ declare(strict_types=1);
|
||||
* Werbe-/Sponsoren-Banner-Slider. Daten: data/banners.json (Single Source of Truth).
|
||||
* Layout je Slide: 2/3 Titel + Text + CTA, 1/3 Firmenlogo.
|
||||
* Pro Slide optional: "variant" (CSS-Modifier, z. B. "house" für Eigenwerbung),
|
||||
* "tag" (Badge-Text, Default "Anzeige"), "background" (Bild), "cta.internal"
|
||||
* "tag" (Badge-Text, Default "Anzeige"), "cta.internal"
|
||||
* (interner Link ohne target=_blank/rel=sponsored, für eigene Seiten/Anker),
|
||||
* "cta_secondary" (zweiter Button als Outline), "logo_light" (helle Logo-Platte
|
||||
* für dunkle Firmenlogos), "accent" (CSS-Farbe; überschreibt das Vereins-Rot
|
||||
* der Buttons für diese Slide, z. B. Sponsor-CI).
|
||||
* für dunkle Firmenlogos).
|
||||
*
|
||||
* "background" ist ein Varianten-Set {base, widths} und wird als <img> gerendert,
|
||||
* NICHT als CSS-Hintergrund. Grund: die CSP (default-src 'self', kein style-src)
|
||||
* verwirft jedes style-Attribut, eine Inline-Variable --banner-bg käme also nie an.
|
||||
* Das fiel lokal nie auf, weil `php -S` keine .htaccess und damit keine CSP kennt —
|
||||
* erst auf echtem Apache blieben die Slides schwarz (31.07.2026). Als <img> ist es
|
||||
* ohnehin besser: srcset lädt am Handy die 640er statt der 1600er. Ein String bleibt
|
||||
* als Einzeldatei zulässig (Bestandsschutz, kein srcset).
|
||||
*
|
||||
* "accent" ist ein NAME, keine Farbe ("breadcrumb") — die Farbe steht als Token in
|
||||
* tokens.css, die Klasse ad-banner__slide--accent-<name> in components.css. Ein
|
||||
* beliebiger Hex-Wert aus der JSON käme nur per Inline-Style in die Seite, und den
|
||||
* verwirft die CSP. Neuer Sponsor mit eigener CI = ein Token plus eine Klasse.
|
||||
* Auto-Rotation + Fortschritts-Indikatoren übernimmt der generische Antrieb
|
||||
* assets/js/carousel.js (pausiert bei Hover/Fokus, respektiert prefers-reduced-motion).
|
||||
* Ohne JS: nur der erste Banner ist sichtbar (Graceful Degradation).
|
||||
@@ -34,25 +46,30 @@ $total = count($banners);
|
||||
$variant = $banner['variant'] ?? null;
|
||||
$accent = $banner['accent'] ?? null;
|
||||
|
||||
// Slug-Filter für beides: der Wert landet in einem Klassennamen, nie ungeprüft.
|
||||
$slug = static fn (string $v): string => (string) preg_replace('/[^a-z0-9-]+/', '', strtolower($v));
|
||||
|
||||
$classes = 'ad-banner__slide';
|
||||
if ($bg) {
|
||||
$classes .= ' ad-banner__slide--bg';
|
||||
}
|
||||
if ($accent) {
|
||||
$classes .= ' ad-banner__slide--accent';
|
||||
$classes .= ' ad-banner__slide--accent ad-banner__slide--accent-' . $slug((string) $accent);
|
||||
}
|
||||
if ($variant) {
|
||||
$classes .= ' ad-banner__slide--' . preg_replace('/[^a-z0-9-]+/', '', strtolower($variant));
|
||||
$classes .= ' ad-banner__slide--' . $slug((string) $variant);
|
||||
}
|
||||
|
||||
$styleParts = [];
|
||||
if ($bg) {
|
||||
$styleParts[] = '--banner-bg: url(' . asset($bg) . ')';
|
||||
// Hintergrundbild als Bild-Prop für die img-Komponente. alt="" — der Hintergrund
|
||||
// ist Dekoration, die Aussage der Slide steht als Text daneben.
|
||||
$bgImage = null;
|
||||
if (is_array($bg) && !empty($bg['base'])) {
|
||||
$bgImage = ['base' => $bg['base'], 'widths' => $bg['widths'] ?? [1600], 'alt' => ''];
|
||||
} elseif (is_string($bg) && $bg !== '') {
|
||||
$datei = PUBLIC_PATH . '/assets/' . ltrim($bg, '/');
|
||||
[$w, $h] = is_file($datei) ? (getimagesize($datei) ?: [0, 0]) : [0, 0];
|
||||
$bgImage = ['src' => $bg, 'width' => $w, 'height' => $h, 'alt' => ''];
|
||||
}
|
||||
if ($accent) {
|
||||
$styleParts[] = '--banner-accent: ' . $accent;
|
||||
}
|
||||
$style = $styleParts !== [] ? ' style="' . e(implode('; ', $styleParts)) . '"' : '';
|
||||
?>
|
||||
<?php
|
||||
// Rendert einen CTA-Link; externe Links öffnen im neuen Tab (rel=sponsored),
|
||||
@@ -69,7 +86,17 @@ $total = count($banners);
|
||||
. '</a>';
|
||||
};
|
||||
?>
|
||||
<li class="<?= e($classes) ?>"<?= $style ?> data-carousel-slide role="group" aria-roledescription="Folie" aria-label="<?= ($i + 1) ?> von <?= $total ?>"<?= $i === 0 ? '' : ' hidden' ?>>
|
||||
<li class="<?= e($classes) ?>" data-carousel-slide role="group" aria-roledescription="Folie" aria-label="<?= ($i + 1) ?> von <?= $total ?>"<?= $i === 0 ? '' : ' hidden' ?>>
|
||||
<?php if ($bgImage !== null): ?>
|
||||
<?php /* Erstes Kind und absolut hinter dem Inhalt. Die erste Slide lädt eager
|
||||
(sie ist above the fold und trägt das LCP), die folgenden lazy. */ ?>
|
||||
<?php component('img', [
|
||||
'image' => $bgImage,
|
||||
'class' => 'ad-banner__bg',
|
||||
'sizes' => '(max-width: 1200px) 100vw, 1200px',
|
||||
'eager' => $i === 0,
|
||||
]); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($banner['logo'])): ?>
|
||||
<div class="ad-banner__logo<?= !empty($banner['logo_light']) ? ' ad-banner__logo--light' : '' ?>">
|
||||
<img src="<?= e(asset($banner['logo'])) ?>" alt="<?= e($banner['company'] ?? $banner['title']) ?>"<?= img_intrinsic_attrs($banner['logo']) ?> loading="lazy" decoding="async">
|
||||
@@ -98,7 +125,7 @@ $total = count($banners);
|
||||
<?php if ($total > 1): ?>
|
||||
<?php /* Ohne JS verborgen (Steuerung wäre funktionslos); carousel.js blendet sie ein. */ ?>
|
||||
<div class="ad-banner__controls" data-carousel-controls hidden>
|
||||
<button class="ad-banner__nav-btn" type="button" data-carousel-prev aria-label="Vorherige Anzeige">
|
||||
<button class="btn btn--icon ad-banner__nav-btn" type="button" data-carousel-prev aria-label="Vorherige Anzeige">
|
||||
<?= icon('chevron-left') ?>
|
||||
</button>
|
||||
<div class="ad-banner__indicators" role="group" aria-label="Anzeige wählen">
|
||||
@@ -108,11 +135,11 @@ $total = count($banners);
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<button class="ad-banner__nav-btn" type="button" data-carousel-next aria-label="Nächste Anzeige">
|
||||
<button class="btn btn--icon ad-banner__nav-btn" type="button" data-carousel-next aria-label="Nächste Anzeige">
|
||||
<?= icon('chevron-right') ?>
|
||||
</button>
|
||||
<?php /* Pause/Play — WCAG 2.2.2. Nur bei aktiver Auto-Rotation sichtbar. */ ?>
|
||||
<button class="ad-banner__toggle" type="button" data-carousel-toggle aria-pressed="false" aria-controls="ad-banner-track" hidden>
|
||||
<button class="btn btn--icon ad-banner__toggle" type="button" data-carousel-toggle aria-pressed="false" aria-controls="ad-banner-track" hidden>
|
||||
<?= icon('pause-fill', 'ad-banner__toggle-icon ad-banner__toggle-icon--pause') ?>
|
||||
<?= icon('play-fill', 'ad-banner__toggle-icon ad-banner__toggle-icon--play') ?>
|
||||
<span class="visually-hidden" data-carousel-toggle-label>Automatischen Wechsel pausieren</span>
|
||||
|
||||
@@ -9,6 +9,20 @@ declare(strict_types=1);
|
||||
* $return string optional — Slug der absendenden Seite (Default '' = Startseite). Die Action
|
||||
* leitet nach PRG hierher zurück (whitelist-validiert), damit /kontakt nicht auf die
|
||||
* Startseite springt.
|
||||
* $notice string optional — hervorgehobener Hinweis DIREKT über dem Formular, plus
|
||||
* $noticeTitle, $noticeTone und $noticeIcon (siehe components/notice.php). Für Dinge, die man wissen muss, BEVOR
|
||||
* man schreibt (Wartelisten-Status der Turnabteilung). Steht bewusst außerhalb
|
||||
* des <form> und damit außerhalb der role="status"-Region: das ist ein
|
||||
* dauerhafter Hinweis, keine Rückmeldung zum Absenden — in der Live-Region
|
||||
* würde ihn ein Screenreader bei jeder Formularmeldung erneut vorlesen.
|
||||
* Der Text selbst gehört in die data/*.json der aufrufenden Seite, damit er
|
||||
* nicht zweimal existiert (auf /turnen ist es derselbe Hinweis, den
|
||||
* group-schedule.php auf der Kinderturn-Unterseite als Kachel zeigt).
|
||||
* $department string optional — '' (allgemein) oder 'turnen'. Steuert Auswahlliste,
|
||||
* Empfänger und Feldregeln über contact_variant() (helpers.php, einzige Quelle).
|
||||
* Die Turn-Variante geht an die Abteilung statt an info@ und braucht keinen
|
||||
* Betreff, weil die Disziplin-Auswahl ihn ersetzt. Bewusst dieselbe Komponente
|
||||
* und dieselbe Action: ein zweites Formular hieße ein zweiter Spam-Schutz.
|
||||
* Felder rendern ausschließlich über component('form-field') — siehe CLAUDE.md.
|
||||
* Liest Flash-Status aus Query-Params (?sent=1 / ?error=...) — PRG-Muster.
|
||||
* Spam-Schutz: Honeypot "company_url" + signierter Timestamp "ft" (form_token()).
|
||||
@@ -16,24 +30,42 @@ declare(strict_types=1);
|
||||
$club = json_load('club');
|
||||
$return = $return ?? '';
|
||||
$intro = $intro ?? null;
|
||||
$notice = $notice ?? '';
|
||||
$noticeTitle = $noticeTitle ?? '';
|
||||
$noticeTone = $noticeTone ?? 'info';
|
||||
$noticeIcon = $noticeIcon ?? 'info-circle';
|
||||
|
||||
$interests = ['Herrenfußball', 'Frauenfußball', 'Jugendfußball', 'Turnen', 'Sonstiges'];
|
||||
$variant = contact_variant((string) ($department ?? ''));
|
||||
$interests = $variant['interests'];
|
||||
// Sichtbare Direkt-Adresse: bei einer Abteilungsanfrage die der Abteilung.
|
||||
$directMail = $variant['to'] !== '' ? $variant['to'] : $club['email'];
|
||||
|
||||
// Fehlerquelle: PRG-Query (?error=…) oder — beim 422-Re-Render ohne JS — die
|
||||
// serverseitigen Feldfehler aus der Action.
|
||||
$sent = isset($_GET['sent']);
|
||||
$error = isset($_GET['error']) ? (string) $_GET['error'] : null;
|
||||
$error = isset($_GET['error']) ? (string) $_GET['error'] : (form_has_errors() ? 'validation' : null);
|
||||
$errorMessages = [
|
||||
'validation' => 'Bitte prüfe deine Eingaben – Pflichtfelder fehlen oder die E-Mail-Adresse ist ungültig.',
|
||||
'validation' => 'Bitte prüfe deine Eingaben: Die markierten Felder brauchen noch etwas Aufmerksamkeit.',
|
||||
'ratelimit' => 'Zu viele Anfragen in kurzer Zeit. Bitte versuche es in ein paar Minuten erneut.',
|
||||
'mail' => 'Deine Nachricht konnte gerade nicht versendet werden. Bitte versuche es später erneut oder schreib uns direkt an ' . $club['email'] . '.',
|
||||
'busy' => 'Bei uns gehen gerade ungewöhnlich viele Anfragen ein. Bitte versuche es später noch einmal oder schreib uns direkt an ' . $directMail . '.',
|
||||
'mail' => 'Deine Nachricht konnte gerade nicht versendet werden. Bitte versuche es später erneut oder schreib uns direkt an ' . $directMail . '.',
|
||||
];
|
||||
|
||||
$f = [
|
||||
'name' => ['type' => 'text', 'name' => 'name', 'id' => 'contact-name', 'label' => 'Name', 'required' => true, 'autocomplete' => 'name', 'maxlength' => 200],
|
||||
'email' => ['type' => 'email', 'name' => 'email', 'id' => 'contact-email', 'label' => 'E-Mail', 'required' => true, 'autocomplete' => 'email', 'maxlength' => 200],
|
||||
'phone' => ['type' => 'tel', 'name' => 'phone', 'id' => 'contact-phone', 'label' => 'Telefonnummer', 'autocomplete' => 'tel', 'maxlength' => 50],
|
||||
'interest' => ['type' => 'select', 'name' => 'interest', 'id' => 'contact-interest', 'label' => 'Ich interessiere mich für', 'options' => $interests],
|
||||
'interest' => ['type' => 'select', 'name' => 'interest', 'id' => 'contact-interest', 'label' => 'Ich interessiere mich für', 'options' => $interests, 'required' => $variant['interest_required']],
|
||||
// Optional und nur in der Turn-Variante (siehe contact_variant). max/min
|
||||
// begrenzen die Auswahl im Datepicker schon im Browser; die Action prüft es
|
||||
// serverseitig noch einmal.
|
||||
'birthdate' => [
|
||||
'type' => 'date', 'name' => 'birthdate', 'id' => 'contact-birthdate',
|
||||
'label' => 'Geburtsdatum des Kindes', 'hint' => 'Optional. Hilft uns, die passende Gruppe zu finden.',
|
||||
'min' => '1900-01-01', 'max' => date('Y-m-d'),
|
||||
],
|
||||
'subject' => ['type' => 'text', 'name' => 'subject', 'id' => 'contact-subject', 'label' => 'Betreff', 'required' => true, 'maxlength' => 200],
|
||||
'message' => ['type' => 'textarea', 'name' => 'message', 'id' => 'contact-message', 'label' => 'Nachricht', 'required' => true, 'maxlength' => 5000, 'rows' => 6],
|
||||
'message' => ['type' => 'textarea', 'name' => 'message', 'id' => 'contact-message', 'label' => 'Nachricht', 'required' => $variant['message_required'], 'maxlength' => 5000, 'rows' => 6],
|
||||
'privacy' => [
|
||||
'type' => 'checkbox', 'name' => 'privacy', 'id' => 'contact-privacy', 'required' => true,
|
||||
'label' => 'Ich stimme den Datenschutzbestimmungen zu.',
|
||||
@@ -47,11 +79,25 @@ $f = [
|
||||
<div class="contact__intro">
|
||||
<h2><?= e($intro['title']) ?></h2>
|
||||
<p><?= e($intro['text']) ?></p>
|
||||
<p class="text-muted">Oder direkt per Mail an <a href="mailto:<?= e($club['email']) ?>"><?= e($club['email']) ?></a></p>
|
||||
<p class="text-muted">Oder direkt per Mail an <a href="mailto:<?= e($directMail) ?>"><?= e($directMail) ?></a></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($notice !== ''): ?>
|
||||
<?php /* level h3, weil der Intro-Block direkt darüber die h2 der Sektion trägt — so
|
||||
bleibt die Überschriften-Folge ohne Sprung. Kein role="alert": der Hinweis steht
|
||||
schon beim Laden da und ist keine Reaktion auf eine Eingabe. */ ?>
|
||||
<?php component('notice', [
|
||||
'title' => $noticeTitle,
|
||||
'text' => $notice,
|
||||
'tone' => $noticeTone,
|
||||
'icon' => $noticeIcon,
|
||||
'level' => 'h3',
|
||||
'class' => 'contact__notice',
|
||||
]); ?>
|
||||
<?php endif; ?>
|
||||
<form class="form" method="post" action="<?= e(url('kontakt-senden')) ?>" novalidate>
|
||||
<div class="form__status" role="status" aria-live="polite" tabindex="-1" data-form-status>
|
||||
<?php /* autofocus nur beim 422-Re-Render: bringt den Fokus auch ohne JS in die Fehlermeldung */ ?>
|
||||
<div class="form__status" role="status" aria-live="polite" tabindex="-1" data-form-status<?= form_has_errors() ? ' autofocus' : '' ?>>
|
||||
<?php if ($sent): ?>
|
||||
<p class="form__success">Danke für deine Anfrage! Wir melden uns so schnell wie möglich bei dir.</p>
|
||||
<?php elseif ($error !== null): ?>
|
||||
@@ -67,9 +113,16 @@ $f = [
|
||||
<div class="form__row">
|
||||
<?php component('form-field', ['field' => $f['phone']]); ?>
|
||||
<?php component('form-field', ['field' => $f['interest']]); ?>
|
||||
<?php if ($variant['ask_birthdate']): ?>
|
||||
<?php component('form-field', ['field' => $f['birthdate']]); ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php /* Kein Betreff in der Abteilungsvariante: die Disziplin-Auswahl sagt schon,
|
||||
worum es geht, und die Action baut den Mail-Betreff daraus. */ ?>
|
||||
<?php if ($variant['ask_subject']): ?>
|
||||
<?php component('form-field', ['field' => $f['subject']]); ?>
|
||||
<?php endif; ?>
|
||||
<?php component('form-field', ['field' => $f['message']]); ?>
|
||||
<?php component('form-field', ['field' => $f['privacy']]); ?>
|
||||
|
||||
@@ -80,6 +133,7 @@ $f = [
|
||||
</div>
|
||||
<input type="hidden" name="ft" value="<?= e(form_token()) ?>">
|
||||
<input type="hidden" name="return" value="<?= e($return) ?>">
|
||||
<input type="hidden" name="department" value="<?= e($variant['department']) ?>">
|
||||
|
||||
<p class="form__submit">
|
||||
<button class="btn" type="submit">Nachricht senden</button>
|
||||
|
||||
@@ -5,11 +5,16 @@ declare(strict_types=1);
|
||||
/**
|
||||
* CTA-Button. Props: $cta array{label: string, slug: string,
|
||||
* style?: 'primary'|'outline', size?: 'sm'|'lg'}
|
||||
* Alternativ zu 'slug': 'href' für nicht-Seiten-Ziele (mailto:, tel:) — gewinnt
|
||||
* gegen slug/hash. Externe http-Ziele gehören hier nicht rein (siehe Regel 1).
|
||||
*/
|
||||
$style = $cta['style'] ?? 'primary';
|
||||
$size = $cta['size'] ?? null;
|
||||
$class = 'btn'
|
||||
. ($style === 'outline' ? ' btn--outline' : '')
|
||||
. ($size === 'lg' ? ' btn--lg' : ($size === 'sm' ? ' btn--sm' : ''));
|
||||
$href = !empty($cta['href'])
|
||||
? $cta['href']
|
||||
: url($cta['slug']) . (!empty($cta['hash']) ? '#' . $cta['hash'] : '');
|
||||
?>
|
||||
<a class="<?= e($class) ?>" href="<?= e(url($cta['slug'])) . (!empty($cta['hash']) ? '#' . e($cta['hash']) : '') ?>"><?= e($cta['label']) ?></a>
|
||||
<a class="<?= e($class) ?>" href="<?= e($href) ?>"><?= e($cta['label']) ?></a>
|
||||
|
||||
@@ -5,12 +5,16 @@ declare(strict_types=1);
|
||||
/**
|
||||
* Abteilungs-Auswahl: gleichwertige Karten, die auf Anker derselben Seite springen.
|
||||
* Props:
|
||||
* $choice array{title?, intro?, items: [{kicker?, title, text, label, anchor}]}
|
||||
* $choice array{title?, intro?, items: [{kicker?, title, text, label, anchor, image?}]}
|
||||
* $anchor string (optional, Sektions-ID)
|
||||
* $title_hidden bool — Titel nur für Screenreader ausgeben (hält die Heading-Kette
|
||||
* h1 → h2 → h3 intakt, wenn die Karten optisch für sich sprechen)
|
||||
* $item['image'] optional: {base, widths, alt} — Bildband oben, wie team-cards.
|
||||
* Bewusst eigenes <a href="#…">-Markup (nicht die cta-Komponente): cta() schickt
|
||||
* jeden Slug durch url() und würde aus "#anker" ein "/#anker" (Startseite) machen.
|
||||
*/
|
||||
$anchor = $anchor ?? null;
|
||||
$title_hidden = !empty($title_hidden);
|
||||
if (empty($choice['items'])) {
|
||||
return;
|
||||
}
|
||||
@@ -18,7 +22,7 @@ if (empty($choice['items'])) {
|
||||
<section class="section department-choice"<?= $anchor ? ' id="' . e($anchor) . '"' : '' ?>>
|
||||
<div class="container">
|
||||
<?php if (!empty($choice['title'])): ?>
|
||||
<h2 class="department-choice__title"><?= e($choice['title']) ?></h2>
|
||||
<h2 class="<?= $title_hidden ? 'visually-hidden' : 'department-choice__title' ?>"><?= e($choice['title']) ?></h2>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($choice['intro'])): ?>
|
||||
<p class="department-choice__intro"><?= e($choice['intro']) ?></p>
|
||||
@@ -26,12 +30,19 @@ if (empty($choice['items'])) {
|
||||
<ul class="department-choice__grid" role="list">
|
||||
<?php foreach ($choice['items'] as $item): ?>
|
||||
<li class="department-choice__card">
|
||||
<?php if (!empty($item['image']['base'])): ?>
|
||||
<div class="department-choice__media">
|
||||
<?php component('img', ['image' => $item['image'], 'sizes' => '(max-width: 600px) 100vw, 50vw', 'class' => 'department-choice__img']); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="department-choice__body">
|
||||
<h3 class="department-choice__heading"><?= e($item['title']) ?></h3>
|
||||
<?php if (!empty($item['subtitle'])): ?>
|
||||
<p class="department-choice__subtitle"><?= e($item['subtitle']) ?></p>
|
||||
<?php endif; ?>
|
||||
<p class="department-choice__text"><?= e($item['text']) ?></p>
|
||||
<a class="btn department-choice__btn" href="#<?= e($item['anchor']) ?>"><?= e($item['label']) ?><?= icon('arrow-right', 'department-choice__arrow') ?></a>
|
||||
</div>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
|
||||
64
app/components/detail-head.php
Normal file
64
app/components/detail-head.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Schmaler Kopf einer Detailseite (statt hero): Rückweg, h1, optionaler
|
||||
* Entwurf-Badge, Meta-Zeile. Props:
|
||||
* $back array{href, label} — Rückweg auf die Übersicht (Pflicht)
|
||||
* $title string — die h1 der Seite (Pflicht)
|
||||
* $badge string (optional) — Auszeichnung neben dem Titel, z. B. 'Entwurf'
|
||||
* $date_iso string (optional) — für <time datetime>
|
||||
* $date_text string (optional) — sichtbares Datum (german_date()/german_date_range())
|
||||
* $location string (optional) — Ort, erscheint mit Nadel-Icon hinter dem Datum
|
||||
*
|
||||
* Gemeinsam genutzt von der Veranstaltungs- und der News-Artikel-Seite. Beide
|
||||
* haben bewusst KEINEN hero: ein Archiv-Eintrag hat kein eigenes Titelbild, und
|
||||
* beim News-Artikel trägt der Aufmacher im Artikelkörper das Bild (Blog-Optik)
|
||||
* statt eines cover-beschnittenen Hintergrunds.
|
||||
*
|
||||
* Der Kopf der Stadionzeitungs-Ausgabe (.stadionzeitung-issue-head) bleibt
|
||||
* bewusst eine eigene Fassung und wird hier NICHT mitbedient: er ist
|
||||
* Viewer-Chrome, kein Artikelkopf — Drei-Zonen-Grid, Titel als <p> (die
|
||||
* Seitenbilder tragen die Ausgabe), Druck-Knopf, zwei Badge-Varianten und zwei
|
||||
* eigene @media-print-Regeln. Ihn in dieselbe Komponente zu zwingen, hieße eine
|
||||
* Prop-Matrix für zwei Layouts zu bauen.
|
||||
*/
|
||||
$back = $back ?? [];
|
||||
$title = (string) ($title ?? '');
|
||||
$badge = (string) ($badge ?? '');
|
||||
$dateIso = (string) ($date_iso ?? '');
|
||||
$dateText = (string) ($date_text ?? '');
|
||||
$location = (string) ($location ?? '');
|
||||
$hasMeta = $dateText !== '' || $location !== '';
|
||||
?>
|
||||
<header class="detail-head">
|
||||
<div class="container detail-head__inner">
|
||||
<?php if (!empty($back['href'])): ?>
|
||||
<a class="detail-head__back" href="<?= e($back['href']) ?>">
|
||||
<?= icon('chevron-left') ?> <?= e($back['label'] ?? 'Zurück') ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<h1 class="detail-head__title">
|
||||
<?= e($title) ?>
|
||||
<?php if ($badge !== ''): ?>
|
||||
<span class="detail-head__badge"><?= e($badge) ?></span>
|
||||
<?php endif; ?>
|
||||
</h1>
|
||||
<?php if ($hasMeta): ?>
|
||||
<p class="detail-head__meta">
|
||||
<?php if ($dateText !== ''): ?>
|
||||
<time<?= $dateIso !== '' ? ' datetime="' . e($dateIso) . '"' : '' ?>><?= e($dateText) ?></time>
|
||||
<?php endif; ?>
|
||||
<?php if ($location !== ''): ?>
|
||||
<?php if ($dateText !== ''): ?>
|
||||
<span class="detail-head__sep" aria-hidden="true">·</span>
|
||||
<?php endif; ?>
|
||||
<?php /* Icon und Ort in EINEM span, sonst bricht die Zeile am Handy zwischen
|
||||
Nadel und Ortsname um. */ ?>
|
||||
<span class="detail-head__ort"><?= icon('geo-alt') ?> <?= e($location) ?></span>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</header>
|
||||
53
app/components/event-feature.php
Normal file
53
app/components/event-feature.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Aufmacher für einen Vereins-Termin — das Gegenstück zu match-feature für
|
||||
* alles, was kein Spiel ist (data/events.json). Gleiches Bauprinzip: drei
|
||||
* Schriftrollen (Meta/Ort in Abel · Titel in Coolvetica · Zeitzeile in Abel),
|
||||
* keine Badges, keine Icons, die Wirkung kommt aus der Größe. Statt des
|
||||
* Fixtures mit Wappen steht links das Datums-Chip (Tag über Monat, dieselbe
|
||||
* Optik wie in der termine-list).
|
||||
* Props:
|
||||
* $event Eintrag aus data/events.json (title, date_start, date_end?, time?, location?)
|
||||
*/
|
||||
$e = $event;
|
||||
$dateStart = (string) ($e['date_start'] ?? '');
|
||||
$dateEnd = $e['date_end'] ?? null;
|
||||
$time = (string) ($e['time'] ?? '');
|
||||
$chip = german_date_chip($dateStart);
|
||||
$dateText = german_date_range($dateStart, $dateEnd);
|
||||
|
||||
// Ganztägige Events (ohne time) sollen im Countdown nicht „heute, 00:00 Uhr"
|
||||
// behaupten — deshalb der zweite Parameter.
|
||||
$when = match_when($dateStart !== '' ? $dateStart . 'T' . ($time !== '' ? $time : '00:00') : '', $time !== '');
|
||||
$dateLine = trim($dateText . ($time !== '' ? ' · ' . $time . ' Uhr' : ''));
|
||||
?>
|
||||
<article class="event-feature">
|
||||
<div class="event-feature__head">
|
||||
<?php if ($chip['day'] !== ''): ?>
|
||||
<span class="event-feature__chip" aria-hidden="true">
|
||||
<span class="event-feature__chip-day"><?= e($chip['day']) ?></span>
|
||||
<span class="event-feature__chip-month"><?= e($chip['month']) ?></span>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<span class="event-feature__body">
|
||||
<span class="event-feature__title"><?= e($e['title'] ?? '') ?></span>
|
||||
<?php if (!empty($e['location'])): ?>
|
||||
<span class="event-feature__where"><?= e($e['location']) ?></span>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
</div>
|
||||
<p class="event-feature__when">
|
||||
<?php if ($dateStart !== ''): ?>
|
||||
<time datetime="<?= e($dateStart) ?>"><?= e($dateLine) ?></time>
|
||||
<?php else: ?>
|
||||
<span><?= e($dateLine) ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if ($when['countdown'] !== ''): ?>
|
||||
<span class="event-feature__sep" aria-hidden="true">·</span>
|
||||
<span class="event-feature__countdown" data-countdown="<?= e($when['iso']) ?>"><?= e($when['countdown']) ?></span>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</article>
|
||||
@@ -41,7 +41,7 @@ if ($faq === [] && !$hasPerson) {
|
||||
<?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>
|
||||
<div class="faq__a"><p><?= inline_links_html($item['a']) ?></p></div>
|
||||
</details>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
@@ -62,7 +62,7 @@ if ($faq === [] && !$hasPerson) {
|
||||
</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>
|
||||
<button type="button" class="contact-slider__dot tap-target" data-contact-dot<?= $i === 0 ? ' aria-current="true"' : '' ?> aria-label="Ansprechpartner <?= $i + 1 ?>: <?= e($p['name']) ?>"></button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* FAQ-Accordion. Props:
|
||||
* $faq array [{q, a}, …]
|
||||
* $faq array [{q, a, ctas?: [{label, slug, hash?, style?}]}, …]
|
||||
* ctas ist optional und rendert Knöpfe unter der Antwort (über die cta-Komponente),
|
||||
* für Antworten, an deren Ende ein Handgriff steht — etwa „so meldest du dich an".
|
||||
* Das FAQPage-Schema ist davon unberührt: faq_schema() liest nur q und a. Die
|
||||
* Antwort muss den Weg deshalb IN WORTEN nennen, sonst steht bei Google eine
|
||||
* Antwort ohne Ziel.
|
||||
* $title string (optional, Default 'Häufige Fragen')
|
||||
* $anchor string (optional, Default 'faq')
|
||||
* Natives <details>/<summary> → ohne JS bedienbar. Das FAQPage-Schema liefert
|
||||
@@ -27,7 +32,16 @@ if ($faq === []) {
|
||||
<?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>
|
||||
<div class="faq__a">
|
||||
<p><?= inline_links_html($item['a']) ?></p>
|
||||
<?php if (!empty($item['ctas'])): ?>
|
||||
<div class="faq__actions">
|
||||
<?php foreach ($item['ctas'] as $cta): ?>
|
||||
<?php component('cta', ['cta' => $cta]); ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</details>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
@@ -6,18 +6,20 @@ declare(strict_types=1);
|
||||
* Einheitliches Formularfeld — EINZIGE Quelle für Feld-Markup aller Formulare.
|
||||
*
|
||||
* Props: $field (array):
|
||||
* type text | email | tel | select | textarea | checkbox (Default: text)
|
||||
* type text | email | tel | date | select | textarea | checkbox (Default: text)
|
||||
* name, label Pflicht
|
||||
* id Default: field-<name>
|
||||
* required bool
|
||||
* autocomplete, maxlength, rows (textarea)
|
||||
* autocomplete, maxlength, rows (textarea), min/max (date)
|
||||
* options[] + placeholder (select)
|
||||
* hint optionaler Hilfetext (aria-describedby)
|
||||
* label_html NUR für vertrauenswürdiges Markup aus Komponenten-Code
|
||||
* (z. B. Datenschutz-Link im Checkbox-Label) — nie Nutzereingaben!
|
||||
*
|
||||
* Jedes Feld bekommt ein leeres Fehler-Element (#<id>-error), das form.js bei
|
||||
* ungültiger Eingabe mit der Browser-Validierungsmeldung füllt (aria-describedby).
|
||||
* Jedes Feld bekommt ein Fehler-Element (#<id>-error, aria-describedby): serverseitig
|
||||
* gefüllt beim 422-Re-Render (form_field_error()), sonst von form.js mit der
|
||||
* Browser-Validierungsmeldung. Eingaben aus einem abgewiesenen POST schreibt
|
||||
* form_old() zurück — deshalb gehen Werte auch ohne JS nie verloren.
|
||||
*/
|
||||
$type = $field['type'] ?? 'text';
|
||||
$name = $field['name'];
|
||||
@@ -26,6 +28,8 @@ $required = !empty($field['required']);
|
||||
$errorId = $id . '-error';
|
||||
$hintId = $id . '-hint';
|
||||
$describedBy = (!empty($field['hint']) ? $hintId . ' ' : '') . $errorId;
|
||||
$old = form_old($name);
|
||||
$fieldError = form_field_error($id);
|
||||
|
||||
// Sichtbarer roter Stern (aria-hidden) + pro Feld erklärendes Pflicht-Wort für
|
||||
// Screenreader — ersetzt die frühere globale „Pflichtfelder mit *"-Hinweiszeile.
|
||||
@@ -39,13 +43,14 @@ $commonAttrs = 'name="' . e($name) . '" id="' . e($id) . '"'
|
||||
. (!empty($field['autocomplete']) ? ' autocomplete="' . e($field['autocomplete']) . '"' : '')
|
||||
. (!empty($field['maxlength']) ? ' maxlength="' . e($field['maxlength']) . '"' : '')
|
||||
. (isset($field['min']) ? ' min="' . e((string) $field['min']) . '"' : '')
|
||||
. (isset($field['max']) ? ' max="' . e((string) $field['max']) . '"' : '');
|
||||
. (isset($field['max']) ? ' max="' . e((string) $field['max']) . '"' : '')
|
||||
. ($fieldError !== '' ? ' aria-invalid="true"' : '');
|
||||
?>
|
||||
<?php if ($type === 'checkbox'): ?>
|
||||
<div class="form__field form__field--checkbox">
|
||||
<input type="checkbox" <?= $commonAttrs ?> value="1">
|
||||
<input type="checkbox" <?= $commonAttrs ?> value="1"<?= $old === '1' ? ' checked' : '' ?>>
|
||||
<label for="<?= e($id) ?>"><?= $field['label_html'] ?? e($field['label']) ?><?= $requiredMark ?></label>
|
||||
<p class="form__error-msg" id="<?= e($errorId) ?>" hidden></p>
|
||||
<p class="form__error-msg" id="<?= e($errorId) ?>"<?= $fieldError === '' ? ' hidden' : '' ?>><?= e($fieldError) ?></p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="form__field">
|
||||
@@ -54,17 +59,17 @@ $commonAttrs = 'name="' . e($name) . '" id="' . e($id) . '"'
|
||||
<select <?= $commonAttrs ?>>
|
||||
<option value=""><?= e($field['placeholder'] ?? 'Bitte wählen') ?></option>
|
||||
<?php foreach ($field['options'] as $option): ?>
|
||||
<option value="<?= e($option) ?>"><?= e($option) ?></option>
|
||||
<option value="<?= e($option) ?>"<?= $old !== '' && $old === (string) $option ? ' selected' : '' ?>><?= e($option) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php elseif ($type === 'textarea'): ?>
|
||||
<textarea <?= $commonAttrs ?> rows="<?= e($field['rows'] ?? 6) ?>" placeholder=" "></textarea>
|
||||
<textarea <?= $commonAttrs ?> rows="<?= e($field['rows'] ?? 6) ?>" placeholder=" "><?= e($old) ?></textarea>
|
||||
<?php else: ?>
|
||||
<input type="<?= e($type) ?>" <?= $commonAttrs ?> placeholder=" ">
|
||||
<input type="<?= e($type) ?>" <?= $commonAttrs ?> value="<?= e($old) ?>" placeholder=" ">
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($field['hint'])): ?>
|
||||
<p class="form__hint" id="<?= e($hintId) ?>"><?= e($field['hint']) ?></p>
|
||||
<?php endif; ?>
|
||||
<p class="form__error-msg" id="<?= e($errorId) ?>" hidden></p>
|
||||
<p class="form__error-msg" id="<?= e($errorId) ?>"<?= $fieldError === '' ? ' hidden' : '' ?>><?= e($fieldError) ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -5,13 +5,43 @@ declare(strict_types=1);
|
||||
/**
|
||||
* Turn-Disziplin im einheitlichen Bento-Look (wie die Mannschaftsseiten). Props:
|
||||
* $location array{venue, street?, zip?, city?} (optional) — Trainingsort + „Route planen"
|
||||
* $lead string (optional) — verantwortliche Übungsleitung (Akzent-Kachel)
|
||||
* $lead string (optional) — Ansprechperson der Disziplin (Akzent-Kachel)
|
||||
* $contactEmail string (optional) — persönliche E-Mail der Ansprechperson für die
|
||||
* Kontakt-Kachel. Überschreibt die Abteilungs-Mail (club.email_turnen), falls
|
||||
* gesetzt — genutzt, wenn eine Disziplin einen eigenen direkten Draht angibt.
|
||||
* $contactPhone string (optional) — Telefonnummer der Ansprechperson (Anzeige-Form). Ergibt
|
||||
* einen zusätzlichen tel:-Link in der Kontakt-Kachel. tel:-Href wie in person-card.php.
|
||||
* $notice string (optional) — hervorgehobener Hinweis (z. B. Wartelisten-Status)
|
||||
* $noticeTitle string (optional) — Überschrift dazu
|
||||
* $noticeTone 'info' (Default) | 'stop' — Tonlage, siehe components/notice.php
|
||||
* $noticeIcon string (optional) — Icon-Name, siehe components/notice.php
|
||||
* Gerendert wird über component('notice') — dieselbe Komponente, die
|
||||
* contact-form.php über dem Formular benutzt, damit derselbe Hinweis an
|
||||
* beiden Orten gleich aussieht und nur EINMAL in data/turnen.json steht.
|
||||
* $groups array von Gruppen:
|
||||
* { name, level?, intro?, details?:[{heading,text}], recommendation?, note?,
|
||||
* trainers?:[…], sessions:[{ label?, day, time, note?, trainers?:[…] }] }
|
||||
* { name, level?, contact?, intro?, details?:[{heading,text}], recommendation?,
|
||||
* note?, trainers?:[…], sessions:[{ label?, day, time, note?, trainers?:[…] }] }
|
||||
* contact = Ansprechperson NUR dieser Gruppe. Braucht es, sobald eine Disziplin
|
||||
* mehrere Gruppen mit verschiedenen Ansprechpersonen hat (Wettkampfturnen:
|
||||
* Flamingos → Theresa Diener, Leistungsturnen → Franziska Trotzke) — die
|
||||
* Kontakt-Kachel oben kann nur EINE nennen und wäre für die andere falsch.
|
||||
* Beschriftet als „Kontakt" wie die Kachel, nicht als „Ansprechpartner":
|
||||
* das Label kennt das Geschlecht der Person nicht (Haus-Stil Paarformen).
|
||||
* $footnote string (optional) — Erläuterung (z. B. zur Sternchen-Kennzeichnung)
|
||||
* $groupsTitle string (optional, Default 'Unsere Gruppen')
|
||||
* $groupsTitleHidden bool (optional) — H2 bleibt im DOM (Dokumentgliederung für
|
||||
* Screenreader, Sektion behält ihren Namen), ist aber visuell versteckt.
|
||||
* Dasselbe Muster wie $title_hidden in team-cards. NICHT die Überschrift
|
||||
* weglassen: dann hätte die Sektion keinen zugänglichen Namen mehr.
|
||||
* $layout 'tabs' (Default) | 'stacked' — greift nur bei beschreibenden Gruppen.
|
||||
* 'stacked' zeigt alle Karten offen untereinander (wenige Gruppen),
|
||||
* 'tabs' ordnet viele textreiche Gruppen (Kinderturnen).
|
||||
* $groupsInDash bool (optional) — nur für SCHLICHTE Gruppen (ohne intro/details): zieht die
|
||||
* Gruppen-Karten als volle Bodenreihe MIT INS Bento-Dashboard, ohne eigene Sektion
|
||||
* und ohne „Unsere Gruppen"-Überschrift. Für Disziplinen mit wenigen kurzen Gruppen
|
||||
* (Trampolin), damit das Dashboard ein zusammenhängender Block bleibt statt einer
|
||||
* losgelösten Kartenreihe darunter. Bei beschreibenden Gruppen (Wettkampfturnen,
|
||||
* Kinderturnen) wirkungslos — die brauchen Platz und ihre eigene Sektion.
|
||||
* $anchor string (optional, Default 'training')
|
||||
*
|
||||
* Eine Gruppe → Trainingszeiten landen als Bento-Kachel im Dashboard.
|
||||
@@ -20,9 +50,17 @@ declare(strict_types=1);
|
||||
*/
|
||||
$anchor = $anchor ?? 'training';
|
||||
$groupsTitle = $groupsTitle ?? 'Unsere Gruppen';
|
||||
$groupsTitleHidden = $groupsTitleHidden ?? false;
|
||||
$layout = ($layout ?? 'tabs') === 'stacked' ? 'stacked' : 'tabs';
|
||||
$lead = $lead ?? '';
|
||||
$contactEmail = $contactEmail ?? '';
|
||||
$contactPhone = $contactPhone ?? '';
|
||||
$notice = $notice ?? '';
|
||||
$noticeTitle = $noticeTitle ?? '';
|
||||
$noticeTone = $noticeTone ?? 'info';
|
||||
$noticeIcon = $noticeIcon ?? 'info-circle';
|
||||
$footnote = $footnote ?? '';
|
||||
$groupsInDash = !empty($groupsInDash);
|
||||
$groups = $groups ?? [];
|
||||
$location = $location ?? [];
|
||||
|
||||
@@ -31,7 +69,8 @@ if ($groups === []) {
|
||||
}
|
||||
|
||||
$club = json_load('club');
|
||||
$email = $club['email_turnen'] ?? ($club['email'] ?? '');
|
||||
$email = $contactEmail !== '' ? $contactEmail : ($club['email_turnen'] ?? ($club['email'] ?? ''));
|
||||
$telHref = $contactPhone !== '' ? 'tel:' . preg_replace('/[^+\d]/', '', $contactPhone) : '';
|
||||
|
||||
$single = count($groups) === 1;
|
||||
$rich = false;
|
||||
@@ -42,6 +81,9 @@ foreach ($groups as $g) {
|
||||
}
|
||||
}
|
||||
|
||||
// Schlichte Gruppen direkt ins Dashboard ziehen (nur wenn mehrere und keine „reich").
|
||||
$groupsInline = $groupsInDash && !$single && !$rich;
|
||||
|
||||
$hasVenue = !empty($location['venue']);
|
||||
$mapsQuery = $hasVenue ? rawurlencode(trim(
|
||||
($location['venue'] ?? '') . ', '
|
||||
@@ -50,7 +92,14 @@ $mapsQuery = $hasVenue ? rawurlencode(trim(
|
||||
', '
|
||||
)) : '';
|
||||
|
||||
/** Eine Trainingszeit (Tag/Zeit + optional Label, Notiz, Übungsleitung) ausgeben. */
|
||||
/**
|
||||
* Eine Trainingszeit (Tag/Zeit + optional Label, Notiz, Trainerteam) ausgeben.
|
||||
*
|
||||
* Das Label heißt „Trainerteam", nicht „Übungsleitung": in der Liste stehen auch
|
||||
* Personen, die KEINE Übungsleitung sind (Eva Wünsch aus der Abteilung, 31.07.2026).
|
||||
* Wer eine ist, trägt ein Sternchen, und das erklärt die Fußnote der Sektion. Die alte
|
||||
* Seite schrieb dafür „TRAINERTEAM (* = ÜBUNGSLEITER)". Nicht zurückbenennen.
|
||||
*/
|
||||
$renderSession = static function (array $s): void {
|
||||
?>
|
||||
<li class="schedule-row">
|
||||
@@ -65,93 +114,20 @@ $renderSession = static function (array $s): void {
|
||||
<span class="schedule-row__note text-muted"><?= e($s['note']) ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($s['trainers'])): ?>
|
||||
<span class="schedule-row__trainers"><span class="schedule-row__trainers-label">Übungsleitung:</span> <?= e(implode(', ', $s['trainers'])) ?></span>
|
||||
<span class="schedule-row__trainers"><span class="schedule-row__trainers-label">Trainerteam:</span> <?= e(implode(', ', $s['trainers'])) ?></span>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<?php
|
||||
};
|
||||
|
||||
$soleSessions = $single ? ($groups[0]['sessions'] ?? []) : [];
|
||||
$soleNote = $single ? ($groups[0]['note'] ?? '') : '';
|
||||
?>
|
||||
<section class="section turnen-dash-section" id="<?= e($anchor) ?>">
|
||||
<div class="container">
|
||||
<div class="turnen-dash<?= $single ? ' turnen-dash--single' : '' ?><?= $notice !== '' ? ' turnen-dash--notice' : '' ?>">
|
||||
<?php if ($notice !== ''): ?>
|
||||
<article class="bento__tile turnen-dash__notice">
|
||||
<?= icon('info-circle', 'turnen-dash__notice-icon') ?>
|
||||
<p><?= e($notice) ?></p>
|
||||
</article>
|
||||
<?php endif; ?>
|
||||
<?php if ($single): ?>
|
||||
<article class="bento__tile turnen-dash__training">
|
||||
<h2 class="bento__heading"><?= icon('clock', 'bento__hicon') ?>Trainingszeiten</h2>
|
||||
<ul class="bento__sessions">
|
||||
<?php foreach ($soleSessions as $s): ?>
|
||||
<li>
|
||||
<span class="bento__day"><?= e($s['day']) ?></span>
|
||||
<span class="bento__time"><?= e($s['time']) ?></span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php if ($soleNote !== ''): ?>
|
||||
<p class="bento__note text-muted"><?= e($soleNote) ?></p>
|
||||
<?php endif; ?>
|
||||
<span class="bento__deco" aria-hidden="true"><?= icon('clock') ?></span>
|
||||
</article>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($lead !== '' || $email !== ''): ?>
|
||||
<article class="bento__tile bento__tile--accent turnen-dash__contact">
|
||||
<span class="bento__stat-label">Kontakt</span>
|
||||
<?php if ($lead !== ''): ?>
|
||||
<span class="bento__stat-value"><?= e($lead) ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if ($email !== ''): ?>
|
||||
<a class="turnen-dash__mail" href="mailto:<?= e($email) ?>"><?= icon('envelope', 'turnen-dash__mail-icon') ?>E-Mail schreiben</a>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($hasVenue): ?>
|
||||
<article class="bento__tile turnen-dash__venue">
|
||||
<div class="turnen-dash__venue-main">
|
||||
<h2 class="bento__heading"><?= icon('geo-alt', 'bento__hicon') ?>Trainingsort</h2>
|
||||
<p class="bento__address">
|
||||
<strong><?= e($location['venue']) ?></strong>
|
||||
<?php if (!empty($location['street'])): ?> · <?= e($location['street']) ?><?php endif; ?>
|
||||
<?php if (!empty($location['zip']) || !empty($location['city'])): ?>, <?= e(trim(($location['zip'] ?? '') . ' ' . ($location['city'] ?? ''))) ?><?php endif; ?>
|
||||
</p>
|
||||
</div>
|
||||
<a class="btn btn--outline turnen-dash__route" href="https://www.google.com/maps/search/?api=1&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>
|
||||
|
||||
<?php if (!$single): ?>
|
||||
<section class="section group-list-section" id="gruppen">
|
||||
<div class="container">
|
||||
<h2 class="group-list__title"><?= e($groupsTitle) ?></h2>
|
||||
<?php if ($rich): ?>
|
||||
<div class="group-tabs" data-group-tabs>
|
||||
<div class="group-tabs__bar" role="tablist" aria-label="<?= e($groupsTitle) ?>">
|
||||
<?php foreach ($groups as $i => $g): ?>
|
||||
<button class="group-tabs__tab" role="tab" type="button"
|
||||
id="gtab-<?= $i ?>"
|
||||
aria-controls="gpanel-<?= $i ?>"
|
||||
aria-selected="<?= $i === 0 ? 'true' : 'false' ?>"
|
||||
><?= e($g['name']) ?></button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php foreach ($groups as $i => $g): ?>
|
||||
<article class="group-card group-tabs__panel" role="tabpanel"
|
||||
id="gpanel-<?= $i ?>"
|
||||
aria-labelledby="gtab-<?= $i ?>"
|
||||
tabindex="0"
|
||||
>
|
||||
/**
|
||||
* Inhalt einer beschreibenden Gruppen-Karte (ohne das <article> drumherum). Als Closure,
|
||||
* weil es diese Karte in zwei Fassungen gibt: als Tab-Panel (mit role/aria) und gestapelt
|
||||
* (ohne). Vorher stand das Markup nur in der Tab-Fassung; eine zweite Kopie daneben wäre
|
||||
* die Stelle, an der beide auseinanderlaufen.
|
||||
*/
|
||||
$renderCardBody = static function (array $g) use ($renderSession): void {
|
||||
?>
|
||||
<div class="group-card__body">
|
||||
<div class="group-card__header">
|
||||
<h3 class="group-card__name"><?= e($g['name']) ?></h3>
|
||||
@@ -180,20 +156,27 @@ $soleNote = $single ? ($groups[0]['note'] ?? '') : '';
|
||||
<ul class="schedule-list">
|
||||
<?php foreach ($g['sessions'] as $s): $renderSession($s); endforeach; ?>
|
||||
</ul>
|
||||
<?php if (!empty($g['contact'])): ?>
|
||||
<p class="group-card__trainers"><span class="schedule-row__trainers-label">Kontakt:</span> <?= e($g['contact']) ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($g['trainers'])): ?>
|
||||
<p class="group-card__trainers"><span class="schedule-row__trainers-label">Übungsleitung:</span> <?= e(implode(', ', $g['trainers'])) ?></p>
|
||||
<p class="group-card__trainers"><span class="schedule-row__trainers-label">Trainerteam:</span> <?= e(implode(', ', $g['trainers'])) ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($g['note'])): ?>
|
||||
<p class="group-card__note text-muted"><?= e($g['note']) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="group-list group-list--grid">
|
||||
<?php foreach ($groups as $g): ?>
|
||||
<?php
|
||||
};
|
||||
|
||||
/**
|
||||
* Schlichte Gruppen-Karte (Name + Trainingszeiten). Als Closure, weil sie an zwei Stellen
|
||||
* gebraucht wird: im Raster der eigenen Gruppen-Sektion und — bei $groupsInline — als Kachel
|
||||
* direkt im Bento-Dashboard.
|
||||
*/
|
||||
$renderSimpleCard = static function (array $g) use ($renderSession): void {
|
||||
?>
|
||||
<article class="group-card">
|
||||
<div class="group-card__body">
|
||||
<div class="group-card__header">
|
||||
@@ -211,7 +194,123 @@ $soleNote = $single ? ($groups[0]['note'] ?? '') : '';
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
<?php
|
||||
};
|
||||
|
||||
$soleSessions = $single ? ($groups[0]['sessions'] ?? []) : [];
|
||||
$soleNote = $single ? ($groups[0]['note'] ?? '') : '';
|
||||
?>
|
||||
<section class="section turnen-dash-section" id="<?= e($anchor) ?>">
|
||||
<div class="container">
|
||||
<div class="turnen-dash<?= $single ? ' turnen-dash--single' : '' ?><?= $notice !== '' ? ' turnen-dash--notice' : '' ?><?= $groupsInline ? ' turnen-dash--groups' : '' ?>">
|
||||
<?php if ($notice !== ''): ?>
|
||||
<?php component('notice', [
|
||||
'title' => $noticeTitle,
|
||||
'text' => $notice,
|
||||
'tone' => $noticeTone,
|
||||
'icon' => $noticeIcon,
|
||||
'class' => 'turnen-dash__notice',
|
||||
]); ?>
|
||||
<?php endif; ?>
|
||||
<?php if ($single): ?>
|
||||
<article class="bento__tile turnen-dash__training">
|
||||
<h2 class="bento__heading"><?= icon('clock', 'bento__hicon') ?>Trainingszeiten</h2>
|
||||
<ul class="bento__sessions">
|
||||
<?php foreach ($soleSessions as $s): ?>
|
||||
<li>
|
||||
<span class="bento__day"><?= e($s['day']) ?></span>
|
||||
<span class="bento__time"><?= e($s['time']) ?></span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php if ($soleNote !== ''): ?>
|
||||
<p class="bento__note text-muted"><?= e($soleNote) ?></p>
|
||||
<?php endif; ?>
|
||||
<span class="bento__deco" aria-hidden="true"><?= icon('clock') ?></span>
|
||||
</article>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($lead !== '' || $email !== '' || $telHref !== ''): ?>
|
||||
<article class="bento__tile bento__tile--accent turnen-dash__contact">
|
||||
<span class="bento__stat-label">Kontakt</span>
|
||||
<?php if ($lead !== ''): ?>
|
||||
<span class="bento__stat-value"><?= e($lead) ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if ($email !== ''): ?>
|
||||
<a class="turnen-dash__mail" href="mailto:<?= e($email) ?>"><?= icon('envelope', 'turnen-dash__mail-icon') ?>E-Mail schreiben</a>
|
||||
<?php endif; ?>
|
||||
<?php if ($telHref !== ''): ?>
|
||||
<a class="turnen-dash__mail" href="<?= e($telHref) ?>"><?= icon('telephone', 'turnen-dash__mail-icon') ?><?= e($contactPhone) ?></a>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($hasVenue): ?>
|
||||
<article class="bento__tile turnen-dash__venue">
|
||||
<div class="turnen-dash__venue-main">
|
||||
<h2 class="bento__heading"><?= icon('geo-alt', 'bento__hicon') ?>Trainingsort</h2>
|
||||
<p class="bento__address">
|
||||
<strong><?= e($location['venue']) ?></strong>
|
||||
<?php if (!empty($location['street'])): ?> · <?= e($location['street']) ?><?php endif; ?>
|
||||
<?php if (!empty($location['zip']) || !empty($location['city'])): ?>, <?= e(trim(($location['zip'] ?? '') . ' ' . ($location['city'] ?? ''))) ?><?php endif; ?>
|
||||
</p>
|
||||
</div>
|
||||
<a class="btn btn--outline turnen-dash__route" href="https://www.google.com/maps/search/?api=1&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; ?>
|
||||
<?php if ($groupsInline): ?>
|
||||
<div class="turnen-dash__groups group-list--grid">
|
||||
<?php foreach ($groups as $g): $renderSimpleCard($g); endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if ($groupsInline && $footnote !== ''): ?>
|
||||
<p class="group-list__footnote text-muted"><?= e($footnote) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php if (!$single && !$groupsInline): ?>
|
||||
<section class="section group-list-section<?= $groupsTitleHidden ? ' group-list-section--tight' : '' ?>" id="gruppen">
|
||||
<div class="container">
|
||||
<h2 class="group-list__title<?= $groupsTitleHidden ? ' visually-hidden' : '' ?>"><?= e($groupsTitle) ?></h2>
|
||||
<?php if ($rich && $layout === 'stacked'): ?>
|
||||
<?php /* Karten direkt untereinander, alle sichtbar. Für Disziplinen mit wenigen Gruppen:
|
||||
Tabs verstecken dort mehr Inhalt als sie ordnen, und die offene Kartenreihe hält
|
||||
den Bento-Eindruck der Seite. Bei vielen textreichen Gruppen (Kinderturnen) sind
|
||||
Tabs weiter richtig, sonst wird die Seite endlos. */ ?>
|
||||
<div class="group-list group-list--stacked">
|
||||
<?php foreach ($groups as $g): ?>
|
||||
<article class="group-card">
|
||||
<?php $renderCardBody($g); ?>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php elseif ($rich): ?>
|
||||
<div class="group-tabs" data-group-tabs>
|
||||
<div class="group-tabs__bar" role="tablist" aria-label="<?= e($groupsTitle) ?>">
|
||||
<?php foreach ($groups as $i => $g): ?>
|
||||
<button class="tab group-tabs__tab" role="tab" type="button"
|
||||
id="gtab-<?= $i ?>"
|
||||
aria-controls="gpanel-<?= $i ?>"
|
||||
aria-selected="<?= $i === 0 ? 'true' : 'false' ?>"
|
||||
><?= e($g['name']) ?></button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php foreach ($groups as $i => $g): ?>
|
||||
<article class="group-card group-tabs__panel" role="tabpanel"
|
||||
id="gpanel-<?= $i ?>"
|
||||
aria-labelledby="gtab-<?= $i ?>"
|
||||
tabindex="0"
|
||||
>
|
||||
<?php $renderCardBody($g); ?>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="group-list group-list--grid">
|
||||
<?php foreach ($groups as $g): $renderSimpleCard($g); endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($footnote !== ''): ?>
|
||||
|
||||
@@ -10,7 +10,7 @@ $nav = json_load('navigation');
|
||||
?>
|
||||
<header class="site-header">
|
||||
<div class="container site-header__inner">
|
||||
<a class="site-header__brand" href="<?= e(url()) ?>" aria-label="<?= e($club['name']) ?> – Startseite">
|
||||
<a class="site-header__brand" href="<?= e(url()) ?>" aria-label="<?= e($club['name']) ?>, Startseite">
|
||||
<img class="site-header__logo" src="<?= e(asset('img/logo.svg')) ?>" alt="" width="75" height="97">
|
||||
<span class="site-header__name" aria-hidden="true"><?= e($club['name']) ?></span>
|
||||
</a>
|
||||
|
||||
74
app/components/hero-upcoming.php
Normal file
74
app/components/hero-upcoming.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Glas-Karte im Startseiten-Hero: die nächsten Termine des Vereins als
|
||||
* automatisch wechselnder Slider. Props:
|
||||
* $items aus upcoming_highlights() (app/helpers.php) — Spiele und
|
||||
* Vereins-Events chronologisch gemischt, je Eintrag type/label/href/
|
||||
* link_text plus die rohen Daten für die Innenvariante
|
||||
*
|
||||
* Zwei Innenvarianten, eine Karte: 'spiel' rendert match-feature (Heim über
|
||||
* Gast mit Wappen, wie im Matchcenter-Band), 'event' rendert event-feature
|
||||
* (Datums-Chip + Titel + Ort). Die Beschriftung („Nächstes Spiel · 1. Mannschaft")
|
||||
* kommt fertig aus dem Helper; die Meta-Zeile von match-feature bleibt in der
|
||||
* Karte per CSS aus, damit Mannschaft/Liga nicht zweimal dastehen.
|
||||
*
|
||||
* Antrieb ist der generische assets/js/carousel.js (dieselben data-Hooks wie
|
||||
* banner-slider): Crossfade, Fortschrittsring im aktiven Indikator, Pause-Taste
|
||||
* (WCAG 2.2.2), Pause bei Hover/Fokus, kein Autoplay bei prefers-reduced-motion.
|
||||
* Ohne JS ist nur der nächste Termin sichtbar und verlinkt (die weiteren Folien
|
||||
* tragen hidden, die Steuerung bleibt verborgen, weil sie funktionslos wäre).
|
||||
*
|
||||
* Die Karten-Wurzel ist bewusst ein div und nicht selbst der Link: die
|
||||
* Steuer-Buttons liegen in der Karte, verschachtelte interaktive Elemente wären
|
||||
* ungültig. Verlinkt ist deshalb der Inhalt jeder Folie.
|
||||
*/
|
||||
$items = $items ?? [];
|
||||
if ($items === []) {
|
||||
return;
|
||||
}
|
||||
$total = count($items);
|
||||
?>
|
||||
<div class="hero__upcoming"<?= $total > 1 ? ' data-carousel aria-roledescription="Karussell" aria-label="Nächste Termine"' : '' ?>>
|
||||
<ul class="hero__upcoming-track" id="hero-upcoming-track" data-carousel-track aria-live="off">
|
||||
<?php foreach ($items as $i => $item): ?>
|
||||
<li class="hero__upcoming-slide"<?= $total > 1 ? ' data-carousel-slide role="group" aria-roledescription="Folie" aria-label="' . ($i + 1) . ' von ' . $total . '"' : '' ?><?= $i === 0 ? '' : ' hidden' ?>>
|
||||
<a class="hero__upcoming-link" href="<?= e($item['href']) ?>">
|
||||
<p class="hero__upcoming-label"><?= e($item['label']) ?></p>
|
||||
<?php if ($item['type'] === 'spiel'): ?>
|
||||
<?php component('match-feature', [
|
||||
'match' => $item['match'],
|
||||
'team_name' => $item['team_name'],
|
||||
'league' => $item['league'],
|
||||
]); ?>
|
||||
<?php else: ?>
|
||||
<?php component('event-feature', ['event' => $item['event']]); ?>
|
||||
<?php endif; ?>
|
||||
<span class="visually-hidden"><?= e($item['link_text']) ?></span>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php if ($total > 1): ?>
|
||||
<?php /* Ohne JS verborgen (Steuerung wäre funktionslos); carousel.js blendet sie ein. */ ?>
|
||||
<div class="hero__upcoming-controls" data-carousel-controls hidden>
|
||||
<div class="hero__upcoming-indicators" role="group" aria-label="Termin wählen">
|
||||
<?php foreach ($items as $i => $item): ?>
|
||||
<button class="hero__upcoming-dot" type="button" data-carousel-dot aria-controls="hero-upcoming-track" aria-label="Termin <?= ($i + 1) ?> von <?= $total ?>: <?= e($item['label']) ?>">
|
||||
<span class="hero__upcoming-progress" data-carousel-progress aria-hidden="true"></span>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php /* Pause/Play — WCAG 2.2.2. Nur bei aktiver Auto-Rotation sichtbar. */ ?>
|
||||
<?php /* Kein btn--sm: dessen 44px kommen über ::after, die Fläche würde
|
||||
in dieser Reihe den letzten Indikator überlappen (siehe CLAUDE.md). */ ?>
|
||||
<button class="btn btn--icon hero__upcoming-toggle" type="button" data-carousel-toggle aria-pressed="false" aria-controls="hero-upcoming-track" hidden>
|
||||
<?= icon('pause-fill', 'hero__upcoming-toggle-icon hero__upcoming-toggle-icon--pause') ?>
|
||||
<?= icon('play-fill', 'hero__upcoming-toggle-icon hero__upcoming-toggle-icon--play') ?>
|
||||
<span class="visually-hidden" data-carousel-toggle-label>Automatischen Wechsel pausieren</span>
|
||||
</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -5,21 +5,30 @@ declare(strict_types=1);
|
||||
/**
|
||||
* Vollflächiger Hero mit Hintergrundbild/-video, Titel, Intro und CTAs.
|
||||
* Props: $hero (array aus home.json: title, text, image, video?, ctas, align?, compact?,
|
||||
* display?, kicker?, scroll_target?)
|
||||
* align-Varianten: 'center' (mittig zentriert), 'bottom-left' (Default).
|
||||
* display?, kicker?, scroll_target?), $upcoming (optional, s. home.php:
|
||||
* die nächsten Termine aus upcoming_highlights() — Spiele und Vereins-Events
|
||||
* gemischt, gerendert über die hero-upcoming-Komponente)
|
||||
* align-Varianten: 'center' (mittig zentriert, Konvention für alle Unterseiten seit 27.07.2026),
|
||||
* 'bottom-left' (ältere Variante, technischer Fallback — für neue Seiten nicht mehr verwenden).
|
||||
* compact: true → niedrigere Variante für Unterseiten-Header.
|
||||
* display: true → großer Editorial-Look (Riesen-Titel, Outline-Tagline, Kicker, Scroll-Cue).
|
||||
*
|
||||
* KONVENTION (siehe CLAUDE.md → Hero-Regel): display + kicker + tagline + video sind der
|
||||
* Startseite vorbehalten. Unterseiten-Heros sind einheitlich „Überschrift + Teaser":
|
||||
* nur title + text, dazu compact: true und align: 'bottom-left'. Kein kicker/tagline,
|
||||
* nur title + text, dazu compact: true und align: 'center'. Kein kicker/tagline,
|
||||
* ctas nur als Ausnahme.
|
||||
*
|
||||
* Liegt mindestens ein Termin in $upcoming, läuft der Display-Hero zweispaltig statt
|
||||
* zentriert („hero--duo"): links Titel/Text/CTAs linksbündig, rechts die Termin-Karte als
|
||||
* Live-Aufhänger. Ohne Termine (Sommerpause) bleibt es beim zentrierten Hero ohne Karte.
|
||||
*/
|
||||
$align = in_array($hero['align'] ?? '', ['center', 'bottom-left'], true) ? $hero['align'] : 'bottom-left';
|
||||
$align = in_array($hero['align'] ?? '', ['center', 'bottom-left'], true) ? $hero['align'] : 'center';
|
||||
$compact = !empty($hero['compact']);
|
||||
$display = !empty($hero['display']);
|
||||
$upcoming = $upcoming ?? [];
|
||||
$duo = $display && $upcoming !== [];
|
||||
?>
|
||||
<section class="hero<?= $align === 'center' ? ' hero--center' : '' ?><?= $compact ? ' hero--compact' : '' ?><?= $display ? ' hero--display' : '' ?>">
|
||||
<section class="hero<?= $duo ? '' : ($align === 'center' ? ' hero--center' : '') ?><?= $compact ? ' hero--compact' : '' ?><?= $display ? ' hero--display' : '' ?><?= $duo ? ' hero--duo' : '' ?>">
|
||||
<div class="hero__media" aria-hidden="true">
|
||||
<?php component('img', ['image' => $hero['image'], 'sizes' => '100vw', 'eager' => true, 'class' => 'hero__img']); ?>
|
||||
<?php if (!empty($hero['video'])): ?>
|
||||
@@ -27,7 +36,9 @@ $display = !empty($hero['display']);
|
||||
<video class="hero__video" src="<?= e(asset($hero['video'])) ?>" autoplay muted loop playsinline preload="none" tabindex="-1"></video>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="container hero__content">
|
||||
<div class="container hero__content<?= $duo ? ' hero__content--duo' : '' ?>">
|
||||
<?php if ($duo): ?>
|
||||
<div class="hero__primary">
|
||||
<?php if (!empty($hero['kicker'])): ?>
|
||||
<p class="hero__kicker"><?= e($hero['kicker']) ?></p>
|
||||
<?php endif; ?>
|
||||
@@ -41,6 +52,24 @@ $display = !empty($hero['display']);
|
||||
<?php component('cta', ['cta' => $cta]); ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php component('hero-upcoming', ['items' => $upcoming]); ?>
|
||||
<?php else: ?>
|
||||
<?php if (!empty($hero['kicker'])): ?>
|
||||
<p class="hero__kicker"><?= e($hero['kicker']) ?></p>
|
||||
<?php endif; ?>
|
||||
<h1 class="hero__title"><?= e($hero['title']) ?><?php if (!empty($hero['tagline'])): ?> <span class="hero__tagline"><?= e($hero['tagline']) ?></span><?php endif; ?></h1>
|
||||
<?php if (!empty($hero['text'])): ?>
|
||||
<p class="hero__text"><?= e($hero['text']) ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($hero['ctas'])): ?>
|
||||
<div class="hero__actions">
|
||||
<?php foreach ($hero['ctas'] as $cta): ?>
|
||||
<?php component('cta', ['cta' => $cta]); ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if ($display): ?>
|
||||
|
||||
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Responsives Bild aus Varianten-Set ODER Einzeldatei. Props:
|
||||
* $image array{base: string, widths: int[], alt: string} — Varianten <base>-<w>.jpg
|
||||
* $image array{base: string, widths: int[], alt: string, ext?: string} — Varianten
|
||||
* <base>-<w>.<ext>, Default-Endung jpg. ext: 'png' für freigestellte Motive,
|
||||
* deren Transparenz JPG zerstören würde (Spielerfoto auf dem Cover).
|
||||
* ODER array{src: string, width: int, height: int, alt: string} — Originaldatei 1:1
|
||||
* $sizes string (optional, default '100vw')
|
||||
* $eager bool (optional) — above the fold: eager + fetchpriority high
|
||||
@@ -24,15 +26,16 @@ if (!empty($image['src'])): ?>
|
||||
|
||||
$widths = $image['widths'];
|
||||
$largest = max($widths);
|
||||
$ext = strtolower((string) ($image['ext'] ?? 'jpg'));
|
||||
$srcset = implode(', ', array_map(
|
||||
static fn (int $w): string => asset("{$image['base']}-{$w}.jpg") . " {$w}w",
|
||||
static fn (int $w): string => asset("{$image['base']}-{$w}.{$ext}") . " {$w}w",
|
||||
$widths
|
||||
));
|
||||
|
||||
// Maße der größten Variante für width/height (CLS-Vermeidung).
|
||||
[$w, $h] = (function () use ($image, $largest): array {
|
||||
[$w, $h] = (function () use ($image, $largest, $ext): array {
|
||||
static $dims = [];
|
||||
$file = PUBLIC_PATH . "/assets/{$image['base']}-{$largest}.jpg";
|
||||
$file = PUBLIC_PATH . "/assets/{$image['base']}-{$largest}.{$ext}";
|
||||
$key = $file;
|
||||
if (!isset($dims[$key])) {
|
||||
$size = is_file($file) ? (getimagesize($file) ?: [0, 0]) : [0, 0];
|
||||
@@ -42,7 +45,7 @@ $srcset = implode(', ', array_map(
|
||||
})();
|
||||
?>
|
||||
<img<?= $class !== '' ? ' class="' . e($class) . '"' : '' ?>
|
||||
src="<?= e(asset("{$image['base']}-{$largest}.jpg")) ?>"
|
||||
src="<?= e(asset("{$image['base']}-{$largest}.{$ext}")) ?>"
|
||||
srcset="<?= e($srcset) ?>"
|
||||
sizes="<?= e($sizes) ?>"
|
||||
alt="<?= e($image['alt']) ?>"
|
||||
|
||||
@@ -16,7 +16,7 @@ $maxPosts = (int) config('instagram.max_posts', 9);
|
||||
<div class="container">
|
||||
<h2>Aktuelles aus dem Fußball</h2>
|
||||
<?php if ($posts !== []): ?>
|
||||
<p class="text-muted">Unsere neuesten Beiträge auf <a href="<?= e($club['social']['instagram']) ?>" rel="noopener">Instagram (@<?= e($feed['username'] ?? 'tsv08kulmbach') ?>)</a><?php if (!empty($club['social']['facebook'])): ?> – folge uns auch auf <a href="<?= e($club['social']['facebook']) ?>" rel="noopener">Facebook</a><?php endif; ?>.</p>
|
||||
<p class="text-muted">Unsere neuesten Beiträge auf <a href="<?= e($club['social']['instagram']) ?>" rel="noopener">Instagram (@<?= e($feed['username'] ?? 'tsv08kulmbach') ?>)</a><?php if (!empty($club['social']['facebook'])): ?>. Folge uns auch auf <a href="<?= e($club['social']['facebook']) ?>" rel="noopener">Facebook</a><?php endif; ?>.</p>
|
||||
<ul class="instagram__grid">
|
||||
<?php foreach (array_slice($posts, 0, $maxPosts) as $post): ?>
|
||||
<li class="instagram__item">
|
||||
@@ -37,5 +37,6 @@ $maxPosts = (int) config('instagram.max_posts', 9);
|
||||
<p><a class="btn" href="<?= e($club['social']['instagram']) ?>" rel="noopener">Folge uns auf Instagram</a></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<p class="instagram__more"><a class="btn btn--outline" href="<?= e(url('news')) ?>">Alle News ansehen</a></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -22,9 +22,11 @@ $hasReleg = in_array('relegation', $zones, true);
|
||||
|
||||
// Vor dem 1. Spieltag liefert der BFV für JEDE Mannschaft Platz 1 und dieselbe
|
||||
// Auf-/Abstiegszone. Beides ist dann bedeutungslos und lässt die Tabelle kaputt
|
||||
// aussehen (zehn Erste, alles grün) — deshalb Ränge und Zonen unterdrücken und
|
||||
// stattdessen sagen, dass noch nichts gewertet ist. Testspiele zählen hier nicht
|
||||
// mit, ein Freundschaftsspiel-Ergebnis ändert die Tabelle also zu Recht nicht.
|
||||
// aussehen (zehn Erste, alles grün) — deshalb Ränge und Zonen unterdrücken.
|
||||
// Ein erklärender Hinweissatz stand hier mal und ist bewusst raus (Felix,
|
||||
// 29.07.2026): eine Tabelle mit 0 Spielen erklärt sich selbst. Testspiele
|
||||
// zählen nicht mit, ein Freundschaftsspiel-Ergebnis ändert die Tabelle also
|
||||
// zu Recht nicht.
|
||||
$notStarted = array_sum(array_map(
|
||||
static fn (array $r): int => (int) ($r['played'] ?? 0),
|
||||
$table
|
||||
@@ -33,9 +35,6 @@ if ($notStarted) {
|
||||
$hasPromo = $hasPromoPlayoff = $hasRelegPlayoff = $hasReleg = false;
|
||||
}
|
||||
?>
|
||||
<?php if ($notStarted): ?>
|
||||
<p class="league-table__note">Die Liga hat noch nicht begonnen – gewertet wird ab dem 1. Spieltag.</p>
|
||||
<?php endif; ?>
|
||||
<div class="table-scroll-frame">
|
||||
<div class="table-scroll" role="region" aria-label="<?= e($caption) ?>" tabindex="0">
|
||||
<table class="league-table">
|
||||
|
||||
41
app/components/lightbox-overlay.php
Normal file
41
app/components/lightbox-overlay.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Overlay für die Bild-Großansicht. Props:
|
||||
* $label string Bezeichnung des Dialogs (Screenreader)
|
||||
* $count int Anzahl der Bilder — bei genau einem gibt es weder
|
||||
* Zähler noch Vor/Zurück (wäre beides sinnlos)
|
||||
*
|
||||
* Gehört IMMER in denselben [data-lightbox-gallery]-Container wie die
|
||||
* zugehörigen Bildlinks (assets/js/lightbox.js sucht von dort aus). Steht
|
||||
* `hidden` im Markup, bis JS es übernimmt — ohne JS bleiben die
|
||||
* Vorschaubilder normale Links auf die Bilddatei.
|
||||
*/
|
||||
$label = $label ?? 'Bild in Großansicht';
|
||||
$count = $count ?? 0;
|
||||
$mehrere = $count > 1;
|
||||
?>
|
||||
<div class="lightbox" role="dialog" aria-modal="true" aria-label="<?= e($label) ?>" data-lightbox-dialog hidden>
|
||||
<?php /* Keine sichtbare Bildunterschrift (Beschluss 30.07.2026): der Alt-Text
|
||||
trägt die Beschreibung. sizes steht statisch, das srcset erbt das Bild
|
||||
vom angeklickten Vorschaubild (lightbox.js). */ ?>
|
||||
<img class="lightbox__img" src="" alt="" sizes="100vw" data-lightbox-img>
|
||||
<?php if ($mehrere): ?>
|
||||
<p class="lightbox__counter" aria-live="polite">
|
||||
<span data-lightbox-current>1</span> von <?= (int) $count ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<button class="btn btn--icon lightbox__btn lightbox__close" type="button" data-lightbox-close aria-label="Ansicht schließen">
|
||||
<?= icon('x-lg') ?>
|
||||
</button>
|
||||
<?php if ($mehrere): ?>
|
||||
<button class="btn btn--icon lightbox__btn lightbox__prev" type="button" data-lightbox-prev aria-label="Vorheriges Bild">
|
||||
<?= icon('chevron-left') ?>
|
||||
</button>
|
||||
<button class="btn btn--icon lightbox__btn lightbox__next" type="button" data-lightbox-next aria-label="Nächstes Bild">
|
||||
<?= icon('chevron-right') ?>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -50,10 +50,10 @@ $tabCur = $season !== '' ? $season : 'Aktuelle Saison';
|
||||
?>
|
||||
<div class="matchcenter-team__seasons group-tabs" data-group-tabs>
|
||||
<div class="group-tabs__bar matchcenter-team__seasons-bar" role="tablist" aria-label="Saison wählen – <?= e($name) ?>">
|
||||
<button class="group-tabs__tab" role="tab" type="button"
|
||||
<button class="tab group-tabs__tab" role="tab" type="button"
|
||||
id="mc-tab-<?= e($key) ?>-cur" aria-controls="mc-panel-<?= e($key) ?>-cur" aria-selected="true"
|
||||
><?= e($tabCur) ?></button>
|
||||
<button class="group-tabs__tab" role="tab" type="button"
|
||||
<button class="tab group-tabs__tab" role="tab" type="button"
|
||||
id="mc-tab-<?= e($key) ?>-prev" aria-controls="mc-panel-<?= e($key) ?>-prev" aria-selected="false"
|
||||
><?= e($tabPrev) ?></button>
|
||||
</div>
|
||||
|
||||
@@ -13,11 +13,14 @@ declare(strict_types=1);
|
||||
$club = json_load('club');
|
||||
$fee_options = $fee_options ?? [];
|
||||
|
||||
// Fehlerquelle: PRG-Query (?error=…) oder — beim 422-Re-Render ohne JS — die
|
||||
// serverseitigen Feldfehler aus der Action.
|
||||
$sent = isset($_GET['sent']);
|
||||
$error = isset($_GET['error']) ? (string) $_GET['error'] : null;
|
||||
$error = isset($_GET['error']) ? (string) $_GET['error'] : (form_has_errors() ? 'validation' : null);
|
||||
$errorMessages = [
|
||||
'validation' => 'Bitte prüfe deine Eingaben – Pflichtfelder fehlen oder die E-Mail-Adresse ist ungültig.',
|
||||
'validation' => 'Bitte prüfe deine Eingaben: Die markierten Felder brauchen noch etwas Aufmerksamkeit.',
|
||||
'ratelimit' => 'Zu viele Anfragen in kurzer Zeit. Bitte versuche es in ein paar Minuten erneut.',
|
||||
'busy' => 'Bei uns gehen gerade ungewöhnlich viele Anfragen ein. Bitte versuche es später noch einmal oder schreib uns direkt an ' . $club['email'] . '.',
|
||||
'mail' => 'Dein Antrag konnte gerade nicht versendet werden. Bitte versuche es später erneut oder schreib uns direkt an ' . $club['email'] . '.',
|
||||
];
|
||||
|
||||
@@ -44,9 +47,10 @@ $f = [
|
||||
];
|
||||
?>
|
||||
<form class="form" method="post" action="<?= e(url('mitglied-werden-senden')) ?>" novalidate>
|
||||
<div class="form__status" role="status" aria-live="polite" tabindex="-1" data-form-status>
|
||||
<?php /* autofocus nur beim 422-Re-Render: bringt den Fokus auch ohne JS in die Fehlermeldung */ ?>
|
||||
<div class="form__status" role="status" aria-live="polite" tabindex="-1" data-form-status<?= form_has_errors() ? ' autofocus' : '' ?>>
|
||||
<?php if ($sent): ?>
|
||||
<p class="form__success">Danke! Dein Mitgliedsantrag ist bei uns eingegangen – wir melden uns und senden dir die Beitrittserklärung per Post zu.</p>
|
||||
<p class="form__success">Danke! Dein Mitgliedsantrag ist bei uns eingegangen. Wir melden uns und senden dir die Beitrittserklärung per Post zu.</p>
|
||||
<?php elseif ($error !== null): ?>
|
||||
<p class="form__error"><?= e($errorMessages[$error] ?? $errorMessages['mail']) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -22,7 +22,10 @@ $scripts = array_merge(['nav.js', 'back-to-top.js'], $meta['scripts'] ?? []);
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= e($title) ?></title>
|
||||
<meta name="description" content="<?= e($description) ?>">
|
||||
<?php if ($current !== '404'): ?>
|
||||
<?php /* noindex: 404-Seiten und alles, was eine Page ausdrücklich anfordert
|
||||
($meta['noindex'], z. B. Stadionzeitungs-Entwürfe) — dann auch kein
|
||||
Self-Canonical, das wäre ein Widerspruch zur Nicht-Indexierung. */ ?>
|
||||
<?php if ($current !== '404' && empty($meta['noindex'])): ?>
|
||||
<link rel="canonical" href="<?= e($canonical) ?>">
|
||||
<?php else: ?>
|
||||
<meta name="robots" content="noindex">
|
||||
@@ -39,9 +42,12 @@ $scripts = array_merge(['nav.js', 'back-to-top.js'], $meta['scripts'] ?? []);
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<link rel="icon" href="/favicon.ico" sizes="32x32">
|
||||
<link rel="icon" href="<?= e(asset('img/logo.svg')) ?>" type="image/svg+xml">
|
||||
<?php /* Preload ohne ?v= (asset(..., false)): base.css fordert dieselbe Datei per
|
||||
@font-face ohne Query an, mit Version wären es für den Browser zwei URLs
|
||||
und der Preload bliebe ungenutzt. */ ?>
|
||||
<?php foreach (['fonts/coolvetica.woff2', 'fonts/abel.woff2'] as $font): ?>
|
||||
<?php if (is_file(PUBLIC_PATH . '/assets/' . $font)): ?>
|
||||
<link rel="preload" href="<?= e(asset($font)) ?>" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="<?= e(asset($font, false)) ?>" as="font" type="font/woff2" crossorigin>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
<?php foreach (['tokens', 'reset', 'base', 'layout', 'components', 'utilities'] as $css): ?>
|
||||
|
||||
@@ -7,7 +7,10 @@ declare(strict_types=1);
|
||||
* Burger-Toggle + Dropdowns übernimmt assets/js/nav.js.
|
||||
* Dropdowns funktionieren auch ohne JS (CSS :hover / :focus-within); JS ergänzt
|
||||
* nur aria-expanded, Escape und Klick-außerhalb-schließt.
|
||||
* CTAs rendert der Header (site-header__actions) — nicht hier.
|
||||
* CTAs: am Desktop rendert sie der Header (site-header__actions), im
|
||||
* Burger-Modus stehen sie am Ende des Menü-Overlays (site-nav__ctas) —
|
||||
* dieselbe Quelle (navigation.json → ctas), CSS blendet je Breakpoint
|
||||
* genau eine der beiden Stellen ein.
|
||||
*/
|
||||
$nav = json_load('navigation');
|
||||
|
||||
@@ -50,5 +53,12 @@ $href = static function (string $slug): string {
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!empty($nav['ctas'])): ?>
|
||||
<li class="site-nav__ctas">
|
||||
<?php foreach ($nav['ctas'] as $cta): ?>
|
||||
<?php component('cta', ['cta' => $cta]); ?>
|
||||
<?php endforeach; ?>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
84
app/components/news-artikel.php
Normal file
84
app/components/news-artikel.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Artikelkörper einer News-Detailseite. Props:
|
||||
* $image array (optional) — Aufmacher {base, widths, alt}, above the fold
|
||||
* $blocks array — data/news.json → artikel[], Blöcke mit `typ`:
|
||||
* text {absaetze: string[]} Fließtext
|
||||
* zwischentitel {text} h2 (h1 steht im detail-head)
|
||||
* bild {base, widths, alt, caption?}
|
||||
* zitat {text, quelle?}
|
||||
*
|
||||
* Ein unbekannter `typ` wird stillschweigend übersprungen; gemeldet wird er von
|
||||
* bin/preflight.php, damit ein Tippfehler vor dem Deploy auffällt und nicht erst
|
||||
* durch einen fehlenden Absatz auf der Seite.
|
||||
*
|
||||
* Fließtext läuft durch inline_links_html(): interne Deeplinks [Label](slug)
|
||||
* werden zu <a>, alles andere escaped. Kein rohes HTML in der JSON.
|
||||
*
|
||||
* Bilder sind wie bei den Veranstaltungen ohne JS normale Links auf die größte
|
||||
* Variante; mit JS fängt assets/js/lightbox.js den Klick ab. Das Overlay kommt
|
||||
* nur dazu, wenn der Artikel überhaupt Bilder hat.
|
||||
*/
|
||||
$image = $image ?? null;
|
||||
$blocks = $blocks ?? [];
|
||||
|
||||
$bilder = array_values(array_filter(
|
||||
$blocks,
|
||||
static fn (array $b): bool => ($b['typ'] ?? '') === 'bild' && !empty($b['base']) && !empty($b['widths'])
|
||||
));
|
||||
$bildAnzahl = count($bilder);
|
||||
$bildIndex = 0;
|
||||
?>
|
||||
<article class="section news-article"<?= $bildAnzahl > 0 ? ' data-lightbox-gallery' : '' ?>>
|
||||
<div class="container">
|
||||
<?php if (!empty($image['base'])): ?>
|
||||
<figure class="news-article__lead">
|
||||
<?php component('img', [
|
||||
'image' => $image,
|
||||
'sizes' => '(max-width: 768px) 100vw, 72ch',
|
||||
'class' => 'news-article__lead-img',
|
||||
'eager' => true,
|
||||
]); ?>
|
||||
</figure>
|
||||
<?php endif; ?>
|
||||
<div class="news-article__body prose">
|
||||
<?php foreach ($blocks as $block): ?>
|
||||
<?php $typ = $block['typ'] ?? ''; ?>
|
||||
<?php if ($typ === 'text'): ?>
|
||||
<?php foreach ($block['absaetze'] ?? [] as $absatz): ?>
|
||||
<p><?= inline_links_html((string) $absatz) ?></p>
|
||||
<?php endforeach; ?>
|
||||
<?php elseif ($typ === 'zwischentitel' && !empty($block['text'])): ?>
|
||||
<h2 class="news-article__heading"><?= e($block['text']) ?></h2>
|
||||
<?php elseif ($typ === 'bild' && !empty($block['base']) && !empty($block['widths'])): ?>
|
||||
<figure class="news-article__figure">
|
||||
<a class="news-article__figure-link" href="<?= e(asset($block['base'] . '-' . max($block['widths']) . '.jpg')) ?>" data-lightbox="<?= $bildIndex++ ?>">
|
||||
<?php component('img', [
|
||||
'image' => $block,
|
||||
'sizes' => '(max-width: 768px) 100vw, 72ch',
|
||||
'class' => 'news-article__img',
|
||||
]); ?>
|
||||
<span class="visually-hidden">(größer ansehen)</span>
|
||||
</a>
|
||||
<?php if (!empty($block['caption'])): ?>
|
||||
<figcaption class="news-article__caption"><?= e($block['caption']) ?></figcaption>
|
||||
<?php endif; ?>
|
||||
</figure>
|
||||
<?php elseif ($typ === 'zitat' && !empty($block['text'])): ?>
|
||||
<figure class="news-article__quote">
|
||||
<blockquote><p><?= e($block['text']) ?></p></blockquote>
|
||||
<?php if (!empty($block['quelle'])): ?>
|
||||
<figcaption class="news-article__quote-source"><?= e($block['quelle']) ?></figcaption>
|
||||
<?php endif; ?>
|
||||
</figure>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ($bildAnzahl > 0): ?>
|
||||
<?php component('lightbox-overlay', ['label' => 'Bild aus dem Artikel', 'count' => $bildAnzahl]); ?>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
146
app/components/news-feed.php
Normal file
146
app/components/news-feed.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Zeilen-Liste für die News-Seite: mischt redaktionelle Meldungen, Instagram-
|
||||
* Posts und Ergebnisse. Props:
|
||||
* $items array{date, title, text, image?, href?, link_text?, source}
|
||||
* `link_text` (optional) ist die sichtbare Weiterlesen-Zeile —
|
||||
* gesetzt vom Normalizer, nicht hier abgeleitet: die Komponente
|
||||
* soll die Quellen nicht kennen (gleiche Prop wie in
|
||||
* upcoming_highlights()/event-feature)
|
||||
* $empty_text string (optional)
|
||||
* $featured bool (optional) — neuester Eintrag als großer Aufmacher über
|
||||
* der Liste (gleiche Hierarchie-Idee wie das „Nächste Spiele"-
|
||||
* Band: ein Aufmacher, darunter Zeilen)
|
||||
* $filterable bool (optional) — Filter-Leiste nach Quelle (Alle/Meldungen/
|
||||
* Ergebnisse/Instagram), gleiche Bausteine wie der Stellen-Filter
|
||||
* auf /mitmachen (.filter-bar + .tab, assets/js/news-filter.js).
|
||||
* Ohne JS sind alle Einträge sichtbar — reine Progressive
|
||||
* Enhancement. Die Seite muss news-filter.js laden.
|
||||
* Kennt nichts von den drei Quellstrukturen — bekommt nur die normalisierte
|
||||
* Form (app/pages/news.php baut sie).
|
||||
*
|
||||
* Bewusst eine Zeilen-Liste statt eines Karten-Grids (wie termine-list.php):
|
||||
* die Einträge sind zu unterschiedlich hoch (hohe Bild-Posts neben kurzen
|
||||
* Text-Ergebnissen) — ein Grid reißt dabei Löcher, weil kurze Karten den
|
||||
* Platz neben einer hohen Karte nicht auffüllen können. Eine einspaltige
|
||||
* Liste hat dieses Problem nicht, jede Zeile ist einfach so hoch wie ihr
|
||||
* eigener Inhalt.
|
||||
*/
|
||||
$items = $items ?? [];
|
||||
$emptyText = $empty_text ?? 'Aktuell keine News.';
|
||||
$featured = !empty($featured);
|
||||
$filterable = !empty($filterable);
|
||||
|
||||
$sourceLabels = [
|
||||
'news' => 'Meldung',
|
||||
'instagram' => 'Instagram',
|
||||
'ergebnis' => 'Ergebnis',
|
||||
];
|
||||
$filters = [
|
||||
'all' => 'Alle',
|
||||
'news' => 'Meldungen',
|
||||
'ergebnis' => 'Ergebnisse',
|
||||
'instagram' => 'Instagram',
|
||||
];
|
||||
?>
|
||||
<?php if ($items === []): ?>
|
||||
<p class="text-muted"><?= e($emptyText) ?></p>
|
||||
<?php else: ?>
|
||||
<?php if ($filterable): ?>
|
||||
<div class="filter-bar" role="group" aria-label="News nach Quelle filtern" data-news-filter>
|
||||
<?php $i = 0; foreach ($filters as $value => $label): ?>
|
||||
<button type="button" class="tab filter-bar__tab" data-filter="<?= e($value) ?>" aria-pressed="<?= $i++ === 0 ? 'true' : 'false' ?>"><?= e($label) ?></button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($featured):
|
||||
$feature = array_shift($items);
|
||||
$featureTag = !empty($feature['href']) ? 'a' : 'div';
|
||||
$featureExternal = $feature['source'] === 'instagram';
|
||||
$featureAttrs = !empty($feature['href'])
|
||||
? ' href="' . e($feature['href']) . '"' . ($featureExternal ? ' target="_blank" rel="noopener"' : '')
|
||||
: '';
|
||||
$featureHasMedia = !empty($feature['image']) || !empty($feature['crest']);
|
||||
?>
|
||||
<div class="news-feature" data-source="<?= e($feature['source']) ?>">
|
||||
<<?= $featureTag ?> class="news-feature__link<?= $featureHasMedia ? ' news-feature__link--media' : '' ?>"<?= $featureAttrs ?>>
|
||||
<?php if (!empty($feature['image'])): ?>
|
||||
<div class="news-feature__media">
|
||||
<?php component('img', ['image' => $feature['image'], 'sizes' => '(max-width: 768px) 100vw, 40vw', 'class' => 'news-feature__img', 'eager' => true]); ?>
|
||||
</div>
|
||||
<?php elseif (!empty($feature['crest'])): ?>
|
||||
<div class="news-feature__media news-feature__media--crest">
|
||||
<?php component('crest', ['src' => $feature['crest']['src'], 'name' => $feature['crest']['name']]); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="news-feature__body">
|
||||
<p class="news-row__meta">
|
||||
<span class="news-row__badge news-row__badge--<?= e($feature['source']) ?>"><?= e($sourceLabels[$feature['source']] ?? $feature['source']) ?></span>
|
||||
<?php if ($feature['date'] !== ''): ?>
|
||||
<time datetime="<?= e($feature['date']) ?>"><?= e(german_date($feature['date'])) ?></time>
|
||||
<?php endif; ?>
|
||||
<?php if ($featureExternal): ?>
|
||||
<span class="visually-hidden"> (öffnet in neuem Tab)</span>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<p class="news-feature__title"><?= e($feature['title']) ?></p>
|
||||
<?php if (!empty($feature['text']) && $feature['text'] !== $feature['title']): ?>
|
||||
<p class="news-feature__text"><?= e($feature['text']) ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($feature['link_text'])): ?>
|
||||
<p class="news-feature__more"><?= e($feature['link_text']) ?> <?= icon('arrow-right') ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</<?= $featureTag ?>>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<ul class="news-list">
|
||||
<?php foreach ($items as $item): ?>
|
||||
<?php
|
||||
$tag = !empty($item['href']) ? 'a' : 'div';
|
||||
$external = $item['source'] === 'instagram';
|
||||
$attrs = '';
|
||||
if (!empty($item['href'])) {
|
||||
$attrs = ' href="' . e($item['href']) . '"' . ($external ? ' target="_blank" rel="noopener"' : '');
|
||||
}
|
||||
?>
|
||||
<li class="news-row" data-source="<?= e($item['source']) ?>">
|
||||
<<?= $tag ?> class="news-row__link"<?= $attrs ?>>
|
||||
<?php if (!empty($item['image'])): ?>
|
||||
<div class="news-row__media">
|
||||
<?php component('img', ['image' => $item['image'], 'sizes' => '96px', 'class' => 'news-row__img']); ?>
|
||||
</div>
|
||||
<?php elseif (!empty($item['crest'])): ?>
|
||||
<div class="news-row__media news-row__media--crest">
|
||||
<?php component('crest', ['src' => $item['crest']['src'], 'name' => $item['crest']['name']]); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="news-row__body">
|
||||
<p class="news-row__meta">
|
||||
<span class="news-row__badge news-row__badge--<?= e($item['source']) ?>"><?= e($sourceLabels[$item['source']] ?? $item['source']) ?></span>
|
||||
<?php if ($item['date'] !== ''): ?>
|
||||
<time datetime="<?= e($item['date']) ?>"><?= e(german_date($item['date'])) ?></time>
|
||||
<?php endif; ?>
|
||||
<?php if ($external): ?>
|
||||
<span class="visually-hidden"> (öffnet in neuem Tab)</span>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<p class="news-row__title"><?= e($item['title']) ?></p>
|
||||
<?php if (!empty($item['text']) && $item['text'] !== $item['title']): ?>
|
||||
<p class="news-row__text"><?= e($item['text']) ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($item['link_text'])): ?>
|
||||
<p class="news-row__more"><?= e($item['link_text']) ?> <?= icon('arrow-right') ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</<?= $tag ?>>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php if ($filterable): ?>
|
||||
<p class="text-muted" data-news-empty hidden>In dieser Kategorie gibt es gerade nichts Neues.</p>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
43
app/components/notice.php
Normal file
43
app/components/notice.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Hervorgehobener Hinweis, den es in zwei Tonlagen gibt. Props:
|
||||
* $text string — der Hinweis selbst (Pflicht, leer → nichts)
|
||||
* $title string (optional) — Überschrift. Ohne sie liest sich besonders die rote
|
||||
* Variante wie eine Fehlermeldung; mit ihr ist klar, dass es eine Information ist.
|
||||
* $tone 'info' (Default) | 'stop' — 'info' ist die neutrale Glas-Fläche mit rotem Icon,
|
||||
* 'stop' die gefüllte rote. Die Tonlage sagt, ob etwas GEHT oder NICHT geht:
|
||||
* „neue Kinder können schnuppern" ist eine Einladung und darf nicht so aussehen
|
||||
* wie „wir können niemanden aufnehmen". Rot ist die Ausnahme, nicht der Standard.
|
||||
* $icon string (optional, Default 'info-circle') — Icon-Name aus public/assets/icons/
|
||||
* $level 'h2' (Default) | 'h3' — Überschriftenebene, passend zur Umgebung, damit die
|
||||
* Folge auf der Seite nicht springt
|
||||
* $class string (optional) — Zusatzklassen für die Einbettung (Position/Breite im
|
||||
* jeweiligen Layout). Farbe und Innenaufbau kommen ausschließlich von hier.
|
||||
*
|
||||
* Bewusst eine Komponente statt dreimal dasselbe Markup: der Hinweis steht auf
|
||||
* /turnen über dem Anfrageformular, als Kachel auf /turnen/eltern-kind-turnen und
|
||||
* auf /turnen/trampolin. Der TEXT gehört nie hierher, sondern in die data/*.json
|
||||
* der Seite (dort steht auch, welche Tonlage gilt).
|
||||
*/
|
||||
$text = trim((string) ($text ?? ''));
|
||||
if ($text === '') {
|
||||
return;
|
||||
}
|
||||
$title = trim((string) ($title ?? ''));
|
||||
$tone = ($tone ?? 'info') === 'stop' ? 'stop' : 'info';
|
||||
$icon = (string) ($icon ?? 'info-circle');
|
||||
$level = ($level ?? 'h2') === 'h3' ? 'h3' : 'h2';
|
||||
$class = trim((string) ($class ?? ''));
|
||||
?>
|
||||
<div class="notice notice--<?= $tone ?><?= $class !== '' ? ' ' . e($class) : '' ?>">
|
||||
<?= icon($icon, 'notice__icon') ?>
|
||||
<div class="notice__body">
|
||||
<?php if ($title !== ''): ?>
|
||||
<<?= $level ?> class="notice__title"><?= e($title) ?></<?= $level ?>>
|
||||
<?php endif; ?>
|
||||
<p><?= e($text) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4,12 +4,31 @@ declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Sponsoren/Partner-Logogrid. Props:
|
||||
* $intro array{title, text, cta?} (aus home.json)
|
||||
* $intro array{title, text, cta?, only?} (aus home.json bzw. dem
|
||||
* partner-Block einer Veranstaltung)
|
||||
* $anchor string (optional, Default 'partner') — Sektions-ID
|
||||
* Logos kommen aus data/partners.json (Single Source of Truth).
|
||||
*
|
||||
* only: Liste von Partnernamen (exakt wie in partners.json) — für Seiten, die
|
||||
* nur einen Teil der Partner zeigen, etwa die Unterstützer eines einzelnen
|
||||
* Fests. Fehlt only, stehen alle da (Verhalten der Startseite). Passt kein
|
||||
* Name (Tippfehler, umbenannter Partner), stehen bewusst wieder ALLE da statt
|
||||
* einer leeren Logo-Wand.
|
||||
*/
|
||||
$anchor = $anchor ?? 'partner';
|
||||
$partners = json_load('partners')['partners'] ?? [];
|
||||
|
||||
if (!empty($intro['only'])) {
|
||||
$auswahl = array_values(array_filter(
|
||||
$partners,
|
||||
static fn (array $p): bool => in_array($p['name'] ?? '', $intro['only'], true)
|
||||
));
|
||||
if ($auswahl !== []) {
|
||||
$partners = $auswahl;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<section class="section partners" id="partner">
|
||||
<section class="section partners" id="<?= e($anchor) ?>">
|
||||
<div class="container">
|
||||
<h2><?= e($intro['title']) ?></h2>
|
||||
<p class="partners__intro text-muted"><?= e($intro['text']) ?></p>
|
||||
|
||||
@@ -5,54 +5,83 @@ declare(strict_types=1);
|
||||
/**
|
||||
* Visitenkarte für eine Person. Props:
|
||||
* $person array{department, name, phone?, email?, image?, whatsapp?}
|
||||
* $level string — Überschriften-Ebene des department-Headings (Default 'h3').
|
||||
* $level string — Überschriften-Ebene des NAMENS (Default 'h3'; der Name ist
|
||||
* das Heading der Karte, die Abteilung nur eine Kicker-Zeile darüber).
|
||||
* Eingebettet (z. B. in position-card unter einem h3) auf 'h4' setzen,
|
||||
* damit die Heading-Outline nicht abflacht. Styling bleibt klassenbasiert.
|
||||
* Ohne image greift der Platzhalter-Avatar (img/persons/platzhalter.jpg).
|
||||
* Kontakt-Link: phone → tel:, sonst email → mailto:.
|
||||
* Optional zusätzlich WhatsApp (wa.me) bei phone + whatsapp=true (opt-in pro Person).
|
||||
* $hide_department bool — Kicker ausblenden, wenn der Kontext die Abteilung
|
||||
* schon trägt (person-grid mit group_by_department).
|
||||
* Ohne image rendert ein Initialen-Avatar im Vereins-Look (dunkle Fläche,
|
||||
* große Initialen — dasselbe Fallback-Prinzip wie crest.php), kein generisches
|
||||
* Silhouetten-Bild.
|
||||
* Kontaktwege als Buttons (mobil sofort tippbar): phone → Anrufen (+ WhatsApp
|
||||
* bei whatsapp=true), sonst email → E-Mail. Nummer bzw. Adresse stehen
|
||||
* zusätzlich als Textzeile (ablesbar).
|
||||
* Es sind reine Icon-Knöpfe im globalen .btn--icon-Format (44px, rund) und sie
|
||||
* sitzen rechts NEBEN dem Text, nicht darunter — das spart pro Karte eine ganze
|
||||
* Button-Zeile Höhe. Weil das Label wegfällt, trägt jeder Knopf den
|
||||
* Personennamen im aria-label: im Ansprechpartner-Grid stünden sonst zwei
|
||||
* Dutzend Links namens „Anrufen" untereinander.
|
||||
*/
|
||||
$level = $level ?? 'h3';
|
||||
if (!in_array($level, ['h2', 'h3', 'h4', 'h5'], true)) {
|
||||
$level = 'h3';
|
||||
}
|
||||
$hideDepartment = !empty($hide_department);
|
||||
$image = $person['image'] ?? null;
|
||||
$src = $image ?: 'img/persons/platzhalter.jpg';
|
||||
|
||||
if (!empty($person['phone'])) {
|
||||
$contactHref = 'tel:' . preg_replace('/[^+\d]/', '', $person['phone']);
|
||||
$contactLabel = $person['phone'];
|
||||
$contactIcon = 'telephone';
|
||||
} elseif (!empty($person['email'])) {
|
||||
$contactHref = 'mailto:' . $person['email'];
|
||||
$contactLabel = $person['email'];
|
||||
$contactIcon = 'envelope';
|
||||
} else {
|
||||
$contactHref = null;
|
||||
$initials = '';
|
||||
foreach (preg_split('/[\s.-]+/', trim((string) ($person['name'] ?? ''))) ?: [] as $word) {
|
||||
if ($word !== '') {
|
||||
$initials .= mb_substr($word, 0, 1);
|
||||
}
|
||||
if (mb_strlen($initials) >= 2) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
$initials = mb_strtoupper($initials !== '' ? $initials : '?');
|
||||
|
||||
$telHref = !empty($person['phone']) ? 'tel:' . preg_replace('/[^+\d]/', '', $person['phone']) : null;
|
||||
$mailHref = ($telHref === null && !empty($person['email'])) ? 'mailto:' . $person['email'] : null;
|
||||
$waHref = (!empty($person['whatsapp']) && !empty($person['phone']))
|
||||
? 'https://wa.me/' . preg_replace('/\D/', '', $person['phone'])
|
||||
: null;
|
||||
$detail = $person['phone'] ?? $person['email'] ?? '';
|
||||
?>
|
||||
<article class="person-card">
|
||||
<?php if ($image): ?>
|
||||
<div class="person-card__media">
|
||||
<img src="<?= e(asset($src)) ?>"
|
||||
alt="<?= $image ? e($person['name']) : '' ?>"
|
||||
<img src="<?= e(asset($image)) ?>"
|
||||
alt="<?= e($person['name']) ?>"
|
||||
width="480" height="600" loading="lazy" decoding="async">
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="person-card__media person-card__media--initials" aria-hidden="true">
|
||||
<span class="person-card__initials"><?= e($initials) ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="person-card__body">
|
||||
<<?= $level ?> class="person-card__department"><?= e($person['department']) ?></<?= $level ?>>
|
||||
<p class="person-card__name"><?= e($person['name']) ?></p>
|
||||
<?php if ($contactHref !== null): ?>
|
||||
<a class="person-card__contact" href="<?= e($contactHref) ?>">
|
||||
<?= icon($contactIcon) ?><span><?= e($contactLabel) ?></span>
|
||||
</a>
|
||||
<div class="person-card__text">
|
||||
<?php if (!$hideDepartment): ?>
|
||||
<p class="person-card__department"><?= e($person['department']) ?></p>
|
||||
<?php endif; ?>
|
||||
<<?= $level ?> class="person-card__name"><?= e($person['name']) ?></<?= $level ?>>
|
||||
<?php if ($detail !== ''): ?>
|
||||
<p class="person-card__detail"><?= e($detail) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if ($telHref !== null || $mailHref !== null): ?>
|
||||
<div class="person-card__actions">
|
||||
<?php if ($telHref !== null): ?>
|
||||
<a class="btn btn--icon" href="<?= e($telHref) ?>" aria-label="<?= e($person['name']) ?> anrufen"><?= icon('telephone') ?></a>
|
||||
<?php endif; ?>
|
||||
<?php if ($waHref !== null): ?>
|
||||
<a class="person-card__contact" href="<?= e($waHref) ?>" target="_blank" rel="noopener">
|
||||
<?= icon('whatsapp') ?><span>WhatsApp</span>
|
||||
</a>
|
||||
<a class="btn btn--outline btn--icon" href="<?= e($waHref) ?>" target="_blank" rel="noopener" aria-label="<?= e($person['name']) ?> per WhatsApp schreiben"><?= icon('whatsapp') ?></a>
|
||||
<?php endif; ?>
|
||||
<?php if ($mailHref !== null): ?>
|
||||
<a class="btn btn--icon" href="<?= e($mailHref) ?>" aria-label="<?= e($person['name']) ?> eine E-Mail schreiben"><?= icon('envelope') ?></a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -11,12 +11,18 @@ declare(strict_types=1);
|
||||
* $intro string — Intro-Text überschreiben (Default aus JSON; '' = ausblenden).
|
||||
* $anchor string — Sektions-ID (Default 'ansprechpartner').
|
||||
* $tinted bool — aufgehelltes Vollbreite-Band (Default true).
|
||||
* $columns int — Spaltenanzahl auf Desktop (null = automatisch bis 5).
|
||||
* $group_by_department bool — Karten nach Abteilung untergruppieren
|
||||
* (Zwischenüberschrift je Abteilung, Karten dann ohne eigenen
|
||||
* Abteilungs-Kicker; löst die Waisen-Optik bei 5 Karten im
|
||||
* 4er-Raster). Reihenfolge = JSON-Reihenfolge.
|
||||
* $embedded bool — nur Panel (div + h2 + Grid) statt eigener section,
|
||||
* für Seiten, die mehrere Grids in EINER Sektion bündeln
|
||||
* (/ansprechpartner mit Bereichs-Tabs). tinted/anchor der
|
||||
* Sektion liefert dann die Seite.
|
||||
*/
|
||||
$data = json_load('ansprechpartner');
|
||||
$anchor = $anchor ?? 'ansprechpartner';
|
||||
$tinted = $tinted ?? true;
|
||||
$columns = $columns ?? null;
|
||||
$title = $title ?? $data['title'];
|
||||
$intro = $intro ?? ($data['intro'] ?? '');
|
||||
|
||||
@@ -37,14 +43,11 @@ if (!empty($names)) {
|
||||
if ($persons === []) {
|
||||
return;
|
||||
}
|
||||
?>
|
||||
<section class="section persons<?= $tinted ? ' section--tinted' : '' ?>" id="<?= e($anchor) ?>">
|
||||
<div class="container">
|
||||
|
||||
if (!empty($embedded)): ?>
|
||||
<div class="persons persons--embedded" id="<?= e($anchor) ?>">
|
||||
<h2><?= e($title) ?></h2>
|
||||
<?php if ($intro !== ''): ?>
|
||||
<p class="persons__intro"><?= e($intro) ?></p>
|
||||
<?php endif; ?>
|
||||
<ul class="persons__grid<?= $columns ? ' persons__grid--cols-' . (int)$columns : '' ?>">
|
||||
<ul class="persons__grid">
|
||||
<?php foreach ($persons as $person): ?>
|
||||
<li>
|
||||
<?php component('person-card', ['person' => $person]); ?>
|
||||
@@ -52,4 +55,38 @@ if ($persons === []) {
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php return; endif; ?>
|
||||
<section class="section persons<?= $tinted ? ' section--tinted' : '' ?>" id="<?= e($anchor) ?>">
|
||||
<div class="container">
|
||||
<h2><?= e($title) ?></h2>
|
||||
<?php if ($intro !== ''): ?>
|
||||
<p class="persons__intro"><?= e($intro) ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($group_by_department)): ?>
|
||||
<?php
|
||||
$byDepartment = [];
|
||||
foreach ($persons as $person) {
|
||||
$byDepartment[$person['department'] ?? ''][] = $person;
|
||||
}
|
||||
?>
|
||||
<?php foreach ($byDepartment as $departmentLabel => $departmentPersons): ?>
|
||||
<h3 class="persons__group-title"><?= e($departmentLabel) ?></h3>
|
||||
<ul class="persons__grid persons__grid--grouped">
|
||||
<?php foreach ($departmentPersons as $person): ?>
|
||||
<li>
|
||||
<?php component('person-card', ['person' => $person, 'level' => 'h4', 'hide_department' => true]); ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<ul class="persons__grid">
|
||||
<?php foreach ($persons as $person): ?>
|
||||
<li>
|
||||
<?php component('person-card', ['person' => $person]); ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -4,25 +4,68 @@ declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Alternierende Bild/Text-Sektion. Props:
|
||||
* $section array{id, title, text, image, flip?, tinted?, cta?} (aus home.json o.ä.)
|
||||
* $section array{id, title, text, image?, gallery?, flip?, tinted?, polaroid?, cta?} (aus home.json o.ä.)
|
||||
* $level int Überschriften-Ebene (default 2)
|
||||
* tinted: sanft aufgehelltes Panel hinter dem Inhalt (Abgrenzung vom Seitenhintergrund).
|
||||
* polaroid: Bild als Polaroid (dicker heller Rahmen, leicht gekippt; flip kippt gegenläufig).
|
||||
* Trägt ein Bild ein optionales caption-Feld, erscheint es als Bildunterschrift
|
||||
* auf dem weißen Unterrand des Rahmens (Abel-Versalien, wie Historie-Captions).
|
||||
* gallery: Array von Bildern statt image — erstes Bild groß, weitere darunter
|
||||
* halbbreit nebeneinander (Collage). Mit polaroid kippen die Rahmen abwechselnd.
|
||||
* text: String oder Liste von Absätzen (mehr als zwei sollten hier nicht stehen —
|
||||
* dann ist es keine Bild/Text-Sektion mehr).
|
||||
*/
|
||||
$level = $level ?? 2;
|
||||
$flip = !empty($section['flip']);
|
||||
$tinted = !empty($section['tinted']);
|
||||
$polaroid = !empty($section['polaroid']);
|
||||
// Ohne image UND ohne gallery läuft die Sektion einspaltig („split--plain") —
|
||||
// für Textabschnitte, für die es (noch) kein Foto gibt, z. B. „Anfahrt und
|
||||
// Parken" auf einer Veranstaltungs-Seite. Ohne diesen Zweig liefe img.php
|
||||
// unten auf ein fehlendes Bild.
|
||||
$hasMedia = !empty($section['image']) || !empty($section['gallery']);
|
||||
$paragraphs = is_array($section['text']) ? $section['text'] : [$section['text']];
|
||||
?>
|
||||
<section class="section split<?= $flip ? ' split--flip' : '' ?><?= $tinted ? ' section--tinted' : '' ?>" id="<?= e($section['id']) ?>">
|
||||
<section class="section split<?= $flip ? ' split--flip' : '' ?><?= $tinted ? ' section--tinted' : '' ?><?= $polaroid ? ' split--polaroid' : '' ?><?= $hasMedia ? '' : ' split--plain' ?>" id="<?= e($section['id']) ?>">
|
||||
<div class="container split__inner">
|
||||
<div class="split__text">
|
||||
<h<?= $level ?>><?= e($section['title']) ?></h<?= $level ?>>
|
||||
<p><?= e($section['text']) ?></p>
|
||||
<?php foreach ($paragraphs as $paragraph): ?>
|
||||
<p><?= e($paragraph) ?></p>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!empty($section['cta'])): ?>
|
||||
<p class="split__cta"><?php component('cta', ['cta' => $section['cta']]); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if ($hasMedia): ?>
|
||||
<div class="split__media">
|
||||
<?php component('img', ['image' => $section['image'], 'sizes' => '(max-width: 992px) 100vw, 50vw', 'class' => 'split__img']); ?>
|
||||
<?php if (!empty($section['gallery'])): ?>
|
||||
<div class="split__gallery">
|
||||
<?php foreach ($section['gallery'] as $i => $galleryImage): ?>
|
||||
<?php $sizes = $i === 0 ? '(max-width: 992px) 100vw, 50vw' : '(max-width: 992px) 50vw, 25vw'; ?>
|
||||
<?php if ($polaroid): ?>
|
||||
<figure class="polaroid">
|
||||
<?php component('img', ['image' => $galleryImage, 'sizes' => $sizes, 'class' => 'split__img']); ?>
|
||||
<?php if (!empty($galleryImage['caption'])): ?>
|
||||
<figcaption class="polaroid__caption"><?= e($galleryImage['caption']) ?></figcaption>
|
||||
<?php endif; ?>
|
||||
</figure>
|
||||
<?php else: ?>
|
||||
<?php component('img', ['image' => $galleryImage, 'sizes' => $sizes, 'class' => 'split__img']); ?>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php elseif ($polaroid): ?>
|
||||
<figure class="polaroid">
|
||||
<?php component('img', ['image' => $section['image'], 'sizes' => '(max-width: 992px) 100vw, 50vw', 'class' => 'split__img']); ?>
|
||||
<?php if (!empty($section['image']['caption'])): ?>
|
||||
<figcaption class="polaroid__caption"><?= e($section['image']['caption']) ?></figcaption>
|
||||
<?php endif; ?>
|
||||
</figure>
|
||||
<?php else: ?>
|
||||
<?php component('img', ['image' => $section['image'], 'sizes' => '(max-width: 992px) 100vw, 50vw', 'class' => 'split__img']); ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -10,11 +10,14 @@ declare(strict_types=1);
|
||||
*/
|
||||
$club = json_load('club');
|
||||
|
||||
// Fehlerquelle: PRG-Query (?error=…) oder — beim 422-Re-Render ohne JS — die
|
||||
// serverseitigen Feldfehler aus der Action.
|
||||
$sent = isset($_GET['sent']);
|
||||
$error = isset($_GET['error']) ? (string) $_GET['error'] : null;
|
||||
$error = isset($_GET['error']) ? (string) $_GET['error'] : (form_has_errors() ? 'validation' : null);
|
||||
$errorMessages = [
|
||||
'validation' => 'Bitte prüfe deine Eingaben – Pflichtfelder fehlen oder die E-Mail-Adresse ist ungültig.',
|
||||
'validation' => 'Bitte prüfe deine Eingaben: Die markierten Felder brauchen noch etwas Aufmerksamkeit.',
|
||||
'ratelimit' => 'Zu viele Anfragen in kurzer Zeit. Bitte versuche es in ein paar Minuten erneut.',
|
||||
'busy' => 'Bei uns gehen gerade ungewöhnlich viele Anfragen ein. Bitte versuche es später noch einmal oder schreib uns direkt an ' . $club['email'] . '.',
|
||||
'mail' => 'Deine Anfrage konnte gerade nicht gesendet werden. Bitte versuche es später erneut oder schreib uns direkt an ' . $club['email'] . '.',
|
||||
];
|
||||
|
||||
@@ -43,7 +46,8 @@ $f = [
|
||||
<p class="text-muted">Oder direkt per Mail an <a href="mailto:<?= e($club['email']) ?>"><?= e($club['email']) ?></a></p>
|
||||
</div>
|
||||
<form class="form" method="post" action="<?= e(url('sportheimbuchung-senden')) ?>" novalidate>
|
||||
<div class="form__status" role="status" aria-live="polite" tabindex="-1" data-form-status>
|
||||
<?php /* autofocus nur beim 422-Re-Render: bringt den Fokus auch ohne JS in die Fehlermeldung */ ?>
|
||||
<div class="form__status" role="status" aria-live="polite" tabindex="-1" data-form-status<?= form_has_errors() ? ' autofocus' : '' ?>>
|
||||
<?php if ($sent): ?>
|
||||
<p class="form__success">Danke für deine Anfrage! Wir melden uns kurzfristig bei dir.</p>
|
||||
<?php elseif ($error !== null): ?>
|
||||
|
||||
155
app/components/stadionzeitung-aufruf.php
Normal file
155
app/components/stadionzeitung-aufruf.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Aufruf-Seite der Stadionzeitung (Live-Seite) — eine redaktionelle Content-
|
||||
* Seite für einen Handlungsaufruf, zuerst gebaut für den Helferaufruf zum
|
||||
* Sportfest, bewusst GENERISCH gehalten (nicht `sportfest`), damit sie später
|
||||
* auch für andere Feste (Weihnachtsfeier o. Ä.) taugt.
|
||||
*
|
||||
* Der Inhalt ist KEIN Skript-Input: er wird wie Vorwort/Interview im Chat
|
||||
* gepflegt und lebt im `aufruf`-Feld der Ausgabe in data/stadionzeitung.json:
|
||||
* kicker kleine Zeile über der Überschrift (z. B. „Sportfest 2026")
|
||||
* heading Überschrift der Seite
|
||||
* intro Vorspann (Lese-Serif, wie Vorwort/Interview) — String ODER
|
||||
* Liste von Zeilen (mehrere Zeilen = ein Absatz mit <br>)
|
||||
* tasks_title Überschrift über der Aufgaben-Liste (optional)
|
||||
* tasks[] wobei Hilfe gebraucht wird
|
||||
* contacts_intro Zeile über den Ansprechpartnern (optional)
|
||||
* contacts[] NAMEN — Nummer/Foto zieht die Seite per Namensabgleich aus
|
||||
* data/ansprechpartner.json (geteilte Quelle, nie duplizieren;
|
||||
* wie die Kontakt-Seite bewusst live statt Snapshot: ein Fest
|
||||
* betrifft ohnehin die Zukunft, alte Nummern nützen niemandem)
|
||||
* link interner Slug fürs Ziel (z. B. „veranstaltungen/sportfest-2026"),
|
||||
* liefert Link-Zeile + QR im CTA-Fuß
|
||||
* poster optional, kleines Plakat rechts neben Intro + Aufgaben
|
||||
* (Varianten-Set {base,widths,alt} oder {src,…}) — z. B. das
|
||||
* Sportfest-Vorderplakat aus data/veranstaltungen.json
|
||||
* bin/stadionzeitung-add.php trägt das Feld über erneute Läufe weiter (wie
|
||||
* `vorwort`/`interview`), plant die Seite nur ein wenn das Feld existiert und
|
||||
* erzeugt den QR (qr-aufruf.png im Ausgaben-Ordner) aus `link`.
|
||||
*
|
||||
* Props: $issue (Eintrag aus data/stadionzeitung.json)
|
||||
*/
|
||||
$issue = $issue ?? [];
|
||||
$aufruf = $issue['aufruf'] ?? null;
|
||||
if (!is_array($aufruf)) {
|
||||
return;
|
||||
}
|
||||
$slug = (string) ($issue['slug'] ?? '');
|
||||
$heading = trim((string) ($aufruf['heading'] ?? 'Mach mit'));
|
||||
$kicker = trim((string) ($aufruf['kicker'] ?? 'Mitmachen'));
|
||||
// intro darf ein String ODER eine Liste von Zeilen sein — mehrere Zeilen
|
||||
// werden als EIN Absatz mit weichen Umbrüchen (<br>) gesetzt, damit ein kurzer
|
||||
// Aufruf bewusst zeilenweise stehen kann statt als Fließblock.
|
||||
$introLines = array_values(array_filter(
|
||||
array_map(static fn ($l): string => trim((string) $l), (array) ($aufruf['intro'] ?? [])),
|
||||
static fn (string $l): bool => $l !== ''
|
||||
));
|
||||
$tasksTitle = trim((string) ($aufruf['tasks_title'] ?? ''));
|
||||
$tasks = array_values(array_filter(
|
||||
array_map(static fn ($t): string => trim((string) $t), $aufruf['tasks'] ?? []),
|
||||
static fn (string $t): bool => $t !== ''
|
||||
));
|
||||
$contactsIntro = trim((string) ($aufruf['contacts_intro'] ?? ''));
|
||||
$link = trim((string) ($aufruf['link'] ?? ''));
|
||||
$ctaLabel = trim((string) ($aufruf['cta_label'] ?? 'Alle Infos'));
|
||||
$ctaTitle = trim((string) ($aufruf['cta_title'] ?? ''));
|
||||
|
||||
// Kontakte per Namensabgleich aus der geteilten Quelle auflösen (Nummer/Foto),
|
||||
// die Reihenfolge folgt dem `contacts`-Feld. Ein unbekannter Name bleibt als
|
||||
// reiner Name stehen (kein Fehler), damit ein Tippfehler die Seite nicht kippt.
|
||||
$byName = [];
|
||||
foreach (json_load('ansprechpartner')['persons'] ?? [] as $person) {
|
||||
$name = (string) ($person['name'] ?? '');
|
||||
if ($name !== '') {
|
||||
$byName[$name] = $person;
|
||||
}
|
||||
}
|
||||
$contacts = [];
|
||||
foreach ($aufruf['contacts'] ?? [] as $name) {
|
||||
$name = trim((string) $name);
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$contacts[] = $byName[$name] ?? ['name' => $name];
|
||||
}
|
||||
|
||||
$qr = ($slug !== '' && is_file(PUBLIC_PATH . "/assets/img/stadionzeitung/{$slug}/qr-aufruf.png"))
|
||||
? "img/stadionzeitung/{$slug}/qr-aufruf.png"
|
||||
: null;
|
||||
$linkLabel = $link !== '' ? club_domain($link) : '';
|
||||
|
||||
// Optionales kleines Plakat rechts neben Intro + Aufgaben (z. B. das Sportfest-
|
||||
// Vorderplakat) — Varianten-Set {base,widths,alt} oder Einzeldatei {src,…}.
|
||||
$poster = $aufruf['poster'] ?? null;
|
||||
if (!is_array($poster) || (empty($poster['base']) && empty($poster['src']))) {
|
||||
$poster = null;
|
||||
}
|
||||
?>
|
||||
<div class="stadionzeitung-aufruf">
|
||||
<header class="stadionzeitung-page__head">
|
||||
<p class="stadionzeitung-page__kicker"><?= e($kicker) ?></p>
|
||||
<h2 class="stadionzeitung-page__heading"><?= e($heading) ?></h2>
|
||||
</header>
|
||||
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
|
||||
<div class="stadionzeitung-aufruf__cols<?= $poster ? ' stadionzeitung-aufruf__cols--poster' : '' ?>">
|
||||
<div class="stadionzeitung-aufruf__col-main">
|
||||
<?php if ($introLines !== []): ?>
|
||||
<p class="stadionzeitung-aufruf__intro"><?= implode('<br>', array_map('e', $introLines)) ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if ($tasks !== []): ?>
|
||||
<div class="stadionzeitung-aufruf__tasks">
|
||||
<?php if ($tasksTitle !== ''): ?>
|
||||
<p class="stadionzeitung-aufruf__tasks-title"><?= e($tasksTitle) ?></p>
|
||||
<?php endif; ?>
|
||||
<ul class="stadionzeitung-aufruf__task-list">
|
||||
<?php foreach ($tasks as $task): ?>
|
||||
<li class="stadionzeitung-aufruf__task"><?= icon('check2', 'stadionzeitung-aufruf__task-icon') ?><span><?= e($task) ?></span></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if ($poster): ?>
|
||||
<?php component('img', ['image' => $poster, 'sizes' => '150px', 'class' => 'stadionzeitung-aufruf__poster']); ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if ($contacts !== []): ?>
|
||||
<div class="stadionzeitung-aufruf__contacts">
|
||||
<?php if ($contactsIntro !== ''): ?>
|
||||
<p class="stadionzeitung-aufruf__contacts-intro"><?= e($contactsIntro) ?></p>
|
||||
<?php endif; ?>
|
||||
<ul class="stadionzeitung-aufruf__contact-list">
|
||||
<?php foreach ($contacts as $contact): ?>
|
||||
<li class="stadionzeitung-aufruf__contact">
|
||||
<span class="stadionzeitung-aufruf__contact-name"><?= e((string) ($contact['name'] ?? '')) ?></span>
|
||||
<?php if (!empty($contact['phone'])): ?>
|
||||
<span class="stadionzeitung-aufruf__contact-phone"><?= icon('telephone', 'stadionzeitung-aufruf__contact-icon') ?><?= e((string) $contact['phone']) ?></span>
|
||||
<?php elseif (!empty($contact['email'])): ?>
|
||||
<span class="stadionzeitung-aufruf__contact-phone"><?= icon('envelope', 'stadionzeitung-aufruf__contact-icon') ?><?= e((string) $contact['email']) ?></span>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($linkLabel !== ''): ?>
|
||||
<div class="stadionzeitung-page__cta">
|
||||
<div class="stadionzeitung-page__cta-text">
|
||||
<p class="stadionzeitung-page__cta-label"><?= e($ctaLabel) ?></p>
|
||||
<?php if ($ctaTitle !== ''): ?>
|
||||
<p class="stadionzeitung-page__cta-title"><?= e($ctaTitle) ?></p>
|
||||
<?php endif; ?>
|
||||
<p class="stadionzeitung-page__cta-link"><?= e($linkLabel) ?></p>
|
||||
</div>
|
||||
<?php if ($qr !== null): ?>
|
||||
<div class="stadionzeitung-page__cta-abriss">
|
||||
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($qr)) ?>" alt="" width="60" height="60" loading="lazy" decoding="async">
|
||||
<span class="stadionzeitung-page__cta-scan">Scan mich</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
56
app/components/stadionzeitung-cards.php
Normal file
56
app/components/stadionzeitung-cards.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Ausgaben-Archiv der Stadionzeitung, ganze Karte verlinkt auf die Ausgabe.
|
||||
* Props: $issues array aus data/stadionzeitung.json (bereits neueste zuerst).
|
||||
* Leer → freundlicher Hinweis statt leerer Sektion (nie brechen).
|
||||
*/
|
||||
$issues = $issues ?? [];
|
||||
?>
|
||||
<section class="section stadionzeitung-cards">
|
||||
<div class="container">
|
||||
<h2 class="visually-hidden">Ausgaben der Stadionzeitung</h2>
|
||||
<?php if ($issues === []): ?>
|
||||
<p class="text-muted">Noch keine Ausgabe digital verfügbar — zur nächsten Heimspiel-Stadionzeitung schauen wir wieder vorbei.</p>
|
||||
<?php else: ?>
|
||||
<ul class="stadionzeitung-cards__grid">
|
||||
<?php foreach ($issues as $issue): ?>
|
||||
<li>
|
||||
<a class="stadionzeitung-card" href="<?= e(url('stadionzeitung/' . $issue['slug'])) ?>">
|
||||
<div class="stadionzeitung-card__media">
|
||||
<?php $firstPage = $issue['images'][0] ?? null; ?>
|
||||
<?php if ($firstPage !== null && ($firstPage['type'] ?? 'image') === 'live' && ($firstPage['page'] ?? '') === 'cover'): ?>
|
||||
<?php /* Seit dem automatischen Cover (Phase 3) beginnt jede Ausgabe mit der
|
||||
echten, live gerenderten Titelseite statt einem Canva-Bild — die
|
||||
dient hier direkt als Karten-Vorschau, keine zweite Bildquelle. */ ?>
|
||||
<?php component('stadionzeitung-cover', ['issue' => $issue]); ?>
|
||||
<?php elseif ($firstPage !== null): ?>
|
||||
<?php component('img', ['image' => $firstPage, 'sizes' => '(max-width: 600px) 100vw, 360px', 'class' => 'stadionzeitung-card__img']); ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="stadionzeitung-card__body">
|
||||
<h3 class="stadionzeitung-card__name">Stadionzeitung zum Heimspiel gegen <?= e($issue['opponent']) ?></h3>
|
||||
<p class="stadionzeitung-card__meta">
|
||||
<time datetime="<?= e($issue['date']) ?>"><?= e(german_date($issue['date'])) ?></time>
|
||||
· <?= (int) $issue['pages'] ?> Seiten
|
||||
</p>
|
||||
<?php /* Coverzeile als Teaser — dieselbe Quelle (stadionzeitung_teaser()), die auch
|
||||
auf dem Cover unter „Im Heft" steht: cover.teaser einer Sonderausgabe, sonst
|
||||
die Coverzeile des Interviews. Sie füllt die breite Zeile mit echtem Inhalt
|
||||
statt Leerfläche und sagt, warum man die Ausgabe öffnen sollte. Gibt es
|
||||
beides nicht, entfällt die Zeile (wie bei .veranstaltung-card__text). */
|
||||
$kartenTeaser = stadionzeitung_teaser($issue); ?>
|
||||
<?php if ($kartenTeaser !== ''): ?>
|
||||
<p class="stadionzeitung-card__text"><?= e($kartenTeaser) ?></p>
|
||||
<?php endif; ?>
|
||||
<span class="stadionzeitung-card__cta">Ausgabe lesen <?= icon('arrow-right', 'stadionzeitung-card__arrow') ?></span>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
205
app/components/stadionzeitung-cover.php
Normal file
205
app/components/stadionzeitung-cover.php
Normal file
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Automatisch generiertes Stadionzeitung-Cover (Phase 3) — ersetzt das bisherige
|
||||
* Canva-Titelbild. Einzige fotografische Stadionzeitungs-Seite: Vereins-Hero-Bild
|
||||
* als Hintergrund (dieselbe Quelle wie die Startseite, data/home.json — nie
|
||||
* duplizieren) mit Abdunklungs-Verlauf, das freigestellte Spielerfoto ist der
|
||||
* Star (sehr großformatig, rechts randabfallend). Kopf wie ein echtes
|
||||
* Zeitschriften-Titelblatt: kleine rote Kicker-Zeile (Saison/Datum), riesiger
|
||||
* Masthead mit Logo auf gleicher Höhe, dezente Tagline darunter. Unten links
|
||||
* gestapelt: Begegnung als große freie Typografie (kein Kasten) über einer
|
||||
* roten Trennlinie und Slogan + QR-Plakette. Dünne Passepartout-Rahmenlinie
|
||||
* gibt den "druckfertig"-Effekt.
|
||||
*
|
||||
* Selbst-Containment: alle Maße hier laufen über CSS Container Queries (cqi,
|
||||
* relativ zur eigenen Breite von .stadionzeitung-cover), NICHT über die
|
||||
* Website-Schriftgrößen-Tokens aus tokens.css — das Cover sieht dadurch in
|
||||
* jedem Kontext (Karten-Vorschau, Swipe-Viewer, Druck) proportional exakt
|
||||
* gleich aus, unabhängig von Viewport/Root-Schriftgröße. Einzige Ausnahme
|
||||
* ganz bewusst: die crest-Komponente (geteiltes Bauteil, bekommt ihre Größe
|
||||
* über --crest-size lokal in cqi mitgegeben, keine eigene Kopie nötig).
|
||||
*
|
||||
* Props: $issue (Eintrag aus data/stadionzeitung.json: opponent, date, team_key,
|
||||
* player_image?, qr_image?)
|
||||
*
|
||||
* Gegner-Wappen wird zur Laufzeit über den Anstoß-Tag in data/matchcenter.json
|
||||
* gesucht (next_matches/last_results des Teams) — nie im JSON dupliziert. Kein
|
||||
* Treffer (z. B. Freundschaftsspiel gegen eine Spielgemeinschaft, die der BFV
|
||||
* nicht führt) → das eigene Wappen kommt trotzdem aus dem lokalen Vereinslogo,
|
||||
* der Gegner läuft über den Initialen-Fallback der crest-Komponente.
|
||||
*/
|
||||
$issue = $issue ?? [];
|
||||
$opponent = (string) ($issue['opponent'] ?? '');
|
||||
$date = (string) ($issue['date'] ?? '');
|
||||
$teamKey = (string) ($issue['team_key'] ?? '');
|
||||
$playerImage = $issue['player_image'] ?? null;
|
||||
$qrImage = $issue['qr_image'] ?? null;
|
||||
|
||||
$club = json_load('club');
|
||||
$heroImage = json_load('home')['hero']['image'] ?? null;
|
||||
|
||||
$team = null;
|
||||
foreach (json_load('matchcenter')['teams'] ?? [] as $t) {
|
||||
if (($t['key'] ?? '') === $teamKey) {
|
||||
$team = $t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$teamName = (string) ($team['name'] ?? '');
|
||||
$league = (string) ($team['league'] ?? '');
|
||||
$season = (string) ($team['season'] ?? '');
|
||||
|
||||
$match = null;
|
||||
foreach (array_merge($team['next_matches'] ?? [], $team['last_results'] ?? []) as $candidate) {
|
||||
if (str_starts_with((string) ($candidate['kickoff'] ?? ''), $date)) {
|
||||
$match = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$ownCrest = 'img/logo.svg';
|
||||
$opponentCrest = null;
|
||||
if ($match !== null) {
|
||||
$ownCrest = $match['is_home'] ? $match['home_crest'] : $match['away_crest'];
|
||||
$opponentCrest = $match['is_home'] ? $match['away_crest'] : $match['home_crest'];
|
||||
}
|
||||
|
||||
// Gedruckte Zeile neben dem QR-Code — immer die echte Domain (club_domain()).
|
||||
$linkText = club_domain();
|
||||
// Aufmacher unten links: normal die Begegnung (Wappen + „Heimspiel gegen …").
|
||||
// Zeigt die Ausgabe auf eine Veranstaltung (Sportfest-Ausgabe), tritt an deren
|
||||
// Stelle das FEST als Titelgeschichte — denn ein Fest bringt mehrere Heimspiele
|
||||
// mit (Erste, Zweite, Frauen), und ein einzelnes davon aufs Cover zu heben wäre
|
||||
// falsch (Felix, 03.09.2026). Titel und Zeitraum kommen aus
|
||||
// data/veranstaltungen.json, nie neu getippt.
|
||||
$feature = null;
|
||||
$featureV = veranstaltung_find((string) ($issue['veranstaltung'] ?? ''));
|
||||
if ($featureV !== null) {
|
||||
$feature = [
|
||||
'title' => trim((string) ($featureV['title'] ?? '')),
|
||||
'range' => german_date_range((string) ($featureV['date_start'] ?? ''), $featureV['date_end'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
// Bildmotiv rechts: normal das freigestellte Spielerfoto. Eine Sonderausgabe mit
|
||||
// Veranstaltung zeigt stattdessen deren HAUPTPLAKAT (erstes Plakat, dieselbe
|
||||
// Quelle wie /veranstaltungen/<slug>, nie eine Kopie) — leicht schräg gelegt mit
|
||||
// zwei abgerundeten Ecken, wie ein aufs Cover gelegter Aushang (Felix,
|
||||
// 03.09.2026). Ohne Plakat bleibt es beim Spielerfoto, ohne beides trägt der
|
||||
// Hintergrund die Seite allein.
|
||||
$poster = null;
|
||||
if ($featureV !== null) {
|
||||
$ersteseite = $featureV['plakate'][0] ?? null;
|
||||
if (is_array($ersteseite) && !empty($ersteseite['base'])) {
|
||||
$poster = $ersteseite;
|
||||
}
|
||||
}
|
||||
|
||||
// Kicker: Saison · Datum, auf jedem Cover gleich. Eine Sonderausgabe bekommt
|
||||
// bewusst KEINE zusätzliche Kennung im Kopf: ein roter Kasten „Sportfest-Ausgabe"
|
||||
// war gebaut und am 03.09.2026 wieder entfernt (Felix) — er stach aus dem
|
||||
// ruhigen Kopf heraus, und die Ausgabe kennzeichnet sich unten links ohnehin
|
||||
// selbst durch den großen Fest-Titel.
|
||||
$kicker = implode(' · ', array_filter([
|
||||
$season,
|
||||
$date !== '' ? german_date($date) : '',
|
||||
], static fn (string $s): bool => $s !== ''));
|
||||
|
||||
// Zweite Coverzeile (Magazin-Praxis: Teaser auf die Geschichte im Heft) —
|
||||
// aus stadionzeitung_teaser(): `cover.teaser` einer Sonderausgabe, sonst die
|
||||
// Coverzeile des Interviews. Dieselbe Funktion speist die Archivkarte.
|
||||
$teaser = stadionzeitung_teaser($issue);
|
||||
?>
|
||||
<div class="stadionzeitung-cover<?= $poster !== null ? ' stadionzeitung-cover--poster' : '' ?>">
|
||||
<?php if ($heroImage): ?>
|
||||
<?php component('img', ['image' => $heroImage, 'sizes' => '480px', 'class' => 'stadionzeitung-cover__bg']); ?>
|
||||
<?php endif; ?>
|
||||
<?php if ($poster !== null): ?>
|
||||
<?php /* Alt-Text bewusst leer: der Plakat-Alt-Text aus veranstaltungen.json
|
||||
beschreibt die Termine, die drei Zeilen weiter unten als Text stehen —
|
||||
auf dem Cover wäre er eine Dopplung fürs Screenreader-Ohr. */ ?>
|
||||
<?php component('img', [
|
||||
'image' => ['base' => $poster['base'], 'widths' => $poster['widths'] ?? [], 'alt' => ''],
|
||||
'class' => 'stadionzeitung-cover__poster',
|
||||
'sizes' => '(max-width: 600px) 55vw, 260px',
|
||||
]); ?>
|
||||
<?php elseif (!empty($playerImage)): ?>
|
||||
<?php
|
||||
/* Varianten-Set (base/widths/ext=png) statt der Originaldatei. Das Original ist
|
||||
ein Freisteller mit 2584x5093 Pixeln und 12 MB: iOS Safari verwirft Bilder,
|
||||
deren dekodierte Größe sein Budget übersteigt (hier gut 53 MB), und auf einer
|
||||
Ausgabe liegen rund 86 Bilder — das Cover blieb am iPhone leer, während Chrome
|
||||
am Desktop es zeigte (31.07.2026). Dargestellt wird es mit 98cqi Höhe, bei
|
||||
480px Seitenbreite also etwa 238x470 CSS-Pixel; die 1000er Variante deckt
|
||||
damit auch Druck und dreifache Pixeldichte ab.
|
||||
sizes nennt die echte Breite, sonst zöge ein Handy mit DPR 3 die größte. */
|
||||
$playerBild = is_array($playerImage)
|
||||
? $playerImage
|
||||
: ['src' => $playerImage, 'width' => 0, 'height' => 0, 'alt' => ''];
|
||||
?>
|
||||
<?php component('img', [
|
||||
'image' => $playerBild,
|
||||
'class' => 'stadionzeitung-cover__player',
|
||||
'sizes' => '(max-width: 600px) 50vw, 240px',
|
||||
]); ?>
|
||||
<?php endif; ?>
|
||||
<div class="stadionzeitung-cover__inner">
|
||||
<header class="stadionzeitung-cover__masthead">
|
||||
<?php if ($kicker !== ''): ?>
|
||||
<p class="stadionzeitung-cover__kicker"><?= e($kicker) ?></p>
|
||||
<?php endif; ?>
|
||||
<div class="stadionzeitung-cover__masthead-row">
|
||||
<img class="stadionzeitung-cover__logo" src="<?= e(asset('img/logo.svg')) ?>" alt="" width="75" height="97">
|
||||
<p class="stadionzeitung-cover__wordmark">Stadionzeitung</p>
|
||||
</div>
|
||||
<p class="stadionzeitung-cover__tagline">Rund ums Heimspiel im Katzbachtal</p>
|
||||
</header>
|
||||
<div class="stadionzeitung-cover__corner">
|
||||
<?php if ($feature !== null): ?>
|
||||
<?php /* Sonderausgabe: das Fest ist die Titelgeschichte, nicht eine der
|
||||
Begegnungen. Titel in der Display-Schrift (Versalien wie der Masthead),
|
||||
Zeitraum darunter als gesperrte Zeile. */ ?>
|
||||
<div class="stadionzeitung-cover__feature">
|
||||
<p class="stadionzeitung-cover__feature-title"><?= e($feature['title']) ?></p>
|
||||
<?php if ($feature['range'] !== ''): ?>
|
||||
<p class="stadionzeitung-cover__meta"><?= e($feature['range']) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="stadionzeitung-cover__headline">
|
||||
<?php /* Bewusst ohne Trennzeichen zwischen den Wappen (Felix, 29.07.2026:
|
||||
ruhiger) — die beiden Logos sprechen für sich. */ ?>
|
||||
<div class="stadionzeitung-cover__crests">
|
||||
<?php component('crest', ['src' => $ownCrest, 'name' => $club['name'] ?? '']); ?>
|
||||
<?php component('crest', ['src' => $opponentCrest, 'name' => $opponent]); ?>
|
||||
</div>
|
||||
<p class="stadionzeitung-cover__matchup">Heimspiel gegen <?= e($opponent) ?></p>
|
||||
<?php if ($teamName !== '' || $league !== ''): ?>
|
||||
<p class="stadionzeitung-cover__meta"><?= e(trim($teamName . ($league !== '' ? ' · ' . $league : ''))) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($teaser !== ''): ?>
|
||||
<p class="stadionzeitung-cover__teaser">
|
||||
<span class="stadionzeitung-cover__teaser-label">Im Heft</span>
|
||||
<?= e($teaser) ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php /* Bewusst ohne Vereins-Slogan (Felix, 29.07.2026: ruhiger) — der
|
||||
Slogan bleibt Footer/JSON-LD der Website vorbehalten. */ ?>
|
||||
<footer class="stadionzeitung-cover__footer">
|
||||
<?php /* QR bewusst minimal (Felix, 29.07.2026): Code + eine Zeile,
|
||||
keine Plakette/kein Rahmen mehr. */ ?>
|
||||
<?php if (!empty($qrImage)): ?>
|
||||
<div class="stadionzeitung-cover__digital">
|
||||
<img class="stadionzeitung-cover__qr" src="<?= e(asset($qrImage)) ?>" alt="" width="60" height="60" loading="lazy">
|
||||
<span class="stadionzeitung-cover__link">Digital lesen · <?= e($linkText) ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
65
app/components/stadionzeitung-danke.php
Normal file
65
app/components/stadionzeitung-danke.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Danke-Seite der Stadionzeitung (Live-Seite): der Dank an unsere Partner mit
|
||||
* der Logo-Wand aus data/partners.json (Single Source, dieselbe Quelle wie die
|
||||
* Startseite) und dem QR auf /partner-werden. Gegenstück zur Akquise-Seite
|
||||
* stadionzeitung-partner.php: hier stehen die, die schon dabei sind.
|
||||
*
|
||||
* Vorgeschichte: der Dank war Teil eines Canva-Bilds, das unten an die
|
||||
* Emons-Anzeige geklebt war — eine leere Fläche mit Wappen und zwei Zeilen
|
||||
* (Felix, 03.09.2026: „sieht schrecklich aus"). Seitdem eine echte Seite, die
|
||||
* die Partner auch zeigt statt nur zu erwähnen.
|
||||
*
|
||||
* Die Anzeige oben ist KEIN Bestandteil der Komponente, sondern kommt als Prop:
|
||||
* bin/stadionzeitung-add.php nimmt Anzeigen mit `platzierung: danke` aus dem
|
||||
* Streu-Pool und schreibt das Motiv in den Seiteneintrag. Dadurch ist es in
|
||||
* images[] eingefroren wie jede andere Anzeige — eine veröffentlichte Ausgabe
|
||||
* ändert sich nicht mehr, wenn der Sponsor später ein neues Motiv liefert.
|
||||
*
|
||||
* Props: $anzeige (Varianten-Set {base,widths,alt} oder null)
|
||||
*/
|
||||
$anzeige = (is_array($anzeige ?? null) && !empty($anzeige['base'])) ? $anzeige : null;
|
||||
$partners = json_load('partners')['partners'] ?? [];
|
||||
$qr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-partner-werden.png')
|
||||
? 'img/stadionzeitung/qr-partner-werden.png'
|
||||
: null;
|
||||
?>
|
||||
<div class="stadionzeitung-danke">
|
||||
<?php if ($anzeige !== null): ?>
|
||||
<?php component('img', ['image' => $anzeige, 'sizes' => '420px', 'class' => 'stadionzeitung-danke__anzeige']); ?>
|
||||
<?php endif; ?>
|
||||
<header class="stadionzeitung-page__head stadionzeitung-danke__head">
|
||||
<p class="stadionzeitung-page__kicker">Unsere Partner</p>
|
||||
<?php /* Überschrift bewusst OHNE Anrede: Partner sind Unternehmen, die wir mit
|
||||
„Sie" ansprechen (Sprachregel, CLAUDE.md), ein „danke, dass ihr dabei
|
||||
seid" würde sie duzen. „Danke für die Unterstützung" trägt den Dank
|
||||
ohne diesen Konflikt, der CTA darunter bleibt in der Sie-Form. */ ?>
|
||||
<h2 class="stadionzeitung-page__heading">Danke für die Unterstützung</h2>
|
||||
</header>
|
||||
<?php if ($partners !== []): ?>
|
||||
<ul class="stadionzeitung-danke__logos">
|
||||
<?php foreach ($partners as $partner): ?>
|
||||
<?php if (empty($partner['logo'])) { continue; } ?>
|
||||
<li class="stadionzeitung-danke__logo">
|
||||
<img src="<?= e(asset((string) $partner['logo'])) ?>" alt="<?= e((string) ($partner['name'] ?? '')) ?>"<?= img_intrinsic_attrs((string) $partner['logo']) ?> loading="lazy" decoding="async">
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
<div class="stadionzeitung-page__cta">
|
||||
<div class="stadionzeitung-page__cta-text">
|
||||
<p class="stadionzeitung-page__cta-label">Mitmachen</p>
|
||||
<p class="stadionzeitung-page__cta-title">Werden Sie unser Partner</p>
|
||||
<p class="stadionzeitung-page__cta-link"><?= e(club_domain('partner-werden')) ?></p>
|
||||
</div>
|
||||
<?php if ($qr !== null): ?>
|
||||
<div class="stadionzeitung-page__cta-abriss">
|
||||
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($qr)) ?>" alt="" width="60" height="60" loading="lazy" decoding="async">
|
||||
<span class="stadionzeitung-page__cta-scan">Scan mich</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
98
app/components/stadionzeitung-druck.php
Normal file
98
app/components/stadionzeitung-druck.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Druckfassung einer Stadionzeitung-Ausgabe (Route ?druck=1, siehe
|
||||
* app/pages/stadionzeitung-ausgabe.php) — die Seiten in BROSCHÜREN-Reihenfolge
|
||||
* statt Lesereihenfolge: das Heft wird als A4 quer beidseitig gedruckt, die
|
||||
* Blätter werden gestapelt und mittig gefaltet. Damit dabei vorn das Cover
|
||||
* und innen die richtige Abfolge liegt, trägt Blatt 1 vorne [letzte Seite |
|
||||
* Seite 1], hinten [Seite 2 | vorletzte Seite] usw. — die klassische
|
||||
* Sattelheftungs-Montage. Deshalb liegt die Montage HIER im PHP (die
|
||||
* DOM-Reihenfolge selbst ist die Druckreihenfolge), nicht in Druck-CSS:
|
||||
* CSS kann Seiten nicht über Blattgrenzen hinweg umsortieren.
|
||||
*
|
||||
* Die Seitenzahl wird dafür auf ein Vielfaches von 4 gebracht: fehlende
|
||||
* Seiten füllt eine weiße Leerseite VOR der Rückseite auf (die Rückseite
|
||||
* bleibt immer außen). Die Regel fürs Heft bleibt trotzdem: Lücken beim
|
||||
* Erzeugen gemeinsam sauber lösen, Leerseiten sind der Notnagel.
|
||||
*
|
||||
* Auf dem Bildschirm zeigt die Route eine Blatt-Vorschau (jedes Blatt als
|
||||
* A4-quer-Fläche mit Beschriftung „Blatt 1 · Vorderseite") plus
|
||||
* Druck-Anleitung; assets/js/stadionzeitung-druck.js liefert den
|
||||
* „Jetzt drucken"-Button (progressive enhancement — ohne JS druckt der
|
||||
* Browser-Dialog dieselbe Ansicht).
|
||||
*
|
||||
* Props: $issue Eintrag aus data/stadionzeitung.json
|
||||
*/
|
||||
$issue = $issue ?? [];
|
||||
$pages = $issue['images'] ?? [];
|
||||
if ($pages === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hasLive = false;
|
||||
foreach ($pages as $entry) {
|
||||
if (($entry['type'] ?? 'image') === 'live') {
|
||||
$hasLive = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$liveData = $hasLive ? stadionzeitung_live_data($issue) : [];
|
||||
|
||||
// Auf Vielfaches von 4 auffüllen — Leerseiten direkt vor der letzten Seite,
|
||||
// damit die gelieferte Rückseite die äußere Rückseite des Hefts bleibt.
|
||||
$fill = (4 - count($pages) % 4) % 4;
|
||||
if ($fill > 0) {
|
||||
array_splice($pages, count($pages) - 1, 0, array_fill(0, $fill, ['type' => 'leer']));
|
||||
}
|
||||
$n = count($pages);
|
||||
|
||||
// Sattelheftung: Blatt k (0-basiert) trägt vorne [n-1-2k | 2k] und hinten
|
||||
// [2k+1 | n-2-2k] (0-basierte Seitenindizes). Beim beidseitigen Druck an der
|
||||
// KURZEN Kante spiegeln, sonst steht jede Rückseite kopf.
|
||||
$sides = [];
|
||||
for ($k = 0; $k < intdiv($n, 4); $k++) {
|
||||
$sides[] = ['label' => 'Blatt ' . ($k + 1) . ' · Vorderseite', 'pages' => [$n - 1 - 2 * $k, 2 * $k]];
|
||||
$sides[] = ['label' => 'Blatt ' . ($k + 1) . ' · Rückseite', 'pages' => [2 * $k + 1, $n - 2 - 2 * $k]];
|
||||
}
|
||||
?>
|
||||
<section class="stadionzeitung-druck">
|
||||
<ol class="stadionzeitung-druck__blaetter">
|
||||
<?php foreach ($sides as $side): ?>
|
||||
<li class="stadionzeitung-druck__blatt">
|
||||
<p class="stadionzeitung-druck__blatt-label" aria-hidden="true"><?= e($side['label']) ?></p>
|
||||
<div class="stadionzeitung-druck__flaeche">
|
||||
<?php foreach ($side['pages'] as $pageIndex): ?>
|
||||
<div class="stadionzeitung-druck__slot">
|
||||
<?php component('stadionzeitung-seite', [
|
||||
'entry' => $pages[$pageIndex],
|
||||
'issue' => $issue,
|
||||
'data' => $liveData,
|
||||
// In der Druckfassung wird JEDE Seite sofort geladen:
|
||||
// lazy geladene Bilder, die nie im Sichtfeld waren,
|
||||
// druckt Chrome sonst als leere Flächen.
|
||||
'eager' => true,
|
||||
]); ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ol>
|
||||
<?php /* Anleitung + Druck-Button bewusst UNTER den Blättern (Wunsch Felix,
|
||||
29.07.2026): erst die Vorschau komplett durchsehen, ganz unten
|
||||
drucken. */ ?>
|
||||
<div class="stadionzeitung-druck__hilfe">
|
||||
<h2 class="stadionzeitung-druck__hilfe-title">Drucken</h2>
|
||||
<p>
|
||||
<?= intdiv($n, 4) ?> Blätter A4 quer<?= $fill > 0 ? ', mit ' . $fill . ' weißen Füllseiten auf ' . $n . ' Seiten aufgefüllt' : ' (' . $n . ' Seiten)' ?>.
|
||||
Beidseitig drucken und dabei <strong>an der kurzen Kante spiegeln</strong>,
|
||||
danach alle Blätter aufeinanderlegen und mittig falten — fertig ist das Heft.
|
||||
</p>
|
||||
<button class="btn stadionzeitung-druck__print-btn" type="button" data-druck-print hidden>
|
||||
<?= icon('printer') ?> Jetzt drucken
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
72
app/components/stadionzeitung-ergebnisse.php
Normal file
72
app/components/stadionzeitung-ergebnisse.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Ergebnis-Rückblick der Stadionzeitung (Live-Seite) — die letzten Ergebnisse
|
||||
* aller Mannschaften als gestaltete Seite (Wappen + Ergebnis aus
|
||||
* data/matchcenter.json), bewusst KEINE Instagram-Post-Bilder: die Seite
|
||||
* funktioniert damit für jedes Spiel, auch ohne passenden Post.
|
||||
*
|
||||
* Zwei Zeilen pro Spiel (Heim über Gast) statt einer langen Einzelzeile —
|
||||
* Vereinsnamen wie „(SG) 1.FC Michelau / 1.FC Fortuna Roth" passen auf der
|
||||
* schmalen A5-Seite nie nebeneinander. Dasselbe Bauprinzip wie match-feature
|
||||
* im „Nächste Spiele"-Band.
|
||||
*
|
||||
* Props: $groups stadionzeitung_live_data()['ergebnisse'] — je Team:
|
||||
* team_name/league/matches[] (Matchcenter-Rohform inkl.
|
||||
* goals_home/goals_away, nur Spiele mit erfasstem Ergebnis).
|
||||
*/
|
||||
$groups = $groups ?? [];
|
||||
|
||||
// Matchcenter-Ticket wohnt hier (nicht auf der volleren Vorschau-Seite):
|
||||
// geteilter QR, von bin/stadionzeitung-add.php mit der aktuellen base_url
|
||||
// erzeugt — fehlt er, erscheint nur der Text-Link.
|
||||
$matchcenterQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-matchcenter.png')
|
||||
? 'img/stadionzeitung/qr-matchcenter.png'
|
||||
: null;
|
||||
$matchcenterLink = club_domain('matchcenter');
|
||||
?>
|
||||
<div class="stadionzeitung-ergebnisse">
|
||||
<header class="stadionzeitung-page__head">
|
||||
<p class="stadionzeitung-page__kicker">Rückblick</p>
|
||||
<h2 class="stadionzeitung-page__heading">Letzte Ergebnisse</h2>
|
||||
</header>
|
||||
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
|
||||
<?php if ($groups === []): ?>
|
||||
<p class="text-muted">Aktuell keine Ergebnisse.</p>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<section class="stadionzeitung-ergebnisse__team">
|
||||
<h3 class="stadionzeitung-ergebnisse__team-name"><?= e(trim(implode(' · ', array_filter([(string) ($group['team_name'] ?? ''), (string) ($group['league'] ?? '')])))) ?></h3>
|
||||
<?php foreach ($group['matches'] ?? [] as $match): ?>
|
||||
<div class="stadionzeitung-ergebnisse__match">
|
||||
<p class="stadionzeitung-ergebnisse__meta">
|
||||
<?= e(trim((string) ($match['date_display'] ?? ''))) ?><?php if (($match['competition'] ?? 'Liga') !== 'Liga'): ?> · <?= e((string) $match['competition']) ?><?php endif; ?>
|
||||
</p>
|
||||
<?php foreach ([['club' => $match['home'] ?? '', 'crest' => $match['home_crest'] ?? null, 'goals' => $match['goals_home'] ?? null],
|
||||
['club' => $match['away'] ?? '', 'crest' => $match['away_crest'] ?? null, 'goals' => $match['goals_away'] ?? null]] as $side): ?>
|
||||
<div class="stadionzeitung-ergebnisse__row<?= str_contains((string) $side['club'], 'TSV 08 Kulmbach') ? ' stadionzeitung-ergebnisse__row--own' : '' ?>">
|
||||
<?php component('crest', ['src' => $side['crest'], 'name' => (string) $side['club']]); ?>
|
||||
<span class="stadionzeitung-ergebnisse__club"><?= e((string) $side['club']) ?></span>
|
||||
<span class="stadionzeitung-ergebnisse__goals"><?= e((string) $side['goals']) ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</section>
|
||||
<?php endforeach; ?>
|
||||
<div class="stadionzeitung-page__cta">
|
||||
<div class="stadionzeitung-page__cta-text">
|
||||
<p class="stadionzeitung-page__cta-label">Mehr online</p>
|
||||
<p class="stadionzeitung-page__cta-title">Alle Spiele, Ergebnisse und Tabellen im Matchcenter</p>
|
||||
<p class="stadionzeitung-page__cta-link"><?= e($matchcenterLink) ?></p>
|
||||
</div>
|
||||
<?php if ($matchcenterQr !== null): ?>
|
||||
<div class="stadionzeitung-page__cta-abriss">
|
||||
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($matchcenterQr)) ?>" alt="" width="60" height="60" loading="lazy" decoding="async">
|
||||
<span class="stadionzeitung-page__cta-scan">Scan mich</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
96
app/components/stadionzeitung-gegner.php
Normal file
96
app/components/stadionzeitung-gegner.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Gegnerstatistik der Stadionzeitung. Alle Werte stammen aus dem bereits
|
||||
* aufgebauten Stadionzeitungs-Snapshot; die Komponente rendert nur. Dadurch
|
||||
* sind Entwurf und veröffentlichte Druckausgabe konsistent und archivfest.
|
||||
*
|
||||
* Props: $info stadionzeitung_live_data($issue)['gegner'][team] — ein Eintrag der
|
||||
* Map je Team-Key (team_name/opponent/league/season/fixture/own/
|
||||
* opponent_stats). Normal nur die tragende Mannschaft; in der
|
||||
* Sportfest-Ausgabe eine Seite je Heimspiel am Fest, deshalb
|
||||
* nennt der Kicker die Mannschaft.
|
||||
*/
|
||||
$info = is_array($info ?? null) ? $info : [];
|
||||
$teamName = trim((string) ($info['team_name'] ?? ''));
|
||||
$fixture = is_array($info['fixture'] ?? null) ? $info['fixture'] : [];
|
||||
$own = is_array($info['own'] ?? null) ? $info['own'] : null;
|
||||
$opponent = is_array($info['opponent_stats'] ?? null) ? $info['opponent_stats'] : null;
|
||||
$opponentName = (string) ($info['opponent'] ?? 'Unser Gegner');
|
||||
|
||||
$kickoff = (string) ($fixture['kickoff'] ?? '');
|
||||
$kickoffDate = $kickoff !== '' ? substr($kickoff, 0, 10) : '';
|
||||
$kickoffTime = $kickoff !== '' ? substr($kickoff, 11, 5) : '';
|
||||
|
||||
$rows = [
|
||||
['label' => 'Tabellenplatz', 'key' => 'rank'],
|
||||
['label' => 'Spiele', 'key' => 'played'],
|
||||
['label' => 'Siege', 'key' => 'won'],
|
||||
['label' => 'Unentschieden', 'key' => 'drawn'],
|
||||
['label' => 'Niederlagen', 'key' => 'lost'],
|
||||
['label' => 'Tordifferenz', 'key' => 'goal_diff', 'signed' => true],
|
||||
['label' => 'Punkte', 'key' => 'points'],
|
||||
];
|
||||
|
||||
$format = static function (?array $row, string $key, bool $signed = false): string {
|
||||
if ($row === null || !array_key_exists($key, $row)) {
|
||||
return '–';
|
||||
}
|
||||
$value = (int) $row[$key];
|
||||
return $signed && $value > 0 ? '+' . $value : (string) $value;
|
||||
};
|
||||
?>
|
||||
<div class="stadionzeitung-gegner">
|
||||
<header class="stadionzeitung-page__head">
|
||||
<p class="stadionzeitung-page__kicker"><?= e($teamName !== '' ? 'Unser Gegner · ' . $teamName : 'Unser Gegner') ?></p>
|
||||
<h2 class="stadionzeitung-page__heading"><?= e($opponentName) ?></h2>
|
||||
<?php if (!empty($info['league']) || !empty($info['season'])): ?>
|
||||
<p class="stadionzeitung-page__sub"><?= e(trim(implode(' · ', array_filter([(string) ($info['league'] ?? ''), (string) ($info['season'] ?? '')])))) ?></p>
|
||||
<?php endif; ?>
|
||||
</header>
|
||||
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
|
||||
|
||||
<div class="stadionzeitung-gegner__duell">
|
||||
<div class="stadionzeitung-gegner__club stadionzeitung-gegner__club--own">
|
||||
<?php component('crest', ['src' => $fixture['home_crest'] ?? $own['crest'] ?? null, 'name' => $fixture['home'] ?? 'TSV 08 Kulmbach']); ?>
|
||||
<strong><?= e((string) ($fixture['home'] ?? 'TSV 08 Kulmbach')) ?></strong>
|
||||
</div>
|
||||
<div class="stadionzeitung-gegner__match">
|
||||
<span>gegen</span>
|
||||
<?php if ($kickoffDate !== ''): ?>
|
||||
<strong><?= e(german_date($kickoffDate)) ?></strong>
|
||||
<?php endif; ?>
|
||||
<?php if ($kickoffTime !== ''): ?>
|
||||
<span><?= e($kickoffTime) ?> Uhr</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="stadionzeitung-gegner__club">
|
||||
<?php component('crest', ['src' => $fixture['away_crest'] ?? $opponent['crest'] ?? null, 'name' => $fixture['away'] ?? $opponentName]); ?>
|
||||
<strong><?= e((string) ($fixture['away'] ?? $opponentName)) ?></strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($own !== null && $opponent !== null): ?>
|
||||
<div class="stadionzeitung-gegner__vergleich" role="table" aria-label="Tabellenvergleich">
|
||||
<div class="stadionzeitung-gegner__vergleich-head" role="row">
|
||||
<?php /* Spaltenköpfe generisch: vorher standen hier „TSV 08" und „ASV" fest im
|
||||
Markup — das Kürzel des Marktschorgast-Gegners wäre in jeder Ausgabe
|
||||
mitgedruckt worden. Die Überschrift nennt den Gegner ohnehin. */ ?>
|
||||
<span role="columnheader">Wir</span>
|
||||
<span role="columnheader">Vergleich</span>
|
||||
<span role="columnheader">Gegner</span>
|
||||
</div>
|
||||
<?php foreach ($rows as $row): ?>
|
||||
<div class="stadionzeitung-gegner__vergleich-row" role="row">
|
||||
<strong role="cell"><?= e($format($own, (string) $row['key'], (bool) ($row['signed'] ?? false))) ?></strong>
|
||||
<span role="rowheader"><?= e((string) $row['label']) ?></span>
|
||||
<strong role="cell"><?= e($format($opponent, (string) $row['key'], (bool) ($row['signed'] ?? false))) ?></strong>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p class="stadionzeitung-gegner__empty">Die aktuellen Vergleichsdaten waren beim letzten Abruf nicht verfügbar.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
58
app/components/stadionzeitung-historie.php
Normal file
58
app/components/stadionzeitung-historie.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Historie-Seite der Stadionzeitung (Live-Seite) — teasert die neu auf der
|
||||
* Website veröffentlichte Vereinsgeschichte (/historie) an und ruft dazu auf,
|
||||
* Material an die Historie-Mailadresse zu schicken (Zulauf für die Chronik
|
||||
* zum 120-jährigen Bestehen 2028, siehe CLAUDE.md → Chronik). Fester
|
||||
* Baustein jeder Ausgabe (Felix, 29.07.2026).
|
||||
*
|
||||
* Bilder und Mailadresse kommen aus den geteilten Quellen (data/historie.json
|
||||
* liegt dasselbe Luftbild zugrunde, club.json → email_historie) — nie
|
||||
* duplizieren. QR: geteilter qr-historie.png (bin/stadionzeitung-add.php).
|
||||
*/
|
||||
$emailHistorie = (string) (json_load('club')['email_historie'] ?? '');
|
||||
$historieQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-historie.png')
|
||||
? 'img/stadionzeitung/qr-historie.png'
|
||||
: null;
|
||||
$historieLink = club_domain('historie');
|
||||
?>
|
||||
<div class="stadionzeitung-historie">
|
||||
<header class="stadionzeitung-page__head">
|
||||
<p class="stadionzeitung-page__kicker">Historie</p>
|
||||
<h2 class="stadionzeitung-page__heading">Unsere Geschichte lebt</h2>
|
||||
</header>
|
||||
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
|
||||
<p class="stadionzeitung-historie__text">Von der Gründung 1908 bis heute: Wir haben unsere Vereinsgeschichte neu aufgearbeitet und erzählen sie jetzt auf unserer Website, mit Fotos und Geschichten aus fast 120 Jahren.</p>
|
||||
<?php /* Archiv-Anmutung passend zum Aufruf („Fotoalben und Schuhkartons"):
|
||||
die Fotos als leicht gedrehte Polaroids, der Aufruf als Notizzettel. */ ?>
|
||||
<div class="stadionzeitung-historie__bilder">
|
||||
<figure class="stadionzeitung-historie__polaroid stadionzeitung-historie__polaroid--links">
|
||||
<img class="stadionzeitung-historie__img" src="<?= e(asset('img/historie/luftbild-historisch.jpg')) ?>" alt="Historische Luftaufnahme unserer Sportanlage" loading="lazy" decoding="async">
|
||||
<figcaption class="stadionzeitung-historie__caption">Unsere Sportanlage von oben</figcaption>
|
||||
</figure>
|
||||
<figure class="stadionzeitung-historie__polaroid stadionzeitung-historie__polaroid--rechts">
|
||||
<img class="stadionzeitung-historie__img" src="<?= e(asset('img/historie/1972-fcbayern.png')) ?>" alt="Unser Freundschaftsspiel gegen den FC Bayern München 1972" loading="lazy" decoding="async">
|
||||
<figcaption class="stadionzeitung-historie__caption">1972: Zu Gast bei uns, der FC Bayern</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<p class="stadionzeitung-historie__aufruf">
|
||||
<strong>Und jetzt bist du dran:</strong> Ein großer Teil unserer Geschichte steckt noch in Fotoalben und Schuhkartons. Wenn du Fotos, Zeitungsausschnitte oder Erinnerungen hast, schreib uns an
|
||||
<span class="stadionzeitung-historie__mail"><?= e($emailHistorie) ?></span>
|
||||
</p>
|
||||
<div class="stadionzeitung-page__cta">
|
||||
<div class="stadionzeitung-page__cta-text">
|
||||
<p class="stadionzeitung-page__cta-label">Mehr online</p>
|
||||
<p class="stadionzeitung-page__cta-title">Die ganze Geschichte auf unserer Website</p>
|
||||
<p class="stadionzeitung-page__cta-link"><?= e($historieLink) ?></p>
|
||||
</div>
|
||||
<?php if ($historieQr !== null): ?>
|
||||
<div class="stadionzeitung-page__cta-abriss">
|
||||
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($historieQr)) ?>" alt="" width="60" height="60" loading="lazy" decoding="async">
|
||||
<span class="stadionzeitung-page__cta-scan">Scan mich</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
59
app/components/stadionzeitung-impressum.php
Normal file
59
app/components/stadionzeitung-impressum.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Impressums-Seite der Stadionzeitung (Live-Seite) — ersetzt das bisherige
|
||||
* Bild aus dem Canva-Bestand. Alle Angaben kommen aus data/club.json
|
||||
* (legal_name, address, email, board — dieselbe Quelle wie das
|
||||
* Website-Impressum, nie duplizieren): ändert sich der Vorstand, stimmt die
|
||||
* nächste Ausgabe automatisch. Zentriertes Layout mit Logo und dünner
|
||||
* Rahmenlinie, wie das Mitglied-Poster. Juristische Seite, deshalb der
|
||||
* ausgeschriebene Vereinsname statt Wir-Form (Sprachregel-Ausnahme).
|
||||
*/
|
||||
$club = json_load('club');
|
||||
$legalName = (string) ($club['legal_name'] ?? $club['name'] ?? '');
|
||||
$address = $club['address'] ?? [];
|
||||
$email = (string) ($club['email'] ?? '');
|
||||
// club_domain() statt base_url: das Heft wird gedruckt, da darf nie
|
||||
// „localhost:8000" stehen.
|
||||
$domain = club_domain();
|
||||
$board = $club['board']['members'] ?? [];
|
||||
$boardNote = (string) ($club['board']['note'] ?? '');
|
||||
?>
|
||||
<div class="stadionzeitung-impressum">
|
||||
<img class="stadionzeitung-impressum__logo" src="<?= e(asset('img/logo.svg')) ?>" alt="" width="75" height="97">
|
||||
<p class="stadionzeitung-page__kicker">Impressum</p>
|
||||
<h2 class="stadionzeitung-impressum__name"><?= e($legalName) ?></h2>
|
||||
<div class="stadionzeitung-impressum__block">
|
||||
<h3 class="stadionzeitung-impressum__label">Herausgeber</h3>
|
||||
<p><?= e($legalName) ?></p>
|
||||
<p><?= e((string) ($address['street'] ?? '')) ?></p>
|
||||
<p><?= e(trim(($address['zip'] ?? '') . ' ' . ($address['city'] ?? ''))) ?></p>
|
||||
</div>
|
||||
<div class="stadionzeitung-impressum__block">
|
||||
<h3 class="stadionzeitung-impressum__label">Kontakt</h3>
|
||||
<p>E-Mail: <?= e($email) ?></p>
|
||||
<p>Internet: <?= e($domain) ?></p>
|
||||
</div>
|
||||
<div class="stadionzeitung-impressum__block">
|
||||
<h3 class="stadionzeitung-impressum__label">Inhaltlich verantwortlich</h3>
|
||||
<p><?= e($legalName) ?></p>
|
||||
</div>
|
||||
<div class="stadionzeitung-impressum__block">
|
||||
<h3 class="stadionzeitung-impressum__label">Design und Layout</h3>
|
||||
<p>Felix Schneider</p>
|
||||
</div>
|
||||
<?php if ($board !== []): ?>
|
||||
<div class="stadionzeitung-impressum__block">
|
||||
<h3 class="stadionzeitung-impressum__label">Vorstand</h3>
|
||||
<?php if ($boardNote !== ''): ?>
|
||||
<p class="stadionzeitung-impressum__note"><?= e($boardNote) ?></p>
|
||||
<?php endif; ?>
|
||||
<p><?= e(implode(' · ', array_map(
|
||||
static fn (array $m): string => trim((string) ($m['name'] ?? '') . (!empty($m['role']) ? ' (' . $m['role'] . ')' : '')),
|
||||
$board
|
||||
))) ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
69
app/components/stadionzeitung-interview.php
Normal file
69
app/components/stadionzeitung-interview.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Interview-Seite der Stadionzeitung (Live-Seite) — das Gespräch, auf das die
|
||||
* Teaser-Zeile des Covers verweist (z. B. mit dem Trainer über die Neuzugänge).
|
||||
* Der Inhalt ist KEIN Skript-Input: er wird wie das Vorwort im Chat gepflegt —
|
||||
* Felix schickt Fragen/Antworten, Claude trägt sie ins `interview`-Feld der
|
||||
* Ausgabe in data/stadionzeitung.json ein:
|
||||
* teaser Coverzeile („Unser Trainer … über die Neuzugänge")
|
||||
* heading Überschrift der Seite
|
||||
* person_name / person_role wer spricht
|
||||
* intro optionaler Vorspann (kursiv, Serif)
|
||||
* fragen[] { frage, antwort }
|
||||
* photo optional, Porträt — entweder ein direkter Asset-Pfad
|
||||
* (z. B. "img/persons/ali-kilic.jpg", die geteilten
|
||||
* Personenfotos haben keine Breiten-Varianten) oder
|
||||
* base/widths wie verfasser_photo
|
||||
* bin/stadionzeitung-add.php trägt das Feld über erneute Läufe hinweg weiter
|
||||
* (wie `vorwort`) und plant die Seite nur ein, wenn das Feld existiert.
|
||||
*
|
||||
* Fragen in Abel fett, Antworten in der Lese-Serif (--ff-serif, PT Serif) —
|
||||
* dieselbe Begründung wie beim Vorwort: redaktioneller Text, keine UI-Fläche.
|
||||
*
|
||||
* Props: $issue (Eintrag aus data/stadionzeitung.json)
|
||||
*/
|
||||
$issue = $issue ?? [];
|
||||
$interview = $issue['interview'] ?? null;
|
||||
if (!is_array($interview)) {
|
||||
return;
|
||||
}
|
||||
$heading = trim((string) ($interview['heading'] ?? $interview['teaser'] ?? 'Interview'));
|
||||
$person = trim(implode(' · ', array_filter([
|
||||
(string) ($interview['person_name'] ?? ''),
|
||||
(string) ($interview['person_role'] ?? ''),
|
||||
])));
|
||||
$photo = $interview['photo'] ?? null;
|
||||
$photoSrc = null;
|
||||
if (is_string($photo) && $photo !== '') {
|
||||
$photoSrc = $photo;
|
||||
} elseif (is_array($photo) && !empty($photo['base']) && !empty($photo['widths'])) {
|
||||
$photoSrc = $photo['base'] . '-' . min($photo['widths']) . '.jpg';
|
||||
}
|
||||
?>
|
||||
<div class="stadionzeitung-interview">
|
||||
<div class="stadionzeitung-interview__head-row">
|
||||
<header class="stadionzeitung-page__head">
|
||||
<p class="stadionzeitung-page__kicker">Interview</p>
|
||||
<h2 class="stadionzeitung-page__heading"><?= e($heading) ?></h2>
|
||||
<?php if ($person !== ''): ?>
|
||||
<p class="stadionzeitung-page__sub"><?= e($person) ?></p>
|
||||
<?php endif; ?>
|
||||
</header>
|
||||
<?php if ($photoSrc !== null): ?>
|
||||
<img class="stadionzeitung-interview__portrait" src="<?= e(asset($photoSrc)) ?>" alt="" loading="lazy" decoding="async">
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
|
||||
<?php if (!empty($interview['intro'])): ?>
|
||||
<p class="stadionzeitung-interview__intro"><?= e((string) $interview['intro']) ?></p>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($interview['fragen'] ?? [] as $qa): ?>
|
||||
<div class="stadionzeitung-interview__qa">
|
||||
<p class="stadionzeitung-interview__frage"><?= e((string) ($qa['frage'] ?? '')) ?></p>
|
||||
<p class="stadionzeitung-interview__antwort"><?= e((string) ($qa['antwort'] ?? '')) ?></p>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
69
app/components/stadionzeitung-kontakt.php
Normal file
69
app/components/stadionzeitung-kontakt.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Ansprechpartner-Seite der Stadionzeitung (Live-Seite) — die Fußball-
|
||||
* Ansprechpartner aus data/ansprechpartner.json (geteilte Quelle mit
|
||||
* /ansprechpartner, nie Namen/Nummern duplizieren), wie die „KONTAKT"-Seite
|
||||
* der Canva-Vorlage: Abteilung + Name + Telefonnummer, rechts das runde
|
||||
* Porträt. Keine Props — die Seite zeigt immer den aktuellen Stand der
|
||||
* geteilten Quelle (Personen ändern sich zwischen Ausgaben praktisch nie;
|
||||
* ein Snapshot würde hier nur veraltete Nummern konservieren).
|
||||
*/
|
||||
$persons = array_values(array_filter(
|
||||
json_load('ansprechpartner')['persons'] ?? [],
|
||||
static fn (array $p): bool => ($p['group'] ?? '') === 'Fußball'
|
||||
));
|
||||
|
||||
// CTA unter der Liste: QR + Link auf /mitmachen (Wunsch Felix, 29.07.2026).
|
||||
// Der QR wird von bin/stadionzeitung-add.php miterzeugt (qr-mitmachen.png,
|
||||
// geteilt über alle Ausgaben — die Ziel-URL ist immer dieselbe); fehlt er
|
||||
// (noch kein Lauf seit Einführung), erscheint nur der Text-Link.
|
||||
$mitmachenQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-mitmachen.png')
|
||||
? 'img/stadionzeitung/qr-mitmachen.png'
|
||||
: null;
|
||||
$mitmachenLink = club_domain('mitmachen');
|
||||
?>
|
||||
<div class="stadionzeitung-kontakt">
|
||||
<header class="stadionzeitung-page__head">
|
||||
<p class="stadionzeitung-page__kicker">Kontakt</p>
|
||||
<h2 class="stadionzeitung-page__heading">Ansprechpartner im Verein</h2>
|
||||
</header>
|
||||
<?php /* Bewusst ohne die Trennlinie der Geschwister-Seiten: die Liste + CTA
|
||||
brauchen die volle Seitenhöhe (Felix, 29.07.2026). Die erste
|
||||
Listenzeile bringt ihre eigene Unterkante mit, das trennt genug. */ ?>
|
||||
<ul class="stadionzeitung-kontakt__list">
|
||||
<?php foreach ($persons as $person): ?>
|
||||
<li class="stadionzeitung-kontakt__item">
|
||||
<div class="stadionzeitung-kontakt__body">
|
||||
<p class="stadionzeitung-kontakt__department"><?= e((string) ($person['department'] ?? '')) ?></p>
|
||||
<p class="stadionzeitung-kontakt__name"><?= e((string) ($person['name'] ?? '')) ?></p>
|
||||
<?php if (!empty($person['phone'])): ?>
|
||||
<p class="stadionzeitung-kontakt__contact"><?= e((string) $person['phone']) ?></p>
|
||||
<?php elseif (!empty($person['email'])): ?>
|
||||
<p class="stadionzeitung-kontakt__contact"><?= e((string) $person['email']) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if (!empty($person['image'])): ?>
|
||||
<img class="stadionzeitung-kontakt__portrait" src="<?= e(asset((string) $person['image'])) ?>" alt="" width="56" height="56" loading="lazy" decoding="async">
|
||||
<?php else: ?>
|
||||
<span class="stadionzeitung-kontakt__portrait stadionzeitung-kontakt__portrait--icon" aria-hidden="true"><?= icon('person') ?></span>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<div class="stadionzeitung-page__cta">
|
||||
<div class="stadionzeitung-page__cta-text">
|
||||
<p class="stadionzeitung-page__cta-label">Mehr online</p>
|
||||
<p class="stadionzeitung-page__cta-title">Du möchtest selbst aktiv werden?</p>
|
||||
<p class="stadionzeitung-page__cta-link"><?= e($mitmachenLink) ?></p>
|
||||
</div>
|
||||
<?php if ($mitmachenQr !== null): ?>
|
||||
<div class="stadionzeitung-page__cta-abriss">
|
||||
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($mitmachenQr)) ?>" alt="" width="60" height="60" loading="lazy" decoding="async">
|
||||
<span class="stadionzeitung-page__cta-scan">Scan mich</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
43
app/components/stadionzeitung-mitglied.php
Normal file
43
app/components/stadionzeitung-mitglied.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Mitglied-werden-Seite der Stadionzeitung (Live-Seite) — Poster statt Liste:
|
||||
* riesige zentrierte Coolvetica-Headline („Werde Teil des Nullachters", das
|
||||
* letzte Wort in Vereinsrot), darunter Claim-Satz und Abteilungszeile,
|
||||
* unten der geteilte CTA-Fuß mit QR auf /mitglied-werden. Fester Baustein
|
||||
* jeder Ausgabe (bin/stadionzeitung-add.php plant sie nach den
|
||||
* Ansprechpartnern ein) — Look bewusst weiß mit Rot-Akzent, damit die Seite
|
||||
* heraussticht, ohne die helle Heft-Optik zu brechen (Felix, 29.07.2026).
|
||||
*
|
||||
* Keine Props — die Seite ist statisch bis auf den geteilten QR
|
||||
* (qr-mitglied-werden.png, von bin/stadionzeitung-add.php mit der aktuellen
|
||||
* base_url erzeugt; fehlt er, erscheint nur der Text-Link).
|
||||
*/
|
||||
$mitgliedQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-mitglied-werden.png')
|
||||
? 'img/stadionzeitung/qr-mitglied-werden.png'
|
||||
: null;
|
||||
$mitgliedLink = club_domain('mitglied-werden');
|
||||
?>
|
||||
<div class="stadionzeitung-mitglied">
|
||||
<div class="stadionzeitung-mitglied__poster">
|
||||
<img class="stadionzeitung-mitglied__logo" src="<?= e(asset('img/logo.svg')) ?>" alt="" width="75" height="97">
|
||||
<h2 class="stadionzeitung-mitglied__headline">
|
||||
<span class="stadionzeitung-mitglied__line">Werde Teil</span>
|
||||
<span class="stadionzeitung-mitglied__line">des</span>
|
||||
<span class="stadionzeitung-mitglied__line stadionzeitung-mitglied__line--accent">Nullachters</span>
|
||||
</h2>
|
||||
<p class="stadionzeitung-mitglied__claim">Seit 1908 in Kulmbach zuhause. Egal ob auf dem Platz, in der Halle oder am Spielfeldrand: bei uns ist Platz für dich.</p>
|
||||
<p class="stadionzeitung-mitglied__departments">Fußball · Jugend · Turnen</p>
|
||||
<?php /* Bewusst KEIN generischer CTA-Fuß wie auf den Listen-Seiten: das
|
||||
Poster endet mit dem großen zentrierten QR (Felix, 29.07.2026) —
|
||||
die Link-Zeile darunter bleibt für alle, die nicht scannen. */ ?>
|
||||
<div class="stadionzeitung-mitglied__qr-block">
|
||||
<?php if ($mitgliedQr !== null): ?>
|
||||
<img class="stadionzeitung-mitglied__qr" src="<?= e(asset($mitgliedQr)) ?>" alt="" width="120" height="120" loading="lazy" decoding="async">
|
||||
<?php endif; ?>
|
||||
<span class="stadionzeitung-mitglied__link"><?= e($mitgliedLink) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
52
app/components/stadionzeitung-momente.php
Normal file
52
app/components/stadionzeitung-momente.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Momente-Seite der Stadionzeitung (Live-Seite, Heft-Abschluss) — die vier
|
||||
* neuesten Fotos aus unserem Instagram-Cache als 2×2-Galerie plus
|
||||
* „Folge uns"-Ticket mit QR aufs Profil. Pflegt sich selbst: im Entwurf
|
||||
* immer die aktuellsten Bilder, beim Veröffentlichen eingefroren
|
||||
* (stadionzeitung_build_snapshot(), app/helpers.php).
|
||||
*
|
||||
* Props: $items stadionzeitung_live_data()['momente'] — image/alt je Foto.
|
||||
* Profil-URL aus club.json → social.instagram (geteilte Quelle), QR:
|
||||
* geteilter qr-instagram.png (bin/stadionzeitung-add.php).
|
||||
*/
|
||||
$items = $items ?? [];
|
||||
$instagramUrl = (string) (json_load('club')['social']['instagram'] ?? '');
|
||||
$instagramHandle = '@' . trim((string) parse_url($instagramUrl, PHP_URL_PATH), '/');
|
||||
$momenteQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-instagram.png')
|
||||
? 'img/stadionzeitung/qr-instagram.png'
|
||||
: null;
|
||||
?>
|
||||
<div class="stadionzeitung-momente">
|
||||
<header class="stadionzeitung-page__head">
|
||||
<p class="stadionzeitung-page__kicker">Instagram</p>
|
||||
<h2 class="stadionzeitung-page__heading">Aktuelles von Instagram</h2>
|
||||
</header>
|
||||
<?php /* Bewusst ohne Trennlinie und mit verdichteten Abständen: jeder
|
||||
gewonnene Millimeter geht in die Fotogröße (Felix, 29.07.2026). */ ?>
|
||||
<?php if ($items === []): ?>
|
||||
<p class="text-muted">Folge uns auf Instagram für alle Momente rund um den Verein.</p>
|
||||
<?php else: ?>
|
||||
<div class="stadionzeitung-momente__galerie">
|
||||
<?php foreach ($items as $item): ?>
|
||||
<img class="stadionzeitung-momente__foto" src="<?= e(asset((string) $item['image'])) ?>" alt="<?= e((string) $item['alt']) ?>" loading="lazy" decoding="async">
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="stadionzeitung-page__cta">
|
||||
<div class="stadionzeitung-page__cta-text">
|
||||
<p class="stadionzeitung-page__cta-label">Mehr online</p>
|
||||
<p class="stadionzeitung-page__cta-title">Folge uns auf Instagram</p>
|
||||
<p class="stadionzeitung-page__cta-link"><?= e($instagramHandle) ?></p>
|
||||
</div>
|
||||
<?php if ($momenteQr !== null): ?>
|
||||
<div class="stadionzeitung-page__cta-abriss">
|
||||
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($momenteQr)) ?>" alt="" width="60" height="60" loading="lazy" decoding="async">
|
||||
<span class="stadionzeitung-page__cta-scan">Scan mich</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
90
app/components/stadionzeitung-news.php
Normal file
90
app/components/stadionzeitung-news.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* News-Seite der Stadionzeitung (Live-Seite) — eigene, einheitliche Liste im
|
||||
* Kopf-Muster der Geschwister-Seiten statt des geteilten news-feed der
|
||||
* /news-Seite: dort variieren die Medienspalten (Bild, Wappen, nichts) und
|
||||
* die Titel springen in der Einrückung — auf der schmalen A5-Seite braucht
|
||||
* jede Zeile dieselbe feste Medienspalte (Wappen, Foto-Thumbnail oder
|
||||
* Icon-Badge als Fallback), damit alle Titel bündig stehen.
|
||||
*
|
||||
* Props: $items stadionzeitung_live_data()['news'] — normalisierte Einträge
|
||||
* aus news_build_feed() (date/title/text/image/crest/source).
|
||||
*/
|
||||
$items = $items ?? [];
|
||||
|
||||
$sourceLabel = static fn (string $source): string => match ($source) {
|
||||
'ergebnis' => 'Ergebnis',
|
||||
'instagram' => 'Instagram',
|
||||
default => 'Meldung',
|
||||
};
|
||||
|
||||
// CTA unter der Liste: QR + Link auf /news (geteilter QR wie bei der
|
||||
// Kontakt-Seite, von bin/stadionzeitung-add.php mit der aktuellen base_url
|
||||
// erzeugt — fehlt er, erscheint nur der Text-Link).
|
||||
$newsQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-news.png')
|
||||
? 'img/stadionzeitung/qr-news.png'
|
||||
: null;
|
||||
$newsLink = club_domain('news');
|
||||
?>
|
||||
<div class="stadionzeitung-news">
|
||||
<header class="stadionzeitung-page__head">
|
||||
<p class="stadionzeitung-page__kicker">News</p>
|
||||
<h2 class="stadionzeitung-page__heading">Aktuelles aus dem Katzbachtal</h2>
|
||||
</header>
|
||||
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
|
||||
<?php if ($items === []): ?>
|
||||
<p class="text-muted">Aktuell keine News.</p>
|
||||
<?php endif; ?>
|
||||
<ul class="stadionzeitung-news__list">
|
||||
<?php foreach ($items as $item): ?>
|
||||
<?php
|
||||
$source = (string) ($item['source'] ?? 'news');
|
||||
// Instagram-Titel ist die erste Zeile der Caption — der Text darunter
|
||||
// wäre dieselbe Caption noch einmal, deshalb dort nur der Titel.
|
||||
$text = $source === 'instagram' ? '' : trim((string) ($item['text'] ?? ''));
|
||||
$image = $item['image'] ?? null;
|
||||
$imageSrc = null;
|
||||
if (is_array($image)) {
|
||||
// Beide Bildformen aus news_build_feed(): Instagram liefert src,
|
||||
// redaktionelle Meldungen base/widths (kleinste Variante reicht
|
||||
// für das winzige Thumbnail).
|
||||
$imageSrc = $image['src'] ?? (isset($image['base'], $image['widths']) ? $image['base'] . '-' . min($image['widths']) . '.jpg' : null);
|
||||
}
|
||||
?>
|
||||
<li class="stadionzeitung-news__item">
|
||||
<?php if (!empty($item['crest']['src']) || (!empty($item['crest']['name']) && $source === 'ergebnis')): ?>
|
||||
<span class="stadionzeitung-news__media stadionzeitung-news__media--crest">
|
||||
<?php component('crest', ['src' => $item['crest']['src'] ?? null, 'name' => (string) ($item['crest']['name'] ?? '')]); ?>
|
||||
</span>
|
||||
<?php elseif ($imageSrc !== null): ?>
|
||||
<img class="stadionzeitung-news__media stadionzeitung-news__media--img" src="<?= e(asset($imageSrc)) ?>" alt="" loading="lazy" decoding="async">
|
||||
<?php else: ?>
|
||||
<span class="stadionzeitung-news__media stadionzeitung-news__media--icon" aria-hidden="true"><?= icon('newspaper') ?></span>
|
||||
<?php endif; ?>
|
||||
<div class="stadionzeitung-news__body">
|
||||
<p class="stadionzeitung-news__meta"><?= e($sourceLabel($source)) ?><?php if (!empty($item['date'])): ?> · <time datetime="<?= e((string) $item['date']) ?>"><?= e(german_date((string) $item['date'])) ?></time><?php endif; ?></p>
|
||||
<p class="stadionzeitung-news__title"><?= e((string) ($item['title'] ?? '')) ?></p>
|
||||
<?php if ($text !== ''): ?>
|
||||
<p class="stadionzeitung-news__text"><?= e($text) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<div class="stadionzeitung-page__cta">
|
||||
<div class="stadionzeitung-page__cta-text">
|
||||
<p class="stadionzeitung-page__cta-label">Mehr online</p>
|
||||
<p class="stadionzeitung-page__cta-title">Mehr News findest du auf unserer Website</p>
|
||||
<p class="stadionzeitung-page__cta-link"><?= e($newsLink) ?></p>
|
||||
</div>
|
||||
<?php if ($newsQr !== null): ?>
|
||||
<div class="stadionzeitung-page__cta-abriss">
|
||||
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($newsQr)) ?>" alt="" width="60" height="60" loading="lazy" decoding="async">
|
||||
<span class="stadionzeitung-page__cta-scan">Scan mich</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
63
app/components/stadionzeitung-partner.php
Normal file
63
app/components/stadionzeitung-partner.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Partner-werden-Seite der Stadionzeitung (Live-Seite) — der Selbstbeweis:
|
||||
* die Seite wirbt für Anzeigen in genau dem Heft, das der Leser gerade in
|
||||
* der Hand hält. Fester Baustein jeder Ausgabe (Felix, 29.07.2026).
|
||||
*
|
||||
* Sie-Form mit Absicht: /partner-werden spricht Unternehmen an — die eine
|
||||
* benannte Ausnahme der Wir/Du-Sprachregel (CLAUDE.md). Die drei Pakete
|
||||
* kommen aus data/partner-werden.json (dieselbe Quelle wie die Website),
|
||||
* der Sponsoring-Ansprechpartner (2. Vorsitzender) aus club.json → board.
|
||||
* QR: geteilter qr-partner-werden.png (bin/stadionzeitung-add.php).
|
||||
*/
|
||||
$channels = json_load('partner-werden')['channels']['items'] ?? [];
|
||||
$sponsoringKontakt = null;
|
||||
foreach (json_load('club')['board']['members'] ?? [] as $member) {
|
||||
if (($member['role'] ?? '') === '2. Vorsitzender') {
|
||||
$sponsoringKontakt = (string) $member['name'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
$partnerQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-partner-werden.png')
|
||||
? 'img/stadionzeitung/qr-partner-werden.png'
|
||||
: null;
|
||||
$partnerLink = club_domain('partner-werden');
|
||||
?>
|
||||
<div class="stadionzeitung-partner">
|
||||
<header class="stadionzeitung-page__head">
|
||||
<p class="stadionzeitung-page__kicker">Partner werden</p>
|
||||
<h2 class="stadionzeitung-page__heading">Hier könnte Ihre Anzeige stehen</h2>
|
||||
</header>
|
||||
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
|
||||
<p class="stadionzeitung-partner__intro">Die Stadionzeitung liegt bei jedem Heimspiel im Katzbachtal aus, unsere Website erreicht Kulmbach jeden Tag. Bringen Sie Ihr Unternehmen dorthin, wo die Region zuhause ist.</p>
|
||||
<ul class="stadionzeitung-partner__pakete">
|
||||
<?php foreach ($channels as $channel): ?>
|
||||
<li class="stadionzeitung-partner__paket">
|
||||
<span class="stadionzeitung-partner__icon" aria-hidden="true"><?= icon((string) ($channel['icon'] ?? 'geo-alt-fill')) ?></span>
|
||||
<div class="stadionzeitung-partner__paket-body">
|
||||
<p class="stadionzeitung-partner__paket-titel"><?= e((string) ($channel['title'] ?? '')) ?></p>
|
||||
<p class="stadionzeitung-partner__paket-text"><?= e((string) ($channel['text'] ?? '')) ?></p>
|
||||
</div>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php if ($sponsoringKontakt !== null): ?>
|
||||
<p class="stadionzeitung-partner__kontakt">Ihr Ansprechpartner: <strong><?= e($sponsoringKontakt) ?></strong> · 2. Vorsitzender</p>
|
||||
<?php endif; ?>
|
||||
<div class="stadionzeitung-page__cta">
|
||||
<div class="stadionzeitung-page__cta-text">
|
||||
<p class="stadionzeitung-page__cta-label">Mehr online</p>
|
||||
<p class="stadionzeitung-page__cta-title">Alle Pakete auf unserer Website</p>
|
||||
<p class="stadionzeitung-page__cta-link"><?= e($partnerLink) ?></p>
|
||||
</div>
|
||||
<?php if ($partnerQr !== null): ?>
|
||||
<div class="stadionzeitung-page__cta-abriss">
|
||||
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($partnerQr)) ?>" alt="" width="60" height="60" loading="lazy" decoding="async">
|
||||
<span class="stadionzeitung-page__cta-scan">Scan mich</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
71
app/components/stadionzeitung-programm.php
Normal file
71
app/components/stadionzeitung-programm.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Programm-Seite der Stadionzeitung (Live-Seite, Sportfest-Ausgabe): das
|
||||
* Tagesprogramm einer Veranstaltung, je Tag eine Gruppe (Wochentag + Datum),
|
||||
* darunter Zeit | Titel (+ Zusatz). Kopf im Muster der Geschwister-Seiten.
|
||||
*
|
||||
* Quelle ist AUSSCHLIESSLICH data/veranstaltungen.json → programm[] der
|
||||
* Veranstaltung, auf die die Ausgabe zeigt (`veranstaltung`-Feld, Slug). Dieselbe
|
||||
* Liste rendert die Website-Detailseite über veranstaltung-programm.php — eine
|
||||
* Quelle für Heft und Website, hier nur eine zweite Darstellung fürs A5-Blatt
|
||||
* (cqi-Maße statt Website-Tokens). Bewusst live, kein Snapshot: chat-gepflegte
|
||||
* Daten wie bei kontakt/aufruf, kein Sync räumt hier etwas weg.
|
||||
*
|
||||
* Props: $issue (Eintrag aus data/stadionzeitung.json)
|
||||
*/
|
||||
$issue = $issue ?? [];
|
||||
$v = veranstaltung_find((string) ($issue['veranstaltung'] ?? ''));
|
||||
$programm = is_array($v['programm'] ?? null) ? $v['programm'] : [];
|
||||
if ($v === null || $programm === []) {
|
||||
return;
|
||||
}
|
||||
$zeitraum = german_date_range((string) ($v['date_start'] ?? ''), $v['date_end'] ?? null);
|
||||
$sub = trim(implode(' · ', array_filter([$zeitraum, (string) ($v['location'] ?? '')])));
|
||||
?>
|
||||
<div class="stadionzeitung-programm">
|
||||
<header class="stadionzeitung-page__head">
|
||||
<p class="stadionzeitung-page__kicker"><?= e((string) ($v['title'] ?? 'Veranstaltung')) ?></p>
|
||||
<h2 class="stadionzeitung-page__heading">Programm</h2>
|
||||
<?php if ($sub !== ''): ?>
|
||||
<p class="stadionzeitung-page__sub"><?= e($sub) ?></p>
|
||||
<?php endif; ?>
|
||||
</header>
|
||||
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
|
||||
<?php foreach ($programm as $tag): ?>
|
||||
<?php
|
||||
$tagIso = (string) ($tag['tag'] ?? '');
|
||||
$weekday = $tagIso !== '' ? german_weekday($tagIso) : '';
|
||||
$tagText = trim(($weekday !== '' ? $weekday . ', ' : '') . ($tagIso !== '' ? german_date($tagIso) : ''));
|
||||
$punkte = is_array($tag['punkte'] ?? null) ? $tag['punkte'] : [];
|
||||
if ($punkte === []) {
|
||||
continue;
|
||||
}
|
||||
?>
|
||||
<section class="stadionzeitung-programm__tag">
|
||||
<?php if ($tagText !== ''): ?>
|
||||
<h3 class="stadionzeitung-programm__tag-titel"><time datetime="<?= e($tagIso) ?>"><?= e($tagText) ?></time></h3>
|
||||
<?php endif; ?>
|
||||
<ul class="stadionzeitung-programm__liste">
|
||||
<?php foreach ($punkte as $punkt): ?>
|
||||
<?php $zeit = trim((string) ($punkt['zeit'] ?? '')); ?>
|
||||
<li class="stadionzeitung-programm__punkt">
|
||||
<?php if ($zeit !== ''): ?>
|
||||
<span class="stadionzeitung-programm__zeit"><time datetime="<?= e($tagIso !== '' ? $tagIso . 'T' . $zeit : $zeit) ?>"><?= e($zeit) ?></time></span>
|
||||
<?php else: ?>
|
||||
<span aria-hidden="true"></span>
|
||||
<?php endif; ?>
|
||||
<span class="stadionzeitung-programm__body">
|
||||
<span class="stadionzeitung-programm__titel"><?= e((string) ($punkt['titel'] ?? '')) ?></span>
|
||||
<?php if (!empty($punkt['text'])): ?>
|
||||
<span class="stadionzeitung-programm__text"><?= e((string) $punkt['text']) ?></span>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</section>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
117
app/components/stadionzeitung-seite.php
Normal file
117
app/components/stadionzeitung-seite.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* EINE Seite einer Stadionzeitung-Ausgabe — die gemeinsame Render-Stelle für
|
||||
* den Swipe-Viewer (stadionzeitung-viewer.php) und die Druckfassung
|
||||
* (stadionzeitung-druck.php): beide zeigen exakt dieselben Seiten, nur in
|
||||
* anderer Anordnung (Lesereihenfolge vs. Broschüren-Reihenfolge). Props:
|
||||
* $entry Eintrag aus issue.images[]:
|
||||
* type 'image' (base/widths/alt),
|
||||
* type 'live' (page: cover|vorwort|interview|programm|aufruf|turnier|
|
||||
* tabelle|termine|news|ergebnisse|gegner|vorschau|kontakt|
|
||||
* mitglied|historie|sportheim|partner|danke|momente|impressum;
|
||||
* tabelle und gegner optional mit team: Team-Key aus
|
||||
* matchcenter.json; turnier mit turnier: Key aus
|
||||
* veranstaltungen.json → turniere[] der Ausgaben-Veranstaltung),
|
||||
* type 'leer' (weiße Füllseite, nur Druckfassung — hält die
|
||||
* Seitenzahl auf einem Vielfachen von 4)
|
||||
* $issue Eintrag aus data/stadionzeitung.json
|
||||
* $data stadionzeitung_live_data($issue) — vom Aufrufer EINMAL berechnet
|
||||
* und an jede Seite durchgereicht (nicht pro Seite neu laden)
|
||||
* $eager bool, Bild ohne lazy-loading laden (erste Seite im Viewer)
|
||||
*/
|
||||
$entry = $entry ?? [];
|
||||
$issue = $issue ?? [];
|
||||
$data = $data ?? [];
|
||||
$eager = $eager ?? false;
|
||||
$type = $entry['type'] ?? 'image';
|
||||
?>
|
||||
<?php if ($type === 'leer'): ?>
|
||||
<div class="stadionzeitung-page stadionzeitung-page--leer" aria-hidden="true"></div>
|
||||
<?php elseif ($type === 'live' && ($entry['page'] ?? '') === 'cover'): ?>
|
||||
<?php component('stadionzeitung-cover', ['issue' => $issue]); ?>
|
||||
<?php elseif ($type === 'live'): ?>
|
||||
<div class="stadionzeitung-page">
|
||||
<?php switch ($entry['page'] ?? ''):
|
||||
case 'vorwort': ?>
|
||||
<?php component('stadionzeitung-vorwort', ['issue' => $issue]); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'tabelle': ?>
|
||||
<?php
|
||||
// Ohne team-Feld (Ausgaben vor der Mehr-Tabellen-Vorlage) zeigt die
|
||||
// Seite wie bisher die Tabelle der Mannschaft der Ausgabe.
|
||||
$tabelleTeam = (string) ($entry['team'] ?? $issue['team_key'] ?? '');
|
||||
component('stadionzeitung-tabelle', ['info' => $data['tabellen'][$tabelleTeam] ?? null]);
|
||||
?>
|
||||
<?php break; ?>
|
||||
<?php case 'interview': ?>
|
||||
<?php component('stadionzeitung-interview', ['issue' => $issue]); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'aufruf': ?>
|
||||
<?php component('stadionzeitung-aufruf', ['issue' => $issue]); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'termine': ?>
|
||||
<?php component('stadionzeitung-termine', ['issue' => $issue]); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'ergebnisse': ?>
|
||||
<?php component('stadionzeitung-ergebnisse', ['groups' => $data['ergebnisse'] ?? []]); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'gegner': ?>
|
||||
<?php
|
||||
// `gegner` ist eine Map je Team-Key (wie `tabellen`), seit die Sportfest-
|
||||
// Ausgabe je Heimspiel eine Gegnerseite hat. Ohne team-Feld (Ausgaben vor
|
||||
// dieser Änderung) greift die tragende Mannschaft.
|
||||
$gegnerTeam = (string) ($entry['team'] ?? $issue['team_key'] ?? '');
|
||||
component('stadionzeitung-gegner', ['info' => $data['gegner'][$gegnerTeam] ?? null]);
|
||||
?>
|
||||
<?php break; ?>
|
||||
<?php case 'programm': ?>
|
||||
<?php component('stadionzeitung-programm', ['issue' => $issue]); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'turnier': ?>
|
||||
<?php component('stadionzeitung-turnier', ['issue' => $issue, 'turnier' => (string) ($entry['turnier'] ?? '')]); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'vorschau': ?>
|
||||
<?php component('stadionzeitung-vorschau', ['items' => $data['vorschau'] ?? []]); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'kontakt': ?>
|
||||
<?php component('stadionzeitung-kontakt', []); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'mitglied': ?>
|
||||
<?php component('stadionzeitung-mitglied', []); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'historie': ?>
|
||||
<?php component('stadionzeitung-historie', []); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'sportheim': ?>
|
||||
<?php component('stadionzeitung-sportheim', []); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'partner': ?>
|
||||
<?php component('stadionzeitung-partner', []); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'danke': ?>
|
||||
<?php /* Das Anzeigenmotiv steht im Seiteneintrag (platzierung: danke), damit es
|
||||
wie jede andere Anzeige in images[] eingefroren ist. */ ?>
|
||||
<?php component('stadionzeitung-danke', ['anzeige' => $entry['anzeige'] ?? null]); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'momente': ?>
|
||||
<?php component('stadionzeitung-momente', ['items' => $data['momente'] ?? []]); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'impressum': ?>
|
||||
<?php component('stadionzeitung-impressum', []); ?>
|
||||
<?php break; ?>
|
||||
<?php case 'news': ?>
|
||||
<?php component('stadionzeitung-news', ['items' => $data['news'] ?? []]); ?>
|
||||
<?php break; ?>
|
||||
<?php endswitch; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<?php component('img', [
|
||||
'image' => $entry,
|
||||
'sizes' => '100vw',
|
||||
'eager' => $eager,
|
||||
'class' => 'stadionzeitung-page__img',
|
||||
]); ?>
|
||||
<?php endif; ?>
|
||||
41
app/components/stadionzeitung-sportheim.php
Normal file
41
app/components/stadionzeitung-sportheim.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Sportheim-Seite der Stadionzeitung (Live-Seite) — Nachbau der gelungenen
|
||||
* Canva-Seite „Feiern Sie Veranstaltungen im Katzbachtal": Kicker + große
|
||||
* Headline, Lese-Serif-Text, unten das einblendende Gastraum-Foto mit
|
||||
* QR-Karte darauf, rote Pinselstrich-Ecken als Gestaltungselement (hier als
|
||||
* CSS-Näherung, keine Bild-Assets). Fester Baustein jeder Ausgabe
|
||||
* (Felix, 29.07.2026).
|
||||
*
|
||||
* Text kommt aus data/sportheimbuchung.json (hero.text — dieselbe Quelle wie
|
||||
* /sportheimbuchung, nie duplizieren), Foto aus dem geteilten Bestand,
|
||||
* QR: geteilter qr-sportheimbuchung.png (bin/stadionzeitung-add.php).
|
||||
*/
|
||||
$heroText = (string) (json_load('sportheimbuchung')['hero']['text'] ?? '');
|
||||
$sportheimQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-sportheimbuchung.png')
|
||||
? 'img/stadionzeitung/qr-sportheimbuchung.png'
|
||||
: null;
|
||||
$sportheimLink = club_domain('sportheimbuchung');
|
||||
?>
|
||||
<?php /* Pinselstrich-Ecken bewusst entfernt (Felix, 29.07.2026) — kommen
|
||||
ggf. später als saubere Bild-Assets zurück. */ ?>
|
||||
<div class="stadionzeitung-sportheim">
|
||||
<header class="stadionzeitung-page__head">
|
||||
<p class="stadionzeitung-page__kicker">Veranstaltung</p>
|
||||
<h2 class="stadionzeitung-page__heading">Feiern im Katzbachtal</h2>
|
||||
</header>
|
||||
<p class="stadionzeitung-sportheim__text"><?= e($heroText) ?></p>
|
||||
<div class="stadionzeitung-sportheim__foto-bereich">
|
||||
<img class="stadionzeitung-sportheim__foto" src="<?= e(asset('img/pages/sportheim-1000.jpg')) ?>" alt="Der Gastraum unseres Sportheims" loading="lazy" decoding="async">
|
||||
<div class="stadionzeitung-sportheim__foto-fade" aria-hidden="true"></div>
|
||||
<div class="stadionzeitung-sportheim__qr-karte">
|
||||
<?php if ($sportheimQr !== null): ?>
|
||||
<img class="stadionzeitung-sportheim__qr" src="<?= e(asset($sportheimQr)) ?>" alt="" width="120" height="120" loading="lazy" decoding="async">
|
||||
<?php endif; ?>
|
||||
<span class="stadionzeitung-sportheim__link"><?= e($sportheimLink) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
31
app/components/stadionzeitung-tabelle.php
Normal file
31
app/components/stadionzeitung-tabelle.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Tabellen-Seite der Stadionzeitung (Live-Seite) — Kopf im Muster der
|
||||
* Geschwister-Seiten (Kicker + Überschrift, siehe stadionzeitung-termine.php),
|
||||
* darunter die geteilte league-table-Komponente (dieselbe wie /fussball,
|
||||
* nie eine zweite Tabellen-Implementierung).
|
||||
*
|
||||
* Props: $info Eintrag aus stadionzeitung_live_data()['tabellen'][team]:
|
||||
* name/league/season/table — null, wenn das Team (noch) nicht
|
||||
* in data/matchcenter.json gepflegt ist.
|
||||
*/
|
||||
$info = $info ?? null;
|
||||
?>
|
||||
<div class="stadionzeitung-tabelle">
|
||||
<header class="stadionzeitung-page__head">
|
||||
<p class="stadionzeitung-page__kicker">Tabelle</p>
|
||||
<h2 class="stadionzeitung-page__heading"><?= e(trim((string) ($info['name'] ?? 'Tabelle'))) ?></h2>
|
||||
<?php if (!empty($info['league']) || !empty($info['season'])): ?>
|
||||
<p class="stadionzeitung-page__sub"><?= e(trim(implode(' · ', array_filter([(string) ($info['league'] ?? ''), (string) ($info['season'] ?? '')])))) ?></p>
|
||||
<?php endif; ?>
|
||||
</header>
|
||||
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
|
||||
<?php if (!empty($info['table'])): ?>
|
||||
<?php component('league-table', ['table' => $info['table'], 'caption' => 'Tabelle ' . (string) ($info['league'] ?? '')]); ?>
|
||||
<?php else: ?>
|
||||
<p class="text-muted">Tabelle aktuell nicht verfügbar.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
92
app/components/stadionzeitung-termine.php
Normal file
92
app/components/stadionzeitung-termine.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Termine-Seite der Stadionzeitung (Live-Seite, wie Tabelle/Vorwort/News) —
|
||||
* schlichte weiße Content-Seite mit Foto-Kopf, angelehnt ans Vorwort-Layout
|
||||
* (Kicker + Überschrift über einblendendem Vereinsfoto), aber mit einer
|
||||
* Icon-Liste statt Fließtext.
|
||||
*
|
||||
* Die Termine sind KEIN Live-Abruf: bin/stadionzeitung-add.php friert
|
||||
* sie beim Anlegen der Ausgabe einmalig ein (`termine_snapshot`, siehe dort
|
||||
* und termine_build_lists()) — eine gedruckte Ausgabe zeigt dauerhaft, was
|
||||
* am Spieltag anstand, nicht was von "heute" aus gesehen ansteht.
|
||||
*
|
||||
* Props: $issue (Eintrag aus data/stadionzeitung.json: termine_snapshot?)
|
||||
*/
|
||||
$issue = $issue ?? [];
|
||||
$items = $issue['termine_snapshot'] ?? [];
|
||||
if ($items === []) {
|
||||
return;
|
||||
}
|
||||
$heroImage = json_load('home')['hero']['image'] ?? null;
|
||||
|
||||
// CTA-Fuß wie News/Kontakt: geteilter QR auf /termine (bin/stadionzeitung-add.php
|
||||
// erzeugt ihn mit der aktuellen base_url — fehlt er, nur der Text-Link).
|
||||
$termineQr = is_file(PUBLIC_PATH . '/assets/img/stadionzeitung/qr-termine.png')
|
||||
? 'img/stadionzeitung/qr-termine.png'
|
||||
: null;
|
||||
$termineLink = club_domain('termine');
|
||||
?>
|
||||
<div class="stadionzeitung-termine">
|
||||
<?php if ($heroImage): ?>
|
||||
<?php component('img', ['image' => $heroImage, 'sizes' => '480px', 'class' => 'stadionzeitung-termine__bg']); ?>
|
||||
<div class="stadionzeitung-termine__bg-fade" aria-hidden="true"></div>
|
||||
<?php endif; ?>
|
||||
<div class="stadionzeitung-termine__inner">
|
||||
<header class="stadionzeitung-termine__head">
|
||||
<p class="stadionzeitung-termine__kicker">Aktuelles</p>
|
||||
<h2 class="stadionzeitung-termine__heading">Kommende Termine</h2>
|
||||
</header>
|
||||
<span class="stadionzeitung-termine__rule" aria-hidden="true"></span>
|
||||
<ul class="stadionzeitung-termine__list">
|
||||
<?php foreach ($items as $item): ?>
|
||||
<?php
|
||||
$isMatch = ($item['type'] ?? '') === 'spiel';
|
||||
$parts = [german_date_range((string) ($item['date'] ?? ''), $item['date_end'] ?? null)];
|
||||
$time = '';
|
||||
if ($isMatch && !empty($item['kickoff'])) {
|
||||
$t = DateTimeImmutable::createFromFormat('Y-m-d\TH:i', (string) $item['kickoff']);
|
||||
$time = $t !== false ? $t->format('H:i') : '';
|
||||
} elseif (!$isMatch) {
|
||||
$time = (string) ($item['time'] ?? '');
|
||||
}
|
||||
if ($time !== '') {
|
||||
$parts[] = $time . ' Uhr';
|
||||
}
|
||||
if ($isMatch && !empty($item['meta'])) {
|
||||
$parts[] = (string) $item['meta'];
|
||||
} elseif (!$isMatch && !empty($item['location'])) {
|
||||
$parts[] = (string) $item['location'];
|
||||
}
|
||||
$title = (string) ($item['title'] ?? '');
|
||||
$meta = implode(' · ', $parts);
|
||||
?>
|
||||
<li class="stadionzeitung-termine__item">
|
||||
<?php /* Icon nach Termin-Art (Wunsch Felix 29.07.2026): Spiele bekommen die
|
||||
Trophäe, Vereins-Termine den Kalender — macht die Liste auf einen
|
||||
Blick unterscheidbar. */ ?>
|
||||
<span class="stadionzeitung-termine__icon" aria-hidden="true"><?= icon($isMatch ? 'trophy' : 'calendar-event') ?></span>
|
||||
<div class="stadionzeitung-termine__body">
|
||||
<p class="stadionzeitung-termine__title" title="<?= e($title) ?>"><?= e($title) ?></p>
|
||||
<p class="stadionzeitung-termine__meta" title="<?= e($meta) ?>"><?= e($meta) ?></p>
|
||||
</div>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<div class="stadionzeitung-page__cta">
|
||||
<div class="stadionzeitung-page__cta-text">
|
||||
<p class="stadionzeitung-page__cta-label">Mehr online</p>
|
||||
<p class="stadionzeitung-page__cta-title">Alle Termine findest du auf unserer Website</p>
|
||||
<p class="stadionzeitung-page__cta-link"><?= e($termineLink) ?></p>
|
||||
</div>
|
||||
<?php if ($termineQr !== null): ?>
|
||||
<div class="stadionzeitung-page__cta-abriss">
|
||||
<img class="stadionzeitung-page__cta-qr" src="<?= e(asset($termineQr)) ?>" alt="" width="60" height="60" loading="lazy" decoding="async">
|
||||
<span class="stadionzeitung-page__cta-scan">Scan mich</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
158
app/components/stadionzeitung-turnier.php
Normal file
158
app/components/stadionzeitung-turnier.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Turnierplan-Seite der Stadionzeitung (Live-Seite, Sportfest-Ausgabe): EIN
|
||||
* Jugendturnier pro Seite — Kopf (Veranstaltung · Tag / „Turnier F1-Jugend" /
|
||||
* Beginn · Spieldauer · Felder · Modus), Teilnehmer, Spielplan mit LEERER
|
||||
* Ergebnisspalte und eine leere Abschlusstabelle. Das Blatt ist zum
|
||||
* Beschreiben gedacht: am Platz trägt man Ergebnisse und Plätze von Hand ein.
|
||||
*
|
||||
* Quelle ist AUSSCHLIESSLICH data/veranstaltungen.json → turniere[] der
|
||||
* Veranstaltung, auf die die Ausgabe zeigt (`veranstaltung`-Feld); welches
|
||||
* Turnier, sagt der `turnier`-Key des Seiteneintrags. Bewusst live, kein
|
||||
* Snapshot (chat-gepflegte Daten, wie kontakt/aufruf/programm).
|
||||
*
|
||||
* Eintrag: key, title, date, start, spieldauer (Minuten), felder? (Zahl),
|
||||
* modus? (Freitext), teams[], spiele[]{nr, zeit, feld?, heim, gast}
|
||||
*
|
||||
* Eigenes Team wie league-table tr.is-own hervorgehoben (Tönung + Abel-Fett);
|
||||
* erkannt über den Vereinsnamen aus data/club.json als Präfix, damit auch
|
||||
* „TSV 08 Kulmbach 2" getroffen wird.
|
||||
*
|
||||
* Props: $issue (Eintrag aus data/stadionzeitung.json), $turnier (Key)
|
||||
*/
|
||||
$issue = $issue ?? [];
|
||||
$turnierKey = trim((string) ($turnier ?? ''));
|
||||
$v = veranstaltung_find((string) ($issue['veranstaltung'] ?? ''));
|
||||
$t = null;
|
||||
foreach ($v['turniere'] ?? [] as $eintrag) {
|
||||
if (is_array($eintrag) && (string) ($eintrag['key'] ?? '') === $turnierKey) {
|
||||
$t = $eintrag;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($v === null || $t === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$clubName = trim((string) (json_load('club')['name'] ?? 'TSV 08 Kulmbach'));
|
||||
$isOwn = static fn (string $team): bool => $clubName !== ''
|
||||
&& str_starts_with(mb_strtolower(trim($team)), mb_strtolower($clubName));
|
||||
|
||||
$date = (string) ($t['date'] ?? '');
|
||||
$weekday = $date !== '' ? german_weekday($date) : '';
|
||||
$kicker = trim(implode(' · ', array_filter([
|
||||
(string) ($v['title'] ?? ''),
|
||||
trim(($weekday !== '' ? $weekday . ', ' : '') . ($date !== '' ? german_date($date) : '')),
|
||||
])));
|
||||
|
||||
$teams = array_values(array_filter(
|
||||
array_map(static fn ($x): string => trim((string) $x), $t['teams'] ?? []),
|
||||
static fn (string $x): bool => $x !== ''
|
||||
));
|
||||
$spiele = array_values(array_filter($t['spiele'] ?? [], 'is_array'));
|
||||
$felder = (int) ($t['felder'] ?? 0);
|
||||
$hatFeld = $felder > 1;
|
||||
|
||||
$meta = [];
|
||||
if (!empty($t['start'])) {
|
||||
$meta[] = 'Beginn ' . (string) $t['start'] . ' Uhr';
|
||||
}
|
||||
if (!empty($t['spieldauer'])) {
|
||||
$meta[] = 'Spieldauer ' . (int) $t['spieldauer'] . ' Min.';
|
||||
}
|
||||
if ($hatFeld) {
|
||||
$meta[] = $felder . ' Felder';
|
||||
}
|
||||
if (!empty($t['modus'])) {
|
||||
$meta[] = (string) $t['modus'];
|
||||
}
|
||||
$sub = trim(implode(' · ', $meta));
|
||||
$title = trim((string) ($t['title'] ?? $turnierKey));
|
||||
// Ab neun Spielen (E-Jugend: zehn) rücken die Zeilen enger, sonst läuft der
|
||||
// Spielplan mit der Abschlusstabelle über die Blattkante.
|
||||
$dicht = count($spiele) > 8;
|
||||
?>
|
||||
<div class="stadionzeitung-turnier<?= $dicht ? ' stadionzeitung-turnier--dicht' : '' ?>">
|
||||
<header class="stadionzeitung-page__head">
|
||||
<?php if ($kicker !== ''): ?>
|
||||
<p class="stadionzeitung-page__kicker"><?= e($kicker) ?></p>
|
||||
<?php endif; ?>
|
||||
<h2 class="stadionzeitung-page__heading"><?= e('Turnier ' . $title) ?></h2>
|
||||
<?php if ($sub !== ''): ?>
|
||||
<p class="stadionzeitung-page__sub"><?= e($sub) ?></p>
|
||||
<?php endif; ?>
|
||||
</header>
|
||||
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
|
||||
<?php if ($teams !== []): ?>
|
||||
<p class="stadionzeitung-turnier__label">Teilnehmer</p>
|
||||
<p class="stadionzeitung-turnier__teams">
|
||||
<?php foreach ($teams as $i => $team): ?>
|
||||
<?= $i > 0 ? ' · ' : '' ?><span class="stadionzeitung-turnier__team<?= $isOwn($team) ? ' is-own' : '' ?>"><?= e($team) ?></span>
|
||||
<?php endforeach; ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php if ($spiele !== []): ?>
|
||||
<table class="stadionzeitung-turnier__spiele">
|
||||
<?php /* Sichtbares Label IST die Tabellenbeschriftung (caption), nicht zusätzlich
|
||||
eine versteckte: sonst liest ein Screenreader beides und damit doppelt. */ ?>
|
||||
<caption class="stadionzeitung-turnier__label">Spielplan</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Nr.</th>
|
||||
<th scope="col">Zeit</th>
|
||||
<?php if ($hatFeld): ?>
|
||||
<th scope="col">Feld</th>
|
||||
<?php endif; ?>
|
||||
<th scope="col">Begegnung</th>
|
||||
<th scope="col" class="stadionzeitung-turnier__ergebnis">Ergebnis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($spiele as $i => $spiel): ?>
|
||||
<?php
|
||||
$heim = trim((string) ($spiel['heim'] ?? ''));
|
||||
$gast = trim((string) ($spiel['gast'] ?? ''));
|
||||
$zeit = trim((string) ($spiel['zeit'] ?? ''));
|
||||
?>
|
||||
<tr>
|
||||
<td class="stadionzeitung-turnier__nr"><?= e((string) ($spiel['nr'] ?? ($i + 1))) ?></td>
|
||||
<td class="stadionzeitung-turnier__zeit"><?php if ($zeit !== ''): ?><time datetime="<?= e($date !== '' ? $date . 'T' . $zeit : $zeit) ?>"><?= e($zeit) ?></time><?php endif; ?></td>
|
||||
<?php if ($hatFeld): ?>
|
||||
<td class="stadionzeitung-turnier__nr"><?= e((string) ($spiel['feld'] ?? '')) ?></td>
|
||||
<?php endif; ?>
|
||||
<td class="stadionzeitung-turnier__paarung"><span class="stadionzeitung-turnier__team<?= $isOwn($heim) ? ' is-own' : '' ?>"><?= e($heim) ?></span> <span class="stadionzeitung-turnier__vs">gegen</span> <span class="stadionzeitung-turnier__team<?= $isOwn($gast) ? ' is-own' : '' ?>"><?= e($gast) ?></span></td>
|
||||
<td class="stadionzeitung-turnier__ergebnis"><span class="stadionzeitung-turnier__blank" aria-hidden="true"></span><span class="stadionzeitung-turnier__colon">:</span><span class="stadionzeitung-turnier__blank" aria-hidden="true"></span></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
<?php if ($teams !== []): ?>
|
||||
<table class="stadionzeitung-turnier__tabelle">
|
||||
<caption class="stadionzeitung-turnier__label">Platzierung zum Eintragen</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Pl.</th>
|
||||
<th scope="col">Teilnehmer</th>
|
||||
<th scope="col"><abbr title="Tore">T</abbr></th>
|
||||
<th scope="col"><abbr title="Tordifferenz">TD</abbr></th>
|
||||
<th scope="col"><abbr title="Punkte">Pkt</abbr></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php for ($i = 1; $i <= count($teams); $i++): ?>
|
||||
<tr>
|
||||
<td class="stadionzeitung-turnier__nr"><?= $i ?>.</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<?php endfor; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
80
app/components/stadionzeitung-viewer.php
Normal file
80
app/components/stadionzeitung-viewer.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Swipe-Viewer für eine Stadionzeitung-Ausgabe. Props:
|
||||
* $issue Eintrag aus data/stadionzeitung.json (opponent, team_key, status,
|
||||
* images[]{type: 'image'{base,widths,alt} | 'live'{page[,team][,turnier]}})
|
||||
*
|
||||
* Ohne JS: alle Seiten normal gestapelt (stadionzeitung-seite-Komponente) —
|
||||
* voll lesbar, sequentiell auch per Screenreader. Mit JS
|
||||
* (stadionzeitung-viewer.js) wird der Track zum horizontalen Scroll-Snap-
|
||||
* Swiper, Zähler + Pfeil-Buttons kommen dazu (vorher [hidden], da ohne JS
|
||||
* funktionslos).
|
||||
*
|
||||
* Das Rendern der einzelnen Seiten (Bild wie Live) liegt in
|
||||
* stadionzeitung-seite.php — geteilt mit der Druckfassung
|
||||
* (stadionzeitung-druck.php), damit Bildschirm und Druck nie
|
||||
* auseinanderlaufen. Die Daten der Live-Seiten kommen EINMAL aus
|
||||
* stadionzeitung_live_data() (app/helpers.php): Entwürfe rechnen live,
|
||||
* veröffentlichte Ausgaben lesen ihren eingefrorenen snapshot.
|
||||
*/
|
||||
$issue = $issue ?? [];
|
||||
$images = $issue['images'] ?? [];
|
||||
$total = count($images);
|
||||
if ($images === []) {
|
||||
return;
|
||||
}
|
||||
$opponent = (string) ($issue['opponent'] ?? '');
|
||||
|
||||
// Live-Daten nur laden, wenn die Ausgabe tatsächlich eine Live-Seite enthält.
|
||||
$hasLive = false;
|
||||
foreach ($images as $entry) {
|
||||
if (($entry['type'] ?? 'image') === 'live') {
|
||||
$hasLive = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$liveData = $hasLive ? stadionzeitung_live_data($issue) : [];
|
||||
?>
|
||||
<section class="stadionzeitung-viewer" data-stadionzeitung-viewer aria-roledescription="Bildergalerie" aria-label="<?= e('Stadionzeitung gegen ' . $opponent . ', Seite für Seite') ?>">
|
||||
<ul class="stadionzeitung-viewer__track" data-viewer-track>
|
||||
<?php foreach ($images as $i => $entry): ?>
|
||||
<li class="stadionzeitung-viewer__page" data-viewer-page>
|
||||
<?php component('stadionzeitung-seite', [
|
||||
'entry' => $entry,
|
||||
'issue' => $issue,
|
||||
'data' => $liveData,
|
||||
'eager' => $i === 0,
|
||||
]); ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<div class="stadionzeitung-viewer__controls" data-viewer-controls hidden>
|
||||
<button class="btn btn--icon stadionzeitung-viewer__nav-btn" type="button" data-viewer-prev aria-label="Vorherige Seite">
|
||||
<?= icon('chevron-left') ?>
|
||||
</button>
|
||||
<p class="stadionzeitung-viewer__counter" data-viewer-counter aria-live="polite">
|
||||
Seite <span data-viewer-current>1</span> von <?= $total ?>
|
||||
</p>
|
||||
<button class="btn btn--icon stadionzeitung-viewer__nav-btn" type="button" data-viewer-next aria-label="Nächste Seite">
|
||||
<?= icon('chevron-right') ?>
|
||||
</button>
|
||||
<?php /* Vollbild-Lesemodus: am Handy fressen Kopfleiste, Navigation und Fußzeile
|
||||
mehr Platz als die Seite selbst. Beide Icons stehen im Markup, welches
|
||||
sichtbar ist entscheidet CSS am Zustand — so muss das JS kein Markup
|
||||
erzeugen. Zustand über aria-pressed, deshalb bleibt das aria-label
|
||||
unverändert. */ ?>
|
||||
<button class="btn btn--icon stadionzeitung-viewer__full-btn" type="button" data-viewer-full aria-pressed="false" aria-label="Vollbild">
|
||||
<span class="stadionzeitung-viewer__icon--open"><?= icon('arrows-fullscreen') ?></span>
|
||||
<span class="stadionzeitung-viewer__icon--close"><?= icon('x-lg') ?></span>
|
||||
</button>
|
||||
</div>
|
||||
<?php /* Hier stand ein „Druckfassung"-Knopf. Er ist raus (31.07.2026, Felix): die
|
||||
Druckfassung ist Werkzeug für die Heft-Produktion, nicht für Besucher — und
|
||||
als sichtbarer Button neben Blättern und Vollbild wurde er versehentlich
|
||||
geklickt und riss Leute aus dem Lesen. Die Route bleibt unverändert
|
||||
erreichbar: an die Ausgaben-URL ein `?druck=1` anhängen. Nicht als Link
|
||||
wieder einbauen; wer sie braucht, kennt sie. */ ?>
|
||||
</section>
|
||||
50
app/components/stadionzeitung-vorschau.php
Normal file
50
app/components/stadionzeitung-vorschau.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Vorschau-Seite der Stadionzeitung (Live-Seite) — das nächste Spiel jeder
|
||||
* Mannschaft NACH dem Spieltag der Ausgabe (berechnet bzw. eingefroren in
|
||||
* stadionzeitung_build_snapshot(), app/helpers.php). Aufbau je Spiel wie die
|
||||
* „VORSCHAU"-Seite der Canva-Vorlage: Meta-Zeile (Mannschaft · Liga), darunter
|
||||
* die Begegnung mit Wappen, rechts Datum/Anstoß.
|
||||
*
|
||||
* Props: $items stadionzeitung_live_data()['vorschau'] — je Team:
|
||||
* team_name/league/match (Matchcenter-Rohform).
|
||||
*/
|
||||
$items = $items ?? [];
|
||||
|
||||
// Bewusst OHNE CTA-Fuß (Felix, 29.07.2026): mit drei Mannschaften füllt die
|
||||
// Liste die Seite — das Matchcenter-Ticket wohnt auf der Ergebnis-Seite.
|
||||
?>
|
||||
<div class="stadionzeitung-vorschau">
|
||||
<header class="stadionzeitung-page__head">
|
||||
<p class="stadionzeitung-page__kicker">Vorschau</p>
|
||||
<h2 class="stadionzeitung-page__heading">Kommende Spiele</h2>
|
||||
</header>
|
||||
<span class="stadionzeitung-page__rule" aria-hidden="true"></span>
|
||||
<?php if ($items === []): ?>
|
||||
<p class="text-muted">Aktuell stehen keine weiteren Spiele fest.</p>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($items as $item): ?>
|
||||
<?php
|
||||
$match = $item['match'] ?? [];
|
||||
$when = array_filter([
|
||||
trim((string) ($match['date_display'] ?? '')),
|
||||
trim((string) ($match['time_display'] ?? '')),
|
||||
]);
|
||||
$place = !empty($match['is_home']) ? 'im Katzbachtal' : 'auswärts';
|
||||
?>
|
||||
<section class="stadionzeitung-vorschau__match">
|
||||
<p class="stadionzeitung-vorschau__meta"><?= e(trim(implode(' · ', array_filter([(string) ($item['team_name'] ?? ''), (string) ($item['league'] ?? '')])))) ?></p>
|
||||
<?php foreach ([['club' => $match['home'] ?? '', 'crest' => $match['home_crest'] ?? null],
|
||||
['club' => $match['away'] ?? '', 'crest' => $match['away_crest'] ?? null]] as $side): ?>
|
||||
<div class="stadionzeitung-vorschau__row<?= str_contains((string) $side['club'], 'TSV 08 Kulmbach') ? ' stadionzeitung-vorschau__row--own' : '' ?>">
|
||||
<?php component('crest', ['src' => $side['crest'], 'name' => (string) $side['club']]); ?>
|
||||
<span class="stadionzeitung-vorschau__club"><?= e((string) $side['club']) ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<p class="stadionzeitung-vorschau__when"><?= e(implode(' · ', $when)) ?><?= $when !== [] ? ' · ' : '' ?><?= e($place) ?></p>
|
||||
</section>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
70
app/components/stadionzeitung-vorwort.php
Normal file
70
app/components/stadionzeitung-vorwort.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Vorwort-Seite der Stadionzeitung (Live-Seite, wie Tabelle/Termine/News) —
|
||||
* schlichte weiße Content-Seite, keine fotografische Ausnahme wie das Cover.
|
||||
* Der Text selbst ist KEIN Skript-Input: er wird im Chat gepflegt (wie
|
||||
* data/events.json/news.json) und lebt im `vorwort`-Feld der Ausgabe in
|
||||
* data/stadionzeitung.json (siehe bin/stadionzeitung-add.php).
|
||||
*
|
||||
* Fließtext bewusst in einer Serifenschrift (--ff-serif, PT Serif) statt
|
||||
* --ff-body — einzige Stelle im Projekt mit einer dritten Schriftart: ein
|
||||
* Vorwort ist ein redaktioneller Brief, keine UI, eine Lese-Serif passt hier
|
||||
* besser als die Website-Grotesk. Überschrift bewusst NICHT in Versalien
|
||||
* (anders als der Cover-Masthead) — ein Begrüßungssatz in Großbuchstaben
|
||||
* wirkt geschrien, hier soll es wie eine echte Zeitschriften-Headline lesen.
|
||||
* Vereins-Hero-Bild (dieselbe Quelle wie Startseite/Cover) blendet unten ein,
|
||||
* wie im ursprünglichen Canva-Vorwort — füllt die sonst leere Fläche unter
|
||||
* dem Fließtext, ohne die Lesbarkeit oben zu stören. Läuft über einen weißen
|
||||
* Gradient-Layer (__bg-fade) statt mask-image auf dem Foto selbst — druckt
|
||||
* zuverlässig mit (siehe CSS-Kommentar), erscheint also auch im PDF.
|
||||
*
|
||||
* Props: $issue (Eintrag aus data/stadionzeitung.json: vorwort?, verfasser_photo?)
|
||||
*/
|
||||
$issue = $issue ?? [];
|
||||
$vorwort = $issue['vorwort'] ?? null;
|
||||
if (!$vorwort) {
|
||||
return;
|
||||
}
|
||||
$heading = (string) ($vorwort['heading'] ?? '');
|
||||
$paragraphs = $vorwort['paragraphs'] ?? [];
|
||||
$closing = (string) ($vorwort['closing'] ?? '');
|
||||
$signeeName = (string) ($vorwort['signee_name'] ?? '');
|
||||
$signeeRole = (string) ($vorwort['signee_role'] ?? '');
|
||||
$verfasserPhoto = $issue['verfasser_photo'] ?? null;
|
||||
$heroImage = json_load('home')['hero']['image'] ?? null;
|
||||
?>
|
||||
<div class="stadionzeitung-vorwort">
|
||||
<?php if ($heroImage): ?>
|
||||
<?php component('img', ['image' => $heroImage, 'sizes' => '480px', 'class' => 'stadionzeitung-vorwort__bg']); ?>
|
||||
<div class="stadionzeitung-vorwort__bg-fade" aria-hidden="true"></div>
|
||||
<?php endif; ?>
|
||||
<header class="stadionzeitung-vorwort__head">
|
||||
<p class="stadionzeitung-vorwort__kicker">Vorwort</p>
|
||||
<img class="stadionzeitung-vorwort__logo" src="<?= e(asset('img/logo.svg')) ?>" alt="" width="75" height="97">
|
||||
</header>
|
||||
<?php if ($heading !== ''): ?>
|
||||
<h2 class="stadionzeitung-vorwort__heading"><?= e($heading) ?></h2>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($paragraphs as $paragraph): ?>
|
||||
<p class="stadionzeitung-vorwort__text"><?= e($paragraph) ?></p>
|
||||
<?php endforeach; ?>
|
||||
<?php if ($signeeName !== ''): ?>
|
||||
<footer class="stadionzeitung-vorwort__signature">
|
||||
<?php if ($verfasserPhoto): ?>
|
||||
<?php component('img', ['image' => $verfasserPhoto, 'sizes' => '80px', 'class' => 'stadionzeitung-vorwort__portrait']); ?>
|
||||
<?php endif; ?>
|
||||
<div class="stadionzeitung-vorwort__signature-text">
|
||||
<?php if ($closing !== ''): ?>
|
||||
<p class="stadionzeitung-vorwort__closing"><?= e($closing) ?></p>
|
||||
<?php endif; ?>
|
||||
<p class="stadionzeitung-vorwort__signee"><?= e($signeeName) ?></p>
|
||||
<?php if ($signeeRole !== ''): ?>
|
||||
<p class="stadionzeitung-vorwort__role"><?= e($signeeRole) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</footer>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Kennzahlen-Band über abgedunkeltem Foto (Vereinsgelände), Kanten verlaufen
|
||||
* weich in den Seitenhintergrund. Props:
|
||||
* $stats array{items: array{value, label}[], image?: array (img-Komponente)}
|
||||
*/
|
||||
$image = $stats['image'] ?? [
|
||||
'base' => 'img/pages/vereinsgelaende',
|
||||
'widths' => [1000, 1600],
|
||||
'alt' => '',
|
||||
];
|
||||
?>
|
||||
<section class="section stats" aria-label="Der Verein in Zahlen">
|
||||
<div class="stats__media" aria-hidden="true">
|
||||
<?php component('img', ['image' => $image, 'sizes' => '100vw', 'class' => 'stats__img']); ?>
|
||||
</div>
|
||||
<div class="container">
|
||||
<ul class="stats__list">
|
||||
<?php foreach ($stats['items'] as $item): ?>
|
||||
<li class="stats__item">
|
||||
<span class="stats__value"><?= e($item['value']) ?></span>
|
||||
<span class="stats__label"><?= e($item['label']) ?></span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
@@ -39,7 +39,7 @@ if (!empty($team['roster']['members']) || !empty($team['roster']['groups'])) {
|
||||
|
||||
component('faq-contact', [
|
||||
'faq' => $team['faq'] ?? [],
|
||||
'faqIntro' => 'Die wichtigsten Fragen rund um die ' . ($team['name'] ?? 'Mannschaft') . ' – kurz beantwortet.',
|
||||
'faqIntro' => 'Die wichtigsten Fragen rund um die ' . ($team['name'] ?? 'Mannschaft') . ', kurz beantwortet.',
|
||||
'persons' => $contacts,
|
||||
'contactTitle' => '',
|
||||
'anchor' => 'kontakt',
|
||||
|
||||
44
app/components/termine-list.php
Normal file
44
app/components/termine-list.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Liste normalisierter Termine (Spiele + Vereins-Events gemischt). Props:
|
||||
* $items array{date, date_end?, title, department, type, meta?, href?}
|
||||
* $empty_text string (optional)
|
||||
* Kennt selbst nichts von Matchcenter-/Events-Datenstruktur — bekommt nur die
|
||||
* bereits gemergte, normalisierte Form (app/pages/termine.php baut sie).
|
||||
*/
|
||||
$items = $items ?? [];
|
||||
$emptyText = $empty_text ?? 'Aktuell keine Termine.';
|
||||
?>
|
||||
<?php if ($items === []): ?>
|
||||
<p class="text-muted"><?= e($emptyText) ?></p>
|
||||
<?php else: ?>
|
||||
<ul class="termine-list">
|
||||
<?php foreach ($items as $item): ?>
|
||||
<?php
|
||||
$tag = !empty($item['href']) ? 'a' : 'div';
|
||||
$chip = german_date_chip($item['date']);
|
||||
$dateText = german_date_range($item['date'], $item['date_end'] ?? null);
|
||||
?>
|
||||
<li class="termine-list__item">
|
||||
<<?= $tag ?> class="termine-list__link"<?= !empty($item['href']) ? ' href="' . e($item['href']) . '"' : '' ?>>
|
||||
<span class="termine-list__date-chip" aria-hidden="true">
|
||||
<span class="termine-list__date-day"><?= e($chip['day']) ?></span>
|
||||
<span class="termine-list__date-month"><?= e($chip['month']) ?></span>
|
||||
</span>
|
||||
<span class="termine-list__body">
|
||||
<span class="termine-list__title"><?= e($item['title']) ?></span>
|
||||
<span class="termine-list__meta">
|
||||
<time datetime="<?= e($item['date']) ?>"><?= e($dateText) ?></time><?php if (!empty($item['meta'])): ?> · <?= e($item['meta']) ?><?php endif; ?>
|
||||
</span>
|
||||
</span>
|
||||
<span class="termine-list__badge termine-list__badge--<?= e($item['department']) ?>">
|
||||
<?= e(department_label($item['department'])) ?>
|
||||
</span>
|
||||
</<?= $tag ?>>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
@@ -4,9 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Interaktive Vereins-Timeline (Museums-Look). Props:
|
||||
* $events array — Stationen aus data/historie.json (year, text, image{src,width,height,alt})
|
||||
* $events array — Stationen aus data/historie.json (year, text, image?{src,width,height,alt})
|
||||
* Bilder werden bewusst in ORIGINALGRÖSSE eingebunden (keine Varianten) —
|
||||
* historisches Material bleibt unangetastet.
|
||||
* `image` ist optional: zu vielen belegten Ereignissen (Ligaereignisse, Chronik-Funde)
|
||||
* gibt es kein Bild, das wir zeigen dürfen — Pressefotos gehören dem Verlag, die Fotos
|
||||
* der alten Vereinsseiten sind verloren. Solche Stationen rendern als Notiz auf
|
||||
* derselben Sepia-Fläche, ohne <figure>/<figcaption> (ohne Bild trägt das nichts).
|
||||
* timeline.js ergänzt Scroll-Reveal + aktive Jahres-Navigation; ohne JS ist
|
||||
* alles sichtbar (Initial-Versteckt-Zustand nur unter .js-reveal).
|
||||
*/
|
||||
@@ -42,6 +46,7 @@ foreach ($events as $event) {
|
||||
<span class="timeline__marker" aria-hidden="true"></span>
|
||||
<article class="timeline__card">
|
||||
<h2 class="timeline__year"><?= e($event['year']) ?></h2>
|
||||
<?php if (!empty($event['image']['src'])): ?>
|
||||
<figure class="timeline__figure">
|
||||
<img src="<?= e(asset($event['image']['src'])) ?>"
|
||||
alt="<?= e($event['image']['alt']) ?>"
|
||||
@@ -49,6 +54,11 @@ foreach ($events as $event) {
|
||||
loading="<?= $i === 0 ? 'eager' : 'lazy' ?>" decoding="async">
|
||||
<figcaption class="timeline__text"><?= e($event['text']) ?></figcaption>
|
||||
</figure>
|
||||
<?php else: ?>
|
||||
<div class="timeline__figure timeline__figure--note">
|
||||
<p class="timeline__text"><?= e($event['text']) ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
|
||||
52
app/components/veranstaltung-galerie.php
Normal file
52
app/components/veranstaltung-galerie.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Bildergalerie einer Veranstaltung: Raster aus Vorschaubildern, Klick öffnet
|
||||
* das Bild groß. Props:
|
||||
* $bilder array [{base, widths, alt, source?}, …] (script-verwaltet,
|
||||
* bin/veranstaltung-bilder.php)
|
||||
* $title string (optional, Default 'Bilder')
|
||||
*
|
||||
* Progressive enhancement: jedes Vorschaubild ist ein normaler Link auf die
|
||||
* größte Variante der Datei — ohne JS führt der Klick einfach zum Bild. Mit JS
|
||||
* fängt assets/js/lightbox.js den Klick ab und zeigt es im Overlay
|
||||
* (Vor/Zurück, Pfeiltasten, Esc). Das Overlay-Markup steht am Ende und ist
|
||||
* `hidden`, bis JS es übernimmt.
|
||||
*
|
||||
* Die Overlay-Grafik erbt ihr srcset vom angeklickten Vorschaubild (siehe
|
||||
* lightbox.js) — deshalb braucht der Link hier keine zweite Bildliste.
|
||||
*/
|
||||
$bilder = $bilder ?? [];
|
||||
$title = $title ?? 'Bilder';
|
||||
|
||||
if ($bilder === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$anzahl = count($bilder);
|
||||
?>
|
||||
<section class="section veranstaltung-galerie" id="bilder" data-lightbox-gallery>
|
||||
<div class="container">
|
||||
<h2><?= e($title) ?></h2>
|
||||
<ul class="veranstaltung-galerie__grid">
|
||||
<?php foreach ($bilder as $i => $bild): ?>
|
||||
<?php
|
||||
$gross = asset($bild['base'] . '-' . max($bild['widths']) . '.jpg');
|
||||
?>
|
||||
<li class="veranstaltung-galerie__item">
|
||||
<a class="veranstaltung-galerie__link" href="<?= e($gross) ?>" data-lightbox="<?= $i ?>">
|
||||
<?php component('img', [
|
||||
'image' => $bild,
|
||||
'sizes' => '(max-width: 600px) 45vw, (max-width: 992px) 30vw, 22vw',
|
||||
'class' => 'veranstaltung-galerie__img',
|
||||
]); ?>
|
||||
<span class="visually-hidden">(größer ansehen)</span>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php component('lightbox-overlay', ['label' => $title, 'count' => $anzahl]); ?>
|
||||
</section>
|
||||
93
app/components/veranstaltung-info.php
Normal file
93
app/components/veranstaltung-info.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Infoblock einer Veranstaltung: links die harten Angaben (Zeitraum, Beginn,
|
||||
* Adresse, Hinweis zum Parken), rechts die Karte. Props:
|
||||
* $date_text string fertig formatierter Zeitraum (german_date_range())
|
||||
* $date_iso string ISO-Startdatum für <time datetime>
|
||||
* $time string 'HH:MM' oder leer
|
||||
* $location string Ortsname der Veranstaltung
|
||||
* $karte string 'club' = auf unserem eigenen Gelände: Adresse kommt aus
|
||||
* data/club.json und die Karte wird gezeigt. Alles andere (oder
|
||||
* leer) → keine Karte, nur der Ortsname (ein Fest woanders hätte
|
||||
* sonst eine falsche Karte daneben).
|
||||
* $anfahrt array optionale Absätze zum Parken/Zugang
|
||||
*
|
||||
* Nur für Feste, die noch ausstehen (app/pages/veranstaltung.php ruft die
|
||||
* Komponente danach nicht mehr auf) — deshalb steht hier durchgehend Zukunft.
|
||||
*
|
||||
* KEINE Leaflet-Karte, sondern ein statisches Kartenbild: Leaflet würde seine
|
||||
* Kacheln zur Laufzeit von einem fremden Tile-Server holen, und genau das
|
||||
* verbietet Regel 1 (die CSP mit img-src 'self' würde sie ohnehin blocken).
|
||||
* Das Bild liegt selbst gehostet bei uns, der Klick führt erst dann zu Google
|
||||
* Maps, wenn jemand ihn macht. Namensnennung für die OSM-Daten steht als
|
||||
* Bildunterschrift, das verlangt die ODbL.
|
||||
*/
|
||||
$dateText = $date_text ?? '';
|
||||
$dateIso = $date_iso ?? '';
|
||||
$time = $time ?? '';
|
||||
$location = $location ?? '';
|
||||
$karte = $karte ?? '';
|
||||
$anfahrt = $anfahrt ?? [];
|
||||
|
||||
$istBeiUns = $karte === 'club';
|
||||
$club = $istBeiUns ? json_load('club') : [];
|
||||
$adresse = $club['address'] ?? [];
|
||||
$geo = $club['geo'] ?? [];
|
||||
|
||||
// Kartenlink ohne API-Schlüssel und ohne Einbettung: Koordinaten aus club.json
|
||||
// (Single Source), damit die Adresse hier nicht als Text zweitverwertet wird.
|
||||
$mapsUrl = '';
|
||||
if ($istBeiUns && !empty($geo['lat']) && !empty($geo['lng'])) {
|
||||
$mapsUrl = 'https://www.google.com/maps/search/?api=1&query=' . $geo['lat'] . ',' . $geo['lng'];
|
||||
}
|
||||
?>
|
||||
<section class="section veranstaltung-info" id="infos">
|
||||
<div class="container">
|
||||
<div class="veranstaltung-info__panel">
|
||||
<div class="veranstaltung-info__facts">
|
||||
<h2>Gut zu wissen</h2>
|
||||
<?php if ($dateText !== ''): ?>
|
||||
<p class="veranstaltung-info__wann">
|
||||
<time datetime="<?= e($dateIso) ?>"><?= e($dateText) ?></time>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php if ($time !== ''): ?>
|
||||
<?php /* Wochentag dazu, weil bei einem mehrtägigen Fest sonst offen bleibt, für
|
||||
welchen Tag die Uhrzeit gilt. */ ?>
|
||||
<?php $wochentag = german_weekday($dateIso); ?>
|
||||
<p class="veranstaltung-info__beginn">Los geht es<?= $wochentag !== '' ? ' am ' . e($wochentag) : '' ?> um <?= e($time) ?> Uhr.</p>
|
||||
<?php endif; ?>
|
||||
<address class="veranstaltung-info__adresse">
|
||||
<?php if ($istBeiUns && !empty($adresse['street'])): ?>
|
||||
<?= e($adresse['venue'] ?? $location) ?><br>
|
||||
<?= e($adresse['street']) ?><br>
|
||||
<?= e($adresse['zip'] ?? '') ?> <?= e($adresse['city'] ?? '') ?>
|
||||
<?php else: ?>
|
||||
<?= e($location) ?>
|
||||
<?php endif; ?>
|
||||
</address>
|
||||
<?php foreach ($anfahrt as $absatz): ?>
|
||||
<p class="veranstaltung-info__hinweis"><?= e($absatz) ?></p>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php if ($mapsUrl !== ''): ?>
|
||||
<figure class="veranstaltung-info__map">
|
||||
<a class="veranstaltung-info__map-link" href="<?= e($mapsUrl) ?>" target="_blank" rel="noopener">
|
||||
<?php component('img', [
|
||||
'image' => ['src' => 'img/pages/sportheim-map.jpg', 'width' => 512, 'height' => 512, 'alt' => 'Karte mit unserem Sportgelände im Katzbachtal'],
|
||||
'class' => 'veranstaltung-info__map-img',
|
||||
]); ?>
|
||||
<span class="veranstaltung-info__map-cta">
|
||||
<?= icon('geo-alt-fill') ?> In Google Maps öffnen
|
||||
<span class="visually-hidden">(öffnet in neuem Tab)</span>
|
||||
</span>
|
||||
</a>
|
||||
<figcaption class="veranstaltung-info__map-credit">Kartendaten © OpenStreetMap-Mitwirkende</figcaption>
|
||||
</figure>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
55
app/components/veranstaltung-plakate.php
Normal file
55
app/components/veranstaltung-plakate.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Die Plakate einer Veranstaltung (Ankündigung, Programm, …) als große,
|
||||
* anklickbare Bilder. Props:
|
||||
* $plakate array [{base, widths, alt, source?}, …] (script-verwaltet)
|
||||
* $title string (optional, Default 'Plakate')
|
||||
* $compact bool (optional) — kleinere Variante für den Rückblick
|
||||
*
|
||||
* Die Überschrift ist sichtbar, WENN die Plakate klein und nachgestellt sind
|
||||
* (Rückblick): nach einer langen Fotogalerie brauchen zwei kleine Bilder am
|
||||
* Seitenende eine Beschriftung. Als Hauptblock einer Ankündigung erklären sie
|
||||
* sich dagegen selbst, dort bleibt die Überschrift visually-hidden — im Markup
|
||||
* steht sie trotzdem, damit die Headings-Hierarchie keine Lücke hat (gleiche
|
||||
* Lösung wie in stadionzeitung-cards).
|
||||
*
|
||||
* Warum eine eigene Komponente und nicht die Fotogalerie: ein Plakat ist kein
|
||||
* Foto vom Fest, sondern trägt Informationen (Termine, Uhrzeiten, Ort). Es
|
||||
* gehört nach vorne statt in den Rückblick und braucht Hochformat statt
|
||||
* beschnittener Raster-Kacheln. Vergrößern läuft aber über dieselbe Lightbox —
|
||||
* gerade bei einem Programmplakat will man das lesen können.
|
||||
*
|
||||
* Ohne JS ist jedes Plakat ein normaler Link auf die größte Variante.
|
||||
*/
|
||||
$plakate = $plakate ?? [];
|
||||
$title = $title ?? 'Plakate';
|
||||
$compact = $compact ?? false;
|
||||
|
||||
if ($plakate === []) {
|
||||
return;
|
||||
}
|
||||
?>
|
||||
<section class="section veranstaltung-plakate<?= $compact ? ' veranstaltung-plakate--compact' : '' ?>" id="plakate" data-lightbox-gallery>
|
||||
<div class="container">
|
||||
<h2<?= $compact ? '' : ' class="visually-hidden"' ?>><?= e($title) ?></h2>
|
||||
<ul class="veranstaltung-plakate__grid">
|
||||
<?php foreach ($plakate as $i => $plakat): ?>
|
||||
<li class="veranstaltung-plakate__item">
|
||||
<a class="veranstaltung-plakate__link" href="<?= e(asset($plakat['base'] . '-' . max($plakat['widths']) . '.jpg')) ?>" data-lightbox="<?= $i ?>">
|
||||
<?php component('img', [
|
||||
'image' => $plakat,
|
||||
'sizes' => count($plakate) > 1 ? '(max-width: 768px) 92vw, 44vw' : '(max-width: 768px) 92vw, 30vw',
|
||||
'eager' => $i === 0 && !$compact,
|
||||
'class' => 'veranstaltung-plakate__img',
|
||||
]); ?>
|
||||
<span class="visually-hidden">(größer ansehen)</span>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php component('lightbox-overlay', ['label' => $title, 'count' => count($plakate)]); ?>
|
||||
</section>
|
||||
66
app/components/veranstaltung-programm.php
Normal file
66
app/components/veranstaltung-programm.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Tagesprogramm einer Veranstaltung. Props:
|
||||
* $programm array [{tag: 'YYYY-MM-DD', punkte: [{zeit: 'HH:MM', titel, text?}]}, …]
|
||||
* $title string (optional, Default 'Programm')
|
||||
* $compact bool (optional) — kleinere Variante für den Rückblick, wo das
|
||||
* Programm nicht mehr informiert, sondern nur noch dokumentiert.
|
||||
*
|
||||
* Ein Tag = eine Gruppe mit Wochentag-Überschrift, damit ein dreitägiges Fest
|
||||
* lesbar bleibt. Ohne Programm rendert die Komponente nichts (die Seite startet
|
||||
* ja mit nichts als dem Plakat).
|
||||
*/
|
||||
$programm = $programm ?? [];
|
||||
$title = $title ?? 'Programm';
|
||||
$compact = $compact ?? false;
|
||||
|
||||
if ($programm === []) {
|
||||
return;
|
||||
}
|
||||
?>
|
||||
<section class="section veranstaltung-programm<?= $compact ? ' veranstaltung-programm--compact' : '' ?>" id="programm">
|
||||
<div class="container">
|
||||
<h2><?= e($title) ?></h2>
|
||||
<div class="veranstaltung-programm__tage">
|
||||
<?php foreach ($programm as $tag): ?>
|
||||
<?php
|
||||
$tagIso = (string) ($tag['tag'] ?? '');
|
||||
$weekday = german_weekday($tagIso);
|
||||
$tagText = trim(($weekday !== '' ? $weekday . ', ' : '') . german_date($tagIso));
|
||||
?>
|
||||
<div class="veranstaltung-programm__tag">
|
||||
<?php if ($tagIso !== ''): ?>
|
||||
<h3 class="veranstaltung-programm__tag-titel">
|
||||
<time datetime="<?= e($tagIso) ?>"><?= e($tagText) ?></time>
|
||||
</h3>
|
||||
<?php endif; ?>
|
||||
<ul class="veranstaltung-programm__liste">
|
||||
<?php foreach ($tag['punkte'] ?? [] as $punkt): ?>
|
||||
<?php $zeit = (string) ($punkt['zeit'] ?? ''); ?>
|
||||
<li class="veranstaltung-programm__punkt">
|
||||
<?php if ($zeit !== ''): ?>
|
||||
<span class="veranstaltung-programm__zeit">
|
||||
<time datetime="<?= e($tagIso !== '' ? $tagIso . 'T' . $zeit : $zeit) ?>"><?= e($zeit) ?></time>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<?php /* Platzhalter hält die Spalte, aber ohne die Zeit-Klasse — sonst stünde
|
||||
dort der rote Balken ohne Inhalt daneben. */ ?>
|
||||
<span aria-hidden="true"></span>
|
||||
<?php endif; ?>
|
||||
<span class="veranstaltung-programm__body">
|
||||
<span class="veranstaltung-programm__titel"><?= e($punkt['titel'] ?? '') ?></span>
|
||||
<?php if (!empty($punkt['text'])): ?>
|
||||
<span class="veranstaltung-programm__text"><?= e($punkt['text']) ?></span>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
68
app/components/veranstaltungen-cards.php
Normal file
68
app/components/veranstaltungen-cards.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Liste der Veranstaltungen, ganze Karte verlinkt auf die Detailseite. Props:
|
||||
* $items array aus data/veranstaltungen.json (bereits vorsortiert)
|
||||
* $cta string (optional, Default 'Ansehen') — Linktext auf der Karte
|
||||
* $empty_text string (optional)
|
||||
*
|
||||
* EINE Kartenform für „Was ansteht" und „Rückblick" (Beschluss 30.07.2026):
|
||||
* Plakat links, Text rechts, volle Breite, untereinander gestapelt. Vorher war
|
||||
* der erste Eintrag breit und alles Weitere ein schmales Raster — zwei Formen
|
||||
* für dieselbe Sache, und der Rückblick sah dadurch nach Restposten aus.
|
||||
*
|
||||
* Vorschaubild ist das Hauptplakat (Hochformat). Fehlt es (die Seite entsteht ja
|
||||
* schon, bevor das Plakat fertig ist), greift das erste Galeriebild; ohne
|
||||
* beides bleibt die Karte reiner Text und bricht nicht.
|
||||
*/
|
||||
$items = $items ?? [];
|
||||
$cta = $cta ?? 'Ansehen';
|
||||
$emptyText = $empty_text ?? 'Aktuell keine Veranstaltungen.';
|
||||
|
||||
/** Erstes verfügbares Bild einer Veranstaltung (Hauptplakat vor Galerie). */
|
||||
$vorschau = static function (array $v): ?array {
|
||||
return $v['plakate'][0] ?? $v['galerie'][0] ?? null;
|
||||
};
|
||||
?>
|
||||
<?php if ($items === []): ?>
|
||||
<p class="text-muted"><?= e($emptyText) ?></p>
|
||||
<?php else: ?>
|
||||
<ul class="veranstaltungen-cards__list">
|
||||
<?php foreach ($items as $v): ?>
|
||||
<?php
|
||||
$bild = $vorschau($v);
|
||||
$dateText = german_date_range((string) $v['date_start'], $v['date_end'] ?? null);
|
||||
?>
|
||||
<li>
|
||||
<a class="veranstaltung-card" href="<?= e(url('veranstaltungen/' . $v['slug'])) ?>">
|
||||
<?php if ($bild !== null): ?>
|
||||
<div class="veranstaltung-card__media">
|
||||
<?php component('img', [
|
||||
'image' => $bild,
|
||||
'sizes' => '(max-width: 768px) 100vw, 300px',
|
||||
'class' => 'veranstaltung-card__img',
|
||||
]); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="veranstaltung-card__body">
|
||||
<h3 class="veranstaltung-card__name"><?= e($v['title']) ?></h3>
|
||||
<p class="veranstaltung-card__meta">
|
||||
<time datetime="<?= e($v['date_start']) ?>"><?= e($dateText) ?></time>
|
||||
<?php if (!empty($v['location'])): ?>
|
||||
· <?= e($v['location']) ?>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<?php if (!empty($v['text'])): ?>
|
||||
<p class="veranstaltung-card__text"><?= e($v['text']) ?></p>
|
||||
<?php endif; ?>
|
||||
<span class="veranstaltung-card__cta">
|
||||
<?= e($cta) ?> <?= icon('arrow-right', 'veranstaltung-card__arrow') ?>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
1428
app/helpers.php
1428
app/helpers.php
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,8 @@ $anchor_slug = static function (string $s): string {
|
||||
|
||||
$meta = [
|
||||
'title' => 'Ansprechpartner',
|
||||
'description' => 'Ansprechpartner und Kontaktwege beim TSV 1908 Kulmbach – Geschäftsstelle, Turnabteilung und Vorstand mit E-Mail und Telefon auf einen Blick.',
|
||||
'description' => 'Alle Ansprechpartner des TSV 08 Kulmbach auf einen Blick: Herren-, Frauen- und Jugendfußball sowie Turnen, mit Telefonnummer oder E-Mail. Sprich uns direkt an!',
|
||||
'scripts' => ['persons-tabs.js'],
|
||||
'schema' => page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Der Verein', 'slug' => 'verein'],
|
||||
@@ -40,7 +41,7 @@ $meta = [
|
||||
];
|
||||
component('hero', ['hero' => [
|
||||
'compact' => true,
|
||||
'align' => 'bottom-left',
|
||||
'align' => 'center',
|
||||
'title' => 'Ansprechpartner',
|
||||
'text' => $page['intro'] ?? '',
|
||||
'image' => [
|
||||
@@ -50,14 +51,29 @@ component('hero', ['hero' => [
|
||||
],
|
||||
]]);
|
||||
|
||||
$i = 0;
|
||||
foreach ($groups as $label => $depts):
|
||||
?>
|
||||
<?php /* EINE Sektion für beide Bereiche, Tab-Leiste direkt über den
|
||||
Karten (assets/js/persons-tabs.js schaltet um und spiegelt den
|
||||
aktiven Bereich in die URL — #kontakt-turnen ist teilbar).
|
||||
Ohne JS bleibt die Leiste hidden und beide Panels stehen mit
|
||||
ihren Überschriften untereinander. */ ?>
|
||||
<section class="section section--tinted" id="kontakt" data-persons-tabs>
|
||||
<div class="container">
|
||||
<div class="filter-bar persons-tabs__bar" role="group" aria-label="Bereich wählen" hidden data-persons-tabs-bar>
|
||||
<?php $i = 0; foreach (array_keys($groups) as $label): ?>
|
||||
<button type="button" class="tab filter-bar__tab" data-tab-target="kontakt-<?= e($anchor_slug($label)) ?>" aria-pressed="<?= $i++ === 0 ? 'true' : 'false' ?>"><?= e($label) ?></button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php
|
||||
foreach ($groups as $label => $depts) {
|
||||
component('person-grid', [
|
||||
'departments' => $depts,
|
||||
'title' => $label,
|
||||
'intro' => '',
|
||||
'anchor' => 'kontakt-' . $anchor_slug($label),
|
||||
'tinted' => ($i % 2 === 0),
|
||||
'embedded' => true,
|
||||
]);
|
||||
$i++;
|
||||
endforeach;
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
|
||||
@@ -5,9 +5,16 @@ 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.
|
||||
* Fonts/Maps/Embeds. Einzige Verarbeitungen: Server-Logs (Hoster), die vier
|
||||
* Formulare (Kontakt, Turnabteilung, Mitgliedsantrag, Sportheimbuchung; Versand
|
||||
* über den All-Inkl-Mailserver, also derselbe Auftragsverarbeiter wie beim
|
||||
* Hosting), server-seitig gecachter Instagram-Feed (Bilder von unserer Domain).
|
||||
* Vereinsdaten aus club.json.
|
||||
*
|
||||
* Alle Empfänger liegen auf unserer eigenen Domain bei All-Inkl (info@,
|
||||
* turnen@) — deshalb stimmt die Aussage weiter unten, dass kein zusätzlicher
|
||||
* E-Mail-Dienstleister im Spiel ist. Zeigt ein Postfach künftig auf einen
|
||||
* fremden Anbieter, muss dieser Satz mit.
|
||||
*/
|
||||
$club = json_load('club');
|
||||
$addr = $club['address'] ?? [];
|
||||
@@ -35,7 +42,7 @@ $meta = [
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="legal__body">
|
||||
<div class="legal__body prose">
|
||||
<section>
|
||||
<h2>Verantwortlicher</h2>
|
||||
<p>Verantwortlich für die Datenverarbeitung auf dieser Website ist:</p>
|
||||
@@ -122,14 +129,20 @@ $meta = [
|
||||
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>
|
||||
<p>
|
||||
Eine Ausnahme betrifft nur den passwortgeschützten internen Bereich
|
||||
(siehe „Interne Dateiablage“): Wer sich dort anmeldet, erhält ein
|
||||
technisch notwendiges Sitzungs-Cookie, das die Anmeldung für die Dauer
|
||||
der Sitzung merkt. Ohne dieses Cookie ist eine Anmeldung nicht möglich.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Kontaktformular & Kontaktaufnahme</h2>
|
||||
<h2>Formulare & 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:
|
||||
Wenn du uns über eines unserer Formulare oder per E-Mail kontaktierst,
|
||||
verarbeiten wir die von dir angegebenen Daten, um deine Anfrage zu bearbeiten.
|
||||
Über das <strong>Kontaktformular</strong> sind das:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Name (Pflichtangabe)</li>
|
||||
@@ -137,6 +150,34 @@ $meta = [
|
||||
<li>Telefonnummer (freiwillig)</li>
|
||||
<li>dein Interessensgebiet, Betreff und deine Nachricht</li>
|
||||
</ul>
|
||||
<p>
|
||||
Über den <strong>Mitgliedsantrag</strong> verarbeiten wir zusätzlich Vorname,
|
||||
Nachname, Geburtsdatum, Anschrift und die gewählte Beitragsklasse. Diese Angaben
|
||||
brauchen wir, um die Mitgliedschaft vorzubereiten; die Beitrittserklärung senden
|
||||
wir dir anschließend per Post zu. Bankdaten erheben wir online bewusst nicht.
|
||||
Rechtsgrundlage ist hier Art. 6 Abs. 1 lit. b DSGVO (Anbahnung und Durchführung
|
||||
der Mitgliedschaft). An die von dir angegebene Adresse senden wir außerdem eine
|
||||
Eingangsbestätigung.
|
||||
</p>
|
||||
<p>
|
||||
Über das Formular <strong>Sportheimbuchung</strong> verarbeiten wir Name,
|
||||
E-Mail-Adresse, optional die Telefonnummer sowie Art, Datum und Personenzahl
|
||||
deiner Veranstaltung, um dir ein Angebot machen zu können.
|
||||
</p>
|
||||
<p>
|
||||
Das <strong>Anfrageformular der Turnabteilung</strong> auf
|
||||
<a href="<?= e(url('turnen')) ?>">/turnen</a> erhebt dieselben Angaben wie das
|
||||
Kontaktformular, statt eines Betreffs jedoch die Turngruppe, für die du dich
|
||||
interessierst; eine Nachricht ist dort freiwillig. Zusätzlich kannst du
|
||||
<strong>freiwillig das Geburtsdatum des Kindes</strong> angeben, um das es bei der
|
||||
Anfrage geht. Wir brauchen es nur, um die passende Turngruppe zuzuordnen, weil die
|
||||
Gruppen nach Alter eingeteilt sind. Das Feld ist keine Pflichtangabe: ohne Datum
|
||||
bearbeiten wir die Anfrage genauso und fragen gegebenenfalls nach dem Alter nach.
|
||||
Diese Anfragen gehen an das
|
||||
Postfach der Turnabteilung
|
||||
(<a href="mailto:<?= e($club['email_turnen']) ?>"><?= e($club['email_turnen']) ?></a>),
|
||||
weil die Abteilung sie selbst beantwortet.
|
||||
</p>
|
||||
<p>
|
||||
Rechtsgrundlage ist deine Einwilligung (Art. 6 Abs. 1 lit. a DSGVO), die du
|
||||
mit dem Absenden erteilst, sowie unser berechtigtes Interesse bzw. die
|
||||
@@ -146,16 +187,60 @@ $meta = [
|
||||
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.
|
||||
Schickst du uns Material für unsere <strong>Vereinshistorie</strong> – Fotos,
|
||||
Zeitungsausschnitte, Dokumente oder Erinnerungen – an
|
||||
<a href="mailto:<?= e($club['email_historie']) ?>"><?= e($club['email_historie']) ?></a>,
|
||||
bewahren wir es gemeinsam mit deinem Namen als Quellenangabe in unserem
|
||||
Vereinsarchiv auf. Grundlage dafür ist unser berechtigtes Interesse an der
|
||||
Dokumentation der Vereinsgeschichte (Art. 6 Abs. 1 lit. f DSGVO).
|
||||
Veröffentlichen – auf dieser Website, in einer Chronik oder Festschrift –
|
||||
tun wir nur, was du uns ausdrücklich freigegeben hast (Art. 6 Abs. 1 lit. a
|
||||
DSGVO); diese Freigabe kannst du jederzeit widerrufen. Sind auf Bildern
|
||||
weitere Personen erkennbar, sag uns bitte, was du darüber weißt.
|
||||
</p>
|
||||
<p>
|
||||
Der technische Versand der Formular-Nachrichten als E-Mail läuft über den
|
||||
Mailserver unseres Webhosters ALL-INKL.COM (siehe oben) — die Daten bleiben
|
||||
damit innerhalb der dort genannten Auftragsverarbeitung. Die Übertragung zum
|
||||
Mailserver ist verschlüsselt (STARTTLS). Einen zusätzlichen
|
||||
E-Mail-Dienstleister setzen wir nicht ein.
|
||||
</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. B. reCAPTCHA) eingebunden.
|
||||
Pflichtfeld und einen signierten Zeitstempel. Außerdem begrenzen wir, wie oft
|
||||
von einem Anschluss abgesendet werden kann. Dafür speichern wir deine
|
||||
IP-Adresse nicht im Klartext, sondern nur als Hashwert, der mit einem
|
||||
geheimen Schlüssel gebildet wird; die Zähler werden spätestens nach 24 Stunden
|
||||
gelöscht. Externe Dienste (z. B. reCAPTCHA) binden wir nicht ein.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Interne Dateiablage</h2>
|
||||
<p>
|
||||
Unter <code>/dateien</code> betreiben wir einen
|
||||
<strong>passwortgeschützten Bereich</strong>, in dem Mitglieder Fotos und
|
||||
Videos von Vereinsveranstaltungen für die Website abliefern können. Er ist
|
||||
nicht öffentlich, nicht verlinkt und von Suchmaschinen ausgenommen; die
|
||||
Zugangsdaten geben wir nur intern weiter.
|
||||
</p>
|
||||
<p>
|
||||
Verarbeitet werden dort ausschließlich die hochgeladenen Dateien selbst
|
||||
(einschließlich der darin enthaltenen Aufnahmen von Personen und der
|
||||
technischen Bilddaten wie Aufnahmezeit oder Kameramodell) sowie der
|
||||
gewählte Benutzername mit dem Zeitpunkt der Anmeldung. Fehlgeschlagene
|
||||
Anmeldeversuche halten wir zum Schutz gegen automatisierte Angriffe kurz
|
||||
fest, dabei die IP-Adresse nur als nicht zurückrechenbare Prüfsumme.
|
||||
</p>
|
||||
<p>
|
||||
Die Dateien liegen ausschließlich auf unserem eigenen Webspace, außerhalb
|
||||
des öffentlich erreichbaren Bereichs, und werden nicht an Dritte
|
||||
weitergegeben. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO
|
||||
(berechtigtes Interesse an der Vereinskommunikation); für die
|
||||
Veröffentlichung von Personenaufnahmen auf der Website gilt zusätzlich der
|
||||
Abschnitt „Foto- und Videoaufnahmen“. Wer nicht möchte, dass eine Aufnahme
|
||||
von ihm dort abgelegt oder veröffentlicht wird, kann sich jederzeit an die
|
||||
oben genannten Kontaktdaten wenden – wir löschen sie dann.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ $cards = [
|
||||
|
||||
$meta = [
|
||||
'title' => 'Fußball in Kulmbach',
|
||||
'description' => 'Herren-, Frauen- und Jugendfußball beim TSV 08 Kulmbach: zwei Herrenmannschaften, Frauenmannschaft und Nachwuchs – aktiv werden im Katzbachtal.',
|
||||
'description' => 'Fußball in Kulmbach beim TSV 08: zwei Herrenmannschaften, Frauenfußball und Jugendteams von der G- bis zur C-Jugend. Komm zum Probetraining im Katzbachtal!',
|
||||
'og_image' => 'img/pages/fussball-hero-1600.jpg',
|
||||
'schema' => page_schema(
|
||||
[
|
||||
|
||||
@@ -3,14 +3,28 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Vereinshistorie — schmaler Hero + interaktive Timeline.
|
||||
* Inhalte: data/historie.json (Single Source of Truth).
|
||||
* Vereinshistorie — schmaler Hero + interaktive Timeline + Aufruf zum Mitsammeln.
|
||||
* Inhalte: data/historie.json (Single Source of Truth), Mailadresse aus data/club.json.
|
||||
*/
|
||||
$historie = json_load('historie');
|
||||
$club = json_load('club');
|
||||
|
||||
// Die Adresse steht ausschließlich in club.json — der Fließtext in historie.json
|
||||
// markiert ihre Stelle mit {mail}, CTA-Label und mailto-Ziel entstehen hier.
|
||||
// Betreff vorbelegt, damit Zuschriften direkt zuordenbar sind.
|
||||
$mail = $club['email_historie'];
|
||||
$contribute = $historie['contribute'] + ['cta' => [
|
||||
'label' => $mail,
|
||||
'href' => 'mailto:' . $mail . '?subject=' . rawurlencode('Material für die Vereinshistorie'),
|
||||
]];
|
||||
$contribute['text'] = array_map(
|
||||
static fn (string $p): string => strtr($p, ['{mail}' => $mail]),
|
||||
$contribute['text']
|
||||
);
|
||||
|
||||
$meta = [
|
||||
'title' => 'Vereinshistorie – Der Katzbachtaler Weg',
|
||||
'description' => 'Erlebe die bewegte Geschichte des TSV 08 Kulmbach – von den ersten Spielen an der Bayreuther Straße 1947 bis zur Hans-Rausch-Sportanlage im Katzbachtal.',
|
||||
'title' => 'Vereinshistorie: Der Katzbachtaler Weg',
|
||||
'description' => 'Erlebe unsere bewegte Geschichte, von den ersten Spielen an der Bayreuther Straße 1947 bis zur Hans-Rausch-Sportanlage im Katzbachtal.',
|
||||
'scripts' => ['timeline.js'],
|
||||
'schema' => page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
@@ -21,3 +35,4 @@ $meta = [
|
||||
|
||||
component('hero', ['hero' => $historie['hero']]);
|
||||
component('timeline', ['events' => $historie['events']]);
|
||||
component('section', ['section' => $contribute]);
|
||||
|
||||
@@ -10,14 +10,17 @@ declare(strict_types=1);
|
||||
$home = json_load('home');
|
||||
|
||||
$meta = [
|
||||
'title' => 'Fußball & Turnen in Kulmbach – TSV 08 Kulmbach',
|
||||
'title' => 'Fußball & Turnen in Kulmbach | TSV 08 Kulmbach',
|
||||
'title_absolute' => true,
|
||||
'description' => 'Dein Sportverein für Fußball und Turnen in Kulmbach: Jugend-, Herren- und Frauenfußball, Turnen und Trampolin – seit 1908 im Katzbachtal. Jetzt Mitglied werden!',
|
||||
'scripts' => ['form.js', 'carousel.js'],
|
||||
'description' => 'Dein Sportverein für Fußball und Turnen in Kulmbach: Jugend-, Herren- und Frauenfußball, Turnen und Trampolin: seit 1908 in Kulmbach. Jetzt Mitglied werden!',
|
||||
'scripts' => ['form.js', 'carousel.js', 'crest.js'],
|
||||
'schema' => [website_schema()],
|
||||
];
|
||||
|
||||
component('hero', ['hero' => $home['hero']]);
|
||||
// Die nächsten drei Termine als Live-Aufhänger im Hero — Spiele und
|
||||
// Vereins-Events gemischt, aus upcoming_highlights() (baut auf derselben
|
||||
// Merge-Logik auf wie /termine). Keine Termine → der Hero rendert ohne Karte.
|
||||
component('hero', ['hero' => $home['hero'], 'upcoming' => upcoming_highlights(3)]);
|
||||
component('banner-slider');
|
||||
component('instagram-feed');
|
||||
|
||||
@@ -25,12 +28,10 @@ foreach ($home['sections'] as $section) {
|
||||
component('section', ['section' => $section]);
|
||||
}
|
||||
|
||||
component('stats', ['stats' => $home['stats']]);
|
||||
component('partner-grid', ['intro' => $home['partners']]);
|
||||
// Auf der Startseite bewusst nur die Haupt-Ansprechpartner je Bereich; die
|
||||
// vollständige Turn-Disziplin-Liste lebt auf /ansprechpartner.
|
||||
component('person-grid', [
|
||||
'departments' => ['Herrenfußball', 'Frauenfußball', 'Jugendfußball', 'Erwachsenenturnen'],
|
||||
'columns' => 3,
|
||||
]);
|
||||
component('contact-form', ['intro' => $home['contact']]);
|
||||
|
||||
@@ -28,7 +28,7 @@ $meta = [
|
||||
<p class="legal__lead">Angaben gemäß § 5 Digitale-Dienste-Gesetz (DDG).</p>
|
||||
</div>
|
||||
|
||||
<div class="legal__body">
|
||||
<div class="legal__body prose">
|
||||
<section>
|
||||
<h2>Anbieter</h2>
|
||||
<div class="legal__card">
|
||||
@@ -38,7 +38,10 @@ $meta = [
|
||||
<?= 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>
|
||||
<?php /* club_url() statt base_url: das Impressum nennt die Adresse des VEREINS,
|
||||
nicht die des Servers, auf dem der Code gerade läuft. Lokal stand hier
|
||||
sonst „localhost:8000" als Vereins-Webadresse. */ ?>
|
||||
Web: <a href="<?= e(club_url()) ?>"><?= e(club_domain()) ?></a>
|
||||
</address>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -21,7 +21,7 @@ $contacts = array_values(array_filter(
|
||||
|
||||
$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.',
|
||||
'description' => 'Jugendfußball in Kulmbach beim TSV 08: Teams von der G- bis zur C-Jugend, Trainingszeiten und kostenloses Schnuppertraining. Komm vorbei ins Katzbachtal!',
|
||||
'og_image' => 'img/pages/jugendfussball-hero-1440.jpg',
|
||||
'scripts' => count($contacts) > 1 ? ['contact-slider.js'] : [],
|
||||
'schema' => page_schema(
|
||||
@@ -40,7 +40,7 @@ component('hero', ['hero' => $page['hero']]);
|
||||
<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']); ?>
|
||||
<?php component('img', ['image' => $page['team_photo'] ?? $page['hero']['image'], 'sizes' => '(max-width: 900px) 100vw, 40vw', 'class' => 'intro-bento__img']); ?>
|
||||
</div>
|
||||
|
||||
<article class="bento__tile intro-bento__about">
|
||||
@@ -70,7 +70,7 @@ component('age-groups', ['groups' => $page['groups'] ?? []]);
|
||||
|
||||
component('faq-contact', [
|
||||
'faq' => $page['faq'] ?? [],
|
||||
'faqIntro' => 'Ruf uns direkt an oder schreib uns unkompliziert per WhatsApp – wir beantworten alle Fragen rund um Schnuppertraining und die passende Altersgruppe.',
|
||||
'faqIntro' => 'Ruf uns direkt an oder schreib uns unkompliziert per WhatsApp. Wir beantworten alle Fragen rund um Schnuppertraining und die passende Altersgruppe.',
|
||||
'persons' => $contacts,
|
||||
'contactTitle' => '',
|
||||
'anchor' => 'kontakt',
|
||||
|
||||
@@ -10,7 +10,7 @@ $page = json_load('kontakt');
|
||||
|
||||
$meta = [
|
||||
'title' => 'Kontakt',
|
||||
'description' => 'Kontakt zum TSV 08 Kulmbach: Schreib uns über das Formular – zu Fußball, Turnen oder einem allgemeinen Anliegen. Wir melden uns so schnell wie möglich.',
|
||||
'description' => 'Kontakt zum TSV 08 Kulmbach: Schreib uns über das Formular zu Fußball, Turnen oder deinem Anliegen. Wir melden uns so schnell wie möglich.',
|
||||
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
|
||||
'scripts' => ['form.js'],
|
||||
'schema' => page_schema([
|
||||
|
||||
@@ -14,9 +14,9 @@ $teams = $mc['teams'] ?? [];
|
||||
$upcoming = $mc['upcoming'] ?? [];
|
||||
|
||||
$meta = [
|
||||
'title' => 'Matchcenter – Spiele, Ergebnisse & Tabellen',
|
||||
'title' => 'Matchcenter · Spiele, Ergebnisse & Tabellen',
|
||||
'description' => 'Spielplan, Ergebnisse und Tabellen der Aktivenmannschaften des TSV 08 Kulmbach: 1. Mannschaft und Frauenmannschaft – automatisch aktuell.',
|
||||
'og_image' => 'img/pages/fussball-hero-1600.jpg',
|
||||
'og_image' => 'img/pages/matchcenter-hero-1600.jpg',
|
||||
'scripts' => ['crest.js', 'group-tabs.js'],
|
||||
'schema' => array_merge(
|
||||
page_schema([
|
||||
@@ -30,11 +30,16 @@ $meta = [
|
||||
|
||||
component('hero', ['hero' => [
|
||||
'compact' => true,
|
||||
'align' => 'bottom-left',
|
||||
'align' => 'center',
|
||||
'title' => 'Matchcenter',
|
||||
'text' => 'Alle Spiele, Ergebnisse und Tabellen unserer Aktivenmannschaften – immer aktuell.',
|
||||
/* Eigenes Hero-Motiv (Spielszene) statt des leeren Platzes von /fussball — die Seite
|
||||
zeigt Spiele, also zeigt der Kopf welche. Quelle: 14_mrz.jpg aus der alten Seite,
|
||||
vor dem Skalieren oben um 230 px beschnitten (1620x1080 → 1620x850). Der Beschnitt
|
||||
rückt die Spielszene in die Bildmitte, sonst schneidet der flache Compact-Hero sie
|
||||
auf Mobil komplett weg. */
|
||||
'image' => [
|
||||
'base' => 'img/pages/fussball-hero',
|
||||
'base' => 'img/pages/matchcenter-hero',
|
||||
'widths' => [640, 1000, 1600],
|
||||
'alt' => '',
|
||||
],
|
||||
|
||||
@@ -19,7 +19,7 @@ $feeOptions = array_map(
|
||||
|
||||
$meta = [
|
||||
'title' => 'Mitglied werden',
|
||||
'description' => 'Mitglied werden beim TSV 08 Kulmbach: Fußball online anmelden oder Turnen per Aufnahmeantrag – alle Beiträge und Infos für deinen Vereinsbeitritt.',
|
||||
'description' => 'Mitglied werden beim TSV 08 Kulmbach: Fußball online anmelden oder Turnen per Aufnahmeantrag. Alle Beiträge und Infos für deinen Vereinsbeitritt.',
|
||||
'og_image' => 'img/hero/hero-1600.jpg',
|
||||
'scripts' => ['form.js'],
|
||||
'schema' => page_schema(
|
||||
@@ -33,7 +33,9 @@ $meta = [
|
||||
|
||||
component('hero', ['hero' => $page['hero']]);
|
||||
|
||||
component('department-choice', ['choice' => $page['choice'], 'anchor' => 'abteilung']);
|
||||
// Titel nur für Screenreader: der Hero sagt bereits „Wähle deine Abteilung", und die
|
||||
// beiden Karten sprechen optisch für sich. Die h2 bleibt für die Heading-Kette stehen.
|
||||
component('department-choice', ['choice' => $page['choice'], 'anchor' => 'abteilung', 'title_hidden' => true]);
|
||||
?>
|
||||
<section class="section membership-signup" id="anmeldung-fussball">
|
||||
<div class="container membership-signup__inner">
|
||||
|
||||
@@ -26,8 +26,8 @@ $contact_for = static function (array $pos) use ($directory): ?array {
|
||||
|
||||
$meta = [
|
||||
'title' => 'Mitmachen & Ehrenamt',
|
||||
'description' => 'Ehrenamt beim TSV 08 Kulmbach: offene Aufgaben als Jugendtrainer, Betreuer oder Helfer bei Veranstaltungen – mit direktem Ansprechpartner, ohne Vorerfahrung.',
|
||||
'og_image' => 'img/pages/jugendfussball-hero-1440.jpg',
|
||||
'description' => 'Ehrenamt beim TSV 08 Kulmbach: offene Aufgaben als Jugendtrainer, Betreuer oder Helfer bei Veranstaltungen. Direkte Ansprechpartner, keine Vorerfahrung nötig!',
|
||||
'og_image' => 'img/pages/mitmachen-hero-1600.jpg',
|
||||
'scripts' => ['position-filter.js'],
|
||||
'schema' => array_merge(
|
||||
page_schema(
|
||||
@@ -52,7 +52,7 @@ component('hero', ['hero' => $page['hero']]);
|
||||
<?php if (!empty($page['filters'])): ?>
|
||||
<div class="filter-bar" role="group" aria-label="Stellen nach Bereich filtern" data-position-filter>
|
||||
<?php foreach ($page['filters'] as $i => $f): ?>
|
||||
<button type="button" class="filter-bar__tab" data-filter="<?= e($f['value']) ?>" aria-pressed="<?= $i === 0 ? 'true' : 'false' ?>"><?= e($f['label']) ?></button>
|
||||
<button type="button" class="tab filter-bar__tab" data-filter="<?= e($f['value']) ?>" aria-pressed="<?= $i === 0 ? 'true' : 'false' ?>"><?= e($f['label']) ?></button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
@@ -61,7 +61,7 @@ component('hero', ['hero' => $page['hero']]);
|
||||
<?php component('position-card', ['position' => $pos, 'contact' => $contact_for($pos)]); ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<p class="positions__empty" data-position-empty hidden>In diesem Bereich sind aktuell keine Stellen ausgeschrieben. Schau bald wieder vorbei – oder sprich uns einfach <a href="<?= e(url('') . '#kontakt') ?>">direkt an</a>, wir finden gemeinsam eine passende Aufgabe.</p>
|
||||
<p class="positions__empty" data-position-empty hidden>In diesem Bereich sind aktuell keine Stellen ausgeschrieben. Schau bald wieder vorbei oder sprich uns einfach <a href="<?= e(url('') . '#kontakt') ?>">direkt an</a>, wir finden gemeinsam eine passende Aufgabe.</p>
|
||||
</div>
|
||||
</section>
|
||||
<?php if (!empty($page['arguments']['items'])): ?>
|
||||
|
||||
82
app/pages/news-artikel.php
Normal file
82
app/pages/news-artikel.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Einzelne Meldung als Artikel: dynamische Route news/<slug>, siehe
|
||||
* public/index.php (Prefix-Match, da app/routes.php nur exakte Slugs kennt).
|
||||
* Ermittelt den Slug selbst wieder aus der URL, Daten aus data/news.json
|
||||
* (chat-gepflegt, keine Automatisierung).
|
||||
*
|
||||
* Die Seite existiert nur für Meldungen MIT `artikel` (news_has_article()):
|
||||
* eine Kurzmeldung bleibt eine Feed-Zeile auf /news, ihre URL liefert 404. Ohne
|
||||
* diese Bedingung stünde unter jedem Slug eine Seite mit nichts als der
|
||||
* Überschrift.
|
||||
*
|
||||
* Geladen wird bewusst UNGEFILTERT (news_find(), nicht news_load()): ein
|
||||
* Entwurf muss unter seiner direkten URL lesbar bleiben, damit er gegengelesen
|
||||
* werden kann. Sichtbar wird er dadurch nirgends sonst, und er trägt noindex.
|
||||
*
|
||||
* Bewusst OHNE hero (wie die Veranstaltungs-Detailseite): das Titelbild ist der
|
||||
* Aufmacher im Artikelkörper, nicht ein cover-beschnittener Hintergrund.
|
||||
*/
|
||||
$urlSlug = trim((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH), '/');
|
||||
$newsSlug = substr($urlSlug, strlen('news/'));
|
||||
|
||||
$n = $newsSlug !== '' ? news_find($newsSlug) : null;
|
||||
|
||||
if ($n === null || !news_has_article($n)) {
|
||||
// http_response_code(404) ist globaler Engine-State (kein Scope-Problem wie
|
||||
// $meta/$current) — public/index.php liest ihn nach render_page() wieder aus,
|
||||
// um $current auf '404' zu setzen (noindex + kein Self-Canonical in meta.php).
|
||||
http_response_code(404);
|
||||
require APP_PATH . '/pages/404.php';
|
||||
return;
|
||||
}
|
||||
|
||||
$isDraft = !news_is_published($n);
|
||||
$pageSlug = 'news/' . $n['slug'];
|
||||
$hatBilder = array_filter(
|
||||
$n['artikel'],
|
||||
static fn (array $b): bool => ($b['typ'] ?? '') === 'bild'
|
||||
) !== [];
|
||||
|
||||
$meta = [
|
||||
'title' => $n['title'],
|
||||
'description' => (string) ($n['text'] ?? $n['title']),
|
||||
'schema' => array_merge(
|
||||
page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Der Verein', 'slug' => 'verein'],
|
||||
['name' => 'News', 'slug' => 'news'],
|
||||
['name' => $n['title'], 'slug' => $pageSlug],
|
||||
]),
|
||||
// Mit $pageSlug: derselbe Article-Knoten (@id) wie auf /news.
|
||||
array_values(array_filter([news_article_schema($n, $pageSlug)]))
|
||||
),
|
||||
];
|
||||
|
||||
// Die Lightbox kommt nur dazu, wenn es auch etwas zu vergrößern gibt.
|
||||
if ($hatBilder) {
|
||||
$meta['scripts'] = ['lightbox.js'];
|
||||
}
|
||||
|
||||
if (!empty($n['image']['base']) && !empty($n['image']['widths'])) {
|
||||
$meta['og_image'] = $n['image']['base'] . '-' . max($n['image']['widths']) . '.jpg';
|
||||
}
|
||||
|
||||
if ($isDraft) {
|
||||
$meta['noindex'] = true;
|
||||
}
|
||||
?>
|
||||
<?php component('detail-head', [
|
||||
'back' => ['href' => url('news'), 'label' => 'Alle News'],
|
||||
'title' => $n['title'],
|
||||
'badge' => $isDraft ? 'Entwurf' : '',
|
||||
'date_iso' => (string) ($n['date'] ?? ''),
|
||||
'date_text' => !empty($n['date']) ? german_date((string) $n['date']) : '',
|
||||
]); ?>
|
||||
<?php component('news-artikel', [
|
||||
'image' => $n['image'] ?? null,
|
||||
'blocks' => $n['artikel'],
|
||||
]); ?>
|
||||
56
app/pages/news.php
Normal file
56
app/pages/news.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Zentrale News-Seite: mischt redaktionelle Meldungen (data/news.json,
|
||||
* chat-gepflegt), Instagram-Posts (data/instagram.json, automatisch gecacht)
|
||||
* und zuletzt gespielte Ergebnisse (data/matchcenter.json, teamübergreifend)
|
||||
* zu einem gemeinsamen, absteigend sortierten Feed. Merge-Logik in
|
||||
* news_build_feed() (app/helpers.php) — dieselbe Grundlage nutzt die
|
||||
* Stadionzeitungs-Vorlage (Phase 3), damit beide nie auseinanderlaufen.
|
||||
*
|
||||
* Meldungen mit Artikeltext (`artikel` in data/news.json) verlinken auf ihre
|
||||
* Detailseite /news/<slug>. Sie stehen immer alle im Feed: die Kappung aus
|
||||
* config('news.max_items') trifft nur Instagram und Ergebnisse, sonst wäre ein
|
||||
* älterer Artikel irgendwann von der Website aus nicht mehr verlinkt.
|
||||
*/
|
||||
$feed = news_build_feed();
|
||||
|
||||
$meta = [
|
||||
'title' => 'News',
|
||||
'description' => 'Alle Neuigkeiten des TSV 08 Kulmbach: aktuelle Meldungen, Fußball-Ergebnisse und Instagram-Beiträge aus dem Vereinsleben in Kulmbach an einem Ort.',
|
||||
'scripts' => ['news-filter.js'],
|
||||
'schema' => array_merge(
|
||||
page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Der Verein', 'slug' => 'verein'],
|
||||
['name' => 'News', 'slug' => 'news'],
|
||||
]),
|
||||
// Meldungen mit Detailseite bekommen ihren Slug mit — dann trägt der
|
||||
// Article-Knoten hier dieselbe @id wie auf /news/<slug>, beide Seiten
|
||||
// beschreiben also nachweislich denselben Artikel.
|
||||
array_values(array_filter(array_map(
|
||||
static fn (array $n): ?array => news_article_schema($n, news_has_article($n) ? 'news/' . $n['slug'] : null),
|
||||
$feed['news']
|
||||
)))
|
||||
),
|
||||
];
|
||||
|
||||
component('hero', ['hero' => [
|
||||
'compact' => true,
|
||||
'align' => 'center',
|
||||
'title' => 'News',
|
||||
'text' => 'Unsere Meldungen, Ergebnisse und Instagram-Beiträge an einem Ort.',
|
||||
'image' => [
|
||||
'base' => 'img/pages/fussball-hero',
|
||||
'widths' => [640, 1000, 1600],
|
||||
'alt' => '',
|
||||
],
|
||||
]]);
|
||||
?>
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<?php component('news-feed', ['items' => $feed['items'], 'featured' => true, 'filterable' => true, 'empty_text' => 'Aktuell gibt es noch keine News.']); ?>
|
||||
</div>
|
||||
</section>
|
||||
@@ -11,7 +11,7 @@ $page = json_load('partner-werden');
|
||||
|
||||
$meta = [
|
||||
'title' => 'Werbepartner werden',
|
||||
'description' => 'Werden Sie Werbepartner des TSV 08 Kulmbach – sichtbar vor Ort, in Print und digital. Einstieg ab 199 € pro Saison. Jetzt unverbindlich anfragen.',
|
||||
'description' => 'Werden Sie Werbepartner des TSV 08 Kulmbach: sichtbar vor Ort, in Print und digital. Einstieg ab 199 € pro Saison. Jetzt unverbindlich anfragen!',
|
||||
'og_image' => 'img/pages/fussball-hero-1600.jpg',
|
||||
'schema' => page_schema(
|
||||
[
|
||||
|
||||
@@ -11,7 +11,7 @@ $club = json_load('club');
|
||||
|
||||
$meta = [
|
||||
'title' => 'Sportheim mieten in Kulmbach',
|
||||
'description' => 'Das Sportheim auf der Hans-Rausch-Sportanlage in Kulmbach für Feiern und Veranstaltungen mieten – Küche, Außenbereich, Beamer optional. Jetzt anfragen.',
|
||||
'description' => 'Sportheim in Kulmbach mieten: auf der Hans-Rausch-Sportanlage, mit Küche, Außenbereich und Beamer auf Wunsch. Jetzt unverbindlich anfragen!',
|
||||
'og_image' => 'img/pages/sportheim-2.jpg',
|
||||
'schema' => page_schema(
|
||||
[
|
||||
|
||||
140
app/pages/stadionzeitung-ausgabe.php
Normal file
140
app/pages/stadionzeitung-ausgabe.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Einzelne Stadionzeitung-Ausgabe: dynamische Route stadionzeitung/<slug>,
|
||||
* siehe public/index.php (Prefix-Match, da app/routes.php nur exakte Slugs
|
||||
* kennt). Ermittelt den Ausgaben-Slug selbst wieder aus der URL, Daten aus
|
||||
* data/stadionzeitung.json (maschinenverwaltet von bin/stadionzeitung-add.php).
|
||||
*
|
||||
* Bewusst OHNE das übliche hero-Component: der Swipe-Viewer soll so viel
|
||||
* Bildschirmhöhe wie möglich bekommen (gerade am Handy) — stattdessen ein
|
||||
* schmaler Kopfbereich direkt über dem Viewer.
|
||||
*/
|
||||
$urlSlug = trim((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH), '/');
|
||||
$ausgabeSlug = substr($urlSlug, strlen('stadionzeitung/'));
|
||||
|
||||
/*
|
||||
* Reservierter Slug „aktuell": leitet auf die neueste veröffentlichte Ausgabe
|
||||
* weiter. Damit zeigt ein GEDRUCKTER QR-Code (Plakat, Aushang) dauerhaft auf die
|
||||
* jeweils aktuelle Ausgabe, ohne dass zu jedem Heimspiel ein neuer Code
|
||||
* entstehen muss. Kollision ausgeschlossen: Ausgaben-Slugs beginnen immer mit
|
||||
* dem Datum (2026-08-01-…).
|
||||
*
|
||||
* Drei Dinge sind hier load-bearing:
|
||||
* 1. **302, nie 301.** Ein 301 wird von Browsern und Crawlern dauerhaft
|
||||
* gemerkt — das Plakat zeigte dann für immer auf die erste Ausgabe, und
|
||||
* zwar unreparierbar auf allen Geräten, die ihn einmal gesehen haben.
|
||||
* 2. `no-store`, damit ein Handy, das im letzten Monat gescannt hat, nicht
|
||||
* das alte Ziel wiederverwendet.
|
||||
* 3. Ohne veröffentlichte Ausgabe geht es aufs Archiv statt in einen 404 —
|
||||
* ein gedruckter Code darf nie ins Leere führen.
|
||||
*/
|
||||
if ($ausgabeSlug === 'aktuell') {
|
||||
$latest = stadionzeitung_latest();
|
||||
header('Cache-Control: no-store');
|
||||
header(
|
||||
'Location: ' . ($latest !== null ? url('stadionzeitung/' . $latest['slug']) : url('stadionzeitung')),
|
||||
true,
|
||||
302
|
||||
);
|
||||
// exit, nicht return: nach einem `return` liefe in public/index.php noch
|
||||
// layout.php und hängte einen kompletten HTML-Body an die Weiterleitung.
|
||||
// Der Ausgabepuffer aus render_page() ist hier noch leer, es geht also
|
||||
// nichts verloren.
|
||||
exit;
|
||||
}
|
||||
|
||||
$issue = null;
|
||||
foreach (json_load('stadionzeitung')['issues'] ?? [] as $entry) {
|
||||
if (($entry['slug'] ?? '') === $ausgabeSlug) {
|
||||
$issue = $entry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($issue === null) {
|
||||
// http_response_code(404) ist globaler Engine-State (kein Scope-Problem wie
|
||||
// $meta/$current) — public/index.php liest ihn nach render_page() wieder aus,
|
||||
// um $current auf '404' zu setzen (noindex + kein Self-Canonical in meta.php).
|
||||
http_response_code(404);
|
||||
require APP_PATH . '/pages/404.php';
|
||||
return;
|
||||
}
|
||||
|
||||
// Entwurf vs. veröffentlicht (Interview-Beschluss 29.07.2026): Entwürfe sind
|
||||
// „unsichtbar, aber offen" — sie fehlen im Archiv und in der Sitemap und
|
||||
// tragen noindex, die direkte URL funktioniert aber (zum Gegenlesen ohne
|
||||
// Login-System). Die Druckfassung (?druck=1) montiert dieselben Seiten in
|
||||
// Broschüren-Reihenfolge fürs gefaltete A5-Heft.
|
||||
$isDraft = !stadionzeitung_is_published($issue);
|
||||
$isDruck = isset($_GET['druck']);
|
||||
|
||||
// Erste Seite ist seit dem automatischen Cover (Phase 3) eine Live-Seite ohne
|
||||
// eigenes Bild — og_image gibt es nur, wenn die Ausgabe (noch/wieder) mit
|
||||
// einem Bild beginnt, sonst greift das globale Default-OG-Bild (meta.php).
|
||||
$firstImage = $issue['images'][0] ?? null;
|
||||
$cover = ($firstImage !== null && ($firstImage['type'] ?? 'image') === 'image') ? $firstImage : null;
|
||||
|
||||
$meta = [
|
||||
'title' => 'Stadionzeitung gegen ' . $issue['opponent'] . ($isDruck ? ' (Druckfassung)' : ''),
|
||||
'description' => 'Stadionzeitung zum Heimspiel gegen ' . $issue['opponent'] . ' am ' . german_date($issue['date']) . ' – digital zum Durchblättern.',
|
||||
'scripts' => [$isDruck ? 'stadionzeitung-druck.js' : 'stadionzeitung-viewer.js'],
|
||||
'schema' => page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Fußball', 'slug' => 'fussball'],
|
||||
['name' => 'Stadionzeitung', 'slug' => 'stadionzeitung'],
|
||||
['name' => 'Gegen ' . $issue['opponent'], 'slug' => 'stadionzeitung/' . $issue['slug']],
|
||||
]),
|
||||
];
|
||||
if ($cover !== null) {
|
||||
$meta['og_image'] = $cover['base'] . '-' . max($cover['widths']) . '.jpg';
|
||||
}
|
||||
// Entwürfe und die Druckfassung (parametrisierte Zweitansicht derselben
|
||||
// Inhalte) gehören nicht in den Index.
|
||||
if ($isDraft || $isDruck) {
|
||||
$meta['noindex'] = true;
|
||||
}
|
||||
?>
|
||||
<?php
|
||||
// Kopfleiste: im Viewer Titel + Datum, in der Druckfassung stattdessen die
|
||||
// Blatt-Bilanz und ein Drucken-Knopf griffbereit oben (der ausführliche
|
||||
// Hinweis + Button am Seitenende bleibt).
|
||||
$druckSeiten = count($issue['images'] ?? []);
|
||||
$druckBlaetter = (int) ceil($druckSeiten / 4);
|
||||
?>
|
||||
<?php /* Kopfleiste in drei Zonen und zwei Reihen: oben Rückweg links, Titel mittig,
|
||||
Aktion rechts — darunter eine ruhige Statuszeile. Die Badges standen
|
||||
früher neben dem Titel und machten die Mitte unruhig; Zustand („Entwurf",
|
||||
„Druckfassung") und Bilanz sind Information, keine Überschrift. */ ?>
|
||||
<header class="stadionzeitung-issue-head<?= $isDruck ? ' stadionzeitung-issue-head--druck' : '' ?>">
|
||||
<div class="container stadionzeitung-issue-head__inner">
|
||||
<a class="stadionzeitung-issue-head__back" href="<?= e($isDruck ? url('stadionzeitung/' . $issue['slug']) : url('stadionzeitung')) ?>">
|
||||
<?= icon('chevron-left') ?> <?= $isDruck ? 'Ausgabe' : 'Archiv' ?>
|
||||
</a>
|
||||
<p class="stadionzeitung-issue-head__title">Gegen <?= e($issue['opponent']) ?></p>
|
||||
<?php if ($isDruck): ?>
|
||||
<p class="stadionzeitung-issue-head__actions">
|
||||
<button class="btn btn--sm stadionzeitung-issue-head__print" type="button" data-druck-print hidden>
|
||||
<?= icon('printer') ?> Drucken
|
||||
</button>
|
||||
</p>
|
||||
<?php else: ?>
|
||||
<p class="stadionzeitung-issue-head__date">
|
||||
<time datetime="<?= e($issue['date']) ?>"><?= e(german_date($issue['date'])) ?></time>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php if ($isDraft || $isDruck): ?>
|
||||
<p class="stadionzeitung-issue-head__state">
|
||||
<?php if ($isDraft): ?>
|
||||
<span class="stadionzeitung-issue-head__badge stadionzeitung-issue-head__badge--entwurf">Entwurf</span>
|
||||
<?php endif; ?>
|
||||
<?php if ($isDruck): ?>
|
||||
<span>Druckfassung · <?= $druckBlaetter ?> Blätter · <?= $druckSeiten ?> Seiten</span>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</header>
|
||||
<?php component($isDruck ? 'stadionzeitung-druck' : 'stadionzeitung-viewer', ['issue' => $issue]); ?>
|
||||
41
app/pages/stadionzeitung.php
Normal file
41
app/pages/stadionzeitung.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Stadionzeitung-Archiv: Vorschau-Karten aller Ausgaben, verlinkt auf den
|
||||
* Swipe-Viewer je Ausgabe (stadionzeitung/<slug>, dynamische Route in
|
||||
* public/index.php). Daten: data/stadionzeitung.json (maschinenverwaltet von
|
||||
* bin/stadionzeitung-add.php — nie von Hand editieren).
|
||||
*/
|
||||
// Entwürfe bleiben unsichtbar (kein Archiv-Eintrag, siehe status-Feld) —
|
||||
// erreichbar sind sie nur über ihre direkte URL, zum Gegenlesen.
|
||||
$issues = array_values(array_filter(
|
||||
json_load('stadionzeitung')['issues'] ?? [],
|
||||
'stadionzeitung_is_published'
|
||||
));
|
||||
|
||||
$meta = [
|
||||
'title' => 'Stadionzeitung',
|
||||
'description' => 'Die Stadionzeitung zu jedem Heimspiel des TSV 08 Kulmbach digital zum Durchblättern – alle Ausgaben im Archiv.',
|
||||
'og_image' => 'img/pages/fussball-hero-1600.jpg',
|
||||
'schema' => page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Fußball', 'slug' => 'fussball'],
|
||||
['name' => 'Stadionzeitung', 'slug' => 'stadionzeitung'],
|
||||
]),
|
||||
];
|
||||
|
||||
component('hero', ['hero' => [
|
||||
'compact' => true,
|
||||
'align' => 'center',
|
||||
'title' => 'Stadionzeitung',
|
||||
'text' => 'Die Stadionzeitung zu jedem Heimspiel – digital zum Durchblättern, auch unterwegs am Handy.',
|
||||
'image' => [
|
||||
'base' => 'img/pages/fussball-hero',
|
||||
'widths' => [640, 1000, 1600],
|
||||
'alt' => '',
|
||||
],
|
||||
]]);
|
||||
|
||||
component('stadionzeitung-cards', ['issues' => $issues]);
|
||||
@@ -10,8 +10,8 @@ $slug = 'fussball/damen';
|
||||
$team = json_load('teams')['damen'];
|
||||
|
||||
$meta = [
|
||||
'title' => 'Frauenmannschaft – Frauenfußball Kulmbach',
|
||||
'description' => 'Frauen- und Mädchenfußball beim TSV 08 Kulmbach: Ob erfahren oder Wiedereinsteigerin – im Katzbachtal findest du dein Team. Komm zum Probetraining.',
|
||||
'title' => 'Frauenmannschaft · Frauenfußball Kulmbach',
|
||||
'description' => 'Frauenfußball in Kulmbach beim TSV 08: Ob erfahren oder Wiedereinsteigerin, im Katzbachtal findest du dein Team. Komm zum Probetraining!',
|
||||
'og_image' => $team['hero']['image']['base'] . '-1600.jpg',
|
||||
'schema' => team_schema($team, $slug),
|
||||
];
|
||||
|
||||
@@ -9,8 +9,8 @@ $slug = 'fussball/erste-mannschaft';
|
||||
$team = json_load('teams')['erste-mannschaft'];
|
||||
|
||||
$meta = [
|
||||
'title' => '1. Mannschaft – Kreisklasse Kulmbach',
|
||||
'description' => 'Die erste Herrenmannschaft des TSV 08 Kulmbach spielt auf Kreisklasse-Niveau – ambitioniert und mit echtem Vereinsgeist. Komm vorbei und spiel mit.',
|
||||
'title' => '1. Mannschaft · Kreisklasse Kulmbach',
|
||||
'description' => 'Die 1. Mannschaft des TSV 08 Kulmbach spielt Fußball in der Kreisklasse im Katzbachtal. Ehrgeizig, aber familiär: Komm zum Probetraining!',
|
||||
'og_image' => $team['hero']['image']['base'] . '-1600.jpg',
|
||||
'schema' => team_schema($team, $slug),
|
||||
];
|
||||
|
||||
@@ -9,8 +9,8 @@ $slug = 'fussball/zweite-mannschaft';
|
||||
$team = json_load('teams')['zweite-mannschaft'];
|
||||
|
||||
$meta = [
|
||||
'title' => '2. Mannschaft – Herrenfußball Kulmbach',
|
||||
'description' => 'Die zweite Herrenmannschaft des TSV 08 Kulmbach spielt in Spielgemeinschaft mit dem TSV Melkendorf – Fußball mit Spaß für alle, die gern kicken.',
|
||||
'title' => '2. Mannschaft · Herrenfußball Kulmbach',
|
||||
'description' => 'Die zweite Herrenmannschaft des TSV 08 Kulmbach: Spielgemeinschaft mit dem TSV Melkendorf. Fußball mit Spaß für alle, die gern kicken. Komm vorbei!',
|
||||
'og_image' => $team['hero']['image']['base'] . '-1600.jpg',
|
||||
'scripts' => (count($team['contact_names'] ?? []) > 1) ? ['contact-slider.js'] : [],
|
||||
'schema' => team_schema($team, $slug),
|
||||
|
||||
62
app/pages/termine.php
Normal file
62
app/pages/termine.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Zentrale Termine/Events-Seite: kombiniert Fußball-Spieltermine (aus
|
||||
* data/matchcenter.json — next_matches/last_results, NICHT dupliziert) mit
|
||||
* manuell gepflegten Vereins-Events (data/events.json, chat-gepflegt wie
|
||||
* teams.json/partners.json — keine Automatisierung, kein Bin-Skript) und den
|
||||
* Veranstaltungen mit eigener Detailseite (data/veranstaltungen.json, dorthin
|
||||
* verlinkt). Getrennt
|
||||
* in kommend/vergangen, je eine gemeinsame chronologische Liste — kein Filter
|
||||
* nach Kategorie (v1, siehe Interview). Merge-Logik in termine_build_lists()
|
||||
* (app/helpers.php) — dieselbe Grundlage nutzt die Stadionzeitungs-Vorlage
|
||||
* (Phase 3), damit beide nie auseinanderlaufen.
|
||||
*/
|
||||
$termine = termine_build_lists();
|
||||
|
||||
$meta = [
|
||||
'title' => 'Termine',
|
||||
'description' => 'Alle kommenden Termine des TSV 08 Kulmbach: Fußball-Spiele unserer Mannschaften und Vereins-Events in Kulmbach auf einen Blick. Schau vorbei im Katzbachtal!',
|
||||
'schema' => array_merge(
|
||||
page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Der Verein', 'slug' => 'verein'],
|
||||
['name' => 'Termine', 'slug' => 'termine'],
|
||||
]),
|
||||
sportsevent_nodes($termine['upcoming_matches']),
|
||||
array_values(array_filter(array_map('event_schema', $termine['events']))),
|
||||
// Veranstaltungen tragen zusätzlich ihre eigene URL, damit dieser Knoten
|
||||
// und der auf der Detailseite dasselbe Event beschreiben.
|
||||
array_values(array_filter(array_map(
|
||||
static fn (array $v): ?array => event_schema($v, 'veranstaltungen/' . (string) ($v['slug'] ?? '')),
|
||||
$termine['veranstaltungen']
|
||||
)))
|
||||
),
|
||||
];
|
||||
|
||||
component('hero', ['hero' => [
|
||||
'compact' => true,
|
||||
'align' => 'center',
|
||||
'title' => 'Termine',
|
||||
'text' => 'Alle kommenden Spiele und Vereins-Events auf einen Blick.',
|
||||
'image' => [
|
||||
'base' => 'img/pages/fussball-hero',
|
||||
'widths' => [640, 1000, 1600],
|
||||
'alt' => '',
|
||||
],
|
||||
]]);
|
||||
?>
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<h2>Kommende Termine</h2>
|
||||
<?php component('termine-list', ['items' => $termine['upcoming'], 'empty_text' => 'Aktuell sind keine Termine geplant.']); ?>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section section--sunken">
|
||||
<div class="container">
|
||||
<h2>Vergangene Termine</h2>
|
||||
<?php component('termine-list', ['items' => $termine['past'], 'empty_text' => 'Noch keine vergangenen Termine erfasst.']); ?>
|
||||
</div>
|
||||
</section>
|
||||
@@ -14,7 +14,7 @@ $d = $data['disciplines']['eltern-kind-turnen'];
|
||||
$meta = [
|
||||
'title' => 'Eltern-Kind- & Kinderturnen in Kulmbach',
|
||||
'description' => $d['hero']['text'],
|
||||
'og_image' => 'img/hero/hero-1600.jpg',
|
||||
'og_image' => 'img/pages/turnen-eltern-kind-1536.jpg',
|
||||
'scripts' => ['group-tabs.js'],
|
||||
'schema' => page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
@@ -28,6 +28,9 @@ component('group-schedule', [
|
||||
'location' => $d['location'] ?? [],
|
||||
'lead' => $d['lead'] ?? '',
|
||||
'notice' => $d['notice'] ?? '',
|
||||
'noticeTitle' => $d['notice_title'] ?? '',
|
||||
'noticeTone' => $d['notice_tone'] ?? 'info',
|
||||
'noticeIcon' => $d['notice_icon'] ?? 'info-circle',
|
||||
'groups' => $d['groups'] ?? [],
|
||||
'footnote' => $data['footnote'] ?? '',
|
||||
]);
|
||||
@@ -11,8 +11,8 @@ $d = json_load('turnen')['disciplines']['erwachsenenturnen'];
|
||||
|
||||
$meta = [
|
||||
'title' => 'Erwachsenenturnen in Kulmbach',
|
||||
'description' => 'Beweglich bleiben, Kraft und Koordination trainieren – Erwachsenenturnen beim TSV 08 Kulmbach im Mäximum Gym. Trainingszeiten und Kontakt auf einen Blick.',
|
||||
'og_image' => 'img/hero/hero-1600.jpg',
|
||||
'description' => 'Beweglich bleiben, Kraft und Koordination trainieren: Erwachsenenturnen beim TSV 08 Kulmbach im Mäximum Gym. Trainingszeiten und Kontakt auf einen Blick.',
|
||||
'og_image' => 'img/pages/turnen-erwachsenenturnen-1536.jpg',
|
||||
'schema' => page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Turnen', 'slug' => 'turnen'],
|
||||
|
||||
38
app/pages/turnen-fitness-pilates.php
Normal file
38
app/pages/turnen-fitness-pilates.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Turnen → Fitness & Pilates. Inhalt: data/turnen.json
|
||||
* (disciplines.fitness-pilates). Struktur: Hero → Kurse/Zeiten → CTA.
|
||||
*
|
||||
* Hieß bis 31.07.2026 „Step Aerobic, Fitness & Pilates" unter dem Slug
|
||||
* turnen/step-aerobic-fitness-pilates. Step Aerobic findet nicht mehr statt
|
||||
* (Rückmeldung der Turnabteilung), die Gruppe ist aus data/turnen.json entfernt.
|
||||
* Seit 19.08.2026 laufen hier Pilates, Functional Fitness I (Mittwoch) und der neue
|
||||
* Functional Fitness II (Donnerstag). Der alte Slug leitet per 301 hierher
|
||||
* (public/.htaccess).
|
||||
*/
|
||||
$slug = 'turnen/fitness-pilates';
|
||||
$d = json_load('turnen')['disciplines']['fitness-pilates'];
|
||||
|
||||
$meta = [
|
||||
'title' => 'Fitness & Pilates in Kulmbach',
|
||||
'description' => 'Ausdauer, Kräftigung und Beweglichkeit für Erwachsene: Fitness und Pilates beim TSV 08 Kulmbach in der Max-Hundt-Schule. Zeiten und Kontakt.',
|
||||
'og_image' => 'img/pages/turnen-fitness-pilates-1536.jpg',
|
||||
'schema' => page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Turnen', 'slug' => 'turnen'],
|
||||
['name' => 'Fitness & Pilates', 'slug' => $slug],
|
||||
]),
|
||||
];
|
||||
|
||||
component('hero', ['hero' => $d['hero']]);
|
||||
component('group-schedule', [
|
||||
'groupsTitle' => 'Unsere Kurse',
|
||||
'location' => $d['location'] ?? [],
|
||||
'lead' => $d['lead'] ?? '',
|
||||
'groups' => $d['groups'] ?? [],
|
||||
// Wenige, beschreibende Kurse → offen untereinander statt Tabs (siehe group-schedule.php).
|
||||
'layout' => 'stacked',
|
||||
]);
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Turnen → Step Aerobic, Fitness & Pilates. Inhalt: data/turnen.json
|
||||
* (disciplines.step-aerobic-fitness-pilates). Struktur: Hero → Kurse/Zeiten → CTA.
|
||||
*/
|
||||
$slug = 'turnen/step-aerobic-fitness-pilates';
|
||||
$d = json_load('turnen')['disciplines']['step-aerobic-fitness-pilates'];
|
||||
|
||||
$meta = [
|
||||
'title' => 'Step Aerobic, Fitness & Pilates in Kulmbach',
|
||||
'description' => 'Ausdauer, Kräftigung und Beweglichkeit für Erwachsene – Step Aerobic, Fitness & Pilates beim TSV 08 Kulmbach in der Max-Hundt-Schule. Zeiten und Kontakt.',
|
||||
'og_image' => 'img/hero/hero-1600.jpg',
|
||||
'schema' => page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Turnen', 'slug' => 'turnen'],
|
||||
['name' => 'Step Aerobic, Fitness & Pilates', 'slug' => $slug],
|
||||
]),
|
||||
];
|
||||
|
||||
component('hero', ['hero' => $d['hero']]);
|
||||
component('group-schedule', [
|
||||
'groupsTitle' => 'Unsere Kurse',
|
||||
'location' => $d['location'] ?? [],
|
||||
'lead' => $d['lead'] ?? '',
|
||||
'groups' => $d['groups'] ?? [],
|
||||
]);
|
||||
@@ -11,8 +11,8 @@ $d = json_load('turnen')['disciplines']['trampolin'];
|
||||
|
||||
$meta = [
|
||||
'title' => 'Trampolinturnen in Kulmbach',
|
||||
'description' => 'Sprünge, Schrauben und Salti auf dem Großtrampolin – Trampolinturnen beim TSV 08 Kulmbach in der Carl-von-Linde-Realschule. Zeiten und Kontakt im Überblick.',
|
||||
'og_image' => 'img/hero/hero-1600.jpg',
|
||||
'description' => 'Sprünge, Schrauben und Salti auf dem Großtrampolin: Trampolinturnen beim TSV 08 Kulmbach in der Carl-von-Linde-Realschule. Zeiten und Kontakt im Überblick.',
|
||||
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
|
||||
'schema' => page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Turnen', 'slug' => 'turnen'],
|
||||
@@ -24,5 +24,13 @@ component('hero', ['hero' => $d['hero']]);
|
||||
component('group-schedule', [
|
||||
'location' => $d['location'] ?? [],
|
||||
'lead' => $d['lead'] ?? '',
|
||||
'contactEmail' => $d['contact_email'] ?? '',
|
||||
'contactPhone' => $d['contact_phone'] ?? '',
|
||||
'notice' => $d['notice'] ?? '',
|
||||
'noticeTitle' => $d['notice_title'] ?? '',
|
||||
'noticeTone' => $d['notice_tone'] ?? 'info',
|
||||
'noticeIcon' => $d['notice_icon'] ?? 'info-circle',
|
||||
'groups' => $d['groups'] ?? [],
|
||||
'groupsInDash' => true,
|
||||
'footnote' => $d['footnote'] ?? '',
|
||||
]);
|
||||
@@ -4,15 +4,21 @@ declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Turnen → Wettkampfturnen. Inhalt: data/turnen.json (disciplines.wettkampfturnen).
|
||||
* Struktur: Hero → Gruppen/Trainingszeiten → CTA.
|
||||
* Struktur: Hero → Gruppen/Trainingszeiten.
|
||||
*
|
||||
* Zwei Gruppen: die Flamingos, die am 31.07.2026 vom Kinderturnen hierher gezogen sind,
|
||||
* und das Leistungsturnen. Dadurch läuft die Seite nicht mehr im Einzelgruppen-Layout
|
||||
* (Trainingszeiten als Kachel im Dashboard), sondern zeigt die Gruppen darunter als
|
||||
* offene Kartenreihe (layout => 'stacked') — deshalb die Sternchen-Fußnote.
|
||||
*/
|
||||
$slug = 'turnen/wettkampfturnen';
|
||||
$d = json_load('turnen')['disciplines']['wettkampfturnen'];
|
||||
$data = json_load('turnen');
|
||||
$d = $data['disciplines']['wettkampfturnen'];
|
||||
|
||||
$meta = [
|
||||
'title' => 'Wettkampfturnen in Kulmbach',
|
||||
'description' => 'Leistungsorientiertes Gerätturnen mit Training und Wettkämpfen – Wettkampfturnen beim TSV 08 Kulmbach im Mäximum Gym. Trainingszeiten und Kontakt im Überblick.',
|
||||
'og_image' => 'img/hero/hero-1600.jpg',
|
||||
'description' => 'Leistungsorientiertes Gerätturnen mit Training und Wettkämpfen: Wettkampfturnen beim TSV 08 Kulmbach im Mäximum Gym. Trainingszeiten und Kontakt im Überblick.',
|
||||
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
|
||||
'schema' => page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Turnen', 'slug' => 'turnen'],
|
||||
@@ -25,4 +31,16 @@ component('group-schedule', [
|
||||
'location' => $d['location'] ?? [],
|
||||
'lead' => $d['lead'] ?? '',
|
||||
'groups' => $d['groups'] ?? [],
|
||||
// Zwei Gruppen, beide offen untereinander statt hinter Tabs: bei so wenigen
|
||||
// Gruppen verstecken Tabs mehr Inhalt als sie ordnen, und die offene Kartenreihe
|
||||
// hält den Bento-Eindruck der Seite. Damit braucht die Seite auch kein
|
||||
// group-tabs.js mehr. Das Kinderturnen bleibt bei Tabs, dort sind es drei
|
||||
// textreiche Gruppen.
|
||||
'layout' => 'stacked',
|
||||
// Überschrift „Unsere Gruppen" nur für Screenreader: die Karten erklären sich
|
||||
// selbst, und ohne die Zeile bleibt der Bento-Fluss ruhiger.
|
||||
'groupsTitleHidden' => true,
|
||||
// Drei Flamingos-Trainerinnen tragen ein Sternchen. Ohne die Fußnote stünden
|
||||
// hier drei unerklärte Sterne auf der Seite.
|
||||
'footnote' => $data['footnote'] ?? '',
|
||||
]);
|
||||
@@ -3,30 +3,73 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Turn-Übersichtsseite (reiner Hub). Inhalt: data/turnen.json.
|
||||
* Struktur: Hero → Disziplin-Karten. Keine weiteren Sektionen — die Detailinhalte
|
||||
* (Beiträge, FAQ, Trainingszeiten) leben auf den Disziplin-Unterseiten.
|
||||
* Turn-Übersichtsseite (Hub + Beiträge + Abteilungskontakt). Inhalt: data/turnen.json.
|
||||
* Struktur: Hero → Disziplin-Karten → Beiträge → FAQ → Anfrageformular.
|
||||
*
|
||||
* Hier stand vorher „Die Detailinhalte (Beiträge, Trainingszeiten) leben auf den
|
||||
* Disziplin-Unterseiten" — das war falsch, und zwar folgenreich: die Seite gab
|
||||
* fees/austritt/faq NIE aus, die Unterseiten auch nicht, und damit standen die
|
||||
* Turn-Beiträge NIRGENDS auf der Website (nachgeprüft 31.07.2026: 0 Treffer auf
|
||||
* /turnen und allen fünf Unterseiten; /mitglied-werden führt nur die Fußball-
|
||||
* Beiträge). Die Turnabteilung verlinkt diese Angaben in Einladungen für neue
|
||||
* Familien, deshalb ist der Beitragsblock hier verankert (#beitraege).
|
||||
* Die Daten selbst stimmen 1:1 mit der alten Seite überein (gegengeprüft an
|
||||
* 2024.tsv08kulmbach.de): Zusatzbeitrag 24/48 €, Neumitglieder 84/94/184 €,
|
||||
* Förderstufen 120/180/240 €, Austritt zum 30. September.
|
||||
*
|
||||
* Der Anmelde-Vorgang der Abteilung (erst Platz anfragen, dann Aufnahmeantrag) steht als
|
||||
* eigene FAQ-Frage samt den zwei Knöpfen, nicht als eigene Sektion. Das war eine bewusste
|
||||
* Entscheidung von Felix (31.07.2026): ein Band unter den Beiträgen war gebaut und wurde
|
||||
* wieder entfernt, weil dieselbe Sache dann zweimal auf der Seite stand. Deshalb gibt es
|
||||
* `overview.cta` nicht mehr — wer die Knöpfe sucht, findet sie in `overview.faq`.
|
||||
*
|
||||
* Das Formular ist die allgemeine contact-form-Komponente mit
|
||||
* department => 'turnen': Auswahlliste sind die Disziplinen, Empfänger ist die
|
||||
* Turnabteilung (club.email_turnen) statt info@. Die alte Seite hatte dafür ein
|
||||
* eigenes, zweites Formular — hier ist es dieselbe Komponente und dieselbe
|
||||
* Action, damit der Spam-Schutz nicht doppelt gepflegt werden muss.
|
||||
*/
|
||||
$page = json_load('turnen')['overview'];
|
||||
|
||||
// Heros nutzen das Vereins-Default-Bild; nur die Übersichts-Kacheln zeigen die
|
||||
// (vorläufigen) themenbezogenen Unsplash-Platzhalter aus public/assets/img/pages/.
|
||||
// Die Kündigung darf formlos per Mail kommen (Turnabteilung, 31.07.2026). Die Adresse
|
||||
// steht ausschließlich in club.json — der Austritt-Text in turnen.json markiert ihre
|
||||
// Stelle mit {mail}, dasselbe Muster wie der Material-Aufruf auf /historie. Ersetzt
|
||||
// wird NACH inline_links_html(): so bleibt der Fließtext escaped und nur der Platzhalter
|
||||
// wird zu HTML. Umgekehrt käme der fertige Link escaped als Text auf die Seite.
|
||||
$mailTurnen = json_load('club')['email_turnen'];
|
||||
$austrittText = strtr(
|
||||
inline_links_html($page['austritt']['text'] ?? ''),
|
||||
['{mail}' => '<a href="mailto:' . e($mailTurnen) . '">' . e($mailTurnen) . '</a>']
|
||||
);
|
||||
|
||||
// Eltern-Kind-, Erwachsenen- und Fitness/Pilates-Seite haben seit 19.08.2026 echte
|
||||
// (Symbolbild-)Fotos aus der Turnabteilung, in Hero UND Kachel. Trampolin- und
|
||||
// Wettkampf-Hero zeigen weiter VORÜBERGEHEND das Drohnen-Luftbild des Vereinsgeländes
|
||||
// (img/pages/vereinsgelaende, Quelle: data/turnen.json); liegt dafür ein Hallenbild vor,
|
||||
// den Hero-Block in data/turnen.json und das og_image der Seiten-Datei mittauschen.
|
||||
$cards = [
|
||||
['name' => 'Erwachsenenturnen', 'slug' => 'turnen/erwachsenenturnen', 'category' => 'Mäximum Gym', 'image' => ['base' => 'img/pages/turnen-erwachsenenturnen', 'widths' => [640, 1000, 1600], 'alt' => 'Erwachsenenturnen beim TSV 08 Kulmbach']],
|
||||
['name' => 'Eltern-Kind- & Kinderturnen', 'slug' => 'turnen/eltern-kind-turnen', 'category' => 'ab ca. 2 Jahren', 'image' => ['base' => 'img/pages/turnen-eltern-kind', 'widths' => [640, 1000, 1600], 'alt' => 'Eltern-Kind- und Kinderturnen beim TSV 08 Kulmbach']],
|
||||
['name' => 'Step Aerobic, Fitness & Pilates', 'slug' => 'turnen/step-aerobic-fitness-pilates', 'category' => 'Max-Hundt-Schule', 'image' => ['base' => 'img/pages/turnen-step-aerobic', 'widths' => [640, 1000, 1600], 'alt' => 'Step Aerobic, Fitness und Pilates beim TSV 08 Kulmbach']],
|
||||
['name' => 'Trampolinturnen', 'slug' => 'turnen/trampolin', 'category' => 'Carl-von-Linde-Realschule', 'image' => ['base' => 'img/pages/turnen-trampolin', 'widths' => [640, 1000, 1600], 'alt' => 'Trampolinturnen beim TSV 08 Kulmbach']],
|
||||
['name' => 'Erwachsenenturnen', 'slug' => 'turnen/erwachsenenturnen', 'category' => 'Mäximum Gym', 'image' => ['base' => 'img/pages/turnen-erwachsenenturnen', 'widths' => [640, 1000, 1536], 'alt' => 'Turnerinnen üben Gleichgewicht auf dem Schwebebalken (Symbolbild)']],
|
||||
['name' => 'Eltern-Kind- & Kinderturnen', 'slug' => 'turnen/eltern-kind-turnen', 'category' => 'ab ca. 2 Jahren', 'image' => ['base' => 'img/pages/turnen-eltern-kind', 'widths' => [640, 1000, 1536], 'alt' => 'Kinder turnen an Balken, Kästen und einer Koordinationsleiter in der Halle (Symbolbild)']],
|
||||
['name' => 'Fitness & Pilates', 'slug' => 'turnen/fitness-pilates', 'category' => 'Max-Hundt-Schule', 'image' => ['base' => 'img/pages/turnen-fitness-pilates', 'widths' => [640, 1000, 1536], 'alt' => 'Gruppe trainiert mit Kniebeugen auf Matten im Sportraum (Symbolbild)']],
|
||||
// Einziges echtes Foto unter den Kacheln (31.07.2026). Nur 640 breit, weil die
|
||||
// Vorlage über WhatsApp kam und dort auf 718 px eingedampft wurde;
|
||||
// resize_image_variants() skaliert nie hoch. Liegt das Original vom Handy vor:
|
||||
// php bin/img-resize.php <quelle> public/assets/img/pages/turnen-trampolin 640,1000,1600 82
|
||||
// und hier wieder alle drei Breiten eintragen.
|
||||
['name' => 'Trampolinturnen', 'slug' => 'turnen/trampolin', 'category' => 'Carl-von-Linde-Realschule', 'image' => ['base' => 'img/pages/turnen-trampolin', 'widths' => [640], 'alt' => 'Aufgebaute Trampoline und Sicherheitsmatten in der Dreifachturnhalle, in der wir trainieren']],
|
||||
['name' => 'Wettkampfturnen', 'slug' => 'turnen/wettkampfturnen', 'category' => 'Leistungssport', 'image' => ['base' => 'img/pages/turnen-wettkampf', 'widths' => [640, 1000, 1600], 'alt' => 'Wettkampfturnen beim TSV 08 Kulmbach']],
|
||||
];
|
||||
|
||||
$meta = [
|
||||
'title' => 'Turnen in Kulmbach',
|
||||
'description' => 'Die Turnabteilung des TSV 08 Kulmbach: Eltern-Kind- und Kinderturnen, Erwachsenenturnen, Step Aerobic, Fitness und Pilates, Trampolin- und Wettkampfturnen.',
|
||||
'og_image' => 'img/hero/hero-1600.jpg',
|
||||
'description' => 'Die Turnabteilung des TSV 08 Kulmbach: Eltern-Kind- und Kinderturnen, Erwachsenenturnen, Fitness und Pilates, Trampolin- und Wettkampfturnen.',
|
||||
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
|
||||
// Zweiter Parameter = FAQ: sichtbare Fragen und FAQPage-Schema kommen aus
|
||||
// derselben Quelle, damit sie nie auseinanderlaufen (CLAUDE.md).
|
||||
'schema' => page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Turnen', 'slug' => 'turnen'],
|
||||
]),
|
||||
], $page['faq'] ?? []),
|
||||
];
|
||||
|
||||
component('hero', ['hero' => $page['hero']]);
|
||||
@@ -34,5 +77,53 @@ component('team-cards', [
|
||||
'teams' => $cards,
|
||||
'title' => 'Unsere Turngruppen',
|
||||
'title_hidden' => true,
|
||||
'intro' => $page['cards_intro'] ?? null,
|
||||
'anchor' => 'gruppen',
|
||||
]);
|
||||
?>
|
||||
<?php /* Beiträge. Der Anker #beitraege ist die Adresse, die die Turnabteilung in
|
||||
Einladungen weitergibt — nicht umbenennen. Drei Tabellen über die
|
||||
bestehende fee-table-Komponente (dieselbe wie auf /mitglied-werden),
|
||||
darunter die Austrittsregel, weil sie zur Beitragsfrage gehört. */ ?>
|
||||
<section class="section section--tinted fees" id="beitraege">
|
||||
<div class="container">
|
||||
<header class="fees__head">
|
||||
<h2><?= e($page['fees_title']) ?></h2>
|
||||
<?php if (!empty($page['fees_intro'])): ?>
|
||||
<p class="fees__intro"><?= e($page['fees_intro']) ?></p>
|
||||
<?php endif; ?>
|
||||
</header>
|
||||
<div class="fee-tables">
|
||||
<?php foreach ($page['fees'] as $feeTable): ?>
|
||||
<?php component('fee-table', ['fees' => $feeTable]); ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php /* Bewusst NICHT .prose: dort rendert ein h3 mit 21px Abel, also exakt
|
||||
Fließtextgröße — in Impressum und Artikeln richtig, hier ist „Austritt"
|
||||
aber eine Auszeichnung und wäre nicht von Fließtext zu unterscheiden. */ ?>
|
||||
<?php if (!empty($page['austritt']['title'])): ?>
|
||||
<div class="fees__note">
|
||||
<h3 class="fees__note-title"><?= e($page['austritt']['title']) ?></h3>
|
||||
<p><?= $austrittText ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
component('faq', ['faq' => $page['faq'] ?? []]);
|
||||
/* Der Wartelisten-Hinweis steht DIREKT über dem Formular, so wie auf der alten Seite.
|
||||
Grund ist Arbeitsaufwand in der Abteilung, kein Design: fuer „Tiger" und „Huehner"
|
||||
kommen laufend Anfragen nach einem Wartelistenplatz, den es nicht gibt, und jede muss
|
||||
einzeln beantwortet werden. Wer hier schreibt, soll es vorher lesen. Der Text kommt aus
|
||||
derselben Stelle wie die Kachel auf der Kinderturn-Unterseite
|
||||
(disciplines.eltern-kind-turnen.notice), ein Hinweis und eine Quelle. */
|
||||
$kinderturnen = json_load('turnen')['disciplines']['eltern-kind-turnen'] ?? [];
|
||||
component('contact-form', [
|
||||
'intro' => $page['contact'],
|
||||
'return' => 'turnen',
|
||||
'department' => 'turnen',
|
||||
'notice' => $kinderturnen['notice'] ?? '',
|
||||
'noticeTitle' => $kinderturnen['notice_title'] ?? '',
|
||||
'noticeTone' => $kinderturnen['notice_tone'] ?? 'info',
|
||||
'noticeIcon' => $kinderturnen['notice_icon'] ?? 'info-circle',
|
||||
]);
|
||||
|
||||
151
app/pages/veranstaltung.php
Normal file
151
app/pages/veranstaltung.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Einzelne Veranstaltung: dynamische Route veranstaltungen/<slug>, siehe
|
||||
* public/index.php (Prefix-Match, da app/routes.php nur exakte Slugs kennt).
|
||||
* Ermittelt den Slug selbst wieder aus der URL, Daten aus
|
||||
* data/veranstaltungen.json — Texte chat-gepflegt, `plakat` und `galerie`
|
||||
* schreibt bin/veranstaltung-bilder.php.
|
||||
*
|
||||
* Geladen wird hier bewusst UNGEFILTERT (veranstaltung_find(), nicht
|
||||
* veranstaltungen_load()): ein Entwurf muss unter seiner direkten URL lesbar
|
||||
* bleiben, damit er gegengelesen werden kann. Sichtbar wird er dadurch nirgends sonst, und er
|
||||
* trägt noindex.
|
||||
*
|
||||
* Bewusst OHNE das übliche hero-Component (wie die Stadionzeitungs-Ausgabe):
|
||||
* ein Archiv-Eintrag hat kein eigenes Titelbild, und das einzige vorhandene
|
||||
* Bild ist ein Plakat im Hochformat, das als cover-beschnittener
|
||||
* Hero-Hintergrund zerstört würde. Stattdessen ein schmaler Kopf mit Rückweg.
|
||||
* Die Übersichtsseite /veranstaltungen behält den regulären Unterseiten-Hero.
|
||||
*/
|
||||
$urlSlug = trim((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH), '/');
|
||||
$veranstaltungSlug = substr($urlSlug, strlen('veranstaltungen/'));
|
||||
|
||||
$v = veranstaltung_find($veranstaltungSlug);
|
||||
|
||||
if ($v === null) {
|
||||
// http_response_code(404) ist globaler Engine-State (kein Scope-Problem wie
|
||||
// $meta/$current) — public/index.php liest ihn nach render_page() wieder aus,
|
||||
// um $current auf '404' zu setzen (noindex + kein Self-Canonical in meta.php).
|
||||
http_response_code(404);
|
||||
require APP_PATH . '/pages/404.php';
|
||||
return;
|
||||
}
|
||||
|
||||
$isDraft = !veranstaltung_is_published($v);
|
||||
// Der Lebenszyklus läuft über das Datum, nicht über ein Feld von Hand:
|
||||
// vor dem Fest wirbt die Seite, danach erzählt sie. Maßgeblich ist der LETZTE
|
||||
// Tag (termin_is_upcoming()), damit ein dreitägiges Fest am Samstag nicht schon
|
||||
// zum Rückblick wird.
|
||||
$isVorbei = !termin_is_upcoming($v, date('Y-m-d'));
|
||||
|
||||
$plakate = $v['plakate'] ?? [];
|
||||
$galerie = $v['galerie'] ?? [];
|
||||
$dateText = german_date_range((string) $v['date_start'], $v['date_end'] ?? null);
|
||||
|
||||
$meta = [
|
||||
'title' => $v['title'],
|
||||
'description' => (string) ($v['text'] ?? $v['title']),
|
||||
'schema' => array_merge(
|
||||
page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Der Verein', 'slug' => 'verein'],
|
||||
['name' => 'Veranstaltungen', 'slug' => 'veranstaltungen'],
|
||||
['name' => $v['title'], 'slug' => 'veranstaltungen/' . $v['slug']],
|
||||
]),
|
||||
array_values(array_filter([event_schema($v, 'veranstaltungen/' . $v['slug'])]))
|
||||
),
|
||||
];
|
||||
|
||||
// Die Lightbox kommt nur dazu, wenn es auch etwas zu vergrößern gibt.
|
||||
if ($plakate !== [] || $galerie !== []) {
|
||||
$meta['scripts'] = ['lightbox.js'];
|
||||
}
|
||||
|
||||
$ogBild = $plakate[0] ?? $galerie[0] ?? null;
|
||||
if ($ogBild !== null && !empty($ogBild['widths'])) {
|
||||
$meta['og_image'] = $ogBild['base'] . '-' . max($ogBild['widths']) . '.jpg';
|
||||
}
|
||||
|
||||
if ($isDraft) {
|
||||
$meta['noindex'] = true;
|
||||
}
|
||||
|
||||
// Einleitung: vor dem Fest die Ankündigung, danach der Rückblick. Fehlt der
|
||||
// jeweilige Text noch, trägt der Teaser aus `text` die Sektion — die Seite darf
|
||||
// nie leer sein, sie startet ja mit nichts als dem Plakat.
|
||||
// Fällt der jeweilige Text noch, trägt der Teaser aus `text` die Sektion — die
|
||||
// Seite darf nie leer sein, sie startet ja mit nichts als dem Plakat.
|
||||
$introText = $isVorbei ? ($v['rueckblick'] ?? null) : ($v['ankuendigung'] ?? null);
|
||||
$introSection = !empty($introText) ? [
|
||||
'id' => 'ueberblick',
|
||||
'title' => $isVorbei ? 'Unser Rückblick' : 'Das erwartet dich',
|
||||
// Ohne Bild läuft die Sektion einspaltig auf Lesebreite (split--plain).
|
||||
'text' => $introText,
|
||||
] : null;
|
||||
?>
|
||||
<?php component('detail-head', [
|
||||
'back' => ['href' => url('veranstaltungen'), 'label' => 'Alle Veranstaltungen'],
|
||||
'title' => $v['title'],
|
||||
'badge' => $isDraft ? 'Entwurf' : '',
|
||||
'date_iso' => (string) $v['date_start'],
|
||||
'date_text' => $dateText,
|
||||
'location' => (string) ($v['location'] ?? ''),
|
||||
]); ?>
|
||||
<?php /* Einleitungstext nur, wenn wirklich einer gepflegt ist — die Regel für die
|
||||
ganze Seite: gezeigt wird, was in der JSON steht, und sonst nichts. */ ?>
|
||||
<?php if ($introSection !== null): ?>
|
||||
<?php component('section', ['section' => $introSection]); ?>
|
||||
<?php endif; ?>
|
||||
<?php /* Plakate und Galerie tauschen den Platz: vor dem Fest wirbt das Plakat,
|
||||
danach tragen die Fotos die Seite und das Plakat bleibt als Andenken. */ ?>
|
||||
<?php if ($isVorbei): ?>
|
||||
<?php component('veranstaltung-galerie', ['bilder' => $galerie, 'title' => 'Bilder']); ?>
|
||||
<?php component('veranstaltung-plakate', ['plakate' => $plakate, 'title' => 'Plakate von damals', 'compact' => true]); ?>
|
||||
<?php else: ?>
|
||||
<?php component('veranstaltung-plakate', ['plakate' => $plakate, 'title' => count($plakate) > 1 ? 'Plakat und Programm' : 'Plakat']); ?>
|
||||
<?php component('veranstaltung-galerie', ['bilder' => $galerie, 'title' => 'Bilder']); ?>
|
||||
<?php endif; ?>
|
||||
<?php /* Zeitraum, Beginn, Adresse und Karte in einem Block — aber nur, solange das
|
||||
Fest noch aussteht. Hinterher wäre es reine Doppelung: Datum und Ort
|
||||
stehen im Kopf, und eine Anfahrtskarte zu einem Fest von letztem Jahr
|
||||
hilft niemandem mehr. Alles Weitere steht auf den Plakaten. */ ?>
|
||||
<?php if (!$isVorbei): ?>
|
||||
<?php component('veranstaltung-info', [
|
||||
'date_text' => $dateText,
|
||||
'date_iso' => (string) $v['date_start'],
|
||||
'time' => (string) ($v['time'] ?? ''),
|
||||
'location' => (string) ($v['location'] ?? ''),
|
||||
'karte' => (string) ($v['karte'] ?? ''),
|
||||
'anfahrt' => $v['anfahrt'] ?? [],
|
||||
]); ?>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
// phase steuert, welche Abschnitte wann stehen: „Anfahrt und Parken" hilft nur
|
||||
// vor dem Fest. Ohne Angabe steht ein Abschnitt immer — der harmlosere Default:
|
||||
// geschriebener Text verschwindet nie überraschend.
|
||||
foreach ($v['abschnitte'] ?? [] as $abschnitt):
|
||||
$phase = $abschnitt['phase'] ?? 'immer';
|
||||
if ($phase !== 'immer' && $phase !== ($isVorbei ? 'rueckblick' : 'ankuendigung')) {
|
||||
continue;
|
||||
}
|
||||
?>
|
||||
<?php component('section', ['section' => $abschnitt]); ?>
|
||||
<?php endforeach; ?>
|
||||
<?php /* Das Programm als Liste ist die Ausnahme, nicht die Regel: normalerweise
|
||||
trägt es das Programmplakat. Nur wenn `programm` gepflegt ist, kommt es
|
||||
zusätzlich als lesbare Liste dazu (Screenreader, Suchmaschinen). */ ?>
|
||||
<?php component('veranstaltung-programm', [
|
||||
'programm' => $v['programm'] ?? [],
|
||||
'title' => $isVorbei ? 'So lief unser Programm' : 'Programm',
|
||||
'compact' => $isVorbei,
|
||||
]); ?>
|
||||
<?php /* Helfer-Aufruf nur vor dem Fest: hinterher sind die Schichten gelaufen. */ ?>
|
||||
<?php if (!$isVorbei && !empty($v['helfer'])): ?>
|
||||
<?php component('cta-band', ['band' => $v['helfer'], 'anchor' => 'helfen']); ?>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($v['partner'])): ?>
|
||||
<?php component('partner-grid', ['intro' => $v['partner'], 'anchor' => 'fest-partner']); ?>
|
||||
<?php endif; ?>
|
||||
77
app/pages/veranstaltungen.php
Normal file
77
app/pages/veranstaltungen.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Übersicht aller Veranstaltungen mit eigener Detailseite (Sportfest,
|
||||
* Jubiläum, …) — Quelle data/veranstaltungen.json, geladen über
|
||||
* veranstaltungen_load() (filtert Entwürfe, sortiert neueste zuerst).
|
||||
*
|
||||
* Zwei Abschnitte statt einer gemischten Liste: oben was ansteht (der nächste
|
||||
* Termin als große Karte mit Plakat), unten das Archiv. Auch in der Sommerpause
|
||||
* ohne anstehendes Fest ist die Seite damit gefüllt.
|
||||
*
|
||||
* Abgrenzung zu /termine: dort steht der vollständige Kalender (Spiele +
|
||||
* Vereins-Termine + Veranstaltungen), hier nur das, was eine eigene Seite hat.
|
||||
*/
|
||||
$today = date('Y-m-d');
|
||||
$alle = veranstaltungen_load();
|
||||
|
||||
$kommend = array_values(array_filter($alle, static fn (array $v): bool => termin_is_upcoming($v, $today)));
|
||||
// veranstaltungen_load() sortiert absteigend (fürs Archiv richtig) — was
|
||||
// ansteht, gehört aufsteigend sortiert: das Nächste zuerst.
|
||||
usort($kommend, static fn (array $a, array $b): int => (string) $a['date_start'] <=> (string) $b['date_start']);
|
||||
|
||||
$vergangen = array_values(array_filter($alle, static fn (array $v): bool => !termin_is_upcoming($v, $today)));
|
||||
|
||||
$meta = [
|
||||
'title' => 'Veranstaltungen',
|
||||
'description' => 'Unsere Feste und Veranstaltungen: Sportfest, Frühschoppen und alles, was wir im Katzbachtal auf die Beine stellen.',
|
||||
'schema' => array_merge(
|
||||
page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Der Verein', 'slug' => 'verein'],
|
||||
['name' => 'Veranstaltungen', 'slug' => 'veranstaltungen'],
|
||||
]),
|
||||
// Nur die anstehenden als Event-Knoten: ein vergangenes Fest ist für
|
||||
// Suchmaschinen kein Termin mehr, den man sich vormerken kann. Mit Slug,
|
||||
// damit es derselbe Knoten ist wie auf /termine und der Detailseite.
|
||||
array_values(array_filter(array_map(
|
||||
static fn (array $v): ?array => event_schema($v, 'veranstaltungen/' . (string) ($v['slug'] ?? '')),
|
||||
$kommend
|
||||
)))
|
||||
),
|
||||
];
|
||||
|
||||
component('hero', ['hero' => [
|
||||
'compact' => true,
|
||||
'align' => 'center',
|
||||
'title' => 'Veranstaltungen',
|
||||
'text' => 'Sportfest, Frühschoppen, Feiern: hier findest du alles zu unseren Festen, vorher das Programm und hinterher die Bilder.',
|
||||
'image' => [
|
||||
'base' => 'img/pages/vereinsgelaende',
|
||||
'widths' => [640, 1000, 1600],
|
||||
'alt' => '',
|
||||
],
|
||||
]]);
|
||||
?>
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<h2>Was ansteht</h2>
|
||||
<?php component('veranstaltungen-cards', [
|
||||
'items' => $kommend,
|
||||
'cta' => 'Zum Programm',
|
||||
'empty_text' => 'Gerade ist kein Fest geplant. Sobald der nächste Termin steht, findest du ihn hier.',
|
||||
]); ?>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section section--sunken">
|
||||
<div class="container">
|
||||
<h2>Rückblick</h2>
|
||||
<?php component('veranstaltungen-cards', [
|
||||
'items' => $vergangen,
|
||||
'cta' => 'Bilder ansehen',
|
||||
'empty_text' => 'Noch kein Fest im Rückblick. Nach unserem nächsten Sportfest findest du hier die Bilder.',
|
||||
]); ?>
|
||||
</div>
|
||||
</section>
|
||||
@@ -26,7 +26,7 @@ $cards = [
|
||||
|
||||
$meta = [
|
||||
'title' => 'Der Verein',
|
||||
'description' => 'Der TSV 1908 Kulmbach e. V. – seit 1908 dein Sportverein im Katzbachtal: über 650 Mitglieder, Fußball und Turnen, zuhause auf der Hans-Rausch-Sportanlage.',
|
||||
'description' => 'Der TSV 1908 Kulmbach e. V.: seit 1908 dein Sportverein in Kulmbach mit über 650 Mitgliedern. Fußball und Turnen auf der Hans-Rausch-Sportanlage im Katzbachtal.',
|
||||
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
|
||||
'schema' => page_schema(
|
||||
[
|
||||
|
||||
@@ -11,16 +11,18 @@ declare(strict_types=1);
|
||||
*/
|
||||
return [
|
||||
'' => ['file' => 'home.php', 'sitemap' => true],
|
||||
'news' => ['file' => 'news.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],
|
||||
'matchcenter' => ['file' => 'matchcenter.php', 'sitemap' => true],
|
||||
'stadionzeitung' => ['file' => 'stadionzeitung.php', 'sitemap' => true],
|
||||
'jugendfussball' => ['file' => 'jugendfussball.php', 'sitemap' => true],
|
||||
'turnen' => ['file' => 'turnen.php', 'sitemap' => true],
|
||||
'turnen/erwachsenenturnen' => ['file' => 'turnen-erwachsenenturnen.php', 'sitemap' => true],
|
||||
'turnen/eltern-kind-turnen' => ['file' => 'turnen-eltern-kind.php', 'sitemap' => true],
|
||||
'turnen/step-aerobic-fitness-pilates' => ['file' => 'turnen-step-aerobic.php', 'sitemap' => true],
|
||||
'turnen/fitness-pilates' => ['file' => 'turnen-fitness-pilates.php', 'sitemap' => true],
|
||||
'turnen/trampolin' => ['file' => 'turnen-trampolin.php', 'sitemap' => true],
|
||||
'turnen/wettkampfturnen' => ['file' => 'turnen-wettkampfturnen.php', 'sitemap' => true],
|
||||
'mitmachen' => ['file' => 'mitmachen.php', 'sitemap' => true],
|
||||
@@ -28,6 +30,8 @@ return [
|
||||
'historie' => ['file' => 'historie.php', 'sitemap' => true],
|
||||
'sportheimbuchung' => ['file' => 'sportheimbuchung.php', 'sitemap' => true],
|
||||
'ansprechpartner' => ['file' => 'ansprechpartner.php', 'sitemap' => true],
|
||||
'termine' => ['file' => 'termine.php', 'sitemap' => true],
|
||||
'veranstaltungen' => ['file' => 'veranstaltungen.php', 'sitemap' => true],
|
||||
'kontakt' => ['file' => 'kontakt.php', 'sitemap' => true],
|
||||
'partner-werden' => ['file' => 'partner-werden.php', 'sitemap' => true],
|
||||
'mitglied-werden' => ['file' => 'mitglied-werden.php', 'sitemap' => true],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user