Compare commits

...

71 Commits

Author SHA1 Message Date
fs
e761231442 Regel ergaenzen: nach dem Veroeffentlichen auf dem Server committen und pushen
Der Snapshot und die eingefrorenen fix-*-Bilder existieren nach
bin/stadionzeitung-publish.php zunaechst nur auf dem Server. Bleiben sie dort
uncommittet, scheitert der naechste Pull, und wer die Aenderung verwirft,
verliert veroeffentlichte Ausgaben. Am 03.09.2026 mit drei Ausgaben passiert;
der Reparaturweg (Server als Wahrheit, Zusammenfuehren je Slug) steht jetzt mit
dabei.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KubsajzuV6N6QsSd3xKNjC
2026-09-03 11:13:33 +02:00
0a01134583 Sportfest-Ausgabe veroeffentlicht 2026-09-03 11:12:31 +02:00
5854e452f7 Veroeffentlichte Ausgaben und eingefrorene Bilder vom Server sichern 2026-09-03 11:11:29 +02:00
fs
f2f193b223 Sportfest-Ausgabe der Stadionzeitung zum Heimspiel gegen ATS Wartenfels
Neue Live-Seiten, geschaltet vom chat-gepflegten Feld `veranstaltung` (Slug aus
data/veranstaltungen.json) — Programm und Turnierpläne kommen damit aus derselben
Quelle wie die Website, nicht als Kopie ins Heft:

- Programm-Seite (programm[], rendert zugleich auf /veranstaltungen/sportfest-2026)
- vier Turnierplan-Seiten (turniere[]) mit leerer Ergebnisspalte und leerer
  Abschlusstabelle zum Eintragen am Platz
- Gegnerseite je Mannschaft mit Heimspiel am Fest statt nur der tragenden:
  stadionzeitung_gegner_fixtures() ist die einzige Stelle, die das entscheidet,
  Seitenplanung und Snapshot lesen beide dort. `gegner` ist deshalb eine Map je
  Team-Key; die Vorschau überspringt jedes Spiel mit eigener Gegnerseite
- Danke-Seite mit Partner-Logo-Wand aus data/partners.json und QR auf
  /partner-werden, dazu das Anzeigen-Feld `platzierung` für Motive mit festem
  Platz auf einer Live-Seite (Emons über der Logo-Wand). Vorher klebte der Dank
  als leere Fläche unten an einem Anzeigen-Bild
- Cover einer Sonderausgabe: das Fest als Titelgeschichte statt einer der drei
  Begegnungen, dazu das Hauptplakat schräg gelegt an Stelle des Spielerfotos

Ausgabe 2026-09-04-ats-wartenfels als ENTWURF angelegt, 48 Seiten (12 Bögen),
Tabellen auf gerader Seite, längster Anzeigenblock zwei. Veröffentlicht wird auf
dem Server, dort liegt der frische Matchcenter-Stand.

Nebenbei behoben: bin/anzeige-add.php räumte beim Anlegen alle Motive weg, deren
Slug mit demselben Text beginnt (`emons` löschte `emons-partnerdank` und
`emons-stuebinger`). Muster jetzt auf die Breiten-Endung eingeschränkt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KubsajzuV6N6QsSd3xKNjC
2026-09-03 10:57:52 +02:00
fs
b4d2cde149 Jugend-Bento: Abteilungsfoto mobil ungeschnitten (3:4 statt 16:10)
Das hochkante Gruppenfoto (1000x1333) wurde auf dem Handy in eine
16:10-Querkachel gecovert und dadurch auf einen Mittelstreifen
zusammengeschnitten. Die Mobil-Kachel bekommt jetzt das echte 3:4-Format,
sodass object-fit: cover passgenau fuellt und niemand beschnitten wird.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WACMqwVB4JpPW5Wkz1Y1DU
2026-09-01 08:53:39 +02:00
fs
0d80f69a76 Veranstaltung Tagesskifahrt 2027 anlegen (Hochzillertal, 20.02.)
Auswärts-Event ohne Karte (kein karte:club), Preise und Anmeldung als
lesbarer Text für Screenreader/Suche, Plakat über bin/veranstaltung-bilder.php.
Direkt veröffentlicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1tTL6VE17FgQhhaH4Pc8h
2026-08-31 21:05:26 +02:00
fs
6313b98f9e Jugend: neue Ansprechpartner-Fotos, Abteilungsbild, aktuelle Trainingszeiten
- Fabi & Kerstin als 480x600-Porträts (Kopf/Schultern, oben ausgerichtet),
  Kerstin in ansprechpartner.json verknüpft
- gemeinsames Jugendbild als Intro-Bento-Bild (ersetzt die Hero-Dublette)
- Trainingszeiten auf Fabis Stand, C/B-Jugend zusammengefasst, B-Karte entfällt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1tTL6VE17FgQhhaH4Pc8h
2026-08-31 20:59:51 +02:00
fs
f88b5f1dd1 Stadionzeitung gegen ASV Marktschorgast vorbereiten 2026-08-28 13:11:43 +02:00
fs
736a588a88 Turnen: neue Fotos + Fitness/Pilates zu Functional Fitness I/II umgebaut
Feedback der Turnabteilung (Batch 13.–18.08.):
- Kinderturnen, Erwachsenenturnen und Fitness/Pilates bekommen echte
  (Symbolbild-)Fotos in Kachel UND Detail-Hero (statt Luftbild-Platzhalter),
  responsive 640/1000/1536, og_image der drei Seiten mitgezogen.
- fitness-pilates: „Fitness & Stretching" -> „Functional Fitness I" (Mi),
  neuer Kurs „Functional Fitness II" (Do, Uhrzeit folgt), Beschreibungen für
  Pilates und beide Functional-Fitness-Kurse, gestapeltes Layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GueFkHGX17MqfsoraXwGM
2026-08-19 22:29:59 +02:00
fs
550616aacf Stadionzeitung: langes Interview passt wieder auf die A5-Seite
Die vergrößerte Interview-Frage (4.6cqi) schob eine Ausgabe mit drei Fragen um
8px über die Blattkante (gemessen). Frage auf 4.2cqi gedeckelt (bleibt größer
als die Antwort) und Abstände von Frage/Absätzen/Trennlinie leicht gestrafft —
Text unverändert, passt jetzt mit Reserve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFnFYLm2MoBskyk9mc84ZW
2026-08-13 17:35:44 +02:00
fs
c688d155cc Stadionzeitung Ausgabe 2 (TSV Ködnitz, 15.08.): Entwurf + neue Aufruf-Seite
- Neue generische Live-Seite `aufruf` (erste Nutzung: Sportfest-Helferaufruf):
  Komponente, Dispatcher, Skript-Einbindung (Planung im Aufschlag, QR aus
  aufruf.link, Feld-Carryover), CSS, optionales kleines Poster rechts.
- Ausgabe 2026-08-15-tsv-koednitz als ENTWURF: Vorwort (Benedikt, Revanche
  dezent), Interview (Ali, 3 Fragen), Sportfest-Helferseite mit Kontakten aus
  ansprechpartner.json; Rückseite/Cover wie Hutschdorf wiederverwendet.
- Interview-Frage größer/prominenter gesetzt.
- Sportfest-Plakat-Anzeige deaktiviert (redundant zur Helferseite) -> 40 Seiten.

Sync-Dateien (instagram/matchcenter) bleiben gitignored, Snapshot wird beim
Veröffentlichen auf dem Server eingefroren.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFnFYLm2MoBskyk9mc84ZW
2026-08-13 17:27:41 +02:00
fs
1839632b66 Turnen: schlichte Gruppen als Bodenreihe direkt ins Bento (groupsInDash)
Neue opt-in-Prop groupsInDash in group-schedule zieht schlichte Gruppen
(ohne intro/details) als volle Bodenreihe MIT ins Dashboard-Bento, ohne
eigene Sektion und ohne "Unsere Gruppen"-Ueberschrift. Trampolin nutzt
das, damit Hinweis, Trainingsort, Kontakt und die drei Gruppen-Karten ein
zusammenhaengender Block sind statt einer losgeloesten Kartenreihe darunter.
Wiederverwendung von .group-list--grid/.group-card, Karten-Markup DRY in
renderSimpleCard. Wettkampfturnen/Kinderturnen bleiben unberuehrt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HXDvgRDHQ9iGjs4KCXykEj
2026-08-03 19:58:39 +02:00
fs
d410c0bedf Trampolin: echte Trainingszeiten, Trainerinnen und Kontakt vom Hallen-Aushang
Drei Karten nach Zielgruppe (Kinder+Erwachsene Mo, Kinder ab 5 Do,
Kinder ab 10 und Fortgeschrittene Do) mit Tag/Zeit und Trainerteam je
Session, Schnupper-Hinweis auf "nach Absprache" umgestellt, Fußnote zu
den Schulferien. group-schedule bekommt zwei optionale Props
(contactEmail/contactPhone) fuer Tinas persoenlichen Kontakt in der
Kontaktkachel (tel:-Muster wie person-card, keine neue CSS-Regel).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HXDvgRDHQ9iGjs4KCXykEj
2026-08-03 19:49:25 +02:00
fs
133c3f926e „Trainerteam" statt „Übungsleitung", Eva Wünsch mit Sternchen
Eva Wünsch aus der Turnabteilung am 31.07.2026: „Der Sternchentext müsste
eigentlich sein, dass die Person mit dem Sternchen Übungsleiter ist. Von daher
müsste vor den Namen 'Trainerteam' stehen. Einige sind auch nicht ÜL."

Ich hatte davon nur die Fußnote erledigt. Das Label blieb „Übungsleitung" und
damit stand die falsche Aussage weiter auf der Seite: neun namentlich genannte
Personen liefen unter „Übungsleitung", vier davon sind es. Das ist keine
Formulierungsfrage, die Seite behauptete etwas über echte Menschen.

Label jetzt „Trainerteam:" an beiden Stellen (Trainingszeit und Gruppe), der
Stern bleibt die Auszeichnung und die Fußnote erklärt ihn. So stand es auch auf
der alten Seite: „TRAINERTEAM (* = ÜBUNGSLEITER)". Ein Kommentar an der Stelle
sagt, warum es nicht zurückbenannt werden darf.

Dabei fiel ein zweiter Datenfehler auf: die alte Seite führt „Annelie Frisque*,
Eva Wünsch*", bei uns stand Eva Wünsch ohne Stern. Sie ist Übungsleiterin.

Geprüft: „Übungsleitung" kommt auf keiner der sechs Turn-Seiten mehr vor, die
Grashüpfer-Zeile lautet jetzt Zeichen für Zeichen wie in Evas Screenshot. Die
Fußnote erscheint nur in Sektionen mit mehreren Gruppen — nachgezählt, dass
beide Disziplinen mit Sternchen-Namen mehrere Gruppen haben, die
Einzelgruppen-Disziplinen tragen keine Sterne.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 22:57:31 +02:00
fs
146471bae8 Wettkampfturnen: Karten offen untereinander statt hinter Tabs
Bei zwei Gruppen verstecken Tabs mehr Inhalt als sie ordnen, und die offene
Kartenreihe hält den Bento-Eindruck der Seite (Entscheidung Felix). Das
Kinderturnen bleibt bei Tabs, dort sind es drei textreiche Gruppen — die
Flamingos-Karte allein ist am Handy 2142px hoch.

.group-list--stacked lag vollständig im CSS, wurde aber von KEINEM PHP
ausgegeben: totes CSS aus der Zeit vor den Tabs, das heute morgen sogar noch
meine minmax(0, …)-Korrektur mitbekommen hat, ohne dass es jemand sehen konnte.
Jetzt ist es lebendig. Dritter toter Rest heute: "layout": "blocks" in
data/turnen.json las ebenfalls niemand, ist raus.

Der Karteninhalt steht dafür jetzt einmal statt zweimal. Er wird in zwei
Fassungen gebraucht (Tab-Panel mit role/aria und gestapelt ohne) und ist
deshalb eine Closure geworden — eine zweite Kopie wäre die Stelle, an der beide
in einem halben Jahr auseinanderlaufen.

Die Überschrift ist visually-hidden, nicht entfernt: sonst hätte die Sektion
keinen zugänglichen Namen und die Überschriften-Gliederung eine Lücke. Dasselbe
Muster wie $title_hidden in team-cards. Damit braucht die Seite auch kein
group-tabs.js mehr, das ich zwei Commits vorher erst dazugeschrieben hatte.

Zwei Nachbesserungen, beide gemessen:
- Die versteckte Überschrift hinterließ ein Loch. Zwischen Dashboard und erster
  Karte standen 160px (80 unten von der einen Sektion, 80 oben von der anderen),
  in denen nur nackte Hintergrund-Textur zu sehen war — vorher füllte die
  Überschrift diesen Raum. .group-list-section--tight nimmt das obere Padding
  weg, jetzt 80px.
- Die gestapelten Karten hatten feste 40px Innenabstand; auf --pad-card-lg
  umgestellt, dieselbe Korrektur wie heute morgen beim Tab-Panel. Am Desktop
  unverändert 40px, am Handy 28px.

Nicht behoben, weil kein Fehler: die Hintergrund-Textur wirkt in leeren Flächen
weich. Sie ist 1920x2545 groß und wird bei 1478px Fensterbreite auf Faktor 0,77
VERKLEINERT, ist also nicht unscharf gerechnet. Sichtbar wurde sie durch den
Leerraum plus background-attachment: fixed, das sie beim Scrollen stehen lässt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 22:56:21 +02:00
fs
f3f004d13e Neues Trampolin-Kachelbild nachgezogen
Die Datei lag geändert im Arbeitsverzeichnis (640×381 statt 640×425, 53 statt
128 KB) und war das einzige Stück, das noch fehlte: die zugehörige Änderung an
app/pages/turnen.php (widths auf [640], Kommentar zur WhatsApp-Quelle) und das
Entfernen der 1000er und 1600er Variante stecken bereits in 592acf0, wo sie
durch mein `git add app/pages/turnen*.php` mitgekommen sind. Ohne diesen Commit
zeigte das Repo das alte Bild bei neuer Deklaration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 22:40:25 +02:00
fs
7bbf783562 Flamingos zum Wettkampfturnen, Inhalte gegen die alte Seite abgeglichen
Die Flamingos sind organisatorisch vom Kinderturnen zum Wettkampfturnen
gewechselt. Felix hat dazu die Fassung geschickt, die die Turnabteilung selbst
auf der alten Seite gepflegt hatte — dabei kamen sieben Abweichungen heraus,
alle korrigiert:

- Die zweite Gruppe heißt "Leistungsturnen", nicht "Wettkampfturnen" (so hieß
  bei uns die Gruppe wie die Disziplin).
- Reihenfolge: Flamingos zuerst, Leistungsturnen danach.
- Den Flamingos fehlte eine Trainingszeit: Montag gibt es ZWEI Blöcke
  (18:00–19:00 und 19:00–20:00), wir hatten nur den ersten.
- Ansprechpersonen sind pro Gruppe verschieden (Flamingos: Theresa Diener,
  Leistungsturnen: Franziska Trotzke). Dafür ist `contact` neu an einer Gruppe.
  Die Kontakt-Kachel oben kann nur EINE Person nennen und wäre für die andere
  Gruppe falsch — deshalb ist `lead` bei dieser Disziplin jetzt leer, die Kachel
  trägt nur noch die Abteilungs-Mail. So war es auf der alten Seite auch: dort
  gab es keine Ansprechperson für die Disziplin, nur je Gruppe.
- Theresa Diener steht im Trainerteam vorn und ohne Stern (bei uns hinten).
- Intro, Trainingsinhalte, Wettkämpfe und der Hinweis waren gekürzt; jetzt im
  Wortlaut der Abteilung. Den Gedankenstrich in "(Kür modifiziert –
  Leistungsklasse 3)" ersetzt ein Komma (Sprache & Ton).
- Die Fußnote sagte "* So im Trainingsplan der Abteilung gekennzeichnet" — eine
  Verlegenheitsformulierung, weil wir die Bedeutung nicht kannten. Die alte
  Seite schreibt "* = ÜBUNGSLEITER", also jetzt "* Übungsleiterinnen und
  Übungsleiter" (Paarform, Haus-Stil).

`level` bleibt "Turnen · fortgeschritten" ohne "Alter nach Absprache" — das
hatte Felix am 31.07.2026 ausdrücklich aus dem Chip genommen.

Zwei Folgen, die sonst still kaputtgegangen wären: die Seite hatte eine einzige
Gruppe ohne Beschreibung und lief damit im Einzelgruppen-Layout. Mit zwei
Gruppen, davon eine mit Beschreibung, rendert group-schedule eine Tab-Leiste —
die braucht group-tabs.js, das die Seite nicht lud (tote Tab-Knöpfe). Und drei
Flamingos-Trainerinnen tragen ein Sternchen, die erklärende Fußnote wurde auf
dieser Seite nicht übergeben (drei unerklärte Sterne).

Geprüft: Tabs "Flamingos" und "Leistungsturnen", Skript geladen, Fußnote da,
Flamingos auf /turnen/eltern-kind-turnen nicht mehr erwähnt, kein Überlauf bei
390px, alle sechs Turn-Seiten 200.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 22:40:25 +02:00
fs
f21224ea84 Nav-Labels an die Seitentitel angeglichen, Dropdown-Breite nachgerechnet
Im Menü stand „Eltern-Kind-Turnen", die Seite heißt „Eltern-Kind- &
Kinderturnen" — das Kinderturnen fehlte also im Menü, obwohl Eltern genau
danach suchen. Beim Prüfen fielen zwei weitere Abweichungen auf, alle drei
sind jetzt gleich:

  Eltern-Kind-Turnen  → Eltern-Kind- & Kinderturnen
  Fitness und Pilates → Fitness & Pilates
  Trampolin           → Trampolinturnen

