Neubau Fundament + Startseite: Designsystem, PHP-Komponenten, Brevo-Formular, Instagram-Sync

- Ordnerstruktur mit public/-Docroot, Deny-.htaccess für app/bin/config/data/storage
- CLAUDE.md mit Leitplanken (self-hosted only, Single Source of Truth, Component-first)
- Design-Tokens aus alter Seite extrahiert (Akzent #e20612, Coolvetica/Abel als woff2)
- Front Controller mit Routen-Register, Sitemap, Canonical, JSON-LD (SportsClub)
- Startseite: Hero, Instagram-Feed (lokaler Cache), Historie/Sportheim, Stats, Partner, Kontakt
- Kontaktformular via PHPMailer/Brevo mit Honeypot + HMAC-Time-Trap (kein reCAPTCHA)
- bin/instagram-sync.php: Scraper mit Strategie-Kette, flock, atomarem Cache, lokalen Bildern

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 21:16:25 +02:00
commit 0f19729a88
70 changed files with 2548 additions and 0 deletions

1
app/.htaccess Normal file
View File

@@ -0,0 +1 @@
Require all denied

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
/**
* POST /kontakt-senden — Kontaktformular validieren und via Brevo SMTP versenden.
* Antwort: PRG-Redirect zu /#kontakt (?sent=1 | ?error=…) oder JSON bei fetch (form.js).
*/
use PHPMailer\PHPMailer\PHPMailer;
$wantsJson = str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json');
$respond = static function (bool $ok, string $error = '') use ($wantsJson): never {
if ($wantsJson) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => $ok, 'error' => $error ?: null]);
} else {
header('Location: ' . url('') . ($ok ? '?sent=1' : '?error=' . $error) . '#kontakt', true, 303);
}
exit;
};
$field = static fn (string $key): string => trim((string) ($_POST[$key] ?? ''));
// --- Spam-Checks: Bots bekommen ein stilles "OK" (kein Feedback-Kanal) ---
if ($field('website') !== '' || !form_token_valid($field('ft'))) {
$respond(true);
}
// --- Validierung ---
$name = $field('name');
$email = $field('email');
$phone = $field('phone');
$interest = $field('interest');
$subject = $field('subject');
$message = $field('message');
$interests = ['', 'Herrenfußball', 'Damenfußball', 'Jugendfußball', 'Turnen', 'Sonstiges'];
$valid = $name !== '' && mb_strlen($name) <= 200
&& filter_var($email, FILTER_VALIDATE_EMAIL) !== false
&& mb_strlen($phone) <= 50
&& in_array($interest, $interests, true)
&& $subject !== '' && mb_strlen($subject) <= 200
&& $message !== '' && mb_strlen($message) <= 5000
&& ($_POST['privacy'] ?? '') === '1'
&& !preg_match('/[\r\n]/', $name . $subject);
if (!$valid) {
$respond(false, 'validation');
}
// --- Versand ---
$smtp = config('smtp');
$body = "Kontaktanfrage über tsv08kulmbach.de\n"
. str_repeat('-', 40) . "\n"
. "Name: {$name}\n"
. "E-Mail: {$email}\n"
. ($phone !== '' ? "Telefon: {$phone}\n" : '')
. ($interest !== '' ? "Interesse: {$interest}\n" : '')
. "Betreff: {$subject}\n"
. str_repeat('-', 40) . "\n\n"
. $message . "\n\n"
. str_repeat('-', 40) . "\n"
. 'Datenschutz zugestimmt: ja (' . date('d.m.Y H:i') . ")\n";
try {
$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host = $smtp['host'];
$mail->Port = (int) $smtp['port'];
$mail->SMTPAuth = true;
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Username = $smtp['username'];
$mail->Password = $smtp['password'];
$mail->CharSet = PHPMailer::CHARSET_UTF8;
$mail->setFrom($smtp['from'], $smtp['from_name']);
$mail->addAddress($smtp['to']);
$mail->addReplyTo($email, $name);
$mail->Subject = 'Kontaktanfrage: ' . $subject;
$mail->Body = $body;
$mail->send();
} catch (Throwable $e) {
error_log('[' . date('c') . '] Mailversand fehlgeschlagen: ' . $e->getMessage() . "\n", 3, STORAGE_PATH . '/logs/mail.log');
$respond(false, 'mail');
}
$respond(true);

