Neue Unterseite /matchcenter ersetzt die alten BFV-Widget-Embeds (verstoßen
gegen CSP) durch eine self-hostete Lösung: Cron-Scraper holt die Daten aus der
öffentlichen BFV-Widget-JSON-API, schreibt data/matchcenter.json und lädt die
Vereinswappen lokal — Besucher kontaktieren nur unsere Domain.
- bin/matchcenter-sync.php: CLI/Cron-Scraper (Muster wie instagram-sync), JSON-API
team/{id}/matches + competition/{compoundId}/table, Wappen via getLogo lokal,
atomarer Write, Fail-safe. Auto-Saison: Tabellen-compoundId wird aus der
Matches-API abgeleitet (team_id ist saison-stabil). Per-Team-Cache-Fallback bei
Fetch-Fehlern, damit Sektionen nie leeren.
- Seite app/pages/matchcenter.php + Route + Nav-Eintrag (unter Fußball).
- Komponenten: match-slider (nächste Spiele), match-card, match-row, league-table
(mit Auf-/Abstiegszonen), matchcenter-section (kicker-Layout: Tabelle 2/3 +
Spiele 1/3), matchcenter-empty, crest (weißes Chip + Initialen-Fallback).
- carousel.js: banner.js zum generischen [data-carousel]-Antrieb verallgemeinert
(Banner- und Match-Slider teilen ihn); crest.js: Logo-Fallback im Browser.
- helpers.php: sportsevent_nodes() für SportsEvent-JSON-LD.
- Icons calendar-event; config.example + .gitignore + CLAUDE.md ergänzt.
- Responsive geprüft (kein Seiten-Overflow 360–1280, Tabelle scrollt in der Karte).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0192vTfGmGpoWeyUse4TuQFj
257 lines
7.4 KiB
PHP
257 lines
7.4 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
/**
|
||
* Config-Wert holen: config('smtp.host') oder config() für das ganze Array.
|
||
*/
|
||
function config(?string $key = null, mixed $default = null): mixed
|
||
{
|
||
$value = $GLOBALS['__config'];
|
||
if ($key === null) {
|
||
return $value;
|
||
}
|
||
foreach (explode('.', $key) as $part) {
|
||
if (!is_array($value) || !array_key_exists($part, $value)) {
|
||
return $default;
|
||
}
|
||
$value = $value[$part];
|
||
}
|
||
return $value;
|
||
}
|
||
|
||
/**
|
||
* HTML-Escaping — für JEDE dynamische Ausgabe verwenden.
|
||
*/
|
||
function e(string|int|float|null $value): string
|
||
{
|
||
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
||
}
|
||
|
||
/**
|
||
* Interner Link aus Slug: url('fussball') → '/fussball', url('') → '/'.
|
||
*/
|
||
function url(string $slug = ''): string
|
||
{
|
||
return '/' . trim($slug, '/');
|
||
}
|
||
|
||
/**
|
||
* Absolute URL für Canonical, OG und Sitemap.
|
||
*/
|
||
function abs_url(string $slug = ''): string
|
||
{
|
||
return rtrim((string) config('base_url'), '/') . url($slug);
|
||
}
|
||
|
||
/**
|
||
* Asset-Pfad mit Cache-Busting über filemtime: asset('css/tokens.css').
|
||
*/
|
||
function asset(string $path): string
|
||
{
|
||
$path = ltrim($path, '/');
|
||
$file = PUBLIC_PATH . '/assets/' . $path;
|
||
$version = is_file($file) ? (string) filemtime($file) : '0';
|
||
return '/assets/' . $path . '?v=' . $version;
|
||
}
|
||
|
||
/**
|
||
* Bootstrap-Icon als Inline-SVG ausgeben (lokal aus public/assets/icons/<name>.svg).
|
||
* Standard: dekorativ (aria-hidden). Mit $label wird es als beschriftetes Bild
|
||
* (role="img") ausgegeben. Größe/Farbe steuert CSS über die Klasse .icon.
|
||
* Quelle: Bootstrap Icons (MIT) — neue Icons via bin/icons-add.php hinzufügen.
|
||
*/
|
||
function icon(string $name, string $class = '', ?string $label = null): string
|
||
{
|
||
static $cache = [];
|
||
if (!array_key_exists($name, $cache)) {
|
||
$file = PUBLIC_PATH . '/assets/icons/' . basename($name) . '.svg';
|
||
$cache[$name] = is_file($file) ? trim((string) file_get_contents($file)) : '';
|
||
}
|
||
if ($cache[$name] === '') {
|
||
return '';
|
||
}
|
||
$attrs = 'class="icon' . ($class !== '' ? ' ' . e($class) : '') . '"';
|
||
$attrs .= $label !== null && $label !== ''
|
||
? ' role="img" aria-label="' . e($label) . '"'
|
||
: ' aria-hidden="true" focusable="false"';
|
||
|
||
return preg_replace('/<svg\b/', '<svg ' . $attrs, $cache[$name], 1);
|
||
}
|
||
|
||
/**
|
||
* JSON-Datendatei laden (data/<name>.json) mit Request-weitem Cache.
|
||
* Wirft bei kaputtem JSON — Datenfehler sollen laut scheitern, nicht leise.
|
||
*/
|
||
function json_load(string $name): array
|
||
{
|
||
static $cache = [];
|
||
if (!array_key_exists($name, $cache)) {
|
||
$file = DATA_PATH . '/' . $name . '.json';
|
||
if (!is_file($file)) {
|
||
return [];
|
||
}
|
||
$cache[$name] = json_decode((string) file_get_contents($file), true, 512, JSON_THROW_ON_ERROR);
|
||
}
|
||
return $cache[$name];
|
||
}
|
||
|
||
/**
|
||
* Komponente rendern: component('hero', ['title' => …]).
|
||
* Props werden als lokale Variablen extrahiert; Komponenten sind dumme Includes.
|
||
*/
|
||
function component(string $name, array $props = []): void
|
||
{
|
||
extract($props, EXTR_SKIP);
|
||
require APP_PATH . '/components/' . $name . '.php';
|
||
}
|
||
|
||
/**
|
||
* Page-Datei ausführen: sie setzt $meta und emittiert ihren Body.
|
||
* Rückgabe: [$meta, $html].
|
||
*/
|
||
function render_page(string $file): array
|
||
{
|
||
$meta = [];
|
||
ob_start();
|
||
require $file;
|
||
return [$meta, (string) ob_get_clean()];
|
||
}
|
||
|
||
/**
|
||
* Signierten Zeitstempel für die Formular-Time-Trap erzeugen.
|
||
*/
|
||
function form_token(): string
|
||
{
|
||
$ts = (string) time();
|
||
return $ts . '.' . hash_hmac('sha256', $ts, (string) config('app_secret'));
|
||
}
|
||
|
||
/**
|
||
* Time-Trap prüfen: Signatur gültig, älter als $min Sekunden, jünger als $max.
|
||
*/
|
||
function form_token_valid(string $token, int $min = 3, int $max = 7200): bool
|
||
{
|
||
$parts = explode('.', $token);
|
||
if (count($parts) !== 2) {
|
||
return false;
|
||
}
|
||
[$ts, $sig] = $parts;
|
||
if (!hash_equals(hash_hmac('sha256', $ts, (string) config('app_secret')), $sig)) {
|
||
return false;
|
||
}
|
||
$age = time() - (int) $ts;
|
||
return $age >= $min && $age <= $max;
|
||
}
|
||
|
||
/**
|
||
* BreadcrumbList-Knoten: $items = [['name'=>…, 'slug'=>…], …] (Reihenfolge = Pfad).
|
||
*/
|
||
function breadcrumb_schema(array $items): array
|
||
{
|
||
$list = [];
|
||
foreach (array_values($items) as $i => $item) {
|
||
$list[] = [
|
||
'@type' => 'ListItem',
|
||
'position' => $i + 1,
|
||
'name' => $item['name'],
|
||
'item' => abs_url($item['slug']),
|
||
];
|
||
}
|
||
return ['@type' => 'BreadcrumbList', 'itemListElement' => $list];
|
||
}
|
||
|
||
/**
|
||
* FAQPage-Knoten aus [['q'=>…, 'a'=>…], …]. Leere Paare werden übersprungen;
|
||
* ohne Fragen wird ein leerer Array zurückgegeben (Aufrufer filtert das raus).
|
||
*/
|
||
function faq_schema(array $faq): array
|
||
{
|
||
$questions = [];
|
||
foreach ($faq as $item) {
|
||
if (empty($item['q']) || empty($item['a'])) {
|
||
continue;
|
||
}
|
||
$questions[] = [
|
||
'@type' => 'Question',
|
||
'name' => $item['q'],
|
||
'acceptedAnswer' => ['@type' => 'Answer', 'text' => $item['a']],
|
||
];
|
||
}
|
||
return $questions === [] ? [] : ['@type' => 'FAQPage', 'mainEntity' => $questions];
|
||
}
|
||
|
||
/**
|
||
* Seiten-Schema-Knoten zusammenstellen (für $meta['schema']):
|
||
* Breadcrumb + optional FAQPage. Generisch für Übersichts-/Jugendseiten.
|
||
*/
|
||
function page_schema(array $breadcrumb, array $faq = []): array
|
||
{
|
||
$nodes = [];
|
||
if ($breadcrumb !== []) {
|
||
$nodes[] = breadcrumb_schema($breadcrumb);
|
||
}
|
||
if ($faq !== [] && ($faqNode = faq_schema($faq)) !== []) {
|
||
$nodes[] = $faqNode;
|
||
}
|
||
return $nodes;
|
||
}
|
||
|
||
/**
|
||
* Schema-Knoten für eine Mannschafts-Seite: SportsTeam (verweist auf den Club
|
||
* via #club) + Breadcrump (Start → Fußball → Team) + optional FAQPage.
|
||
* $team: Eintrag aus data/teams.json; $slug: voller Seiten-Slug.
|
||
*/
|
||
function team_schema(array $team, string $slug): array
|
||
{
|
||
$teamNode = [
|
||
'@type' => 'SportsTeam',
|
||
'name' => $team['name'],
|
||
'sport' => 'Fußball',
|
||
'url' => abs_url($slug),
|
||
'memberOf' => ['@id' => abs_url() . '#club'],
|
||
];
|
||
if (!empty($team['hero']['text'])) {
|
||
$teamNode['description'] = $team['hero']['text'];
|
||
}
|
||
|
||
$nodes = [$teamNode];
|
||
$nodes = array_merge($nodes, page_schema(
|
||
[
|
||
['name' => 'Startseite', 'slug' => ''],
|
||
['name' => 'Fußball', 'slug' => 'fussball'],
|
||
['name' => $team['name'], 'slug' => $slug],
|
||
],
|
||
$team['faq'] ?? []
|
||
));
|
||
return $nodes;
|
||
}
|
||
|
||
/**
|
||
* SportsEvent-Knoten für anstehende Spiele (Matchcenter). $upcoming: Einträge aus
|
||
* data/matchcenter.json → upcoming[] (home, away, kickoff, …). startDate nur, wenn
|
||
* der Anstoß als ISO-Zeit vorliegt. Aufrufer hängt das Ergebnis an $meta['schema'].
|
||
*/
|
||
function sportsevent_nodes(array $upcoming): array
|
||
{
|
||
$nodes = [];
|
||
foreach ($upcoming as $m) {
|
||
if (empty($m['home']) || empty($m['away'])) {
|
||
continue;
|
||
}
|
||
$node = [
|
||
'@type' => 'SportsEvent',
|
||
'name' => $m['home'] . ' – ' . $m['away'],
|
||
'sport' => 'Fußball',
|
||
'homeTeam' => ['@type' => 'SportsTeam', 'name' => $m['home']],
|
||
'awayTeam' => ['@type' => 'SportsTeam', 'name' => $m['away']],
|
||
'eventStatus' => 'https://schema.org/EventScheduled',
|
||
];
|
||
if (!empty($m['kickoff'])) {
|
||
$node['startDate'] = $m['kickoff'];
|
||
}
|
||
$nodes[] = $node;
|
||
}
|
||
return $nodes;
|
||
}
|