Punkte („…") braucht es dafür nicht: das Dropdown hat min-width und
white-space: nowrap, das Panel wächst also mit dem längsten Punkt statt zu
beschneiden. Ein abgeschnittener Menüpunkt wäre auch die schlechtere Lösung,
weil man dann nicht mehr liest, wohin der Link führt.

Es war aber knapper als es aussah. Gemessen mit dem längsten Punkt (187,6px):
ruhend 190,0px Platz, im Hover UND auf der aktiven Seite nur 186,0px, weil
padding-left dort von --space-s (16px) auf --space-m (20px) wächst. Das Panel
wäre beim Rübergehen um zwei Pixel gewachsen und auf dieser Seite dauerhaft
breiter gewesen — sichtbar als Flackern, weil es mittig sitzt und sich nach
beiden Seiten schiebt.

Behoben mit einem Wert: min-width 15rem → 15,5rem. Alle drei Panels bleiben
jetzt in jedem Zustand bei 248px, mit 8,4px Luft statt 1,6px Fehlbetrag. Auch
bei 1250px Viewport (der schmalsten Breite mit Desktop-Menü) liegt kein Panel
am Rand. Die Rechnung steht als Kommentar über der Regel, damit der nächste
längere Menüpunkt nachgemessen wird.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 22:31:58 +02:00
fs
592acf02b0 Turn-Anmeldung als eigene FAQ-Frage, Knöpfe in FAQ-Antworten möglich
Der Anmelde-Vorgang der Abteilung (erst Platz anfragen, dann Aufnahmeantrag)
stand nirgends auf der Seite. Auf der alten Seite gab es dafür einen Block
„ANMELDUNG" mit zwei Knöpfen.

Erst als eigene Sektion unter den Beiträgen gebaut (cta-band), auf Wunsch von
Felix wieder entfernt und stattdessen als eigene FAQ-Frage umgesetzt. Zwei
Sektionen mit denselben Knöpfen auf einer Seite schwächen beide Stellen, und
man weiß nicht mehr, welcher der richtige Weg ist. Deshalb ist auch
overview.cta ganz aus data/turnen.json raus statt unbenutzt liegen zu bleiben —
genau so ein toter Block hat heute schon einmal in die Irre geführt (die Notiz
„NOCH unbenutzt" stand monatelang im Docblock von turnen.php).

faq.php bekommt dafür ein optionales ctas-Feld pro Eintrag, gerendert über die
bestehende cta-Komponente, also keine neue Knopf-Variante. .faq__actions
bricht um, weil zwei Knöpfe auf einem schmalen Handy nicht nebeneinander passen.

Die Antwort nennt beide Wege bewusst AUCH in Worten: faq_schema() liest nur q
und a, Knöpfe landen nie im FAQPage-Schema. Stünde in der Antwort nur „so
geht's" plus zwei Knöpfe, hätte Google eine Antwort ohne Ziel. Nachgeprüft:
sechs Fragen im Schema, der neue Antworttext ist HTML- und Markup-frei.

Gemessen bei 390px: kein Überlauf, Knöpfe 130x44 und 193x44 (Trefferfläche
erfüllt). Die fünf Turn-Unterseiten hatten als einzige von 105 PHP-Dateien
keinen Zeilenumbruch am Dateiende, das ist mitkorrigiert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 22:31:57 +02:00
fs
63a045835e Austritt Turnen: Kündigung darf formlos per Mail kommen
Rückmeldung der Turnabteilung. Der Austritt-Block auf /turnen nannte bisher nur
die Schriftform und die Frist zum 30. September, nicht aber, wohin die Kündigung
geht.

Die Adresse steht wie überall ausschließlich in club.json (email_turnen), der
Satz in turnen.json markiert ihre Stelle nur mit {mail} — dasselbe Muster wie
der Material-Aufruf auf /historie. Ersetzt wird nach inline_links_html(), damit
der Fließtext escaped bleibt und nur der Platzhalter zu einem mailto-Link wird.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWoqDYuANFbJDGFQAgaRdT
2026-07-31 22:26:37 +02:00
fs
06a18a2d19 301-Weiterleitungen der alten CMS-Adressen
Der Aufnahmeantrag der Turnabteilung ging als alte URL in den Einladungen an
die neuen Familien raus und lief seit dem Umzug in einen 404, ebenso der Link
auf die Turn-Seite. Beide leiten jetzt weiter, dazu alle uebrigen Seiten der
alten Navigation unter /08/de/.

Die PDF-Regel matcht am Umlaut vorbei: der Dateiname kommt je nach Mailprogramm
als NFC, als NFD (so stand er in der alten Datenbank) oder roh als UTF-8 an.
Die Sammelregel wirft den alten Query-String weg, sonst haengt mod_rewrite
?group_filter_id=14 ans neue Ziel.

Erledigt damit den offenen Punkt F3 aus docs/deploy.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWoqDYuANFbJDGFQAgaRdT
2026-07-31 22:11:08 +02:00
fs
e7572b34d6 Trampolin: Schnupper-Einladung, Hinweise bekommen zwei Tonlagen
Info von Eva aus der Trampolin-Gruppe: neue Kinder können ab September
(Schulbeginn) gerne schnuppern kommen. Die Info stand nirgends.

Rot wäre dafür falsch gewesen. Die Farbe bekommt deshalb eine Bedeutung:
--stop (gefüllt rot) heißt „geht nicht", --info (Glas-Fläche, Rot nur im Icon)
heißt „geht". Eine Einladung in Alarmrot liest sich wie eine Absage, und wenn
alles rot ist, stumpft Rot ab und der echte Wartelisten-Stopp bei „Tiger" und
„Hühner" verliert seine Wirkung. --info ist ab jetzt der Default.

Damit steht der Hinweis an drei Stellen, also greift die Bedingung aus dem
letzten Commit („kommt ein dritter Ort dazu, gehören die gemeinsamen
Eigenschaften in eine geteilte Klasse"): neu app/components/notice.php als
einzige Render-Stelle statt dreimal dasselbe Markup. Die beiden bestehenden
Klassen schrumpfen auf das, was sie wirklich beitragen —
.turnen-dash__notice nur noch grid-area, .contact__notice nur noch die
Formularbreite. Die tote .turnen-dash__notice-icon-Regel ist raus, der
CSS-Abschnitt neu sortiert (notice als eigener Block hinter turnen-dash statt
mitten hinein).

Text, Tonlage und Icon stehen wie gehabt in data/turnen.json bei der
Disziplin, nicht im Template. Kinderturnen trägt jetzt explizit
notice_tone: stop, Trampolin nichts und ist damit neutral.

Das Grid-Template für --notice + --single gab es schon, an beiden
Breakpoints — die Trampolin-Seite hat eine Gruppe und wäre sonst
auseinandergefallen.

Gemessen bei 390px: kein Überlauf auf allen drei Seiten, Trampolin-Fläche
rgba(0,0,0,0.22) (Glas), die beiden anderen #e20612. Zwölf Seiten geprüft,
alle 200. preflight ohne neue Meldungen.

OFFEN, mit Eva oder Tina zu klären: der zweite Satz („Schreib uns davor
einfach kurz eine Nachricht") ist von mir, Eva hat ihn nicht gesagt. Wenn die
Kinder einfach zum Training kommen dürfen, gehört er weg. Und die Einladung
steht bisher nur auf /turnen/trampolin, nicht auf der Übersicht /turnen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 22:04:41 +02:00
fs
921a55635c Wartelisten-Hinweis für Tiger und Hühner, über dem Anfrageformular
Die Turnabteilung (Maxi) beantwortet laufend Anfragen nach einem Wartelistenplatz
für „Tiger" und „Hühner", den es nicht gibt. Auf der alten Seite stand der Hinweis
direkt über dem Anfrageformular, hier fehlte er.

Schlimmer: an der Stelle stand das GEGENTEIL. Der bisherige Hinweis lautete
"Aktuell sind alle Kinderturngruppen belegt. Bei Interesse setzen wir sie gerne
auf die Warteliste." — für alle Kindergruppen, also auch für die beiden, die
keine Liste führen. Der Satz hat einen Teil der Anfragen vermutlich erst erzeugt.
Er ist nicht gelöscht, sondern auf die übrigen Gruppen eingegrenzt und aufs Du
umgestellt (Haus-Stil; vorher stand dort klein "sie").

Der Text steht genau EINMAL, in data/turnen.json unter
disciplines.eltern-kind-turnen.notice + notice_title. Zwei Stellen lesen ihn:
die rote Kachel auf /turnen/eltern-kind-turnen (gab es schon) und neu ein Block
direkt über dem Formular auf /turnen. Ändert die Abteilung die Lage, ist das eine
Zeile in einer Datei.

Umsetzung:
- notice_title ist neu, weil eine rote Fläche ohne Überschrift wie eine
  Fehlermeldung liest. Abel statt Coolvetica, der Schnitt ist bei ~19px in
  Versalien unleserlich (CLAUDE.md, Design).
- Der Block steht AUSSERHALB des <form> und damit außerhalb der
  role="status"-Region: ein dauerhafter Hinweis, keine Rückmeldung zum Absenden.
  In der Live-Region läse ihn ein Screenreader bei jeder Formularmeldung erneut
  vor. Kein role="alert", er steht schon beim Laden da.
- Überschrift als h3 unter der h2 des Intro-Blocks, damit die Folge nicht springt.
- .contact__notice übernimmt bewusst die Optik von .turnen-dash__notice, damit
  beide Stellen als derselbe Hinweis erkennbar sind. Weiß auf --clr-accent
  erreicht 4,92:1 und trägt damit Fließtext (WCAG 1.4.3). Kommt ein dritter Ort
  dazu, gehören die gemeinsamen Eigenschaften in eine geteilte .notice-Klasse;
  für zwei wäre das verfrüht.

Gemessen: Hinweisbreite gleich Formularbreite (720px bei 1280, 347px bei 390),
kein Überlauf. preflight ohne neue Meldungen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 21:54:10 +02:00
fs
25f36e5a53 Karten-Innenabstände fluid: mehr Platz auf schmalen Handys
Auf schmalen Bildschirmen stapeln sich Container- und Kartenabstand so, dass für
den Inhalt kaum Breite bleibt. Gemessen bei 309px: Container 11,5 + Karte 32 je
Seite, das Eingabefeld hatte 220px, also 71% der Bildschirmbreite.

Kein harter Breakpoint ("ab 310px vollflächig"), sondern fluide Abstände — ein
Umbruch bei 310px sähe bei 311px wieder falsch aus. Das Muster gab es im Projekt
schon: fee-table, membership-card, legal__card und matchcenter-empty nutzen
clamp()-Abstände. Fest waren genau die drei, über die es stolperte: .form (32px),
.group-card (32px) und .group-tabs__panel (40px).

Zwei neue Token in tokens.css als einzige Quelle, --pad-card (20…32px) und
--pad-card-lg (24…40px). Die OBERGRENZEN sind die bisherigen festen Werte, am
Desktop ändert sich also nichts — nachgemessen: 32px bei 1269px Viewport, wie
vorher. Es wird nur nach unten schmaler.

Ergebnis: Feldbreite bei 309px von 71% auf 76%, bei 379px von 74% auf 80%.
Kein Überlauf auf /turnen, den Disziplinseiten und /kontakt bei 320px und 1280px.

Die vier bereits fluiden Karten behalten ihre eigenen clamp()-Werte, damit ihr
Desktop-Bild unangetastet bleibt; sie in die Token einzusammeln ist eine
Aufräumung für später.

VORBESTEHEND und nicht von dieser Änderung verursacht (per git stash gegengeprüft,
mit und ohne Änderung identisch): /mitglied-werden läuft bei 320px um 12px über,
/turnen bei 300px um 13px. Die 320px sind eine echte Gerätebreite (iPhone SE),
das gehört als Nächstes angeschaut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 21:34:29 +02:00
fs
6a9244d847 Turn-Gruppenkarten: fr-Spuren begrenzen (Textumbruch bei 300px)
Bei sehr schmalen Breiten stand der Text über den Kartenrand hinaus. Gemessen bei
300px Viewport (clientWidth 289): Dokument 317px, also 28px Überlauf; die einzelne
Grid-Spur des Panels rechnete sich auf 267,7px aus, obwohl die Karte nur 193px
Inhaltsfläche hat.

Ursache: `grid-template-columns: 1.6fr 1fr` bzw. `1fr` — eine fr-Spur hat als
automatisches Minimum min-content und kann deshalb nicht schmaler werden als das
längste Wort darin. Bei deutschen Komposita ("sportartenübergreifende",
"Grundlagenausbildung") sprengt das die Karte. Jetzt minmax(0, …) in beiden
Spurdefinitionen von .group-tabs__panel, und dieselbe Korrektur bei
.group-list--stacked, wo das Muster latent drinsteckte.

Zwei Umwege, die ich mir hätte sparen können: min-width: 0 am Grid-KIND reicht
nicht, die SPUR muss das Schrumpfen erlauben. Und overflow-wrap: break-word
verkleinert die min-content-Breite nicht (nur `anywhere` täte das) — beides bleibt
trotzdem drin, zusammen mit hyphens: auto, weil die Seite lang="de" ist und der
Browser die Komposita dann sauber trennt statt hart umzubrechen.

Geprüft: alle sechs Turn-Seiten bei 300px, 320px und 390px ohne Überlauf. Desktop
unverändert zweispaltig (648,6 / 405,4 px, Verhältnis 1,6:1).

OFFEN und bewusst nicht mitgefixt: /turnen läuft bei 300px noch um 13px über, ab
320px nicht mehr. Es kommt aus dem Kontaktformular, nicht aus den Beitragskarten
oder Gruppenkarten — die min-content-Breite des Formulars in der Grid-Spur von
.container. .container global anzufassen ist mir für 13px auf einer Breite, die
kein Gerät hat, zu riskant (das schmalste verbreitete Display ist 320px).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 21:27:56 +02:00
fs
8c15b2584b Flamingos: Chip gekürzt und Ursache des Mobil-Überlaufs behoben
Rückmeldung der Turnabteilung mit Screenshot: auf dem iPhone war im
Flamingos-Panel der Fließtext rechts abgeschnitten.

Gemessen bei 390px mit aktivem Flamingos-Tab (die anderen Panels sind
ausgeblendet, ihre Chips haben Breite 0 — deshalb fiel es beim Prüfen der Seite
nicht auf): Dokument 442px bei 379px Bildschirm, also 63px Überlauf; der Chip
393px breit in einer 345px-Karte.

Ursache war nicht die Textlänge allein, sondern .group-card__level mit
`white-space: nowrap` UND `flex-shrink: 0`: der Chip konnte seinen Text nicht
brechen und auch nicht schrumpfen, seine Mindestbreite spannte die ganze Karte
auf. Von vier level-Chips war nur dieser betroffen (47 Zeichen gegen 17 bis 25).

Zwei Änderungen:
- Inhalt wie gewünscht gekürzt: "Turnen · fortgeschritten · Alter nach Absprache"
  → "Turnen · fortgeschritten".
- Sicherheitsnetz im CSS: nowrap raus, max-width: 100% rein. Ein Chip trägt
  Information, deshalb umbrechen statt mit … kürzen. flex-shrink: 0 bleibt, es
  hält den Chip neben einem kurzen Gruppennamen in Form.

Gegenprobe, damit der Fix nicht nur am kürzeren Text hängt: mit dem LANGEN Text
und dem CSS-Fix bleibt das Dokument bei 379px, der Chip bricht auf zwei Zeilen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 21:17:24 +02:00
fs
c4ffe4a1f4 Turn-Anfrageformular: Feld „Geburtsdatum des Kindes" wieder da
Auf der alten Seite gab es das Feld, beim Neubau ist es untergegangen. Gegen
2024.tsv08kulmbach.de geprüft: dort hieß es input_alter_kind, war type="date",
OPTIONAL und trug das Label „Geburtsdatum des Kindes" — genau so wieder
eingebaut. Die Abteilung braucht es, weil die Gruppen nach Alter eingeteilt sind.

Nur in der Turn-Variante. In der allgemeinen wäre es sinnlos und würde ohne Not
personenbezogene Daten Minderjähriger erheben. Dafür drei Stellen:
- contact_variant(): Schalter ask_birthdate (true nur für turnen)
- contact-form.php: Feld definiert, bedingt gerendert, direkt nach der
  Gruppenauswahl wie auf der alten Seite
- contact-submit.php: Validierung UND Rücksetzen auf leer, wenn die Variante das
  Feld nicht anbietet — sonst könnte ein manipulierter POST es an der allgemeinen
  Variante vorbei einschmuggeln

Validierung serverseitig, nicht nur per type=date: echtes Datum, nicht in der
Zukunft, nicht vor 1900. createFromFormat mit Rückvergleich, weil PHP sonst
Scheindaten wie 2019-02-31 stillschweigend auf den 3. März verschiebt.
Geprüft mit ausreichend altem Token (die Time-Trap verlangt 3 s, meine ersten
Versuche liefen deshalb als Bot durch): 2019-02-31 abgewiesen, 2030-01-01
abgewiesen, gültiges Datum passiert. Ohne JS bleibt der Wert bei HTTP 422
erhalten (value="2019-03-12" im Re-Render nachgewiesen).

Kein neues Markup: form-field kann type=date und min/max längst, die Prop für
den Hilfetext heißt hint (nicht help, das war mein erster Fehlgriff). Docblock
der Komponente um date und min/max ergänzt.

Datenschutzerklärung mitgezogen, wie die Projektregel es für neue Formularfelder
verlangt — mit Zweckbindung (Zuordnung zur Altersgruppe) und dem ausdrücklichen
Hinweis, dass es keine Pflichtangabe ist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 21:10:47 +02:00
fs
75c06dbd04 Beitragsblock: Sektions-Kopf nach Hausmuster, Preise größer
Drei Dinge, zwei aus deinem Feedback und eines von mir.

1) Der Einleitungstext klebte ohne Abstand an der Überschrift und lief über die
   volle Breite, über 120 Zeichen je Zeile ("schaut weird aus"). Ursache: ich
   hatte <p class="lead"> geschrieben — eine Klasse, die es im Projekt gar nicht
   gibt, der Absatz war also völlig unformatiert. Jetzt das etablierte Muster
   (.membership-signup__head, instagram, partners): zentrierter Kopf, 60ch breit,
   Luft nach unten. Anmerkung im Code: dasselbe Kopf-Muster steht damit zum
   vierten Mal fast gleich in components.css — ein gemeinsames .section__head
   wäre die Aufräumung, aber ein eigener Umbau.

2) Preise von 28px auf 34px. Sie tragen die Hervorhebung allein, seit die Farbe
   weg ist, und sind die Information, auf die man in einer Beitragstabelle sieht.

3) Selbst aufgefallen: die Überschrift "Austritt" lag in .prose und rendert dort
   mit 21px Abel — exakt Fließtextgröße, also gar keine Auszeichnung. In
   Impressum und Artikeln ist das richtig, deshalb .prose nicht angetastet,
   sondern eigene Klassen für den Hinweis: 28px Coolvetica (die Untergrenze für
   Versalien) mit derselben dezenten Sperrung wie die Kartentitel.

Außerdem: data/turnen.json ist wieder in seiner ursprünglichen Formatierung.
Mein JSON-Schreiber hatte im vorigen Commit die kompakten Arrays aufgeklappt
("widths": [640, 1000, 1600] wurde vierzeilig) — inhaltlich identisch, aber 371
Diff-Zeilen für eine Umbenennung. Jetzt textuell ersetzt statt neu geschrieben:
17 Diff-Zeilen, Inhalt per Vergleich identisch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 20:55:48 +02:00
fs
6bc6b0d599 Step Aerobic entfernt, Seite heißt jetzt Fitness & Pilates
Rückmeldung der Turnabteilung: Step Aerobic findet nicht mehr statt.

Wichtig war der Befund vor der Änderung: die Seite bündelt DREI Angebote —
Pilates, Fitness & Stretching und Step-Aerobic. Ein pauschales "die Card raus"
hätte Pilates und Fitness mitgenommen. Entfernt ist deshalb nur die eine Gruppe
(Donnerstag 19:30, Heike Wahler); Pilates und Fitness & Stretching bei Karola
Lorz laufen unverändert weiter. Heike Wahler stand nur in dieser Gruppe und
erscheint damit nicht mehr auf der Website — das ist die einzige Nebenwirkung,
die jemandem auffallen könnte.

Umbenannt in "Fitness & Pilates" an allen zehn Fundstellen: navigation.json,
turnen.json (Disziplin-Schlüssel, Hero-Titel, drei Übersichtstexte),
ansprechpartner.json, news.json, turnen.php (Karte, Alt-Text, Meta) und die
Seiten-Datei selbst.

URL mitgezogen wie besprochen: turnen/step-aerobic-fitness-pilates →
turnen/fitness-pilates, samt Seiten-Datei und den drei Bilddateien (git mv,
Historie bleibt). Der alte Slug war in der Sitemap und ist indexiert, deshalb
eine 301 in public/.htaccess — VOR dem Front Controller, sonst würde index.php
den Pfad als unbekannten Slug abfangen und 404 rendern.

Geprüft: /turnen/fitness-pilates liefert 200 mit nur noch zwei Gruppen,
Sitemap führt ausschließlich den neuen Slug, Navigation, Übersichtskarte und der
News-Artikel verweisen darauf, keine Referenz auf die alten Bildnamen mehr.
Der alte Slug gibt lokal 404, weil php -S keine .htaccess liest; die 301 greift
auf Apache.

Nicht geändert: das Wort "Step-Aerobic" in der CLAUDE.md-Sprachregel. Dort ist
es ein Beispiel für orthografisch nötige Bindestriche und bleibt als solches
richtig, auch ohne den Kurs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 20:50:47 +02:00
fs
9244149bac Sperrung der Kartentitel: neuer Token --ls-heading statt --ls-caps
--ls-caps (0.08em) war zu viel: bei 34px sind das 2,7px und die Überschriften
wirkten auseinandergezogen. Gewollt war ein Hauch Luft, etwa 0,4px (Felix).

Dafür gibt es keinen Token, und rohe Werte verbietet die Token-Disziplin — also
--ls-heading: 0.012em, was bei 34px genau 0,408px ergibt (nachgemessen). Der Fall
trifft jede Coolvetica-Überschrift in h3-Größe, nicht nur diese Karten: der
Schnitt ist condensed, bei 60px trägt er ohne Sperrung, bei 34px kleben die
Versalien. --ls-caps bleibt für kleine Labels, wofür es gedacht ist.

Klarstellung zum vorigen Commit: die Titel waren und bleiben Coolvetica. Der
Verweis auf Abel in meiner Begründung war ein Zitat der CLAUDE.md-Regel für
kleine Auszeichnungen, keine Änderung an der Schriftart.

CLAUDE.md entsprechend berichtigt, inklusive der Warnung, hier nicht --ls-caps
zu nehmen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 20:46:25 +02:00
fs
97676e91ee Beitragskarten: Titel gesperrt, Preise weiß statt rot
Zwei Punkte aus dem Gegenlesen, beide gemessen statt geschätzt.

1) Die Kartentitel waren schwer lesbar. Gemessen war letter-spacing "normal",
   also gar keins gesetzt — nicht zu eng eingestellt. Coolvetica ist ein
   Condensed-Schnitt: bei 60px (Abschnitts-h2) trägt das, bei 34px in einer
   schmalen Kartenspalte kleben die Versalien. Jetzt var(--ls-caps), der
   dokumentierte Token für gesperrte Uppercase-Auszeichnungen.

2) Die Preise standen in Akzentrot. Das ist kein Geschmacksfehler, sondern ein
   Kontrastfehler: #e20612 auf dem Kartengrund (rgba(0 0 0 / 0.22) über #222)
   erreicht 3,50:1 und verfehlt die 4,5:1 für Fließtext. Weiß liegt bei 17,22:1.
   CLAUDE.md verbietet Rot für Fließtext auf dunklem Grund ohnehin. Hervorgehoben
   bleibt der Preis durch Coolvetica und 28px gegen 21px Fließtext.

Die Preisfarbe wirkt auch auf /mitglied-werden, wo dieselbe Komponente steht —
dort ist der Grund dasselbe dunkle Glas (geprüft), die Änderung verbessert also
beide Seiten.

Beide Erkenntnisse als Regel in CLAUDE.md, mit den gemessenen Zahlen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 20:44:17 +02:00
fs
a975bed33c Turn-Beiträge sichtbar machen (standen nirgends auf der Website)
Die Turnabteilung verlinkt die Beiträge in Einladungen für neue Familien und
nimmt zum September auf — die Angaben waren aber auf der ganzen Website nicht
zu finden. Nachgeprüft: 0 Treffer auf /turnen und allen fünf Disziplin-Seiten,
/mitglied-werden führt nur die Fußball-Beiträge.

Es war keine verlorene Seite, sondern eine Lücke im Code: app/pages/turnen.php
rendert nur Hero, Karten und Formular. In data/turnen.json lagen ungenutzt
cards_intro, fees (drei Tabellen), austritt und faq (fünf Fragen). Der Docblock
behauptete, die Beiträge lägen auf den Unterseiten — sie lagen dort nicht, und
in der Git-Historie wurden sie nie gerendert.

Inhalte 1:1 wie auf der alten Seite (gegengeprüft an 2024.tsv08kulmbach.de,
Tab "Allgemeines"): Zusatzbeitrag 24/48 €, Neumitglieder 84/94/184 €,
Förderstufen 120/180/240 €, Austritt zum 30. September. Nichts umformuliert,
die Abteilung entscheidet über Änderungen.

