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:
92
app/components/contact-form.php
Normal file
92
app/components/contact-form.php
Normal 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
10
app/components/cta.php
Normal 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
46
app/components/footer.php
Normal 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>© <?= e(date('Y')) ?> <?= e($club['legal_name']) ?></p>
|
||||
</div>
|
||||
</footer>
|
||||
18
app/components/header.php
Normal file
18
app/components/header.php
Normal 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
23
app/components/hero.php
Normal 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
41
app/components/img.php
Normal 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"' ?>>
|
||||
41
app/components/instagram-feed.php
Normal file
41
app/components/instagram-feed.php
Normal 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
31
app/components/jsonld.php
Normal 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
52
app/components/meta.php
Normal 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
33
app/components/nav.php
Normal 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>
|
||||
33
app/components/partner-grid.php
Normal file
33
app/components/partner-grid.php
Normal 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>
|
||||
26
app/components/section.php
Normal file
26
app/components/section.php
Normal 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
20
app/components/stats.php
Normal 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>
|
||||
Reference in New Issue
Block a user