24
app/bootstrap.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
define('ROOT_PATH', dirname(__DIR__));
define('APP_PATH', __DIR__);
define('DATA_PATH', ROOT_PATH . '/data');
define('STORAGE_PATH', ROOT_PATH . '/storage');
define('PUBLIC_PATH', ROOT_PATH . '/public');
// Config laden — ohne config.php läuft die Seite mit der Vorlage weiter (Mailversand schlägt dann fehl).
$configFile = ROOT_PATH . '/config/config.php';
$GLOBALS['__config'] = require (is_file($configFile) ? $configFile : ROOT_PATH . '/config/config.example.php');
error_reporting(E_ALL);
ini_set('display_errors', ($GLOBALS['__config']['env'] ?? 'production') === 'development' ? '1' : '0');
ini_set('log_errors', '1');
ini_set('error_log', STORAGE_PATH . '/logs/php-errors.log');
require APP_PATH . '/helpers.php';
if (is_file(ROOT_PATH . '/vendor/autoload.php')) {
require ROOT_PATH . '/vendor/autoload.php';
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
/**
* Kontaktformular (Felder wie auf der alten Seite). Props:
* $intro array{title, text}
* Liest Flash-Status aus Query-Params (?sent=1 / ?error=...) — PRG-Muster.
* Spam-Schutz: Honeypot "website" + signierter Timestamp "ft" (form_token()).
*/
$club = json_load('club');
$interests = ['Herrenfußball', 'Damenfußball', 'Jugendfußball', 'Turnen', 'Sonstiges'];
$sent = isset($_GET['sent']);
$error = isset($_GET['error']) ? (string) $_GET['error'] : null;
$errorMessages = [
'validation' => 'Bitte prüfe deine Eingaben Pflichtfelder fehlen oder die E-Mail-Adresse ist ungültig.',
'mail' => 'Deine Nachricht konnte gerade nicht versendet werden. Bitte versuche es später erneut oder schreib uns direkt an ' . $club['email'] . '.',
];
?>
<section class="section contact" id="kontakt">
<div class="container contact__inner">
<div class="contact__intro">
<h2><?= e($intro['title']) ?></h2>
<p><?= e($intro['text']) ?></p>
<p class="text-muted">Oder direkt per Mail an <a href="mailto:<?= e($club['email']) ?>"><?= e($club['email']) ?></a></p>
</div>
<form class="form" method="post" action="<?= e(url('kontakt-senden')) ?>" novalidate>
<div class="form__status" role="status" aria-live="polite" data-form-status>
<?php if ($sent): ?>
<p class="form__success">Danke für deine Anfrage! Wir melden uns so schnell wie möglich bei dir.</p>
<?php elseif ($error !== null): ?>
<p class="form__error"><?= e($errorMessages[$error] ?? $errorMessages['mail']) ?></p>
<?php endif; ?>
</div>
<div class="form__row">
<div class="form__field">
<label for="contact-name">Name *</label>
<input type="text" id="contact-name" name="name" required autocomplete="name" maxlength="200">
</div>
<div class="form__field">
<label for="contact-email">E-Mail *</label>
<input type="email" id="contact-email" name="email" required autocomplete="email" maxlength="200">
</div>
</div>
<div class="form__row">
<div class="form__field">
<label for="contact-phone">Telefonnummer</label>
<input type="tel" id="contact-phone" name="phone" autocomplete="tel" maxlength="50">
</div>
<div class="form__field">
<label for="contact-interest">Ich interessiere mich für</label>
<select id="contact-interest" name="interest">
<option value="">Bitte wählen</option>
<?php foreach ($interests as $interest): ?>
<option value="<?= e($interest) ?>"><?= e($interest) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="form__field">
<label for="contact-subject">Betreff *</label>
<input type="text" id="contact-subject" name="subject" required maxlength="200">
</div>
<div class="form__field">
<label for="contact-message">Nachricht *</label>
<textarea id="contact-message" name="message" rows="6" required maxlength="5000"></textarea>
</div>
<div class="form__field form__field--checkbox">
<input type="checkbox" id="contact-privacy" name="privacy" value="1" required>
<label for="contact-privacy">Ich stimme den <a href="<?= e(url('datenschutz')) ?>">Datenschutzbestimmungen</a> zu. *</label>
</div>
<?php /* Honeypot — für Menschen unsichtbar, Bots füllen es aus */ ?>
<div class="visually-hidden" aria-hidden="true">
<label for="contact-website">Website (bitte leer lassen)</label>
<input type="text" id="contact-website" name="website" tabindex="-1" autocomplete="off">
</div>
<input type="hidden" name="ft" value="<?= e(form_token()) ?>">
<p class="form__submit">
<button class="btn" type="submit">Nachricht senden</button>
</p>
</form>
</div>
</section>

10
app/components/cta.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
/**
* CTA-Button. Props: $cta array{label: string, slug: string, style?: 'primary'|'outline'}
*/
$style = $cta['style'] ?? 'primary';
?>
<a class="btn<?= $style === 'outline' ? ' btn--outline' : '' ?>" href="<?= e(url($cta['slug'])) ?>"><?= e($cta['label']) ?></a>

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

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/**
* Footer: Vereinsdaten (NAP aus club.json), Social-Links, Rechtliches.
*/
$club = json_load('club');
$nav = json_load('navigation');
$socialLabels = ['instagram' => 'Instagram', 'facebook' => 'Facebook', 'youtube' => 'YouTube'];
?>
<footer class="site-footer">
<div class="container site-footer__grid">
<div>
<h2 class="site-footer__heading"><?= e($club['legal_name']) ?></h2>
<address class="site-footer__address">
<?= e($club['address']['venue']) ?><br>
<?= e($club['address']['street']) ?><br>
<?= e($club['address']['zip']) ?> <?= e($club['address']['city']) ?><br>
<a href="mailto:<?= e($club['email']) ?>"><?= e($club['email']) ?></a>
</address>
</div>
<div>
<h2 class="site-footer__heading">Folge uns</h2>
<ul class="site-footer__list">
<?php foreach ($club['social'] as $key => $socialUrl): ?>
<?php if ($socialUrl): ?>
<li><a href="<?= e($socialUrl) ?>" rel="noopener"><?= e($socialLabels[$key] ?? ucfirst($key)) ?></a></li>
<?php endif; ?>
<?php endforeach; ?>
</ul>
</div>
<div>
<h2 class="site-footer__heading">Rechtliches</h2>
<ul class="site-footer__list">
<?php foreach ($nav['legal'] as $item): ?>
<li><a href="<?= e(url($item['slug'])) ?>"><?= e($item['label']) ?></a></li>
<?php endforeach; ?>
</ul>
</div>
</div>
<div class="container site-footer__copyright">
<p>&copy; <?= e(date('Y')) ?> <?= e($club['legal_name']) ?></p>
</div>
</footer>

18
app/components/header.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
/**
* Seitenkopf mit Logo und Navigation. Props: $current (Slug).
*/
$club = json_load('club');
?>
<header class="site-header">
<div class="container site-header__inner">
<a class="site-header__brand" href="<?= e(url()) ?>" aria-label="<?= e($club['name']) ?> Startseite">
<img class="site-header__logo" src="<?= e(asset('img/logo.svg')) ?>" alt="" width="75" height="97">
<span class="site-header__name"><?= e($club['name']) ?></span>
</a>
<?php component('nav', ['current' => $current]); ?>
</div>
</header>

23
app/components/hero.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/**
* Vollflächiger Hero mit Hintergrundbild, Titel, Intro und CTAs.
* Props: $hero (array aus home.json: title, text, image, ctas)
*/
?>
<section class="hero">
<div class="hero__media" aria-hidden="true">
<?php component('img', ['image' => $hero['image'], 'sizes' => '100vw', 'eager' => true, 'class' => 'hero__img']); ?>
</div>
<div class="container hero__content">
<h1 class="hero__title"><?= e($hero['title']) ?></h1>
<p class="hero__text"><?= e($hero['text']) ?></p>
<div class="hero__actions">
<?php foreach ($hero['ctas'] as $cta): ?>
<?php component('cta', ['cta' => $cta]); ?>
<?php endforeach; ?>
</div>
</div>
</section>

41
app/components/img.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/**
* Responsives Bild aus Varianten-Set. Props:
* $image array{base: string, widths: int[], alt: string} — base relativ zu assets/, Dateien: <base>-<w>.jpg
* $sizes string (optional, default '100vw')
* $eager bool (optional) — above the fold: eager + fetchpriority high
* $class string (optional)
*/
$sizes = $sizes ?? '100vw';
$eager = $eager ?? false;
$class = $class ?? '';
$widths = $image['widths'];
$largest = max($widths);
$srcset = implode(', ', array_map(
static fn (int $w): string => asset("{$image['base']}-{$w}.jpg") . " {$w}w",
$widths
));
// Maße der größten Variante für width/height (CLS-Vermeidung).
[$w, $h] = (function () use ($image, $largest): array {
static $dims = [];
$file = PUBLIC_PATH . "/assets/{$image['base']}-{$largest}.jpg";
$key = $file;
if (!isset($dims[$key])) {
$size = is_file($file) ? (getimagesize($file) ?: [0, 0]) : [0, 0];
$dims[$key] = [$size[0], $size[1]];
}
return $dims[$key];
})();
?>
<img<?= $class !== '' ? ' class="' . e($class) . '"' : '' ?>
src="<?= e(asset("{$image['base']}-{$largest}.jpg")) ?>"
srcset="<?= e($srcset) ?>"
sizes="<?= e($sizes) ?>"
alt="<?= e($image['alt']) ?>"
width="<?= e($w) ?>" height="<?= e($h) ?>"
<?= $eager ? 'loading="eager" fetchpriority="high"' : 'loading="lazy" decoding="async"' ?>>

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/**
* "Aktuelles": Instagram-Grid aus dem lokalen Cache (data/instagram.json,
* geschrieben von bin/instagram-sync.php). Besucher laden ausschließlich
* lokal gehostete Bilder. Ohne Cache: CTA-Karte als Degradation.
*/
$club = json_load('club');
$feed = json_load('instagram');
$posts = $feed['posts'] ?? [];
$maxPosts = (int) config('instagram.max_posts', 9);
?>
<section class="section instagram" id="aktuelles">
<div class="container">
<h2>Aktuelles</h2>
<?php if ($posts !== []): ?>
<p class="text-muted">Unsere neuesten Beiträge auf <a href="<?= e($club['social']['instagram']) ?>" rel="noopener">Instagram (@<?= e($feed['username'] ?? 'tsv08kulmbach') ?>)</a>.</p>
<ul class="instagram__grid">
<?php foreach (array_slice($posts, 0, $maxPosts) as $post): ?>
<li class="instagram__item">
<a class="instagram__link" href="<?= e($post['url']) ?>" rel="noopener">
<img src="<?= e(asset($post['image'])) ?>"
alt="<?= e($post['alt'] ?? 'Instagram-Beitrag des TSV 08 Kulmbach') ?>"
width="640" height="640" loading="lazy" decoding="async">
<?php if (!empty($post['is_video'])): ?>
<span class="instagram__badge" aria-hidden="true">▶</span>
<?php endif; ?>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<div class="instagram__fallback">
<p>Was bei uns gerade los ist, siehst du auf unserem Instagram-Kanal.</p>
<p><a class="btn" href="<?= e($club['social']['instagram']) ?>" rel="noopener">Folge uns auf Instagram</a></p>
</div>
<?php endif; ?>
</div>
</section>

31
app/components/jsonld.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/**
* SportsClub-Schema aus data/club.json — auf jeder Seite identisch (NAP-Konsistenz für SEO/GEO).
*/
$club = json_load('club');
$schema = [
'@context' => 'https://schema.org',
'@type' => 'SportsClub',
'name' => $club['name'],
'alternateName' => $club['legal_name'],
'foundingDate' => $club['founded'],
'description' => $club['description'],
'sport' => $club['departments'],
'email' => $club['email'],
'url' => abs_url(),
'logo' => rtrim((string) config('base_url'), '/') . '/assets/img/logo.svg',
'address' => [
'@type' => 'PostalAddress',
'streetAddress' => $club['address']['street'],
'postalCode' => $club['address']['zip'],
'addressLocality' => $club['address']['city'],
'addressCountry' => $club['address']['country'],
],
'sameAs' => array_values(array_filter($club['social'])),
];
?>
<script type="application/ld+json"><?= json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?></script>

52
app/components/meta.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
/**
* <head>-Inhalt. Props: $meta (array aus der Page), $current (Slug).
* $meta: title, description, og_image (optional, Pfad relativ zu assets/),
* title_absolute (bool — Title ohne Suffix), scripts (array zusätzlicher JS-Dateien).
*/
$club = json_load('club');
$title = $meta['title'] ?? $club['name'];
if (empty($meta['title_absolute'])) {
$title .= ' | ' . $club['name'];
}
$description = $meta['description'] ?? $club['description'];
$canonical = abs_url($current === '404' ? '' : $current);
$ogImage = rtrim((string) config('base_url'), '/') . '/assets/' . ltrim($meta['og_image'] ?? 'img/og-default.jpg', '/');
$scripts = array_merge(['nav.js'], $meta['scripts'] ?? []);
?>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($title) ?></title>
<meta name="description" content="<?= e($description) ?>">
<?php if ($current !== '404'): ?>
<link rel="canonical" href="<?= e($canonical) ?>">
<?php else: ?>
<meta name="robots" content="noindex">
<?php endif; ?>
<meta name="theme-color" content="#222222">
<meta property="og:type" content="website">
<meta property="og:site_name" content="<?= e($club['name']) ?>">
<meta property="og:title" content="<?= e($title) ?>">
<meta property="og:description" content="<?= e($description) ?>">
<meta property="og:url" content="<?= e($canonical) ?>">
<meta property="og:image" content="<?= e($ogImage) ?>">
<meta property="og:locale" content="de_DE">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="/favicon.ico" sizes="32x32">
<link rel="icon" href="<?= e(asset('img/logo.svg')) ?>" type="image/svg+xml">
<?php foreach (['fonts/coolvetica.woff2', 'fonts/abel.woff2'] as $font): ?>
<?php if (is_file(PUBLIC_PATH . '/assets/' . $font)): ?>
<link rel="preload" href="<?= e(asset($font)) ?>" as="font" type="font/woff2" crossorigin>
<?php endif; ?>
<?php endforeach; ?>
<?php foreach (['tokens', 'reset', 'base', 'layout', 'components', 'utilities'] as $css): ?>
<link rel="stylesheet" href="<?= e(asset("css/{$css}.css")) ?>">
<?php endforeach; ?>
<?php foreach ($scripts as $js): ?>
<script defer src="<?= e(asset("js/{$js}")) ?>"></script>
<?php endforeach; ?>
<?php component('jsonld'); ?>

33
app/components/nav.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
/**
* Hauptnavigation aus data/navigation.json. Props: $current (Slug).
* Burger-Toggle übernimmt assets/js/nav.js (aria-expanded/aria-controls).
*/
$nav = json_load('navigation');
$href = static function (string $slug): string {
// Anker-Slugs ("#aktuelles") zeigen auf Startseiten-Sektionen.
return str_starts_with($slug, '#') ? url() . $slug : url($slug);
};
?>
<nav class="site-nav" aria-label="Hauptnavigation">
<button class="site-nav__toggle" type="button" aria-expanded="false" aria-controls="site-nav-list">
<span class="site-nav__toggle-bar" aria-hidden="true"></span>
<span class="visually-hidden">Menü öffnen</span>
</button>
<ul class="site-nav__list" id="site-nav-list">
<?php foreach ($nav['main'] as $item): ?>
<li>
<a href="<?= e($href($item['slug'])) ?>"<?= $item['slug'] === $current ? ' aria-current="page"' : '' ?>><?= e($item['label']) ?></a>
</li>
<?php endforeach; ?>
<?php if (!empty($nav['cta'])): ?>
<li>
<a class="btn" href="<?= e($href($nav['cta']['slug'])) ?>"><?= e($nav['cta']['label']) ?></a>
</li>
<?php endif; ?>
</ul>
</nav>

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
/**
* Sponsoren/Partner-Logogrid. Props:
* $intro array{title, text, cta?} (aus home.json)
* Logos kommen aus data/partners.json (Single Source of Truth).
*/
$partners = json_load('partners')['partners'] ?? [];
?>
<section class="section partners" id="partner">
<div class="container">
<h2><?= e($intro['title']) ?></h2>
<p><?= e($intro['text']) ?></p>
<ul class="partners__grid">
<?php foreach ($partners as $partner): ?>
<li class="partners__item">
<?php if (!empty($partner['url'])): ?>
<a href="<?= e($partner['url']) ?>" rel="noopener">
<img src="<?= e(asset($partner['logo'])) ?>" alt="<?= e($partner['name']) ?>" loading="lazy" decoding="async">
</a>
<?php else: ?>
<img src="<?= e(asset($partner['logo'])) ?>" alt="<?= e($partner['name']) ?>" loading="lazy" decoding="async">
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php if (!empty($intro['cta'])): ?>
<p><?php component('cta', ['cta' => $intro['cta']]); ?></p>
<?php endif; ?>
</div>
</section>

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/**
* Alternierende Bild/Text-Sektion. Props:
* $section array{id, title, text, image, flip?, cta?} (aus home.json o.ä.)
* $level int Überschriften-Ebene (default 2)
*/
$level = $level ?? 2;
$flip = !empty($section['flip']);
?>
<section class="section split<?= $flip ? ' split--flip' : '' ?>" id="<?= e($section['id']) ?>">
<div class="container split__inner">
<div class="split__text">
<h<?= $level ?>><?= e($section['title']) ?></h<?= $level ?>>
<p><?= e($section['text']) ?></p>
<?php if (!empty($section['cta'])): ?>
<p class="split__cta"><?php component('cta', ['cta' => $section['cta']]); ?></p>
<?php endif; ?>
</div>
<div class="split__media">
<?php component('img', ['image' => $section['image'], 'sizes' => '(max-width: 992px) 100vw, 50vw', 'class' => 'split__img']); ?>
</div>
</div>
</section>

20
app/components/stats.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/**
* Kennzahlen-Band. Props: $stats array{items: array{value, label}[]}
*/
?>
<section class="section stats" aria-label="Der Verein in Zahlen">
<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>

121
app/helpers.php Normal file
View File

@@ -0,0 +1,121 @@
<?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;
}
/**
* 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;
}

22
app/layout.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/**
* Master-Shell. Erwartet: $meta (array), $content (string), $current (Slug der aktiven Route).
*/
?>
<!doctype html>
<html lang="de">
<head>
<?php component('meta', ['meta' => $meta, 'current' => $current]); ?>
</head>
<body>
<a class="skip-link" href="#main">Zum Inhalt springen</a>
<?php component('header', ['current' => $current]); ?>
<main id="main">
<?= $content ?>
</main>
<?php component('footer'); ?>
</body>
</html>