Struktur jetzt Hero → Karten → Beiträge (#beitraege) → FAQ → Formular. Der
Anker ist die Adresse für die Einladungen. Nur bestehende Komponenten benutzt
(fee-table wie auf /mitglied-werden, faq, team-cards mit intro); neu ist allein
der Wrapper .fee-tables. Die FAQ füllt zusätzlich das FAQPage-Schema, weil
page_schema() sie als zweiten Parameter bekommt.

Feinschliff nach Sichtprüfung: Kartentitel von --fs-650 auf --fs-675, weil 28px
Coolvetica neben 21px Fließtext zu klein wirkten; Karten gleich hoch (der
Grid-Default stretch statt align-items: start, gemessen vorher 252/377/471px)
mit dem Hinweis am Kartenfuß; und minmax(min(22rem, 100%), 1fr) statt
minmax(22rem, 1fr), weil die Karten am Handy 352px breit in einem 347px-Raster
standen und überliefen.

NOCH offen: overview.cta ist weiter unbenutzt. Der Block verlinkt "Zur
Turn-Übersicht" und gehört auf die Disziplin-Unterseiten, nicht auf die
Übersicht — dort wäre es ein Selbstlink.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 20:40:23 +02:00
fs
e0b2184372 Swipe-Viewer: alle Seiten wieder einheitlich A5 (kaputter Kommentar)
Am Handy waren die Seitenhöhen beim Durchwischen unterschiedlich: 350 bis 559px
je Inhalt, statt einheitlich 515. Nur Cover, Vorwort, Termine und Sportheim
trafen A5 — die vier haben eine eigene aspect-ratio.

Ursache war ein Fehler von mir aus dem vorigen Commit: der ergänzte Kommentar
landete HINTER dem schließenden */ des bestehenden. Damit stand roher Text vor
der Regel, und der CSS-Parser verwarf den kompletten Block
".stadionzeitung-viewer.is-enhanced .stadionzeitung-page, … { aspect-ratio }".
Der Browser meldete aspect-ratio: auto, obwohl die Regel im ausgelieferten CSS
stand und der Selektor per matches() passte — genau das Bild, das ein
verworfener Block erzeugt. Ein Testlauf mit aspect-ratio: 1/3 änderte nichts,
was den Verdacht bestätigte. php -l prüft CSS nicht, es gab also keinen
Hinweis.

Dass es vor der align-items-Änderung einheitlich aussah, war Zufall: der
Flex-Default stretch streckte alle Seiten auf dieselbe Zeilenhöhe (559) und
verdeckte damit, dass die A5-Regel nie griff.

Zusätzlich sind die Bild-Seiten (Anzeigen) jetzt in derselben A5-Regel. Sie
leiteten ihre Höhe aus dem eigenen Bildverhältnis ab; drei Motive sind
1440x2037 statt 1440x2043 und waren dadurch 513 statt 515px hoch, was beim
Wischen als Sprung auffiel. object-fit: cover fängt die 0,4% Abweichung ab,
wie die Druckfassung es ohnehin tut.

Gemessen: 40 von 40 Seiten 363x515 bei 390px Viewport. Zwei Seiten brauchen
dort internen Scroll (Ergebnisse +44px, Vorschau +11px), weil lange
Vereinsnamen umbrechen — Textumbruch skaliert nicht mit den cqi-Maßen. Im
Druck ist beides unauffällig, per PDF-Sichtprüfung an der Ergebnisse-Seite
bestätigt: Überschrift, drei Ergebnisblöcke und CTA-Kasten vollständig.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 19:38:23 +02:00
fs
c7b23b9f39 Archiv-Zeile: Titel größer und selbsterklärend
Titel lautet jetzt "Stadionzeitung zum Heimspiel gegen <Gegner>" statt nur
"Gegen <Gegner>". Damit ist die Zeile für sich verständlich, auch als Link-Text
für Screenreader. Das "zum" ist eingefügt, damit der Satz grammatisch trägt.

Größe von --fs-650 auf --fs-690, den Token für Karten-Überschriften (28px auf
32 bis 44px). Die alte Begründung für die kleinere Stufe war, dass lange
Gegner-Bezeichnungen in die 280px-Karte passen mussten — die Karte ist jetzt
eine Zeile über die volle Breite, der Grund ist weg. Nach unten bleibt
--fs-650 die harte Grenze für Coolvetica in Versalien.

Geprüft bei 1280px (Titel auf einer Zeile, füllt die Zeile) und bei echten
390px (bricht auf zwei Zeilen, liest sich sauber).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 18:32:32 +02:00
fs
f6efdfb4b4 Stadionzeitungs-Archiv: Liste über die volle Breite statt Regal
Cover links, Angaben rechts daneben — dasselbe Muster wie .veranstaltung-card,
und aus demselben Grund: der Bestand ist klein.

Vorher war es ein horizontales Regal (grid-auto-flow: column, 280px-Spalten,
Scroll-Snap). Der Kommentar dort nannte genau die richtige Gefahr, "bei 1-3
Karten wie eine leere Fläche mit einer verlorenen Karte", löste sie aber nicht:
mit einer veröffentlichten Ausgabe stand eine 280px-Karte allein links auf einer
1280px breiten Seite. Eine Zeile über die volle Breite füllt die Fläche bei jeder
Anzahl.

Zwei Dinge sind dabei anders als bei den Veranstaltungen, beide begründet:
- Die Medienspalte ist schmaler (clamp bis 230px statt 26%), weil das Medium
  hier die live gerenderte Cover-Seite mit festem A5-Verhältnis ist. Ein <img>
  liesse sich per object-fit beschneiden, das Cover nicht — es bestimmt die
  Zeilenhöhe. 230px Cover ergeben 326px Zeile.
- Das margin-top: auto am CTA ist weg. Es stammte aus der hochformatigen Karte,
  wo der Link an den Kartenfuss gehörte; in der breiten Zeile riss es die drei
  kurzen Zeilen auf 150px auseinander.

Neu in der Zeile: die Coverzeile des Interviews als Teaser (interview.teaser,
dieselbe Quelle wie "Im Heft" auf dem Cover). Sie füllt die Breite mit echtem
Inhalt statt Leerfläche und sagt, warum man die Ausgabe öffnen sollte; ohne
Interview entfällt sie, wie .veranstaltung-card__text.

Geprüft per Screenshot bei 1280px und bei echten 390px (iframe-Wrapper, weil
headless Chrome sonst eine Mindestbreite erzwingt): mobil steht das Cover über
den Angaben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 18:30:16 +02:00
fs
e24b63147d CLAUDE.md: Falz-Steg und die drei Seiten mit eigener aspect-ratio
Nachtrag zum vorigen Commit — die Doku-Ergänzung war durchgefallen, weil meine
Textsuche an einem Zeilenumbruch scheiterte. Hält beide Fallen fest: Steg nur
für alle Bögen oder für keinen, und die min-height-Absicherung gehört für
Vorwort, Sportheim und Termine in einen gemeinsamen Selektor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 18:20:48 +02:00
fs
24a3d6234c Druckbogen: Falz-Steg raus, drei Seiten füllen ihre Fläche
Zwei Befunde aus dem Gegenlesen des Hefts, beide gemessen.

1) Die Seiten waren unterschiedlich breit: Umschlagbogen 448,5px,
   Innenblätter 441px. Ursache war ein Falz-Steg von 2%, den nur die
   Innenbögen hatten (:nth-child(n+3)). Der Gedanke war richtig — randabfallende
   Anzeigen stoßen sonst mit 0mm an die Blattmitte — die Umsetzung aber
   uneinheitlich, und das fiel als "das Programm ist schmaler als das Vorwort
   drüber" auf. Entfernt zugunsten von "alle Bögen gleich"; die 9,9mm
   Innenabstand der Seiten halten den Inhalt aus dem Knick. Soll er zurück,
   dann für alle Bögen.

2) Unter dem Foto der Sportheim-Seite blieb ein weißer Streifen: die Seite war
   625,7px hoch in einer 636,4px hohen Fläche. Sie hat wie Vorwort und Termine
   eine eigene aspect-ratio plus negativen Margin, aber die min-height-
   Absicherung hatte nur das Vorwort bekommen. Die drei stehen jetzt in EINER
   Regel, damit eine vierte nicht wieder abdriftet.

Nachgemessen: Sportheim 448,5 x 636,4 statt 441 x 625,7, Foto bündig 0/0/0,
alle Slots ohne Padding, im gedruckten PDF Bogen 11 unten 0,0mm. Sichtprüfung
an Bogen 11: Foto läuft bis an linke und untere Kante.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 18:20:14 +02:00
fs
f678608140 Druckfassung: alle Bögen randabfallend, unterer Weißstreifen weg
Im Druck war oben fast kein Rand und unten viel — im Chrome-Export etwa 3 zu
8 mm. Der Rand war nicht die Ursache: der Blatt-Inhalt war über eine
aspect-ratio bemaßt und nur 199,1 mm hoch statt 210 mm. Die fehlenden 10,9 mm
sammelten sich immer unten, weil der Inhalt oben ausgerichtet ist. Gemessen an
Blatt 4 des erzeugten PDF:

  margin 5mm  ->  oben 4,8 / unten 5,8 mm   (Rest 0,6 fiel kaum auf)
  margin 0    ->  oben 0,0 / unten 10,9 mm  (der ganze Rest unten)

Ein Rand hätte den Fehler also nur kaschiert. Randabfallend ist die
geometrisch saubere Lösung, weil zwei A5 exakt ein A4 quer sind
(2 x 148,5 = 297 mm, Überschuss null) — es bleibt nichts zu verteilen.

Umgesetzt durch Auflösen einer Ausnahme statt neuer Sonderregeln: die zwei
Umschlagbögen liefen längst randabfallend (@page umschlag, Fläche 210mm), das
gilt jetzt für alle Bögen. Damit fällt auch der Bruch weg, dass der Umschlag
anders gedruckt wurde als die Innenblätter.

Nachgemessen: oben und unten je 0,0 mm auf Blatt 1, 4 und 6. Verbleibendes Weiß
steckt in den Anzeigenmotiven selbst (viele Sponsoren gestalten auf weißem
Grund, 100% weiße Randpixel) und ist nicht unser Layout. Sichtprüfung an
Blatt 4: Sportfest-Plakat läuft bis an alle vier Kanten, nichts beschnitten.

Bewusst in Kauf genommen (Felix): der Bürodrucker beschneidet auf sein
Hardware-Minimum von 4 bis 5 mm und liefert den Rand selbst. Unser Fließtext
verliert dabei nichts, die Live-Seiten haben 9,9 mm Innenabstand. Ein
Falz-Abstand entfällt, die Seiten stoßen mittig aneinander.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 18:09:59 +02:00
8be0441341 Hutschdorf-Ausgabe eingefroren 2026-07-31 17:50:30 +02:00
fs
de5132c2ec CLAUDE.md: Bild-Einfrieren beim Veröffentlichen dokumentieren
Nachtrag zu 7223122 — der Doku-Teil war beim Commit durchgefallen, weil meine
Textsuche in CLAUDE.md nicht griff. Enthält auch den Hinweis, dass veröffentlicht
werden muss, wo der frische Sync-Stand liegt: auf dem Server. data/matchcenter.json
ist gitignored, lokal ist er also älter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 17:48:27 +02:00
fs
7223122220 Veröffentlichen friert auch die Bilder ein
Der Snapshot fror die Daten ein, verwies aber weiter auf Ordner, die die
Sync-Skripte verwalten: matchcenter-sync räumt Wappen weg, die der BFV nicht mehr
führt (eine Saison), instagram-sync ältere Posts (Wochen). Am 31.07.2026
nachgewiesen — ein Lauf löschte 18 Wappen und 3 Bilder. Eine archivierte Ausgabe
wäre also mit der Zeit von selbst zerfallen, und "gedruckt = fixiert" war damit
nur die halbe Wahrheit.

bin/stadionzeitung-publish.php kopiert jetzt alles, worauf der Snapshot zeigt
(img/crests, img/instagram, img/news), als fix-* in den Ausgaben-Ordner und
schreibt die Pfade um. Varianten-Sets {base, widths} werden über alle Breiten
kopiert und nur umgeschrieben, wenn alle da sind — ein halbes srcset wäre
schlimmer als der alte Pfad; genau daran fiel img/news/neue-website beim ersten
Versuch auf. --entwurf löscht die fix-*-Dateien wieder, sie gehören zum Snapshot.
Flache Dateinamen statt Unterordner, weil die Aufräum-Logik in
stadionzeitung-add.php nur Dateien entfernt und ein Unterordner als Waise bliebe.

bin/preflight.php warnt, wenn eine veröffentlichte Ausgabe noch auf die
flüchtigen Ordner zeigt — sonst verfiele sie still.

Geprüft am Hutschdorf-Heft: 39 Dateien eingefroren, Snapshot verweist nur noch
auf den Ausgaben-Ordner, 88 Bilder in Viewer und Druckfassung erreichbar, keins
fehlt, preflight-Warnung verschwindet. Der Testlauf wurde danach zurückgenommen:
der lokale Matchcenter-Stand ist einen Tag alt, eingefroren wird auf dem Server
nach dem letzten Sync vor dem Druck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 17:47:44 +02:00
fs
344727bed0 Vorwort-Seite: Rasenbild bis zur Blattkante
Unter dem einblendenden Rasenbild blieb ein weißer Streifen, responsiv wurde er
größer. Gemessen bei 390px Viewport: Seite 559px hoch, Vorwort 515px — 44px
Lücke.

Ursache lag nicht am Vorwort, sondern an der Seite: .stadionzeitung-viewer__page
ist display:flex, und der Flex-Default align-items:stretch streckt die Seite auf
die Höhe der Flex-Zeile und übersteuert ihre aspect-ratio. Die Seite war damit
gar kein A5 mehr (559/363 statt 210/148) — das betraf ALLE Seiten, sichtbar wurde
es nur beim Vorwort, weil dort ein Bild die Blattkante erreichen muss.

align-items: center behebt es an der Wurzel: alle Seiten sind wieder exakt A5,
Lücke 0px. Zusätzlich bekommt das Vorwort min-height: calc(100% + 2x Padding) als
Absicherung — die aspect-ratio trägt weiter, wenn die Seite keine definite Höhe
hat (No-JS-Stapel, Druck), die min-height füllt sie, wenn sie eine hat.

Gemessen vorher/nachher, Desktop-Viewer per Screenshot gegengeprüft.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 17:43:52 +02:00
fs
a63822f3d4 Druckfassung-Button aus dem Viewer entfernen
Die Druckfassung ist Werkzeug für die Heft-Produktion, kein Besucher-Angebot.
Als sichtbarer Knopf neben Blättern und Vollbild wurde er versehentlich geklickt
und riss Leute aus dem Lesen (Felix, 31.07.2026). Die Route bleibt unverändert
erreichbar, nur über die URL: ?druck=1 an die Ausgabe hängen.

Mit dem Knopf fallen drei CSS-Stellen weg, die es ohne ihn nicht mehr braucht:
die Basisregel, die Mobil-Ausblendung (dort wäre er eine Sackgasse gewesen) und
der Eintrag in der Ausblend-Liste der Druckansicht. In CLAUDE.md vermerkt,
inklusive der Bitte, ihn nicht als Link wieder einzubauen.

Geprüft: Viewer enthält kein druck=1 und kein viewer__print mehr, Vollbild und
Blättern intakt, ?druck=1 antwortet weiter mit 200 und montiert die Blätter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 17:36:15 +02:00
fs
827a717a02 Stadionzeitungs-Cover: definite Breite im Swipe-Viewer
Das Cover fehlte am iPhone, Chrome am Desktop zeigte es. Es hatte im Viewer eine
eigene Regel mit "max-height: 72vh; width: auto" — und width:auto ist bei
inline-size-Containment genau die Falle, die für .stadionzeitung-page schon
behoben war: der Inhalt zählt für die eigene Breite per Definition nicht mit.
Sichtbar wird der Unterschied, weil .stadionzeitung-viewer__page display:flex ist:
auf einem Flex-Kind heißt width:auto "richte dich nach dem Inhalt". Chrome
rettet sich über aspect-ratio plus gestreckte Höhe, Safari nicht.

Cover und Seite stehen jetzt in EINER Regel mit derselben definiten Breite, damit
sie nicht wieder auseinanderlaufen; nur overflow-y bleibt den Seiten vorbehalten.
Gemessen bei 390px Viewport: Cover 363x559, identisch zu den Folgeseiten.

EHRLICHE GRENZE: Chrome zeigt vor und nach der Änderung dieselben Maße, der
Fehler ist im Desktop-Browser nicht reproduzierbar. Die Ursache ist damit
begründet, aber nicht bewiesen — Gegenprobe nur am echten Gerät. In CLAUDE.md
vermerkt, damit das nächste Mal niemand im Desktop-Browser "verifiziert".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 17:31:35 +02:00
fs
05c64c8f12 Takt über public/cron.php: KAS-Cronjobs können nur URLs
Korrektur meiner eigenen Annahme von heute früh. Der Tarif hat SSH und
Cronjobs, aber das KAS-Formular kennt nur ein Feld "Protokoll / Pfad" mit
https:// — ein `php bin/…` ist dort nicht eintragbar. Der Endpoint, den ich
vorhin stillgelegt hatte, ist damit wieder der einzige Takt-Geber, und zwar in
einer besseren Form als geplant: Aufrufer ist der Hoster selbst, der Schlüssel
verlässt den Webspace nie, kein Dritter ist beteiligt.

Dritter Job 'logrotate' in der Whitelist, denn ohne ihn liefe die Logpflege nie
automatisch und die 2-MB-Grenze samt 90-Tage-Frist wäre toter Code. Er passt in
denselben Rahmen wie die Syncs: kein Parameter aus der URL, keine Verbindung nach
außen, schreibt nur in storage/logs. bin/log-rotate.php trägt dafür denselben
Riegel wie die Syncs (CRON_HTTP) und require_once. Eine vierte Ausnahme braucht
wieder denselben Aufwand an Begründung.

Geprüft: falscher Schlüssel 403, unbekannter Job 403, gültiger Aufruf 204 mit
leerem Body, sofortige Wiederholung 204 plus WARN, Logdateien unversehrt.

Zeitraster angepasst, weil das KAS Wochentage nicht mit Halbstunden kombiniert:
Matchcenter stündlich statt 2x/Tag plus Wochenende, Instagram als zwei
Tageseinträge, Logpflege täglich nachts. Die Abwägung steht in
config.example.php und docs/deploy.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 17:16:12 +02:00
fs
964ffb932b config.example: Cron-Doku auf den echten Stand
Zwei Korrekturen an Stellen, die beim Anlegen der Cronjobs in die Irre geführt
hätten: (1) der Abschnitt behauptete noch, der Tarif habe keine Cronjobs —
seit 31.07.2026 ist Weg A unser Weg, cron.key ist leer und public/cron.php
stillgelegt. (2) Die Cron-Zeilen leiteten die Fehlerausgabe in die Kanal-Logs,
deren Format `[Zeit] LEVEL Meldung` eine rohe PHP-Warnung bricht (bin/logs.php
wertet Kanäle nach Level aus). Sie geht jetzt nach php-errors.log, das
bin/logs.php bereits als "eigenes Format" behandelt. Dazu die echten Pfade
dieses Webspace statt Platzhalter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 17:09:35 +02:00
fs
913c058c93 send_mail: Erfolg als INFO ins mail-Log
Bisher schrieb nur der Fehlerfall. Damit war ein funktionierender Versand von
einem stummen nicht zu unterscheiden: beim Deploy am 31.07.2026 zeigte
bin/logs.php gar keinen mail-Kanal, was "nichts abgeschickt" und "einwandfrei
versandt" gleichzeitig bedeuten konnte.

Geloggt wird nur der Kontext (welches Formular) — die Werte sind feste Labels
wie "Kontaktanfrage" oder "Sportheimbuchung", nie Empfänger, Absender oder
Inhalt. Das verbietet die Logging-Regel, und für "läuft der Versand?" braucht es
davon auch nichts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 17:07:51 +02:00
fs
f1ba364922 config.example: transiente SMTP-Auth-Fehler und Rückfallweg dokumentieren
Auf dem Server scheiterte die Anmeldung mit "Could not authenticate", lief
Minuten später ohne jede Änderung fehlerfrei und wurde parallel vom
Entwicklungsrechner durchgehend angenommen. Es war also vorübergehend, nicht
falsch konfiguriert — vermutlich der Brute-Force-Schutz des Mailservers.

Notiert, damit das nicht als Konfigurationsfehler behandelt wird, plus der
nachgewiesene Rückfallweg (localhost:25 ohne Anmeldung, der lokale Relay).
Bewusst nicht der Standard: mit localhost liefen Dev und Produktion auseinander.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 17:05:02 +02:00
fs
748f029afb smtp-test: verdeckte Passwort-Eingabe und Zugangsweg-Sonde
Auf dem Server scheiterte der Login mit "Could not authenticate", während
dieselben Zugangsdaten vom Entwicklungsrechner angenommen werden. Beide Namen
(w01ca75d.kasserver.com und der Webserver) zeigen auf dieselbe IP — vom Webspace
aus verbindet sich PHP also mit der eigenen Maschine, und die kann Anmeldungen
anders behandeln als Verbindungen von außen.

--probe testet vier Zugangswege (587/STARTTLS, 465/SSL, localhost:25 mit und
ohne Anmeldung) und nennt den ersten, der trägt. Bewusst nur vier: jeder
Fehlversuch ist ein fehlgeschlagener Login, zu viele sperren ein Postfach.

--frage fragt das Passwort verdeckt ab, damit ein neu gesetztes Postfach-Passwort
geprüft werden kann, BEVOR es in eine Datei auf zwei Rechnern wandert.
Die Fehlerausgabe nennt bei "Could not authenticate" jetzt die drei Ursachen,
die dahinter stecken können.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 17:03:00 +02:00
fs
2066b43a57 Cover-Spielerfoto: Varianten statt 12-MB-Original (iOS Safari)
Das Cover blieb am iPhone leer, während Chrome am Desktop es zeigte. Ursache
war kein fehlendes Bild: das freigestellte Spielerfoto hatte 2584x5093 Pixel und
12,3 MB. iOS Safari verwirft Bilder, deren dekodierte Größe sein Budget
übersteigt — hier gut 53 MB, und auf einer Ausgabe liegen 86 Bilder.

Dargestellt wird das Foto mit 98cqi Höhe, bei 480px Seitenbreite also etwa
238x470 CSS-Pixel. Es gibt jetzt 320/640/1000 als PNG mit erhaltener
Transparenz (0,2 / 0,8 / 1,9 MB); sizes nennt die echte Breite, sonst zöge ein
Handy mit DPR 3 die größte Variante. Das Original ist gelöscht.

Dafür kann resize_image_variants() nun Alpha erhalten ($alpha = true schreibt
PNG statt JPG) und die img-Komponente eine Endung ($image['ext']). Der Default
bleibt JPG, weil bestehende Aufrufer PNG-Quellen absichtlich zu JPG verkleinern.

bin/stadionzeitung-add.php erzeugt die Varianten künftig selbst. Dort stand
"unverändert kopieren, Felix liefert bereits ein web-taugliches Foto" — genau
diese Annahme ist gerissen.

Außerdem: smtp.password stand in der Produktiv-Config noch auf dem Platzhalter
POSTFACH_PASSWORT (von preflight auf dem Server gefunden, einziger harter
Fehler). Übernommen wurde das Passwort aus der Dev-Config, mit dem der Versand
am 27.07.2026 verifiziert wurde; Host, Postfach und Empfänger sind identisch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:58:43 +02:00
fs
a3cfd4941d Banner-Hintergründe CSP-fest: <img> statt Inline-Variable
Beim ersten Deploy blieben die Slides des Banner-Sliders schwarz. Ursache war
kein fehlendes Bild, sondern die CSP: public/.htaccess setzt
default-src 'self' ohne style-src, der Browser verwirft damit jedes
style-Attribut — und die Komponente lieferte Bild und Akzentfarbe genau so
(style="--banner-bg: url(…); --banner-accent: #6cbbbc"). Lokal war das
unsichtbar, weil php -S keine .htaccess liest.

Der Hintergrund ist jetzt ein <img class="ad-banner__bg"> über die bestehende
img-Komponente (CSP erlaubt img-src) und bringt srcset mit: das Handy lädt die
640er statt der 1600er, die erste Slide lädt eager mit fetchpriority. Der
dunkle Leseverlauf liegt auf ::before statt im background-image.

Die Akzentfarbe kann als beliebiger Hex-Wert nicht ohne Inline-Style in die
Seite. Sie steht deshalb als Token in tokens.css (wo Farben laut CLAUDE.md
hingehören), components.css bildet den Namen darauf ab, und banners.json nennt
nur noch "accent": "breadcrumb".

bin/preflight.php bricht ab, wenn unter app/ wieder ein style-Attribut
auftaucht — der Fehler wäre sonst erneut erst in Produktion sichtbar. Beim
Bauen dieser Wache zweimal selbst hineingetappt: ein Muster mit [^>]* bricht an
jedem schließenden PHP-Tag ab, und die Zeichenfolge dieses Tags beendet den
PHP-Modus auch in einem //-Kommentar (preflight gab ab dort seinen eigenen
Quelltext aus, php -l meldete nichts).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:50:43 +02:00
fs
e8296a465b Deploy-Anleitung: Verzeichnisschutz ehrlich als Empfehlung einordnen
Stand vorher "nicht weglassen". Tatsächlich mindern die Canonical-Tags auf
www.tsv08kulmbach.de das Indexierungsrisiko so weit, dass der Schutz eine
Empfehlung ist und kein Blocker. Am 31.07.2026 bewusst weggelassen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:39:39 +02:00
1213fa4ca1 Sync-Stand vom Server: Wappen und Instagram-Bilder aktualisiert 2026-07-31 16:36:23 +02:00
fs
8ed5707d24 Deploy-Anleitung: Git-Identität auf dem Server als eigener Schritt
Fehlte, und ohne sie bricht jeder Commit auf dem Server mit "Author identity
unknown" ab — womit der Update-Ablauf aus F1 nicht funktioniert. Dazu der
Umgang mit einem abgewiesenen Push (pull --rebase).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:36:03 +02:00
fs
3cf8c2688b Deploy-Anleitung: Platzhalter ohne spitze Klammern
<host> liest die zsh als Eingabe-Umleitung, der scp brach mit "no such file or
directory: host" ab. Platzhalter heißen jetzt SERVER.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:28:58 +02:00
fs
dd18906348 Deploy-Anleitung: absolute Pfade, ~ ist auf diesem Webspace /
Das SSH-Konto hat die Systemwurzel als Home. cd ~ führt aus dem Webspace heraus,
~/tsv08kulmbach-website existiert nicht. Betraf das scp-Ziel und jede cd-Zeile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:21:09 +02:00
fs
4f1bfce8b2 Deploy-Anleitung: echte Server-Pfade statt Platzhalter
B1 auf dem Webspace durchgeführt: PHP-CLI 8.3.29 unter /usr/bin/php, Composer
2.9.8 global, Git 2.43. Der Ordnername tsv08kulmbach.de ist von der alten
CMS-Seite belegt (die der Rückweg beim Umschalten ist), der neue heißt deshalb
tsv08kulmbach-website.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:19:22 +02:00
fs
f0ec8614b2 Deploy-Anleitung + Cron-Endpoint stilllegen
Der Tarif hat jetzt SSH und Shell-Cronjobs. Damit fällt die Begründung für
public/cron.php weg: der Endpoint war ausschließlich der Ersatz dafür.
cron.key steht in der Produktiv-Config auf '' (403 auf jeden Aufruf), der Code
bleibt als Rückweg samt Begründung stehen.

docs/deploy.md beschreibt den Weg in kleinen Schritten. Kern: die neue Seite
läuft erst auf einer per Verzeichnisschutz geschlossenen Test-Subdomain mit
DERSELBEN Produktiv-Config, die später live geht — kein Config-Wechsel beim
Umschalten. Möglich, weil die Host-Weiterleitung in public/.htaccess nur beim
exakten Host ohne www greift. Die alte CMS-Seite bleibt bis zuletzt online und
ist der Rückweg.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:13:14 +02:00
fs
a495250db8 CLAUDE.md: uploads-Regel dokumentieren
Der Satz „Weil uploads/ gitignored ist" stimmte nicht mehr. Ersetzt durch die
Regel, die den Ordner im Repo tragfähig macht (Server pusht zuerst) und den
dauerhaften Preis (Historie schrumpft nie).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:07:13 +02:00
fs
aee8a3a901 Dateiablage /dateien versionieren (Vereinsmaterial sichern)
483 Dateien, 174 MB: Fotos und Videos vom Sportfest 2025, die Fundstücke der
Chronik 2028 samt ihren .quelle.txt-Belegen, das Logo. Damit ist das Material
gesichert und beim Entwickeln lokal verfügbar.

Der Ordner ist ein Schreibziel für andere, deshalb hat die Reihenfolge Bedeutung:
auf dem Server committen und pushen, dann lokal pullen, dann entwickeln. Und da
Git nichts vergisst, schrumpft das Repo nie wieder — große Videos sparsam.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:05:19 +02:00
fs
e6f72a0e4b Wappen und Instagram-Bilder versionieren
Eine veröffentlichte Stadionzeitung friert Verweise auf genau diese Dateien
dauerhaft ein: 37 der 86 Bilder im Hutschdorf-Heft lagen in gitignorierten
Ordnern (33 Wappen, 4 Instagram-Fotos). Auf einem frischen Klon wären sie
kaputt, und der Instagram-Sync räumt alte Posts irgendwann weg, womit eine
archivierte Ausgabe nachträglich zerfiele.

Die JSON-Caches bleiben draußen, die baut der Sync neu. Maschinenverwaltet und
flüchtig ist nicht dasselbe wie maschinenverwaltet und für immer referenziert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 16:02:56 +02:00
fs
2f8619e78d Gedruckte Adressen aus club.website statt base_url
QR-Codes und Adresszeilen trugen `http://localhost:8000` ins Druckmaterial, weil
sie über abs_url() liefen. Nach dem Produktivgang hätten alle zehn Codes neu
erzeugt werden müssen — und ein gedruckter Code mit localhost darin ist Altpapier.

Die öffentliche Domain ist Vereinsidentität, keine Umgebungseinstellung: neues
Feld `club.website` in data/club.json plus club_url()/club_domain(). base_url
bleibt umgebungsabhängig, weil Canonical, OG, Sitemap und JSON-LD zur
ausliefernden Adresse passen müssen.

Umgestellt: Cover-QR und die acht geteilten CTA-QRs, die Internet-Zeile im
Heft-Impressum, die Cover-Zeile neben dem QR, die acht Linkzeilen unter den
CTA-QRs der Live-Seiten (die hatte ich beim ersten Durchgang übersehen), das
Web-Feld im Website-Impressum und bin/qr.php.