16
app/pages/404.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
$meta = [
'title' => 'Seite nicht gefunden',
'description' => 'Die angeforderte Seite existiert nicht.',
];
?>
<section class="section section--center">
<div class="container">
<h1>404 Seite nicht gefunden</h1>
<p>Die angeforderte Seite gibt es nicht (mehr). Vielleicht hilft dir die Startseite weiter.</p>
<p><a class="btn" href="<?= e(url()) ?>">Zur Startseite</a></p>
</div>
</section>

28
app/pages/home.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
/**
* Startseite — Sektions-Reihenfolge wie auf der alten Seite:
* Hero → Aktuelles (Instagram) → Historie → Sportheim → Zahlen → Partner → Kontakt.
* Inhalte: data/home.json (Single Source of Truth).
*/
$home = json_load('home');
$meta = [
'title' => 'TSV 08 Kulmbach Fußball & Turnen in Kulmbach',
'title_absolute' => true,
'description' => json_load('club')['description'],
'scripts' => ['form.js'],
];
component('hero', ['hero' => $home['hero']]);
component('instagram-feed');
foreach ($home['sections'] as $section) {
component('section', ['section' => $section]);
}
component('stats', ['stats' => $home['stats']]);
component('partner-grid', ['intro' => $home['partners']]);
component('contact-form', ['intro' => $home['contact']]);

15
app/routes.php Normal file
View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
/**
* EINZIGES Slug-Register der Seite (siehe CLAUDE.md).
* Slug => ['file' => Page-Datei in app/pages/, 'sitemap' => in sitemap.xml aufnehmen?]
*
* Neue Seite = Datei in app/pages/ + Eintrag hier (+ optional data/navigation.json).
* Canonical und Sitemap folgen automatisch.
*/
return [
'' => ['file' => 'home.php', 'sitemap' => true],
'404' => ['file' => '404.php', 'sitemap' => false],
];