Nebenbei: club_domain() ohne Pfad hängte einen Wurzel-Slash an
(„tsv08kulmbach.de/"), und der Kopf-Kommentar von stadionzeitung-add.php
beschrieb noch die alten vier Anzeigen-Gruppen.

Nicht verifizierbar und deshalb dokumentiert: die vendorte QR-Bibliothek
arbeitet nicht reproduzierbar (gleiche URL, andere Pixel — anderes Maskenmuster,
beides gültig). Ein PNG-Vergleich beweist nichts, nachweisbar ist nur der
Code-Pfad. Jeden Druck-QR einmal scannen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 15:40:59 +02:00
fs
93b67bc9d3 CLAUDE.md: Anzeigen-Bestand, Verteilungsregel, Veranstaltungen, Lightbox
Neue Single-Source-Zeilen für data/anzeigen.json und data/veranstaltungen.json,
Vorlage der Stadionzeitung als Blöcke mit Lücken statt vier Anzeigen-Gruppen,
Doppelseiten-Regel jetzt maschinell. Dazu die Fallen, in die wir gelaufen sind:
Kontext-Regeln setzen Custom Properties und kein Layout (… .crest), --header-space
statt --header-h, [hidden] gegen .lightbox, QR-Codes tragen die base_url fest
eingebacken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
fs
a101b0c3ec Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung
Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News,
Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover,
Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen
kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei
hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das
Skript selbst, statt sie von Hand zu prüfen.

Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus
über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit
Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie
doppelt gepflegt wird.

News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head.

Behobene Fehler:
- Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das
  Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen
  (Ergebnis-Rückblick, Vorschau, Cover).
- --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px
  Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links
  unter dem Logo.
- base_url in der Produktions-Config ohne www, während .htaccess auf www
  umleitet; preflight prüft das jetzt.
- .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen.

Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php
(Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit
Warnung, wenn base_url nicht auf die echte Domain zeigt).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
fs
3fb480dd78 Inhalte und Bilder: Stadionzeitung, Veranstaltungen, News, Anzeigen-Bestand
Neue Datenquellen:
- data/veranstaltungen.json  Feste mit eigener Detailseite (dritte Termin-Quelle)
- data/anzeigen.json         Sponsoren-Anzeigen als dauerhafter Bestand statt
                             pro Ausgabe abgeliefert
- data/stadionzeitung.json   Ausgaben-Archiv (maschinenverwaltet)
- data/news.json             redaktionelle Meldungen mit Artikelseiten

Bilder einmal statt pro Ausgabe: public/assets/img/anzeigen/ hält die 19
Sponsorenmotive plus drei Eigenanzeigen; vorher lag jede Anzeige als Kopie
unter jeder Stadionzeitungs-Ausgabe.

Zuerst die Inhalte, dann der Code: so ist der Baum in jedem Schritt gültig.
Daten ohne die zugehörigen Seiten sind harmlos, Seiten ohne Daten nicht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:54:37 +02:00
fs
82cf21085d Design-System: Fokus vereinheitlichen, Formularfeld-Ring WCAG-fest machen
Letzter Teil. base.css hatte längst eine globale :focus-visible-Regel, trotzdem
existierten 15 Fokus-Regeln — die meisten davon Ballast.

Fünf Overrides wiederholten die Basisregel praktisch wortgleich (.back-to-top,
.ad-banner__dot, .faq__q, .table-scroll, .btn) und sind ersatzlos entfallen; sie
unterschieden sich höchstens um 1px Offset. Die Basisregel selbst läuft jetzt
über --focus-ring/--focus-offset, .position-card__head wechselt von Akzentrot
auf Weiß. Übrig bleiben zehn Regeln, davon deklarieren nur noch vier überhaupt
eine outline — jede mit Grund (inset-Offset bei .tab und .position-card__head,
geschichteter Ring beim Formularfeld). Die anderen sechs sind reine Zusatz-
effekte (transform, Farbe, Fläche) und lassen den Basis-Ring durch.

Der eigentliche Fund steckte im Formularfeld: es setzte outline: none und
ersetzte den Ring durch einen roten Rahmen mit 30%-Glow. Akzentrot erreicht auf
der Feldfläche aber nur 2,8:1 und verfehlt damit WCAG 1.4.11 (>=3:1) für
Fokus-Indikatoren — Tastaturnutzer hatten dort also den schwächsten Fokus der
ganzen Seite. Roter Rahmen und Glow bleiben als Marken-Signal, der weiße
Standard-Ring kommt zurück; outline-offset entspricht der Glow-Breite, beide
Ringe liegen bündig nebeneinander statt übereinander.

CLAUDE.md dokumentiert die neuen Regeln: Token-Disziplin mit Tabelle (Spacing,
Typo, Icons, Sperrung, Fokus, Trefferfläche) samt der bewussten em-Ausnahme,
das Button- und Tab-System inklusive der (0,2,0)-Falle durch die
CSS-Ladereihenfolge, und die Fokus-Regel. Dazu ein Punkt in der Checkliste.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:33:53 +02:00
fs
eacf9449e0 Design-System: Button-Varianten konsolidieren, 44px-Trefferflächen
Dritter Teil. .btn hatte nur primary + outline + sm/lg, daneben lebten mehrere
Controls als Eigenbau — teils zeichengleich dupliziert.

Tabs: .group-tabs__tab und .filter-bar__tab waren bis auf flex-shrink und 4px
Innenabstand identisch. Beide laufen jetzt über eine gemeinsame .tab-Basis, die
aria-pressed (Filter) und aria-selected (Tabs) gleich behandelt; von den
BEM-Klassen bleibt nur das jeweilige Delta. Innenabstand vereinheitlicht auf
--space-m, die Gruppen-Tabs verlieren dadurch 4px pro Seite.

Icon-Buttons: .ad-banner__nav-btn und .ad-banner__toggle waren vollständige
Duplikate voneinander. Sie tragen jetzt .btn .btn--icon und behalten nur die
Glas-Optik als Delta. Der Selektor schreibt .btn mit — utilities.css lädt nach
components.css, bei gleicher Spezifität gewänne sonst die rote .btn-Fläche.

Trefferflächen auf 44px (WCAG 2.5.5): .btn bekommt min-height und wird
inline-flex, damit Icon und Label ohne Zusatz-Markup zentriert liegen.
Bezeichnend auch hier — .department-choice__btn hatte inline-flex, align-items
und exakt denselben gap bereits selbst deklariert und gibt sie jetzt ab.
Controls, die optisch klein bleiben sollen, strecken die Fläche stattdessen
unsichtbar per Pseudo-Element (.tap-target, automatisch auch für .btn--sm) —
gewertet wird die Trefferfläche, nicht die gezeichnete.

Der Banner-Indikator brauchte einen anderen Weg: er trug overflow: hidden, um
den Fortschrittsbalken zu beschneiden, was jede Erweiterung wieder abgeschnitten
hätte. Der sichtbare 6px-Balken liegt jetzt im ::before, der Fortschritt ist
selbst rund — overflow entfällt, der Button ist 48x44px.

Haupt-Nav-Links, Dropdown-Trigger und Footer-Social-Links bekommen min-height
statt einer Pseudo-Erweiterung: sie stehen in Reihen, wo überlappende
Trefferflächen den Nachbarn verdecken würden. In der 60px hohen Kopfleiste ist
die Höhe ohnehin nicht sichtbar.

Bewusst unverändert: die Formular-Checkbox bleibt bei 24px (WCAG 2.5.8).
<input> ist ein Replaced Element, dort greift ::after nicht zuverlässig — und
das zugehörige <label> ist klickbar und liefert die große Fläche.

.btn--ghost ist angelegt, hat aber aktuell keinen Abnehmer: die vermuteten
nackten Links sind bei näherem Hinsehen Bildkarten (.instagram__link),
Wrapper (.split__cta) oder eigenständige Karten-Affordanzen (.team-card__cta),
keine tertiären Buttons.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:28:02 +02:00
fs
3fa521eaf1 Design-System: Typo-Skala schließen, h4–h6 auf Abel, Icon-Größen tokenisieren
Zweiter Teil der Konsolidierung. Vorher standen 31 font-size-Werte außerhalb
der Skala — beim Durchgehen stellte sich heraus, dass 20 davon gar keine
Textgrößen sind: der icon()-Helper rendert 1em, also steuerte font-size dort
die Icon-Größe. Dafür gibt es jetzt eine eigene, kleine Skala (--icon-s/m/l/xl
in rem, --icon-inline-sm/--icon-inline/--icon-inline-lg in em für Icons, die im
Textfluss mitwachsen sollen). Elf Ad-hoc-Werte fallen damit auf sieben Stufen.

h4–h6 laufen nicht mehr über Coolvetica. Sie lagen mit 26px und 21px unter der
--fs-650-Grenze aus CLAUDE.md, ab der der Condensed-Schnitt in Uppercase
unleserlich wird — die Regel war also nur dokumentiert, nicht durchsetzbar.
Jetzt tragen h1–h3 Coolvetica (alle über der Grenze) und h4–h6 Abel mit
Sperrung. Bezeichnend: das einzige <h4> im Markup hatte sich genau dieses
Muster bereits von Hand gebaut. Die Basisregel liefert es jetzt, die Komponente
gibt ihre drei Duplikat-Deklarationen ab — sichtbar ändert sich dadurch nichts.

Neue Stufen schließen die Lücken: --fs-550 (fluider Lead), --fs-690 (Karten-
Überschrift) und --fs-1000 (Display-Hero). Vier fast deckungsgleiche clamp()-
Rampen für Karten-Überschriften (bento__heading, bento__stat-value,
group-card__name, team-card__name) laufen jetzt über --fs-690; team-card__name
wächst dabei am Desktop um ~5px, die anderen drei bleiben im 1px-Bereich.

Die Wappen-Initialen im Matchcenter hingen an zwei Mini-Schriftgrößen (10,4 und
11,2px) unterhalb der Skala. Sie sind in Wahrheit ~0,4 × Kreisdurchmesser,
folgen jetzt also --crest-size statt einer eigenen font-size. Nebenbei fallen
drei redundante Doppel-Selektoren weg: .crest--initials trägt immer auch .crest.

Ebenfalls tokenisiert: 27 letter-spacing-Werte mit elf verschiedenen Zahlen auf
vier Stufen (--ls-tight/caps/wide/wider), maximale Abweichung 0,02em.

Danach steht kein font-size- und kein letter-spacing-Wert mehr außerhalb von
tokens.css.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:17:13 +02:00
fs
87055bd14b Design-System: Spacing auf Tokens migrieren
Alle padding/margin/gap-Deklarationen in components.css, layout.css und
base.css nutzen jetzt die Spacing-Skala — 414 Token-Verwendungen, kein roher
rem-Wert mehr in einer Abstands-Deklaration.

Die Substitution lief mechanisch (nächste Rasterstufe, Gleichstand aufgerundet),
damit sie deterministisch und nachvollziehbar ist statt 450-mal von Hand
geraten. Rund 325 Werte lagen bereits exakt auf einer Stufe und sind
verlustfrei übernommen. 125 Werte verschieben sich:

  ±0,4–2,0px   106 Werte  (0.1/0.15/0.3/0.35/0.4/0.6/0.85/0.9/1.1/1.6rem …)
  ±4px          12 Werte  (2.25 -> 2.5, 2.75 -> 3rem)
  ±8px           8 Werte  (3.5 -> 4, 4.5 -> 5, 7.5 -> 7rem)

Die 8px-Sprünge treffen ausschließlich Sektionsabstände (padding-block großer
Bänder, margin-top von Blöcken), wo sie optisch nicht ins Gewicht fallen. Die
±4px-Gruppe ist 2.25rem, das exakt zwischen zwei Stufen lag.

em-Werte bleiben bewusst roh: sie skalieren mit der font-size (Button-Padding,
Badge-Innenabstände) und gehören damit nicht auf ein festes px-Raster. Nullen,
auto und Prozentwerte sind unangetastet, calc() wurde korrekt mitgezogen
(calc(var(--header-h) + var(--space-5xl))).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:09:33 +02:00
fs
8304095dc3 Design-System: Token-Ebene für Spacing, Fokus und Tap-Targets
Vorbereitung für die Konsolidierung. tokens.css war für Farben und Typo schon
die Single Source of Truth, für Abstände aber nur mit --space-section und
--container-pad besetzt — dem standen ~450 Spacing-Deklarationen mit 39
verschiedenen rem-Werten gegenüber.

Neu: 14-stufige Spacing-Skala auf 4px-Raster, aus dem Ist-Bestand geclustert
statt frei erfunden. Die neun häufigsten Werte der Codebase (1, 1.5, 1.25, 2.5,
0.75, 0.5, 1.75, 2, 3rem — zusammen ~60% aller Deklarationen) liegen bereits
exakt auf Stufen, die Migration ist dort verlustfrei. Nach oben wird das Raster
gröber (3 → 4 → 5 → 7rem), weil große Abstände das vertragen. --space-4xs (2px)
ist ausdrücklich nur für optische Korrekturen an Icon-/Baseline-Nudges.

Dazu --tap-min (44px, WCAG 2.5.5) und --focus-ring/--focus-offset. Der Fokus
wird auf Weiß festgeschrieben: 15,9:1 auf --clr-bg gegenüber 3,2:1 für Rot,
das WCAG 1.4.11 nur knapp schafft und als einziger Indikator zu schwach ist.

--container-pad und --space-section verweisen jetzt auf die Skala statt eigene
Werte zu führen. Entfernt: --clr-bg-light und --clr-surface-raised, beide
projektweit 0× verwendet.

Consumer folgen in den nächsten Commits — dieser Schritt ändert die Darstellung
noch nicht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:07:31 +02:00
fs
96a698ebdc Mobiles Menü als Drawer: oben verankert, Accordion statt Sprung
Das Overlay war vertikal zentriert. Beim Aufklappen eines Dropdowns wuchs die
Liste und rezentrierte sich — der gerade angetippte Punkt sprang unter dem
Finger weg, bei langen Menüs lief der Inhalt aus dem Screen (nicht scrollbar).

Jetzt ist die Liste oben verankert (justify-content: flex-start, padding unter
Header + Logo-Überhang) und scrollbar mit overscroll-behavior: contain. Punkte
sind volle Tap-Breite mit Hairline-Trennern, Chevron rechtsbündig, aktive Seite
mit rotem Balken links. Untermenüs animieren über die 0fr/1fr-Zeile des
li-Grids statt display: none — weiches Auf-/Zuklappen ohne Layout-Sprung.

Unterpunkte in Abel statt Coolvetica (unter 28px unleserlich, siehe CLAUDE.md),
linksbündig, Desktop-Panel-Optik im Overlay komplett zurückgesetzt (inkl.
backdrop-filter/radius/translateX, die sonst durchschlugen).

nav.js setzt .site-nav--js auf die Nav: nur ohne JS öffnet :focus-within die
Untermenüs. Mit JS wäre das ein Bug — der Button behält nach dem Tipp den
Fokus, das Menü ließe sich nie wieder zuklappen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 09:46:40 +02:00
1841 changed files with 103450 additions and 1799 deletions

36
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# Secrets — nur config.example.php wird committed # Secrets — nur config.example.php wird committed
/config/config.php /config/config.php
/config/config.production.php
# Composer # Composer
/vendor/ /vendor/
@@ -8,19 +9,40 @@
/storage/logs/* /storage/logs/*
/storage/cache/* /storage/cache/*
/storage/ratelimit/* /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/logs/.gitkeep
!/storage/cache/.gitkeep !/storage/cache/.gitkeep
!/storage/ratelimit/.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 /data/instagram.json
/public/assets/img/instagram/*
!/public/assets/img/instagram/.gitkeep
# Maschinenverwaltet (bin/matchcenter-sync.php)
/data/matchcenter.json /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 # Referenz-Material der alten Seite — NIE committen
# (.context_db enthält personenbezogene Daten: Mail-Logs, Hashes) # (.context_db enthält personenbezogene Daten: Mail-Logs, Hashes)

1124
CLAUDE.md

File diff suppressed because it is too large Load Diff

View File

@@ -3,13 +3,18 @@
declare(strict_types=1); declare(strict_types=1);
/** /**
* POST /kontakt-senden — Kontaktformular validieren und via Brevo SMTP versenden. * POST /kontakt-senden — Kontaktformular validieren und über All-Inkl-SMTP versenden.
* Antwort: PRG-Redirect zurück zur absendenden Seite (#kontakt, ?sent=1 | ?error=…) * 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. * 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] ?? '')); $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. // 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'; $route = 'kontakt';
$token = $field('ft'); $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) --- // --- Spam-Checks: Bots bekommen ein stilles "OK" (kein Feedback-Kanal) ---
if ($field('company_url') !== '') { if ($field('company_url') !== '') {
log_spam($route, 'honeypot'); log_spam($route, 'honeypot');
@@ -44,87 +55,133 @@ if (!form_token_valid($token)) {
$respond(true); $respond(true);
} }
// Rate-Limit pro IP (sichtbarer Hinweis statt still — legitime NAT-Nutzer nicht im Dunkeln lassen). // --- Eingaben ---
if (!rate_limit_ok('submit|' . $route . '|' . client_ip(), 5, 600)) {
log_spam($route, 'ratelimit');
$respond(false, 'ratelimit');
}
// --- Validierung ---
$name = $field('name'); $name = $field('name');
$email = $field('email'); $email = $field('email');
$phone = $field('phone'); $phone = $field('phone');
$interest = $field('interest'); $interest = $field('interest');
$birthdate = $field('birthdate');
$subject = $field('subject'); $subject = $field('subject');
$message = $field('message'); $message = $field('message');
$privacy = ($_POST['privacy'] ?? '') === '1';
$interests = ['', 'Herrenfußball', 'Frauenfußball', 'Jugendfußball', 'Turnen', 'Sonstiges']; // Variante bestimmen (unbekannter Wert fällt auf die allgemeine zurück, siehe
// contact_variant()) — dieselbe Quelle, aus der die Komponente ihr Formular baut.
$valid = $name !== '' && mb_strlen($name) <= 200 $variant = contact_variant($field('department'));
&& filter_var($email, FILTER_VALIDATE_EMAIL) !== false $interests = $variant['interests'];
&& mb_strlen($phone) <= 50 if (!$variant['interest_required']) {
&& in_array($interest, $interests, true) $interests[] = ''; // die allgemeine Variante darf das Feld leer lassen
&& $subject !== '' && mb_strlen($subject) <= 200 }
&& $message !== '' && mb_strlen($message) <= 5000 if (!$variant['ask_subject']) {
&& ($_POST['privacy'] ?? '') === '1' $subject = ''; // Feld gibt es dort nicht, ein mitgeschicktes ignorieren
&& !preg_match('/[\r\n]/', $name . $subject); }
if (!$variant['ask_birthdate']) {
if (!$valid) { $birthdate = ''; // dito — nur die Turn-Variante fragt danach
$respond(false, 'validation');
} }
// --- 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) { if (preg_match_all('~https?://~i', $subject . ' ' . $message) > 2) {
log_spam($route, 'links'); log_spam($route, 'links');
$respond(true); $respond(true);
} }
// Globaler Tages-Cap als Brevo-Kosten-Backstop — die Flut soll ihn nicht bemerken.
if (!rate_limit_ok('daily', 100, 86400)) { // --- Volumen-Grenzen: erst ab hier, damit Tippfehler kein Kontingent verbrauchen.
log_spam($route, 'daily-cap'); // Sie antworten ehrlich — stilles "Danke" ohne Versand verschluckt echte Anfragen. ---
$respond(true); 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). // Token-Mehrfachnutzung begrenzen (Replay-Schutz). IP im Schlüssel, weil form_token()
if (!rate_limit_ok('token|' . $token, 3, 86400)) { // 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'); 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 --- // --- 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" . str_repeat('-', 40) . "\n"
. "Name: {$name}\n" . "Name: {$name}\n"
. "E-Mail: {$email}\n" . "E-Mail: {$email}\n"
. ($phone !== '' ? "Telefon: {$phone}\n" : '') . ($phone !== '' ? "Telefon: {$phone}\n" : '')
. ($interest !== '' ? "Interesse: {$interest}\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" . str_repeat('-', 40) . "\n\n"
. $message . "\n\n" . ($message !== '' ? $message . "\n\n" : "(keine Nachricht angegeben)\n\n")
. str_repeat('-', 40) . "\n" . str_repeat('-', 40) . "\n"
. 'Datenschutz zugestimmt: ja (' . date('d.m.Y H:i') . ")\n"; . 'Datenschutz zugestimmt: ja (' . date('d.m.Y H:i') . ")\n";
try { $sent = send_mail([
$mail = new PHPMailer(true); 'context' => $variant['context'],
$mail->isSMTP(); 'subject' => $mailSubject,
$mail->Host = $smtp['host']; 'body' => $body,
$mail->Port = (int) $smtp['port']; 'reply_to' => ['email' => $email, 'name' => $name],
$mail->SMTPAuth = true; // Leer heißt: Standardempfänger aus config('smtp.to') (send_mail()).
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; 'to' => $variant['to'] !== '' ? $variant['to'] : null,
$mail->Username = $smtp['username']; ]);
$mail->Password = $smtp['password'];
$mail->CharSet = PHPMailer::CHARSET_UTF8;
$mail->setFrom($smtp['from'], $smtp['from_name']); $respond($sent, $sent ? '' : 'mail');
$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);

View File

@@ -6,12 +6,11 @@ declare(strict_types=1);
* POST /mitglied-werden-senden — Mitgliedsantrag Fußball (schlank, ohne Bankdaten). * POST /mitglied-werden-senden — Mitgliedsantrag Fußball (schlank, ohne Bankdaten).
* Versendet eine interne Vereins-Mail (erfolgskritisch) und eine Eingangsbestätigung * Versendet eine interne Vereins-Mail (erfolgskritisch) und eine Eingangsbestätigung
* an den Antragsteller (optional, Fehler dort wird nicht an den Besucher geleakt). * an den Antragsteller (optional, Fehler dort wird nicht an den Besucher geleakt).
* Antwort: PRG-Redirect zu /mitglied-werden#anmeldung-fussball (?sent=1 | ?error=…) * Antwort: PRG-Redirect zu /mitglied-werden#anmeldung-fussball (?sent=1 | ?error=…),
* oder JSON bei fetch (form.js). * 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'); $wantsJson = str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json');
$respond = static function (bool $ok, string $error = '') use ($wantsJson): never { $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'; $route = 'mitglied-werden';
$token = $field('ft'); $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) --- // --- Spam-Checks: Bots bekommen ein stilles "OK" (kein Feedback-Kanal) ---
if ($field('company_url') !== '') { if ($field('company_url') !== '') {
log_spam($route, 'honeypot'); log_spam($route, 'honeypot');
@@ -39,12 +44,6 @@ if (!form_token_valid($token)) {
$respond(true); $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 --- // --- Eingaben ---
$firstName = $field('first_name'); $firstName = $field('first_name');
$lastName = $field('last_name'); $lastName = $field('last_name');
@@ -55,6 +54,8 @@ $zip = $field('zip');
$city = $field('city'); $city = $field('city');
$email = $field('email'); $email = $field('email');
$feeClass = $field('fee_class'); $feeClass = $field('fee_class');
$statute = ($_POST['statute'] ?? '') === '1';
$privacy = ($_POST['privacy'] ?? '') === '1';
// Beitragsklassen-Whitelist identisch zur Seite aus data/mitglied-werden.json ableiten. // Beitragsklassen-Whitelist identisch zur Seite aus data/mitglied-werden.json ableiten.
$data = json_load('mitglied-werden'); $data = json_load('mitglied-werden');
@@ -63,39 +64,84 @@ $feeOptions = array_map(
$data['fussball']['fees']['rows'] ?? [] $data['fussball']['fees']['rows'] ?? []
); );
// --- Validierung (Whitelist-Prinzip) --- // Geburtsdatum: Format, echter Kalendertag und plausibler Zeitraum.
$valid = $firstName !== '' && mb_strlen($firstName) <= 100 $birthValid = false;
&& $lastName !== '' && mb_strlen($lastName) <= 100 if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $birthDate, $m) === 1) {
&& preg_match('/^\d{4}-\d{2}-\d{2}$/', $birthDate) === 1 $birthValid = checkdate((int) $m[2], (int) $m[3], (int) $m[1])
&& $phone !== '' && mb_strlen($phone) <= 50 && $birthDate <= date('Y-m-d')
&& $street !== '' && mb_strlen($street) <= 200 && (int) $m[1] >= (int) date('Y') - 120;
&& $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');
} }
// --- Aufkommens-Heuristiken (still abweisen) --- // --- Validierung (Whitelist-Prinzip, Fehler pro Feld) ---
// Globaler Tages-Cap als Brevo-Kosten-Backstop — die Flut soll ihn nicht bemerken. $errors = [];
if (!rate_limit_ok('daily', 100, 86400)) { if ($firstName === '' || mb_strlen($firstName) > 100 || preg_match('/[\r\n]/', $firstName)) {
log_spam($route, 'daily-cap'); $errors['mm-first-name'] = 'Bitte gib den Vornamen an (max. 100 Zeichen).';
$respond(true);
} }
// Token-Mehrfachnutzung begrenzen (Replay-Schutz, großzügig für menschliches Mehrfach-Senden). if ($lastName === '' || mb_strlen($lastName) > 100 || preg_match('/[\r\n]/', $lastName)) {
if (!rate_limit_ok('token|' . $token, 3, 86400)) { $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'); 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 --- // --- Versand ---
$smtp = config('smtp');
$body = "Neuer Mitgliedsantrag (Fußball) über tsv08kulmbach.de\n" $body = "Neuer Mitgliedsantrag (Fußball) über tsv08kulmbach.de\n"
. str_repeat('-', 40) . "\n" . str_repeat('-', 40) . "\n"
. "Vorname: {$firstName}\n" . "Vorname: {$firstName}\n"
@@ -109,49 +155,31 @@ $body = "Neuer Mitgliedsantrag (Fußball) über tsv08kulmbach.de\n"
. "Satzung anerkannt: ja\n" . "Satzung anerkannt: ja\n"
. "Datenschutz: ja\n" . "Datenschutz: ja\n"
. 'Eingegangen: ' . date('d.m.Y H:i') . "\n\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"; . "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. // Mail 1 (intern) — erfolgskritisch.
try { $sent = send_mail([
$mail = $buildMailer(); 'context' => 'Mitgliedsantrag',
$mail->setFrom($smtp['from'], $smtp['from_name']); 'subject' => 'Mitgliedsantrag Fußball: ' . $firstName . ' ' . $lastName,
$mail->addAddress($smtp['to']); 'body' => $body,
$mail->addReplyTo($email, $firstName . ' ' . $lastName); 'reply_to' => ['email' => $email, 'name' => $firstName . ' ' . $lastName],
$mail->Subject = 'Mitgliedsantrag Fußball: ' . $firstName . ' ' . $lastName; ]);
$mail->Body = $body;
$mail->send(); if (!$sent) {
} catch (Throwable $e) {
error_log('[' . date('c') . '] Mitgliedsantrag-Versand fehlgeschlagen: ' . $e->getMessage() . "\n", 3, STORAGE_PATH . '/logs/mail.log');
$respond(false, 'mail'); $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; $welcome = $data['welcome_mail'] ?? null;
if (is_array($welcome) && !empty($welcome['subject']) && !empty($welcome['body'])) { if (is_array($welcome) && !empty($welcome['subject']) && !empty($welcome['body'])) {
try { send_mail([
$confirm = $buildMailer(); 'context' => 'Bestätigungsmail Mitgliedsantrag',
$confirm->setFrom($smtp['from'], $smtp['from_name']); 'to' => $email,
$confirm->addAddress($email, $firstName . ' ' . $lastName); 'to_name' => $firstName . ' ' . $lastName,
$confirm->Subject = $welcome['subject']; 'subject' => (string) $welcome['subject'],
$confirm->Body = $welcome['body']; 'body' => (string) $welcome['body'],
$confirm->send(); ]);
} catch (Throwable $e) {
error_log('[' . date('c') . '] Bestätigungsmail fehlgeschlagen: ' . $e->getMessage() . "\n", 3, STORAGE_PATH . '/logs/mail.log');
}
} }
$respond(true); $respond(true);

View File

@@ -3,12 +3,13 @@
declare(strict_types=1); declare(strict_types=1);
/** /**
* POST /sportheimbuchung-senden — Sportheim-Buchungsanfrage validieren und via Brevo SMTP versenden. * POST /sportheimbuchung-senden — Sportheim-Buchungsanfrage validieren und über
* Antwort: PRG-Redirect zu /sportheimbuchung#buchung (?sent=1 | ?error=…). * 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'); $wantsJson = str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json');
$respond = static function (bool $ok, string $error = '') use ($wantsJson): never { $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'; $route = 'sportheimbuchung';
$token = $field('ft'); $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) --- // --- Spam-Checks: Bots bekommen ein stilles "OK" (kein Feedback-Kanal) ---
if ($field('company_url') !== '') { if ($field('company_url') !== '') {
log_spam($route, 'honeypot'); log_spam($route, 'honeypot');
@@ -36,13 +43,7 @@ if (!form_token_valid($token)) {
$respond(true); $respond(true);
} }
// Rate-Limit pro IP (sichtbarer Hinweis statt still — legitime NAT-Nutzer nicht im Dunkeln lassen). // --- Eingaben ---
if (!rate_limit_ok('submit|' . $route . '|' . client_ip(), 5, 600)) {
log_spam($route, 'ratelimit');
$respond(false, 'ratelimit');
}
// --- Validierung ---
$name = $field('name'); $name = $field('name');
$email = $field('email'); $email = $field('email');
$phone = $field('phone'); $phone = $field('phone');
@@ -50,43 +51,86 @@ $eventType = $field('event_type');
$eventDate = $field('event_date'); $eventDate = $field('event_date');
$attendees = $field('attendees'); $attendees = $field('attendees');
$message = $field('message'); $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 // Wunschdatum: echter Kalendertag, nicht in der Vergangenheit, höchstens 3 Jahre voraus.
&& filter_var($email, FILTER_VALIDATE_EMAIL) !== false $dateValid = false;
&& mb_strlen($phone) <= 50 if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $eventDate, $m) === 1) {
&& in_array($eventType, $validTypes, true) $dateValid = checkdate((int) $m[2], (int) $m[3], (int) $m[1])
&& ($eventDate === '' || preg_match('/^\d{4}-\d{2}-\d{2}$/', $eventDate)) && $eventDate >= date('Y-m-d')
&& ($attendees === '' || (ctype_digit($attendees) && (int) $attendees >= 1 && (int) $attendees <= 500)) && $eventDate <= date('Y-m-d', strtotime('+3 years'));
&& mb_strlen($message) <= 3000
&& ($_POST['privacy'] ?? '') === '1'
&& !preg_match('/[\r\n]/', $name);
if (!$valid) {
$respond(false, 'validation');
} }
// --- 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) { if (preg_match_all('~https?://~i', $message) > 2) {
log_spam($route, 'links'); log_spam($route, 'links');
$respond(true); $respond(true);
} }
// Globaler Tages-Cap als Brevo-Kosten-Backstop — die Flut soll ihn nicht bemerken.
if (!rate_limit_ok('daily', 100, 86400)) { // --- Volumen-Grenzen: erst ab hier, damit Tippfehler kein Kontingent verbrauchen.
log_spam($route, 'daily-cap'); // Sie antworten ehrlich — stilles "Danke" ohne Versand verschluckt echte Anfragen. ---
$respond(true); 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). // Token-Mehrfachnutzung begrenzen (Replay-Schutz). IP im Schlüssel, weil form_token()
if (!rate_limit_ok('token|' . $token, 3, 86400)) { // 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'); 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 --- // --- Versand ---
$smtp = config('smtp'); $dateFormatted = date('d.m.Y', (int) strtotime($eventDate));
$dateFormatted = $eventDate !== '' ? date('d.m.Y', strtotime($eventDate)) : '';
$body = "Sportheimbuchung-Anfrage über tsv08kulmbach.de\n" $body = "Sportheimbuchung-Anfrage über tsv08kulmbach.de\n"
. str_repeat('-', 40) . "\n" . str_repeat('-', 40) . "\n"
@@ -95,33 +139,17 @@ $body = "Sportheimbuchung-Anfrage über tsv08kulmbach.de\n"
. ($phone !== '' ? "Telefon: {$phone}\n" : '') . ($phone !== '' ? "Telefon: {$phone}\n" : '')
. ($eventType !== '' ? "Veranstaltungstyp: {$eventType}\n" : '') . ($eventType !== '' ? "Veranstaltungstyp: {$eventType}\n" : '')
. "Gewünschtes Datum: {$dateFormatted}\n" . "Gewünschtes Datum: {$dateFormatted}\n"
. ($attendees !== '' ? "Personenzahl: {$attendees}\n" : '') . "Personenzahl: {$attendees}\n"
. str_repeat('-', 40) . "\n\n" . str_repeat('-', 40) . "\n\n"
. ($message !== '' ? $message . "\n\n" : '') . ($message !== '' ? $message . "\n\n" : '')
. str_repeat('-', 40) . "\n" . str_repeat('-', 40) . "\n"
. 'Datenschutz zugestimmt: ja (' . date('d.m.Y H:i') . ")\n"; . 'Datenschutz zugestimmt: ja (' . date('d.m.Y H:i') . ")\n";
try { $sent = send_mail([
$mail = new PHPMailer(true); 'context' => 'Sportheimbuchung',
$mail->isSMTP(); 'subject' => 'Sportheimbuchung: ' . ($eventType !== '' ? $eventType : 'Anfrage') . ', ' . $dateFormatted,
$mail->Host = $smtp['host']; 'body' => $body,
$mail->Port = (int) $smtp['port']; 'reply_to' => ['email' => $email, 'name' => $name],
$mail->SMTPAuth = true; ]);
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Username = $smtp['username'];
$mail->Password = $smtp['password'];
$mail->CharSet = PHPMailer::CHARSET_UTF8;
$mail->setFrom($smtp['from'], $smtp['from_name']); $respond($sent, $sent ? '' : 'mail');
$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);

View File

@@ -6,11 +6,23 @@ declare(strict_types=1);
* Werbe-/Sponsoren-Banner-Slider. Daten: data/banners.json (Single Source of Truth). * Werbe-/Sponsoren-Banner-Slider. Daten: data/banners.json (Single Source of Truth).
* Layout je Slide: 2/3 Titel + Text + CTA, 1/3 Firmenlogo. * Layout je Slide: 2/3 Titel + Text + CTA, 1/3 Firmenlogo.
* Pro Slide optional: "variant" (CSS-Modifier, z. B. "house" für Eigenwerbung), * 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), * (interner Link ohne target=_blank/rel=sponsored, für eigene Seiten/Anker),
* "cta_secondary" (zweiter Button als Outline), "logo_light" (helle Logo-Platte * "cta_secondary" (zweiter Button als Outline), "logo_light" (helle Logo-Platte
* für dunkle Firmenlogos), "accent" (CSS-Farbe; überschreibt das Vereins-Rot * für dunkle Firmenlogos).
* der Buttons für diese Slide, z. B. Sponsor-CI). *
* "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 * Auto-Rotation + Fortschritts-Indikatoren übernimmt der generische Antrieb
* assets/js/carousel.js (pausiert bei Hover/Fokus, respektiert prefers-reduced-motion). * assets/js/carousel.js (pausiert bei Hover/Fokus, respektiert prefers-reduced-motion).
* Ohne JS: nur der erste Banner ist sichtbar (Graceful Degradation). * Ohne JS: nur der erste Banner ist sichtbar (Graceful Degradation).
@@ -34,25 +46,30 @@ $total = count($banners);
$variant = $banner['variant'] ?? null; $variant = $banner['variant'] ?? null;
$accent = $banner['accent'] ?? 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'; $classes = 'ad-banner__slide';
if ($bg) { if ($bg) {
$classes .= ' ad-banner__slide--bg'; $classes .= ' ad-banner__slide--bg';
} }
if ($accent) { if ($accent) {
$classes .= ' ad-banner__slide--accent'; $classes .= ' ad-banner__slide--accent ad-banner__slide--accent-' . $slug((string) $accent);
} }
if ($variant) { if ($variant) {
$classes .= ' ad-banner__slide--' . preg_replace('/[^a-z0-9-]+/', '', strtolower($variant)); $classes .= ' ad-banner__slide--' . $slug((string) $variant);
} }
$styleParts = []; // Hintergrundbild als Bild-Prop für die img-Komponente. alt="" — der Hintergrund
if ($bg) { // ist Dekoration, die Aussage der Slide steht als Text daneben.
$styleParts[] = '--banner-bg: url(' . asset($bg) . ')'; $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 <?php
// Rendert einen CTA-Link; externe Links öffnen im neuen Tab (rel=sponsored), // Rendert einen CTA-Link; externe Links öffnen im neuen Tab (rel=sponsored),
@@ -69,7 +86,17 @@ $total = count($banners);
. '</a>'; . '</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'])): ?> <?php if (!empty($banner['logo'])): ?>
<div class="ad-banner__logo<?= !empty($banner['logo_light']) ? ' ad-banner__logo--light' : '' ?>"> <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"> <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 if ($total > 1): ?>
<?php /* Ohne JS verborgen (Steuerung wäre funktionslos); carousel.js blendet sie ein. */ ?> <?php /* Ohne JS verborgen (Steuerung wäre funktionslos); carousel.js blendet sie ein. */ ?>
<div class="ad-banner__controls" data-carousel-controls hidden> <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') ?> <?= icon('chevron-left') ?>
</button> </button>
<div class="ad-banner__indicators" role="group" aria-label="Anzeige wählen"> <div class="ad-banner__indicators" role="group" aria-label="Anzeige wählen">
@@ -108,11 +135,11 @@ $total = count($banners);
</button> </button>
<?php endforeach; ?> <?php endforeach; ?>
</div> </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') ?> <?= icon('chevron-right') ?>
</button> </button>
<?php /* Pause/Play — WCAG 2.2.2. Nur bei aktiver Auto-Rotation sichtbar. */ ?> <?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('pause-fill', 'ad-banner__toggle-icon ad-banner__toggle-icon--pause') ?>
<?= icon('play-fill', 'ad-banner__toggle-icon ad-banner__toggle-icon--play') ?> <?= icon('play-fill', 'ad-banner__toggle-icon ad-banner__toggle-icon--play') ?>
<span class="visually-hidden" data-carousel-toggle-label>Automatischen Wechsel pausieren</span> <span class="visually-hidden" data-carousel-toggle-label>Automatischen Wechsel pausieren</span>

View File

@@ -9,6 +9,20 @@ declare(strict_types=1);
* $return string optional — Slug der absendenden Seite (Default '' = Startseite). Die Action * $return string optional — Slug der absendenden Seite (Default '' = Startseite). Die Action
* leitet nach PRG hierher zurück (whitelist-validiert), damit /kontakt nicht auf die * leitet nach PRG hierher zurück (whitelist-validiert), damit /kontakt nicht auf die
* Startseite springt. * 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. * Felder rendern ausschließlich über component('form-field') — siehe CLAUDE.md.
* Liest Flash-Status aus Query-Params (?sent=1 / ?error=...) — PRG-Muster. * Liest Flash-Status aus Query-Params (?sent=1 / ?error=...) — PRG-Muster.
* Spam-Schutz: Honeypot "company_url" + signierter Timestamp "ft" (form_token()). * Spam-Schutz: Honeypot "company_url" + signierter Timestamp "ft" (form_token()).
@@ -16,24 +30,42 @@ declare(strict_types=1);
$club = json_load('club'); $club = json_load('club');
$return = $return ?? ''; $return = $return ?? '';
$intro = $intro ?? null; $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']); $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 = [ $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.', '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 = [ $f = [
'name' => ['type' => 'text', 'name' => 'name', 'id' => 'contact-name', 'label' => 'Name', 'required' => true, 'autocomplete' => 'name', 'maxlength' => 200], '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], '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], '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], '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' => [ 'privacy' => [
'type' => 'checkbox', 'name' => 'privacy', 'id' => 'contact-privacy', 'required' => true, 'type' => 'checkbox', 'name' => 'privacy', 'id' => 'contact-privacy', 'required' => true,
'label' => 'Ich stimme den Datenschutzbestimmungen zu.', 'label' => 'Ich stimme den Datenschutzbestimmungen zu.',
@@ -47,11 +79,25 @@ $f = [
<div class="contact__intro"> <div class="contact__intro">
<h2><?= e($intro['title']) ?></h2> <h2><?= e($intro['title']) ?></h2>
<p><?= e($intro['text']) ?></p> <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> </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; ?> <?php endif; ?>
<form class="form" method="post" action="<?= e(url('kontakt-senden')) ?>" novalidate> <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): ?> <?php if ($sent): ?>
<p class="form__success">Danke für deine Anfrage! Wir melden uns so schnell wie möglich bei dir.</p> <p class="form__success">Danke für deine Anfrage! Wir melden uns so schnell wie möglich bei dir.</p>
<?php elseif ($error !== null): ?> <?php elseif ($error !== null): ?>
@@ -67,9 +113,16 @@ $f = [
<div class="form__row"> <div class="form__row">
<?php component('form-field', ['field' => $f['phone']]); ?> <?php component('form-field', ['field' => $f['phone']]); ?>
<?php component('form-field', ['field' => $f['interest']]); ?> <?php component('form-field', ['field' => $f['interest']]); ?>
<?php if ($variant['ask_birthdate']): ?>
<?php component('form-field', ['field' => $f['birthdate']]); ?>
<?php endif; ?>
</div> </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 component('form-field', ['field' => $f['subject']]); ?>
<?php endif; ?>
<?php component('form-field', ['field' => $f['message']]); ?> <?php component('form-field', ['field' => $f['message']]); ?>
<?php component('form-field', ['field' => $f['privacy']]); ?> <?php component('form-field', ['field' => $f['privacy']]); ?>
@@ -80,6 +133,7 @@ $f = [
</div> </div>
<input type="hidden" name="ft" value="<?= e(form_token()) ?>"> <input type="hidden" name="ft" value="<?= e(form_token()) ?>">
<input type="hidden" name="return" value="<?= e($return) ?>"> <input type="hidden" name="return" value="<?= e($return) ?>">
<input type="hidden" name="department" value="<?= e($variant['department']) ?>">
<p class="form__submit"> <p class="form__submit">
<button class="btn" type="submit">Nachricht senden</button> <button class="btn" type="submit">Nachricht senden</button>

View File

@@ -5,11 +5,16 @@ declare(strict_types=1);
/** /**
* CTA-Button. Props: $cta array{label: string, slug: string, * CTA-Button. Props: $cta array{label: string, slug: string,
* style?: 'primary'|'outline', size?: 'sm'|'lg'} * 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'; $style = $cta['style'] ?? 'primary';
$size = $cta['size'] ?? null; $size = $cta['size'] ?? null;
$class = 'btn' $class = 'btn'
. ($style === 'outline' ? ' btn--outline' : '') . ($style === 'outline' ? ' btn--outline' : '')
. ($size === 'lg' ? ' btn--lg' : ($size === 'sm' ? ' btn--sm' : '')); . ($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>

View File

@@ -5,12 +5,16 @@ declare(strict_types=1);
/** /**
* Abteilungs-Auswahl: gleichwertige Karten, die auf Anker derselben Seite springen. * Abteilungs-Auswahl: gleichwertige Karten, die auf Anker derselben Seite springen.
* Props: * 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) * $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 * Bewusst eigenes <a href="#…">-Markup (nicht die cta-Komponente): cta() schickt
* jeden Slug durch url() und würde aus "#anker" ein "/#anker" (Startseite) machen. * jeden Slug durch url() und würde aus "#anker" ein "/#anker" (Startseite) machen.
*/ */
$anchor = $anchor ?? null; $anchor = $anchor ?? null;
$title_hidden = !empty($title_hidden);
if (empty($choice['items'])) { if (empty($choice['items'])) {
return; return;
} }
@@ -18,7 +22,7 @@ if (empty($choice['items'])) {
<section class="section department-choice"<?= $anchor ? ' id="' . e($anchor) . '"' : '' ?>> <section class="section department-choice"<?= $anchor ? ' id="' . e($anchor) . '"' : '' ?>>
<div class="container"> <div class="container">
<?php if (!empty($choice['title'])): ?> <?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 endif; ?>
<?php if (!empty($choice['intro'])): ?> <?php if (!empty($choice['intro'])): ?>
<p class="department-choice__intro"><?= e($choice['intro']) ?></p> <p class="department-choice__intro"><?= e($choice['intro']) ?></p>
@@ -26,12 +30,19 @@ if (empty($choice['items'])) {
<ul class="department-choice__grid" role="list"> <ul class="department-choice__grid" role="list">
<?php foreach ($choice['items'] as $item): ?> <?php foreach ($choice['items'] as $item): ?>
<li class="department-choice__card"> <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> <h3 class="department-choice__heading"><?= e($item['title']) ?></h3>
<?php if (!empty($item['subtitle'])): ?> <?php if (!empty($item['subtitle'])): ?>
<p class="department-choice__subtitle"><?= e($item['subtitle']) ?></p> <p class="department-choice__subtitle"><?= e($item['subtitle']) ?></p>
<?php endif; ?> <?php endif; ?>
<p class="department-choice__text"><?= e($item['text']) ?></p> <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> <a class="btn department-choice__btn" href="#<?= e($item['anchor']) ?>"><?= e($item['label']) ?><?= icon('arrow-right', 'department-choice__arrow') ?></a>
</div>
</li> </li>
<?php endforeach; ?> <?php endforeach; ?>
</ul> </ul>

View 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>

View 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>

View File

@@ -41,7 +41,7 @@ if ($faq === [] && !$hasPerson) {
<?php foreach ($faq as $item): ?> <?php foreach ($faq as $item): ?>
<details class="faq__item"> <details class="faq__item">
<summary class="faq__q"><span><?= e($item['q']) ?></span><?= icon('chevron-down', 'faq__chevron') ?></summary> <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> </details>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
@@ -62,7 +62,7 @@ if ($faq === [] && !$hasPerson) {
</ul> </ul>
<div class="contact-slider__dots"> <div class="contact-slider__dots">
<?php foreach ($persons as $i => $p): ?> <?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; ?> <?php endforeach; ?>
</div> </div>
</div> </div>

View File

@@ -4,7 +4,12 @@ declare(strict_types=1);
/** /**
* FAQ-Accordion. Props: * 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') * $title string (optional, Default 'Häufige Fragen')
* $anchor string (optional, Default 'faq') * $anchor string (optional, Default 'faq')
* Natives <details>/<summary> → ohne JS bedienbar. Das FAQPage-Schema liefert * Natives <details>/<summary> → ohne JS bedienbar. Das FAQPage-Schema liefert
@@ -27,7 +32,16 @@ if ($faq === []) {
<?php foreach ($faq as $item): ?> <?php foreach ($faq as $item): ?>
<details class="faq__item"> <details class="faq__item">
<summary class="faq__q"><span><?= e($item['q']) ?></span><?= icon('chevron-down', 'faq__chevron') ?></summary> <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> </details>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>

View File

@@ -6,18 +6,20 @@ declare(strict_types=1);
* Einheitliches Formularfeld — EINZIGE Quelle für Feld-Markup aller Formulare. * Einheitliches Formularfeld — EINZIGE Quelle für Feld-Markup aller Formulare.
* *
* Props: $field (array): * 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 * name, label Pflicht
* id Default: field-<name> * id Default: field-<name>
* required bool * required bool
* autocomplete, maxlength, rows (textarea) * autocomplete, maxlength, rows (textarea), min/max (date)
* options[] + placeholder (select) * options[] + placeholder (select)
* hint optionaler Hilfetext (aria-describedby) * hint optionaler Hilfetext (aria-describedby)
* label_html NUR für vertrauenswürdiges Markup aus Komponenten-Code * label_html NUR für vertrauenswürdiges Markup aus Komponenten-Code
* (z. B. Datenschutz-Link im Checkbox-Label) — nie Nutzereingaben! * (z. B. Datenschutz-Link im Checkbox-Label) — nie Nutzereingaben!
* *
* Jedes Feld bekommt ein leeres Fehler-Element (#<id>-error), das form.js bei * Jedes Feld bekommt ein Fehler-Element (#<id>-error, aria-describedby): serverseitig
* ungültiger Eingabe mit der Browser-Validierungsmeldung füllt (aria-describedby). * 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'; $type = $field['type'] ?? 'text';
$name = $field['name']; $name = $field['name'];
@@ -26,6 +28,8 @@ $required = !empty($field['required']);
$errorId = $id . '-error'; $errorId = $id . '-error';
$hintId = $id . '-hint'; $hintId = $id . '-hint';
$describedBy = (!empty($field['hint']) ? $hintId . ' ' : '') . $errorId; $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 // Sichtbarer roter Stern (aria-hidden) + pro Feld erklärendes Pflicht-Wort für
// Screenreader — ersetzt die frühere globale „Pflichtfelder mit *"-Hinweiszeile. // 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['autocomplete']) ? ' autocomplete="' . e($field['autocomplete']) . '"' : '')
. (!empty($field['maxlength']) ? ' maxlength="' . e($field['maxlength']) . '"' : '') . (!empty($field['maxlength']) ? ' maxlength="' . e($field['maxlength']) . '"' : '')
. (isset($field['min']) ? ' min="' . e((string) $field['min']) . '"' : '') . (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'): ?> <?php if ($type === 'checkbox'): ?>
<div class="form__field form__field--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> <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> </div>
<?php else: ?> <?php else: ?>
<div class="form__field"> <div class="form__field">
@@ -54,17 +59,17 @@ $commonAttrs = 'name="' . e($name) . '" id="' . e($id) . '"'
<select <?= $commonAttrs ?>> <select <?= $commonAttrs ?>>
<option value=""><?= e($field['placeholder'] ?? 'Bitte wählen') ?></option> <option value=""><?= e($field['placeholder'] ?? 'Bitte wählen') ?></option>
<?php foreach ($field['options'] as $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; ?> <?php endforeach; ?>
</select> </select>
<?php elseif ($type === 'textarea'): ?> <?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: ?> <?php else: ?>
<input type="<?= e($type) ?>" <?= $commonAttrs ?> placeholder=" "> <input type="<?= e($type) ?>" <?= $commonAttrs ?> value="<?= e($old) ?>" placeholder=" ">
<?php endif; ?> <?php endif; ?>
<?php if (!empty($field['hint'])): ?> <?php if (!empty($field['hint'])): ?>
<p class="form__hint" id="<?= e($hintId) ?>"><?= e($field['hint']) ?></p> <p class="form__hint" id="<?= e($hintId) ?>"><?= e($field['hint']) ?></p>
<?php endif; ?> <?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> </div>
<?php endif; ?> <?php endif; ?>

View File

@@ -5,13 +5,43 @@ declare(strict_types=1);
/** /**
* Turn-Disziplin im einheitlichen Bento-Look (wie die Mannschaftsseiten). Props: * Turn-Disziplin im einheitlichen Bento-Look (wie die Mannschaftsseiten). Props:
* $location array{venue, street?, zip?, city?} (optional) — Trainingsort + „Route planen" * $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) * $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: * $groups array von Gruppen:
* { name, level?, intro?, details?:[{heading,text}], recommendation?, note?, * { name, level?, contact?, intro?, details?:[{heading,text}], recommendation?,
* trainers?:[…], sessions:[{ label?, day, time, note?, trainers?:[…] }] } * 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) * $footnote string (optional) — Erläuterung (z. B. zur Sternchen-Kennzeichnung)
* $groupsTitle string (optional, Default 'Unsere Gruppen') * $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') * $anchor string (optional, Default 'training')
* *
* Eine Gruppe → Trainingszeiten landen als Bento-Kachel im Dashboard. * Eine Gruppe → Trainingszeiten landen als Bento-Kachel im Dashboard.
@@ -20,9 +50,17 @@ declare(strict_types=1);
*/ */
$anchor = $anchor ?? 'training'; $anchor = $anchor ?? 'training';
$groupsTitle = $groupsTitle ?? 'Unsere Gruppen'; $groupsTitle = $groupsTitle ?? 'Unsere Gruppen';
$groupsTitleHidden = $groupsTitleHidden ?? false;
$layout = ($layout ?? 'tabs') === 'stacked' ? 'stacked' : 'tabs';
$lead = $lead ?? ''; $lead = $lead ?? '';
$contactEmail = $contactEmail ?? '';
$contactPhone = $contactPhone ?? '';
$notice = $notice ?? ''; $notice = $notice ?? '';
$noticeTitle = $noticeTitle ?? '';
$noticeTone = $noticeTone ?? 'info';
$noticeIcon = $noticeIcon ?? 'info-circle';
$footnote = $footnote ?? ''; $footnote = $footnote ?? '';
$groupsInDash = !empty($groupsInDash);
$groups = $groups ?? []; $groups = $groups ?? [];
$location = $location ?? []; $location = $location ?? [];
@@ -31,7 +69,8 @@ if ($groups === []) {
} }
$club = json_load('club'); $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; $single = count($groups) === 1;
$rich = false; $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']); $hasVenue = !empty($location['venue']);
$mapsQuery = $hasVenue ? rawurlencode(trim( $mapsQuery = $hasVenue ? rawurlencode(trim(
($location['venue'] ?? '') . ', ' ($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 { $renderSession = static function (array $s): void {
?> ?>
<li class="schedule-row"> <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> <span class="schedule-row__note text-muted"><?= e($s['note']) ?></span>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($s['trainers'])): ?> <?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; ?> <?php endif; ?>
</li> </li>
<?php <?php
}; };
$soleSessions = $single ? ($groups[0]['sessions'] ?? []) : []; /**
$soleNote = $single ? ($groups[0]['note'] ?? '') : ''; * 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
<section class="section turnen-dash-section" id="<?= e($anchor) ?>"> * (ohne). Vorher stand das Markup nur in der Tab-Fassung; eine zweite Kopie daneben wäre
<div class="container"> * die Stelle, an der beide auseinanderlaufen.
<div class="turnen-dash<?= $single ? ' turnen-dash--single' : '' ?><?= $notice !== '' ? ' turnen-dash--notice' : '' ?>"> */
<?php if ($notice !== ''): ?> $renderCardBody = static function (array $g) use ($renderSession): void {
<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&amp;query=<?= e($mapsQuery) ?>" target="_blank" rel="noopener">Route planen<span class="visually-hidden"> (öffnet Google Maps in neuem Tab)</span></a>
<span class="bento__deco" aria-hidden="true"><?= icon('geo-alt') ?></span>
</article>
<?php endif; ?>
</div>
</div>
</section>
<?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"
>
<div class="group-card__body"> <div class="group-card__body">
<div class="group-card__header"> <div class="group-card__header">
<h3 class="group-card__name"><?= e($g['name']) ?></h3> <h3 class="group-card__name"><?= e($g['name']) ?></h3>
@@ -180,20 +156,27 @@ $soleNote = $single ? ($groups[0]['note'] ?? '') : '';
<ul class="schedule-list"> <ul class="schedule-list">
<?php foreach ($g['sessions'] as $s): $renderSession($s); endforeach; ?> <?php foreach ($g['sessions'] as $s): $renderSession($s); endforeach; ?>
</ul> </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'])): ?> <?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 endif; ?>
<?php if (!empty($g['note'])): ?> <?php if (!empty($g['note'])): ?>
<p class="group-card__note text-muted"><?= e($g['note']) ?></p> <p class="group-card__note text-muted"><?= e($g['note']) ?></p>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
</article> <?php
<?php endforeach; ?> };
</div>
<?php else: ?> /**
<div class="group-list group-list--grid"> * Schlichte Gruppen-Karte (Name + Trainingszeiten). Als Closure, weil sie an zwei Stellen
<?php foreach ($groups as $g): ?> * 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"> <article class="group-card">
<div class="group-card__body"> <div class="group-card__body">
<div class="group-card__header"> <div class="group-card__header">
@@ -211,7 +194,123 @@ $soleNote = $single ? ($groups[0]['note'] ?? '') : '';
</div> </div>
<?php endif; ?> <?php endif; ?>
</article> </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; ?> <?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&amp;query=<?= e($mapsQuery) ?>" target="_blank" rel="noopener">Route planen<span class="visually-hidden"> (öffnet Google Maps in neuem Tab)</span></a>
<span class="bento__deco" aria-hidden="true"><?= icon('geo-alt') ?></span>
</article>
<?php endif; ?>
<?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> </div>
<?php endif; ?> <?php endif; ?>
<?php if ($footnote !== ''): ?> <?php if ($footnote !== ''): ?>

View File

@@ -10,7 +10,7 @@ $nav = json_load('navigation');
?> ?>
<header class="site-header"> <header class="site-header">
<div class="container site-header__inner"> <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"> <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> <span class="site-header__name" aria-hidden="true"><?= e($club['name']) ?></span>
</a> </a>

View 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>

View File

@@ -5,21 +5,30 @@ declare(strict_types=1);
/** /**
* Vollflächiger Hero mit Hintergrundbild/-video, Titel, Intro und CTAs. * Vollflächiger Hero mit Hintergrundbild/-video, Titel, Intro und CTAs.
* Props: $hero (array aus home.json: title, text, image, video?, ctas, align?, compact?, * Props: $hero (array aus home.json: title, text, image, video?, ctas, align?, compact?,
* display?, kicker?, scroll_target?) * display?, kicker?, scroll_target?), $upcoming (optional, s. home.php:
* align-Varianten: 'center' (mittig zentriert), 'bottom-left' (Default). * 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. * compact: true → niedrigere Variante für Unterseiten-Header.
* display: true → großer Editorial-Look (Riesen-Titel, Outline-Tagline, Kicker, Scroll-Cue). * 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 * KONVENTION (siehe CLAUDE.md → Hero-Regel): display + kicker + tagline + video sind der
* Startseite vorbehalten. Unterseiten-Heros sind einheitlich „Überschrift + Teaser": * 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. * 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']); $compact = !empty($hero['compact']);
$display = !empty($hero['display']); $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"> <div class="hero__media" aria-hidden="true">
<?php component('img', ['image' => $hero['image'], 'sizes' => '100vw', 'eager' => true, 'class' => 'hero__img']); ?> <?php component('img', ['image' => $hero['image'], 'sizes' => '100vw', 'eager' => true, 'class' => 'hero__img']); ?>
<?php if (!empty($hero['video'])): ?> <?php if (!empty($hero['video'])): ?>
@@ -27,7 +36,26 @@ $display = !empty($hero['display']);
<video class="hero__video" src="<?= e(asset($hero['video'])) ?>" autoplay muted loop playsinline preload="none" tabindex="-1"></video> <video class="hero__video" src="<?= e(asset($hero['video'])) ?>" autoplay muted loop playsinline preload="none" tabindex="-1"></video>
<?php endif; ?> <?php endif; ?>
</div> </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; ?>
<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; ?>
</div>
<?php component('hero-upcoming', ['items' => $upcoming]); ?>
<?php else: ?>
<?php if (!empty($hero['kicker'])): ?> <?php if (!empty($hero['kicker'])): ?>
<p class="hero__kicker"><?= e($hero['kicker']) ?></p> <p class="hero__kicker"><?= e($hero['kicker']) ?></p>
<?php endif; ?> <?php endif; ?>
@@ -41,6 +69,7 @@ $display = !empty($hero['display']);
<?php component('cta', ['cta' => $cta]); ?> <?php component('cta', ['cta' => $cta]); ?>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php if ($display): ?> <?php if ($display): ?>

View File

@@ -4,7 +4,9 @@ declare(strict_types=1);
/** /**
* Responsives Bild aus Varianten-Set ODER Einzeldatei. Props: * 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 * ODER array{src: string, width: int, height: int, alt: string} — Originaldatei 1:1
* $sizes string (optional, default '100vw') * $sizes string (optional, default '100vw')
* $eager bool (optional) — above the fold: eager + fetchpriority high * $eager bool (optional) — above the fold: eager + fetchpriority high
@@ -24,15 +26,16 @@ if (!empty($image['src'])): ?>
$widths = $image['widths']; $widths = $image['widths'];
$largest = max($widths); $largest = max($widths);
$ext = strtolower((string) ($image['ext'] ?? 'jpg'));
$srcset = implode(', ', array_map( $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 $widths
)); ));
// Maße der größten Variante für width/height (CLS-Vermeidung). // 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 = []; static $dims = [];
$file = PUBLIC_PATH . "/assets/{$image['base']}-{$largest}.jpg"; $file = PUBLIC_PATH . "/assets/{$image['base']}-{$largest}.{$ext}";
$key = $file; $key = $file;
if (!isset($dims[$key])) { if (!isset($dims[$key])) {
$size = is_file($file) ? (getimagesize($file) ?: [0, 0]) : [0, 0]; $size = is_file($file) ? (getimagesize($file) ?: [0, 0]) : [0, 0];
@@ -42,7 +45,7 @@ $srcset = implode(', ', array_map(
})(); })();
?> ?>
<img<?= $class !== '' ? ' class="' . e($class) . '"' : '' ?> <img<?= $class !== '' ? ' class="' . e($class) . '"' : '' ?>
src="<?= e(asset("{$image['base']}-{$largest}.jpg")) ?>" src="<?= e(asset("{$image['base']}-{$largest}.{$ext}")) ?>"
srcset="<?= e($srcset) ?>" srcset="<?= e($srcset) ?>"
sizes="<?= e($sizes) ?>" sizes="<?= e($sizes) ?>"
alt="<?= e($image['alt']) ?>" alt="<?= e($image['alt']) ?>"

View File

@@ -16,7 +16,7 @@ $maxPosts = (int) config('instagram.max_posts', 9);
<div class="container"> <div class="container">
<h2>Aktuelles aus dem Fußball</h2> <h2>Aktuelles aus dem Fußball</h2>
<?php if ($posts !== []): ?> <?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"> <ul class="instagram__grid">
<?php foreach (array_slice($posts, 0, $maxPosts) as $post): ?> <?php foreach (array_slice($posts, 0, $maxPosts) as $post): ?>
<li class="instagram__item"> <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> <p><a class="btn" href="<?= e($club['social']['instagram']) ?>" rel="noopener">Folge uns auf Instagram</a></p>
</div> </div>
<?php endif; ?> <?php endif; ?>
<p class="instagram__more"><a class="btn btn--outline" href="<?= e(url('news')) ?>">Alle News ansehen</a></p>
</div> </div>
</section> </section>

View File

@@ -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 // 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 // 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 // aussehen (zehn Erste, alles grün) — deshalb Ränge und Zonen unterdrücken.
// stattdessen sagen, dass noch nichts gewertet ist. Testspiele zählen hier nicht // Ein erklärender Hinweissatz stand hier mal und ist bewusst raus (Felix,
// mit, ein Freundschaftsspiel-Ergebnis ändert die Tabelle also zu Recht nicht. // 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( $notStarted = array_sum(array_map(
static fn (array $r): int => (int) ($r['played'] ?? 0), static fn (array $r): int => (int) ($r['played'] ?? 0),
$table $table
@@ -33,9 +35,6 @@ if ($notStarted) {
$hasPromo = $hasPromoPlayoff = $hasRelegPlayoff = $hasReleg = false; $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-frame">
<div class="table-scroll" role="region" aria-label="<?= e($caption) ?>" tabindex="0"> <div class="table-scroll" role="region" aria-label="<?= e($caption) ?>" tabindex="0">
<table class="league-table"> <table class="league-table">

View 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>

View File

@@ -50,10 +50,10 @@ $tabCur = $season !== '' ? $season : 'Aktuelle Saison';
?> ?>
<div class="matchcenter-team__seasons group-tabs" data-group-tabs> <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) ?>"> <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" id="mc-tab-<?= e($key) ?>-cur" aria-controls="mc-panel-<?= e($key) ?>-cur" aria-selected="true"
><?= e($tabCur) ?></button> ><?= 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" id="mc-tab-<?= e($key) ?>-prev" aria-controls="mc-panel-<?= e($key) ?>-prev" aria-selected="false"
><?= e($tabPrev) ?></button> ><?= e($tabPrev) ?></button>
</div> </div>

View File

@@ -13,11 +13,14 @@ declare(strict_types=1);
$club = json_load('club'); $club = json_load('club');
$fee_options = $fee_options ?? []; $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']); $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 = [ $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.', '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'] . '.', '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> <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): ?> <?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): ?> <?php elseif ($error !== null): ?>
<p class="form__error"><?= e($errorMessages[$error] ?? $errorMessages['mail']) ?></p> <p class="form__error"><?= e($errorMessages[$error] ?? $errorMessages['mail']) ?></p>
<?php endif; ?> <?php endif; ?>

View File

@@ -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"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($title) ?></title> <title><?= e($title) ?></title>
<meta name="description" content="<?= e($description) ?>"> <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) ?>"> <link rel="canonical" href="<?= e($canonical) ?>">
<?php else: ?> <?php else: ?>
<meta name="robots" content="noindex"> <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"> <meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="/favicon.ico" sizes="32x32"> <link rel="icon" href="/favicon.ico" sizes="32x32">
<link rel="icon" href="<?= e(asset('img/logo.svg')) ?>" type="image/svg+xml"> <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 foreach (['fonts/coolvetica.woff2', 'fonts/abel.woff2'] as $font): ?>
<?php if (is_file(PUBLIC_PATH . '/assets/' . $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 endif; ?>
<?php endforeach; ?> <?php endforeach; ?>
<?php foreach (['tokens', 'reset', 'base', 'layout', 'components', 'utilities'] as $css): ?> <?php foreach (['tokens', 'reset', 'base', 'layout', 'components', 'utilities'] as $css): ?>

View File

@@ -7,7 +7,10 @@ declare(strict_types=1);
* Burger-Toggle + Dropdowns übernimmt assets/js/nav.js. * Burger-Toggle + Dropdowns übernimmt assets/js/nav.js.
* Dropdowns funktionieren auch ohne JS (CSS :hover / :focus-within); JS ergänzt * Dropdowns funktionieren auch ohne JS (CSS :hover / :focus-within); JS ergänzt
* nur aria-expanded, Escape und Klick-außerhalb-schließt. * 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'); $nav = json_load('navigation');
@@ -50,5 +53,12 @@ $href = static function (string $slug): string {
</li> </li>
<?php endif; ?> <?php endif; ?>
<?php endforeach; ?> <?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> </ul>
</nav> </nav>

View 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>

View 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
View 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>

View File

@@ -4,12 +4,31 @@ declare(strict_types=1);
/** /**
* Sponsoren/Partner-Logogrid. Props: * 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). * 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'] ?? []; $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"> <div class="container">
<h2><?= e($intro['title']) ?></h2> <h2><?= e($intro['title']) ?></h2>
<p class="partners__intro text-muted"><?= e($intro['text']) ?></p> <p class="partners__intro text-muted"><?= e($intro['text']) ?></p>

View File

@@ -5,54 +5,83 @@ declare(strict_types=1);
/** /**
* Visitenkarte für eine Person. Props: * Visitenkarte für eine Person. Props:
* $person array{department, name, phone?, email?, image?, whatsapp?} * $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, * Eingebettet (z. B. in position-card unter einem h3) auf 'h4' setzen,
* damit die Heading-Outline nicht abflacht. Styling bleibt klassenbasiert. * damit die Heading-Outline nicht abflacht. Styling bleibt klassenbasiert.
* Ohne image greift der Platzhalter-Avatar (img/persons/platzhalter.jpg). * $hide_department bool — Kicker ausblenden, wenn der Kontext die Abteilung
* Kontakt-Link: phone → tel:, sonst email → mailto:. * schon trägt (person-grid mit group_by_department).
* Optional zusätzlich WhatsApp (wa.me) bei phone + whatsapp=true (opt-in pro Person). * 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'; $level = $level ?? 'h3';
if (!in_array($level, ['h2', 'h3', 'h4', 'h5'], true)) { if (!in_array($level, ['h2', 'h3', 'h4', 'h5'], true)) {
$level = 'h3'; $level = 'h3';
} }
$hideDepartment = !empty($hide_department);
$image = $person['image'] ?? null; $image = $person['image'] ?? null;
$src = $image ?: 'img/persons/platzhalter.jpg';
if (!empty($person['phone'])) { $initials = '';
$contactHref = 'tel:' . preg_replace('/[^+\d]/', '', $person['phone']); foreach (preg_split('/[\s.-]+/', trim((string) ($person['name'] ?? ''))) ?: [] as $word) {
$contactLabel = $person['phone']; if ($word !== '') {
$contactIcon = 'telephone'; $initials .= mb_substr($word, 0, 1);
} elseif (!empty($person['email'])) { }
$contactHref = 'mailto:' . $person['email']; if (mb_strlen($initials) >= 2) {
$contactLabel = $person['email']; break;
$contactIcon = 'envelope'; }
} else {
$contactHref = null;
} }
$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'])) $waHref = (!empty($person['whatsapp']) && !empty($person['phone']))
? 'https://wa.me/' . preg_replace('/\D/', '', $person['phone']) ? 'https://wa.me/' . preg_replace('/\D/', '', $person['phone'])
: null; : null;
$detail = $person['phone'] ?? $person['email'] ?? '';
?> ?>
<article class="person-card"> <article class="person-card">
<?php if ($image): ?>
<div class="person-card__media"> <div class="person-card__media">
<img src="<?= e(asset($src)) ?>" <img src="<?= e(asset($image)) ?>"
alt="<?= $image ? e($person['name']) : '' ?>" alt="<?= e($person['name']) ?>"
width="480" height="600" loading="lazy" decoding="async"> width="480" height="600" loading="lazy" decoding="async">
</div> </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"> <div class="person-card__body">
<<?= $level ?> class="person-card__department"><?= e($person['department']) ?></<?= $level ?>> <div class="person-card__text">
<p class="person-card__name"><?= e($person['name']) ?></p> <?php if (!$hideDepartment): ?>
<?php if ($contactHref !== null): ?> <p class="person-card__department"><?= e($person['department']) ?></p>
<a class="person-card__contact" href="<?= e($contactHref) ?>"> <?php endif; ?>
<?= icon($contactIcon) ?><span><?= e($contactLabel) ?></span> <<?= $level ?> class="person-card__name"><?= e($person['name']) ?></<?= $level ?>>
</a> <?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 endif; ?>
<?php if ($waHref !== null): ?> <?php if ($waHref !== null): ?>
<a class="person-card__contact" href="<?= e($waHref) ?>" target="_blank" rel="noopener"> <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>
<?= icon('whatsapp') ?><span>WhatsApp</span> <?php endif; ?>
</a> <?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; ?> <?php endif; ?>
</div> </div>
</article> </article>

View File

@@ -11,12 +11,18 @@ declare(strict_types=1);
* $intro string — Intro-Text überschreiben (Default aus JSON; '' = ausblenden). * $intro string — Intro-Text überschreiben (Default aus JSON; '' = ausblenden).
* $anchor string — Sektions-ID (Default 'ansprechpartner'). * $anchor string — Sektions-ID (Default 'ansprechpartner').
* $tinted bool — aufgehelltes Vollbreite-Band (Default true). * $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'); $data = json_load('ansprechpartner');
$anchor = $anchor ?? 'ansprechpartner'; $anchor = $anchor ?? 'ansprechpartner';
$tinted = $tinted ?? true; $tinted = $tinted ?? true;
$columns = $columns ?? null;
$title = $title ?? $data['title']; $title = $title ?? $data['title'];
$intro = $intro ?? ($data['intro'] ?? ''); $intro = $intro ?? ($data['intro'] ?? '');
@@ -37,19 +43,50 @@ if (!empty($names)) {
if ($persons === []) { if ($persons === []) {
return; return;
} }
?>
if (!empty($embedded)): ?>
<div class="persons persons--embedded" id="<?= e($anchor) ?>">
<h2><?= e($title) ?></h2>
<ul class="persons__grid">
<?php foreach ($persons as $person): ?>
<li>
<?php component('person-card', ['person' => $person]); ?>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php return; endif; ?>
<section class="section persons<?= $tinted ? ' section--tinted' : '' ?>" id="<?= e($anchor) ?>"> <section class="section persons<?= $tinted ? ' section--tinted' : '' ?>" id="<?= e($anchor) ?>">
<div class="container"> <div class="container">
<h2><?= e($title) ?></h2> <h2><?= e($title) ?></h2>
<?php if ($intro !== ''): ?> <?php if ($intro !== ''): ?>
<p class="persons__intro"><?= e($intro) ?></p> <p class="persons__intro"><?= e($intro) ?></p>
<?php endif; ?> <?php endif; ?>
<ul class="persons__grid<?= $columns ? ' persons__grid--cols-' . (int)$columns : '' ?>"> <?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): ?> <?php foreach ($persons as $person): ?>
<li> <li>
<?php component('person-card', ['person' => $person]); ?> <?php component('person-card', ['person' => $person]); ?>
</li> </li>
<?php endforeach; ?> <?php endforeach; ?>
</ul> </ul>
<?php endif; ?>
</div> </div>
</section> </section>

View File

@@ -4,25 +4,68 @@ declare(strict_types=1);
/** /**
* Alternierende Bild/Text-Sektion. Props: * 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) * $level int Überschriften-Ebene (default 2)
* tinted: sanft aufgehelltes Panel hinter dem Inhalt (Abgrenzung vom Seitenhintergrund). * 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; $level = $level ?? 2;
$flip = !empty($section['flip']); $flip = !empty($section['flip']);
$tinted = !empty($section['tinted']); $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="container split__inner">
<div class="split__text"> <div class="split__text">
<h<?= $level ?>><?= e($section['title']) ?></h<?= $level ?>> <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'])): ?> <?php if (!empty($section['cta'])): ?>
<p class="split__cta"><?php component('cta', ['cta' => $section['cta']]); ?></p> <p class="split__cta"><?php component('cta', ['cta' => $section['cta']]); ?></p>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php if ($hasMedia): ?>
<div class="split__media"> <div class="split__media">
<?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 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> </div>
<?php endif; ?>
</div> </div>
</section> </section>

View File

@@ -10,11 +10,14 @@ declare(strict_types=1);
*/ */
$club = json_load('club'); $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']); $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 = [ $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.', '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'] . '.', '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> <p class="text-muted">Oder direkt per Mail an <a href="mailto:<?= e($club['email']) ?>"><?= e($club['email']) ?></a></p>
</div> </div>
<form class="form" method="post" action="<?= e(url('sportheimbuchung-senden')) ?>" novalidate> <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): ?> <?php if ($sent): ?>
<p class="form__success">Danke für deine Anfrage! Wir melden uns kurzfristig bei dir.</p> <p class="form__success">Danke für deine Anfrage! Wir melden uns kurzfristig bei dir.</p>
<?php elseif ($error !== null): ?> <?php elseif ($error !== null): ?>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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; ?>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -39,7 +39,7 @@ if (!empty($team['roster']['members']) || !empty($team['roster']['groups'])) {
component('faq-contact', [ component('faq-contact', [
'faq' => $team['faq'] ?? [], '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, 'persons' => $contacts,
'contactTitle' => '', 'contactTitle' => '',
'anchor' => 'kontakt', 'anchor' => 'kontakt',

View 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; ?>

View File

@@ -4,9 +4,13 @@ declare(strict_types=1);
/** /**
* Interaktive Vereins-Timeline (Museums-Look). Props: * 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) — * Bilder werden bewusst in ORIGINALGRÖSSE eingebunden (keine Varianten) —
* historisches Material bleibt unangetastet. * 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 * timeline.js ergänzt Scroll-Reveal + aktive Jahres-Navigation; ohne JS ist
* alles sichtbar (Initial-Versteckt-Zustand nur unter .js-reveal). * 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> <span class="timeline__marker" aria-hidden="true"></span>
<article class="timeline__card"> <article class="timeline__card">
<h2 class="timeline__year"><?= e($event['year']) ?></h2> <h2 class="timeline__year"><?= e($event['year']) ?></h2>
<?php if (!empty($event['image']['src'])): ?>
<figure class="timeline__figure"> <figure class="timeline__figure">
<img src="<?= e(asset($event['image']['src'])) ?>" <img src="<?= e(asset($event['image']['src'])) ?>"
alt="<?= e($event['image']['alt']) ?>" alt="<?= e($event['image']['alt']) ?>"
@@ -49,6 +54,11 @@ foreach ($events as $event) {
loading="<?= $i === 0 ? 'eager' : 'lazy' ?>" decoding="async"> loading="<?= $i === 0 ? 'eager' : 'lazy' ?>" decoding="async">
<figcaption class="timeline__text"><?= e($event['text']) ?></figcaption> <figcaption class="timeline__text"><?= e($event['text']) ?></figcaption>
</figure> </figure>
<?php else: ?>
<div class="timeline__figure timeline__figure--note">
<p class="timeline__text"><?= e($event['text']) ?></p>
</div>
<?php endif; ?>
</article> </article>
</li> </li>
<?php endforeach; ?> <?php endforeach; ?>

View 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>

View 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>

View 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>

View 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>

View 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; ?>

File diff suppressed because it is too large Load Diff

View File

@@ -31,7 +31,8 @@ $anchor_slug = static function (string $s): string {
$meta = [ $meta = [
'title' => 'Ansprechpartner', '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([ 'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''], ['name' => 'Startseite', 'slug' => ''],
['name' => 'Der Verein', 'slug' => 'verein'], ['name' => 'Der Verein', 'slug' => 'verein'],
@@ -40,7 +41,7 @@ $meta = [
]; ];
component('hero', ['hero' => [ component('hero', ['hero' => [
'compact' => true, 'compact' => true,
'align' => 'bottom-left', 'align' => 'center',
'title' => 'Ansprechpartner', 'title' => 'Ansprechpartner',
'text' => $page['intro'] ?? '', 'text' => $page['intro'] ?? '',
'image' => [ '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', [ component('person-grid', [
'departments' => $depts, 'departments' => $depts,
'title' => $label, 'title' => $label,
'intro' => '',
'anchor' => 'kontakt-' . $anchor_slug($label), 'anchor' => 'kontakt-' . $anchor_slug($label),
'tinted' => ($i % 2 === 0), 'embedded' => true,
]); ]);
$i++; }
endforeach; ?>
</div>
</section>
<?php

View File

@@ -5,9 +5,16 @@ declare(strict_types=1);
/** /**
* Datenschutzerklärung — zugeschnitten auf den tatsächlichen Stack: * Datenschutzerklärung — zugeschnitten auf den tatsächlichen Stack:
* komplett self-hosted, keine Tracker/Analyse/Werbe-Dienste, keine externen * komplett self-hosted, keine Tracker/Analyse/Werbe-Dienste, keine externen
* Fonts/Maps/Embeds. Einzige Verarbeitungen: Server-Logs (Hoster), * Fonts/Maps/Embeds. Einzige Verarbeitungen: Server-Logs (Hoster), die vier
* Kontaktformular (Versand via Brevo SMTP), server-seitig gecachter * Formulare (Kontakt, Turnabteilung, Mitgliedsantrag, Sportheimbuchung; Versand
* Instagram-Feed (Bilder von unserer Domain). Vereinsdaten aus club.json. * ü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'); $club = json_load('club');
$addr = $club['address'] ?? []; $addr = $club['address'] ?? [];
@@ -35,7 +42,7 @@ $meta = [
</p> </p>
</div> </div>
<div class="legal__body"> <div class="legal__body prose">
<section> <section>
<h2>Verantwortlicher</h2> <h2>Verantwortlicher</h2>
<p>Verantwortlich für die Datenverarbeitung auf dieser Website ist:</p> <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 Tracking-Zwecken</strong> und bindet keine Dienste ein, die solche Cookies
setzen würden. Aus diesem Grund benötigen wir auch kein Cookie-Banner. setzen würden. Aus diesem Grund benötigen wir auch kein Cookie-Banner.
</p> </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>
<section> <section>
<h2>Kontaktformular &amp; Kontaktaufnahme</h2> <h2>Formulare &amp; Kontaktaufnahme</h2>
<p> <p>
Wenn du uns über das Kontaktformular oder per E-Mail kontaktierst, verarbeiten Wenn du uns über eines unserer Formulare oder per E-Mail kontaktierst,
wir die von dir angegebenen Daten, um deine Anfrage zu bearbeiten. Über das verarbeiten wir die von dir angegebenen Daten, um deine Anfrage zu bearbeiten.
Formular sind das: Über das <strong>Kontaktformular</strong> sind das:
</p> </p>
<ul> <ul>
<li>Name (Pflichtangabe)</li> <li>Name (Pflichtangabe)</li>
@@ -137,6 +150,34 @@ $meta = [
<li>Telefonnummer (freiwillig)</li> <li>Telefonnummer (freiwillig)</li>
<li>dein Interessensgebiet, Betreff und deine Nachricht</li> <li>dein Interessensgebiet, Betreff und deine Nachricht</li>
</ul> </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> <p>
Rechtsgrundlage ist deine Einwilligung (Art. 6 Abs. 1 lit. a DSGVO), die du Rechtsgrundlage ist deine Einwilligung (Art. 6 Abs. 1 lit. a DSGVO), die du
mit dem Absenden erteilst, sowie unser berechtigtes Interesse bzw. die mit dem Absenden erteilst, sowie unser berechtigtes Interesse bzw. die
@@ -146,16 +187,60 @@ $meta = [
Aufbewahrungspflichten entgegenstehen. Aufbewahrungspflichten entgegenstehen.
</p> </p>
<p> <p>
Für den technischen Versand der Formular-Nachrichten als E-Mail nutzen wir den Schickst du uns Material für unsere <strong>Vereinshistorie</strong> Fotos,
Dienstleister Brevo (Sendinblue GmbH, Köln) als Auftragsverarbeiter. Dabei Zeitungsausschnitte, Dokumente oder Erinnerungen an
werden die im Formular angegebenen Daten an die Mailserver von Brevo <a href="mailto:<?= e($club['email_historie']) ?>"><?= e($club['email_historie']) ?></a>,
übermittelt. Die Übertragung erfolgt verschlüsselt. 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>
<p class="legal__muted"> <p class="legal__muted">
Hinweis: Zum Schutz vor automatisiertem Spam verwenden wir ein verstecktes Hinweis: Zum Schutz vor automatisiertem Spam verwenden wir ein verstecktes
Pflichtfeld sowie einen signierten Zeitstempel. Dabei werden keine Pflichtfeld und einen signierten Zeitstempel. Außerdem begrenzen wir, wie oft
zusätzlichen personenbezogenen Daten erhoben und keine externen Dienste von einem Anschluss abgesendet werden kann. Dafür speichern wir deine
(z.&nbsp;B. reCAPTCHA) eingebunden. 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.&nbsp;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> </p>
</section> </section>

View File

@@ -21,7 +21,7 @@ $cards = [
$meta = [ $meta = [
'title' => 'Fußball in Kulmbach', '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', 'og_image' => 'img/pages/fussball-hero-1600.jpg',
'schema' => page_schema( 'schema' => page_schema(
[ [

View File

@@ -3,14 +3,28 @@
declare(strict_types=1); declare(strict_types=1);
/** /**
* Vereinshistorie — schmaler Hero + interaktive Timeline. * Vereinshistorie — schmaler Hero + interaktive Timeline + Aufruf zum Mitsammeln.
* Inhalte: data/historie.json (Single Source of Truth). * Inhalte: data/historie.json (Single Source of Truth), Mailadresse aus data/club.json.
*/ */
$historie = json_load('historie'); $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 = [ $meta = [
'title' => 'Vereinshistorie Der Katzbachtaler Weg', '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.', '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'], 'scripts' => ['timeline.js'],
'schema' => page_schema([ 'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''], ['name' => 'Startseite', 'slug' => ''],
@@ -21,3 +35,4 @@ $meta = [
component('hero', ['hero' => $historie['hero']]); component('hero', ['hero' => $historie['hero']]);
component('timeline', ['events' => $historie['events']]); component('timeline', ['events' => $historie['events']]);
component('section', ['section' => $contribute]);

View File

@@ -10,14 +10,17 @@ declare(strict_types=1);
$home = json_load('home'); $home = json_load('home');
$meta = [ $meta = [
'title' => 'Fußball & Turnen in Kulmbach TSV 08 Kulmbach', 'title' => 'Fußball & Turnen in Kulmbach | TSV 08 Kulmbach',
'title_absolute' => true, '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!', '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'], 'scripts' => ['form.js', 'carousel.js', 'crest.js'],
'schema' => [website_schema()], '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('banner-slider');
component('instagram-feed'); component('instagram-feed');
@@ -25,12 +28,10 @@ foreach ($home['sections'] as $section) {
component('section', ['section' => $section]); component('section', ['section' => $section]);
} }
component('stats', ['stats' => $home['stats']]);
component('partner-grid', ['intro' => $home['partners']]); component('partner-grid', ['intro' => $home['partners']]);
// Auf der Startseite bewusst nur die Haupt-Ansprechpartner je Bereich; die // Auf der Startseite bewusst nur die Haupt-Ansprechpartner je Bereich; die
// vollständige Turn-Disziplin-Liste lebt auf /ansprechpartner. // vollständige Turn-Disziplin-Liste lebt auf /ansprechpartner.
component('person-grid', [ component('person-grid', [
'departments' => ['Herrenfußball', 'Frauenfußball', 'Jugendfußball', 'Erwachsenenturnen'], 'departments' => ['Herrenfußball', 'Frauenfußball', 'Jugendfußball', 'Erwachsenenturnen'],
'columns' => 3,
]); ]);
component('contact-form', ['intro' => $home['contact']]); component('contact-form', ['intro' => $home['contact']]);

View File

@@ -28,7 +28,7 @@ $meta = [
<p class="legal__lead">Angaben gemäß § 5 Digitale-Dienste-Gesetz (DDG).</p> <p class="legal__lead">Angaben gemäß § 5 Digitale-Dienste-Gesetz (DDG).</p>
</div> </div>
<div class="legal__body"> <div class="legal__body prose">
<section> <section>
<h2>Anbieter</h2> <h2>Anbieter</h2>
<div class="legal__card"> <div class="legal__card">
@@ -38,7 +38,10 @@ $meta = [
<?= e($addr['zip']) ?> <?= e($addr['city']) ?><br> <?= e($addr['zip']) ?> <?= e($addr['city']) ?><br>
<br> <br>
E-Mail: <a href="mailto:<?= e($club['email']) ?>"><?= e($club['email']) ?></a><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> </address>
</div> </div>
</section> </section>

View File

@@ -21,7 +21,7 @@ $contacts = array_values(array_filter(
$meta = [ $meta = [
'title' => 'Jugendfußball in Kulmbach', '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', 'og_image' => 'img/pages/jugendfussball-hero-1440.jpg',
'scripts' => count($contacts) > 1 ? ['contact-slider.js'] : [], 'scripts' => count($contacts) > 1 ? ['contact-slider.js'] : [],
'schema' => page_schema( 'schema' => page_schema(
@@ -40,7 +40,7 @@ component('hero', ['hero' => $page['hero']]);
<div class="container"> <div class="container">
<div class="intro-bento"> <div class="intro-bento">
<div class="bento__tile intro-bento__image"> <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> </div>
<article class="bento__tile intro-bento__about"> <article class="bento__tile intro-bento__about">
@@ -70,7 +70,7 @@ component('age-groups', ['groups' => $page['groups'] ?? []]);
component('faq-contact', [ component('faq-contact', [
'faq' => $page['faq'] ?? [], '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, 'persons' => $contacts,
'contactTitle' => '', 'contactTitle' => '',
'anchor' => 'kontakt', 'anchor' => 'kontakt',

View File

@@ -10,7 +10,7 @@ $page = json_load('kontakt');
$meta = [ $meta = [
'title' => 'Kontakt', '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', 'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
'scripts' => ['form.js'], 'scripts' => ['form.js'],
'schema' => page_schema([ 'schema' => page_schema([

View File

@@ -14,9 +14,9 @@ $teams = $mc['teams'] ?? [];
$upcoming = $mc['upcoming'] ?? []; $upcoming = $mc['upcoming'] ?? [];
$meta = [ $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.', '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'], 'scripts' => ['crest.js', 'group-tabs.js'],
'schema' => array_merge( 'schema' => array_merge(
page_schema([ page_schema([
@@ -30,11 +30,16 @@ $meta = [
component('hero', ['hero' => [ component('hero', ['hero' => [
'compact' => true, 'compact' => true,
'align' => 'bottom-left', 'align' => 'center',
'title' => 'Matchcenter', 'title' => 'Matchcenter',
'text' => 'Alle Spiele, Ergebnisse und Tabellen unserer Aktivenmannschaften immer aktuell.', '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' => [ 'image' => [
'base' => 'img/pages/fussball-hero', 'base' => 'img/pages/matchcenter-hero',
'widths' => [640, 1000, 1600], 'widths' => [640, 1000, 1600],
'alt' => '', 'alt' => '',
], ],

View File

@@ -19,7 +19,7 @@ $feeOptions = array_map(
$meta = [ $meta = [
'title' => 'Mitglied werden', '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', 'og_image' => 'img/hero/hero-1600.jpg',
'scripts' => ['form.js'], 'scripts' => ['form.js'],
'schema' => page_schema( 'schema' => page_schema(
@@ -33,7 +33,9 @@ $meta = [
component('hero', ['hero' => $page['hero']]); 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"> <section class="section membership-signup" id="anmeldung-fussball">
<div class="container membership-signup__inner"> <div class="container membership-signup__inner">

View File

@@ -26,8 +26,8 @@ $contact_for = static function (array $pos) use ($directory): ?array {
$meta = [ $meta = [
'title' => 'Mitmachen & Ehrenamt', 'title' => 'Mitmachen & Ehrenamt',
'description' => 'Ehrenamt beim TSV 08 Kulmbach: offene Aufgaben als Jugendtrainer, Betreuer oder Helfer bei Veranstaltungen mit direktem Ansprechpartner, ohne Vorerfahrung.', '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/jugendfussball-hero-1440.jpg', 'og_image' => 'img/pages/mitmachen-hero-1600.jpg',
'scripts' => ['position-filter.js'], 'scripts' => ['position-filter.js'],
'schema' => array_merge( 'schema' => array_merge(
page_schema( page_schema(
@@ -52,7 +52,7 @@ component('hero', ['hero' => $page['hero']]);
<?php if (!empty($page['filters'])): ?> <?php if (!empty($page['filters'])): ?>
<div class="filter-bar" role="group" aria-label="Stellen nach Bereich filtern" data-position-filter> <div class="filter-bar" role="group" aria-label="Stellen nach Bereich filtern" data-position-filter>
<?php foreach ($page['filters'] as $i => $f): ?> <?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; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
@@ -61,7 +61,7 @@ component('hero', ['hero' => $page['hero']]);
<?php component('position-card', ['position' => $pos, 'contact' => $contact_for($pos)]); ?> <?php component('position-card', ['position' => $pos, 'contact' => $contact_for($pos)]); ?>
<?php endforeach; ?> <?php endforeach; ?>
</div> </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> </div>
</section> </section>
<?php if (!empty($page['arguments']['items'])): ?> <?php if (!empty($page['arguments']['items'])): ?>

View 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
View 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>

View File

@@ -11,7 +11,7 @@ $page = json_load('partner-werden');
$meta = [ $meta = [
'title' => 'Werbepartner werden', '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', 'og_image' => 'img/pages/fussball-hero-1600.jpg',
'schema' => page_schema( 'schema' => page_schema(
[ [

View File

@@ -11,7 +11,7 @@ $club = json_load('club');
$meta = [ $meta = [
'title' => 'Sportheim mieten in Kulmbach', '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', 'og_image' => 'img/pages/sportheim-2.jpg',
'schema' => page_schema( 'schema' => page_schema(
[ [

View 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]); ?>

View 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]);

View File

@@ -10,8 +10,8 @@ $slug = 'fussball/damen';
$team = json_load('teams')['damen']; $team = json_load('teams')['damen'];
$meta = [ $meta = [
'title' => 'Frauenmannschaft Frauenfußball Kulmbach', '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.', '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', 'og_image' => $team['hero']['image']['base'] . '-1600.jpg',
'schema' => team_schema($team, $slug), 'schema' => team_schema($team, $slug),
]; ];

View File

@@ -9,8 +9,8 @@ $slug = 'fussball/erste-mannschaft';
$team = json_load('teams')['erste-mannschaft']; $team = json_load('teams')['erste-mannschaft'];
$meta = [ $meta = [
'title' => '1. Mannschaft Kreisklasse Kulmbach', '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.', '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', 'og_image' => $team['hero']['image']['base'] . '-1600.jpg',
'schema' => team_schema($team, $slug), 'schema' => team_schema($team, $slug),
]; ];

View File

@@ -9,8 +9,8 @@ $slug = 'fussball/zweite-mannschaft';
$team = json_load('teams')['zweite-mannschaft']; $team = json_load('teams')['zweite-mannschaft'];
$meta = [ $meta = [
'title' => '2. Mannschaft Herrenfußball Kulmbach', '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.', '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', 'og_image' => $team['hero']['image']['base'] . '-1600.jpg',
'scripts' => (count($team['contact_names'] ?? []) > 1) ? ['contact-slider.js'] : [], 'scripts' => (count($team['contact_names'] ?? []) > 1) ? ['contact-slider.js'] : [],
'schema' => team_schema($team, $slug), 'schema' => team_schema($team, $slug),

62
app/pages/termine.php Normal file
View 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>

View File

@@ -14,7 +14,7 @@ $d = $data['disciplines']['eltern-kind-turnen'];
$meta = [ $meta = [
'title' => 'Eltern-Kind- & Kinderturnen in Kulmbach', 'title' => 'Eltern-Kind- & Kinderturnen in Kulmbach',
'description' => $d['hero']['text'], 'description' => $d['hero']['text'],
'og_image' => 'img/hero/hero-1600.jpg', 'og_image' => 'img/pages/turnen-eltern-kind-1536.jpg',
'scripts' => ['group-tabs.js'], 'scripts' => ['group-tabs.js'],
'schema' => page_schema([ 'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''], ['name' => 'Startseite', 'slug' => ''],
@@ -27,7 +27,10 @@ component('hero', ['hero' => $d['hero']]);
component('group-schedule', [ component('group-schedule', [
'location' => $d['location'] ?? [], 'location' => $d['location'] ?? [],
'lead' => $d['lead'] ?? '', 'lead' => $d['lead'] ?? '',
'notice' => $d['notice'] ?? '', 'notice' => $d['notice'] ?? '',
'noticeTitle' => $d['notice_title'] ?? '',
'noticeTone' => $d['notice_tone'] ?? 'info',
'noticeIcon' => $d['notice_icon'] ?? 'info-circle',
'groups' => $d['groups'] ?? [], 'groups' => $d['groups'] ?? [],
'footnote' => $data['footnote'] ?? '', 'footnote' => $data['footnote'] ?? '',
]); ]);

View File

@@ -11,8 +11,8 @@ $d = json_load('turnen')['disciplines']['erwachsenenturnen'];
$meta = [ $meta = [
'title' => 'Erwachsenenturnen in Kulmbach', '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.', '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', 'og_image' => 'img/pages/turnen-erwachsenenturnen-1536.jpg',
'schema' => page_schema([ 'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''], ['name' => 'Startseite', 'slug' => ''],
['name' => 'Turnen', 'slug' => 'turnen'], ['name' => 'Turnen', 'slug' => 'turnen'],

View 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',
]);

View File

@@ -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'] ?? [],
]);

View File

@@ -11,8 +11,8 @@ $d = json_load('turnen')['disciplines']['trampolin'];
$meta = [ $meta = [
'title' => 'Trampolinturnen in Kulmbach', '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.', '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', 'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
'schema' => page_schema([ 'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''], ['name' => 'Startseite', 'slug' => ''],
['name' => 'Turnen', 'slug' => 'turnen'], ['name' => 'Turnen', 'slug' => 'turnen'],
@@ -22,7 +22,15 @@ $meta = [
component('hero', ['hero' => $d['hero']]); component('hero', ['hero' => $d['hero']]);
component('group-schedule', [ component('group-schedule', [
'location' => $d['location'] ?? [], 'location' => $d['location'] ?? [],
'lead' => $d['lead'] ?? '', 'lead' => $d['lead'] ?? '',
'groups' => $d['groups'] ?? [], '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'] ?? '',
]); ]);

View File

@@ -4,15 +4,21 @@ declare(strict_types=1);
/** /**
* Turnen → Wettkampfturnen. Inhalt: data/turnen.json (disciplines.wettkampfturnen). * 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'; $slug = 'turnen/wettkampfturnen';
$d = json_load('turnen')['disciplines']['wettkampfturnen']; $data = json_load('turnen');
$d = $data['disciplines']['wettkampfturnen'];
$meta = [ $meta = [
'title' => 'Wettkampfturnen in Kulmbach', '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.', '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', 'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
'schema' => page_schema([ 'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''], ['name' => 'Startseite', 'slug' => ''],
['name' => 'Turnen', 'slug' => 'turnen'], ['name' => 'Turnen', 'slug' => 'turnen'],
@@ -25,4 +31,16 @@ component('group-schedule', [
'location' => $d['location'] ?? [], 'location' => $d['location'] ?? [],
'lead' => $d['lead'] ?? '', 'lead' => $d['lead'] ?? '',
'groups' => $d['groups'] ?? [], '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'] ?? '',
]); ]);

View File

@@ -3,30 +3,73 @@
declare(strict_types=1); declare(strict_types=1);
/** /**
* Turn-Übersichtsseite (reiner Hub). Inhalt: data/turnen.json. * Turn-Übersichtsseite (Hub + Beiträge + Abteilungskontakt). Inhalt: data/turnen.json.
* Struktur: Hero → Disziplin-Karten. Keine weiteren Sektionen — die Detailinhalte * Struktur: Hero → Disziplin-Karten → Beiträge → FAQ → Anfrageformular.
* (Beiträge, FAQ, Trainingszeiten) leben auf den Disziplin-Unterseiten. *
* 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']; $page = json_load('turnen')['overview'];
// Heros nutzen das Vereins-Default-Bild; nur die Übersichts-Kacheln zeigen die // Die Kündigung darf formlos per Mail kommen (Turnabteilung, 31.07.2026). Die Adresse
// (vorläufigen) themenbezogenen Unsplash-Platzhalter aus public/assets/img/pages/. // 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 = [ $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' => '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, 1600], 'alt' => 'Eltern-Kind- und Kinderturnen 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, 1536], 'alt' => 'Kinder turnen an Balken, Kästen und einer Koordinationsleiter in der Halle (Symbolbild)']],
['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' => '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)']],
['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']], // 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']], ['name' => 'Wettkampfturnen', 'slug' => 'turnen/wettkampfturnen', 'category' => 'Leistungssport', 'image' => ['base' => 'img/pages/turnen-wettkampf', 'widths' => [640, 1000, 1600], 'alt' => 'Wettkampfturnen beim TSV 08 Kulmbach']],
]; ];
$meta = [ $meta = [
'title' => 'Turnen in Kulmbach', 'title' => 'Turnen in Kulmbach',
'description' => 'Die Turnabteilung des TSV 08 Kulmbach: Eltern-Kind- und Kinderturnen, Erwachsenenturnen, Step Aerobic, Fitness und Pilates, Trampolin- und Wettkampfturnen.', 'description' => 'Die Turnabteilung des TSV 08 Kulmbach: Eltern-Kind- und Kinderturnen, Erwachsenenturnen, Fitness und Pilates, Trampolin- und Wettkampfturnen.',
'og_image' => 'img/hero/hero-1600.jpg', '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([ 'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''], ['name' => 'Startseite', 'slug' => ''],
['name' => 'Turnen', 'slug' => 'turnen'], ['name' => 'Turnen', 'slug' => 'turnen'],
]), ], $page['faq'] ?? []),
]; ];
component('hero', ['hero' => $page['hero']]); component('hero', ['hero' => $page['hero']]);
@@ -34,5 +77,53 @@ component('team-cards', [
'teams' => $cards, 'teams' => $cards,
'title' => 'Unsere Turngruppen', 'title' => 'Unsere Turngruppen',
'title_hidden' => true, 'title_hidden' => true,
'intro' => $page['cards_intro'] ?? null,
'anchor' => 'gruppen', '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
View 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; ?>

View 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>

View File

@@ -26,7 +26,7 @@ $cards = [
$meta = [ $meta = [
'title' => 'Der Verein', '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', 'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
'schema' => page_schema( 'schema' => page_schema(
[ [

View File

@@ -11,16 +11,18 @@ declare(strict_types=1);
*/ */
return [ return [
'' => ['file' => 'home.php', 'sitemap' => true], '' => ['file' => 'home.php', 'sitemap' => true],
'news' => ['file' => 'news.php', 'sitemap' => true],
'fussball' => ['file' => 'fussball.php', 'sitemap' => true], 'fussball' => ['file' => 'fussball.php', 'sitemap' => true],
'fussball/erste-mannschaft' => ['file' => 'team-erste.php', 'sitemap' => true], 'fussball/erste-mannschaft' => ['file' => 'team-erste.php', 'sitemap' => true],
'fussball/zweite-mannschaft' => ['file' => 'team-zwei.php', 'sitemap' => true], 'fussball/zweite-mannschaft' => ['file' => 'team-zwei.php', 'sitemap' => true],
'fussball/damen' => ['file' => 'team-damen.php', 'sitemap' => true], 'fussball/damen' => ['file' => 'team-damen.php', 'sitemap' => true],
'matchcenter' => ['file' => 'matchcenter.php', 'sitemap' => true], 'matchcenter' => ['file' => 'matchcenter.php', 'sitemap' => true],
'stadionzeitung' => ['file' => 'stadionzeitung.php', 'sitemap' => true],
'jugendfussball' => ['file' => 'jugendfussball.php', 'sitemap' => true], 'jugendfussball' => ['file' => 'jugendfussball.php', 'sitemap' => true],
'turnen' => ['file' => 'turnen.php', 'sitemap' => true], 'turnen' => ['file' => 'turnen.php', 'sitemap' => true],
'turnen/erwachsenenturnen' => ['file' => 'turnen-erwachsenenturnen.php', 'sitemap' => true], 'turnen/erwachsenenturnen' => ['file' => 'turnen-erwachsenenturnen.php', 'sitemap' => true],
'turnen/eltern-kind-turnen' => ['file' => 'turnen-eltern-kind.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/trampolin' => ['file' => 'turnen-trampolin.php', 'sitemap' => true],
'turnen/wettkampfturnen' => ['file' => 'turnen-wettkampfturnen.php', 'sitemap' => true], 'turnen/wettkampfturnen' => ['file' => 'turnen-wettkampfturnen.php', 'sitemap' => true],
'mitmachen' => ['file' => 'mitmachen.php', 'sitemap' => true], 'mitmachen' => ['file' => 'mitmachen.php', 'sitemap' => true],
@@ -28,6 +30,8 @@ return [
'historie' => ['file' => 'historie.php', 'sitemap' => true], 'historie' => ['file' => 'historie.php', 'sitemap' => true],
'sportheimbuchung' => ['file' => 'sportheimbuchung.php', 'sitemap' => true], 'sportheimbuchung' => ['file' => 'sportheimbuchung.php', 'sitemap' => true],
'ansprechpartner' => ['file' => 'ansprechpartner.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], 'kontakt' => ['file' => 'kontakt.php', 'sitemap' => true],
'partner-werden' => ['file' => 'partner-werden.php', 'sitemap' => true], 'partner-werden' => ['file' => 'partner-werden.php', 'sitemap' => true],
'mitglied-werden' => ['file' => 'mitglied-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