Neue Seiten: Mitglied/Partner werden, Sportheimbuchung & Ansprechpartner
Mitglied werden: Hub-Seite mit Online-Anmeldung (membership-form + membership-submit-Action, Honeypot + Time-Trap), Abteilungswahl (department-choice), Beitragstabelle (fee-table) und Turn-Aufnahmeantrag als PDF-Download. Daten in data/mitglied-werden.json. Partner werden & Sportheimbuchung: neue Seiten samt Routing und Navigation. Ansprechpartner: echter Hero (Vereinsgelände), Bereiche aus dem group-Feld der Single Source (Fußball / Turnen) zu zwei Sektionen gebündelt, Disziplin als Karten-Überschrift. Fehlende Turn-Disziplin-Leads (Frisque, Lorz, Kornatz, Trotzke) aus den Turnen-Unterseiten extrahiert und in data/ansprechpartner.json ergänzt (allgemeine Turnabteilungs-Mail als Kontakt). WhatsApp für alle Personen mit Telefonnummer. person-grid: kompaktere Karten, Hairline unter der Bereichsüberschrift, 5 Spalten am Desktop. Startseite auf die Haupt-Ansprechpartner je Bereich begrenzt. Dazu: neue Icons (check-circle, download, file-earmark-text, whatsapp), Anpassungen an Header/Footer/CTA/age-groups sowie tokens/layout/utilities. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RZsKTL5kRSG4KkK8w2LKkE
This commit is contained in:
131
app/actions/membership-submit.php
Normal file
131
app/actions/membership-submit.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* POST /mitglied-werden-senden — Mitgliedsantrag Fußball (schlank, ohne Bankdaten).
|
||||
* Versendet eine interne Vereins-Mail (erfolgskritisch) und eine Eingangsbestätigung
|
||||
* an den Antragsteller (optional, Fehler dort wird nicht an den Besucher geleakt).
|
||||
* Antwort: PRG-Redirect zu /mitglied-werden#anmeldung-fussball (?sent=1 | ?error=…)
|
||||
* oder JSON bei fetch (form.js).
|
||||
*/
|
||||
|
||||
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('mitglied-werden') . ($ok ? '?sent=1' : '?error=' . $error) . '#anmeldung-fussball', 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);
|
||||
}
|
||||
|
||||
// --- Eingaben ---
|
||||
$firstName = $field('first_name');
|
||||
$lastName = $field('last_name');
|
||||
$birthDate = $field('birth_date');
|
||||
$phone = $field('phone');
|
||||
$street = $field('street');
|
||||
$zip = $field('zip');
|
||||
$city = $field('city');
|
||||
$email = $field('email');
|
||||
$feeClass = $field('fee_class');
|
||||
|
||||
// Beitragsklassen-Whitelist identisch zur Seite aus data/mitglied-werden.json ableiten.
|
||||
$data = json_load('mitglied-werden');
|
||||
$feeOptions = array_map(
|
||||
static fn (array $r): string => $r['label'] . ' — ' . $r['price'],
|
||||
$data['fussball']['fees']['rows'] ?? []
|
||||
);
|
||||
|
||||
// --- Validierung (Whitelist-Prinzip) ---
|
||||
$valid = $firstName !== '' && mb_strlen($firstName) <= 100
|
||||
&& $lastName !== '' && mb_strlen($lastName) <= 100
|
||||
&& preg_match('/^\d{4}-\d{2}-\d{2}$/', $birthDate) === 1
|
||||
&& $phone !== '' && mb_strlen($phone) <= 50
|
||||
&& $street !== '' && mb_strlen($street) <= 200
|
||||
&& $zip !== '' && mb_strlen($zip) <= 10
|
||||
&& $city !== '' && mb_strlen($city) <= 100
|
||||
&& filter_var($email, FILTER_VALIDATE_EMAIL) !== false
|
||||
&& in_array($feeClass, $feeOptions, true)
|
||||
&& ($_POST['statute'] ?? '') === '1'
|
||||
&& ($_POST['privacy'] ?? '') === '1'
|
||||
&& !preg_match('/[\r\n]/', $firstName . $lastName . $city . $street);
|
||||
|
||||
if (!$valid) {
|
||||
$respond(false, 'validation');
|
||||
}
|
||||
|
||||
// --- Versand ---
|
||||
$smtp = config('smtp');
|
||||
|
||||
$body = "Neuer Mitgliedsantrag (Fußball) über tsv08kulmbach.de\n"
|
||||
. str_repeat('-', 40) . "\n"
|
||||
. "Vorname: {$firstName}\n"
|
||||
. "Nachname: {$lastName}\n"
|
||||
. "Geboren am: {$birthDate}\n"
|
||||
. "Adresse: {$street}, {$zip} {$city}\n"
|
||||
. "Telefon: {$phone}\n"
|
||||
. "E-Mail: {$email}\n"
|
||||
. "Beitragsklasse: {$feeClass}\n"
|
||||
. str_repeat('-', 40) . "\n"
|
||||
. "Satzung anerkannt: ja\n"
|
||||
. "Datenschutz: ja\n"
|
||||
. 'Eingegangen: ' . date('d.m.Y H:i') . "\n\n"
|
||||
. "Hinweis: Bankdaten werden online nicht erhoben – Beitrittserklärung\n"
|
||||
. "per Post zur Unterschrift zusenden.\n";
|
||||
|
||||
$buildMailer = static function () use ($smtp): PHPMailer {
|
||||
$mail = new PHPMailer(true);
|
||||
$mail->isSMTP();
|
||||
$mail->Host = $smtp['host'];
|
||||
$mail->Port = (int) $smtp['port'];
|
||||
$mail->SMTPAuth = true;
|
||||
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
|
||||
$mail->Username = $smtp['username'];
|
||||
$mail->Password = $smtp['password'];
|
||||
$mail->CharSet = PHPMailer::CHARSET_UTF8;
|
||||
return $mail;
|
||||
};
|
||||
|
||||
// Mail 1 (intern) — erfolgskritisch.
|
||||
try {
|
||||
$mail = $buildMailer();
|
||||
$mail->setFrom($smtp['from'], $smtp['from_name']);
|
||||
$mail->addAddress($smtp['to']);
|
||||
$mail->addReplyTo($email, $firstName . ' ' . $lastName);
|
||||
$mail->Subject = 'Mitgliedsantrag Fußball: ' . $firstName . ' ' . $lastName;
|
||||
$mail->Body = $body;
|
||||
$mail->send();
|
||||
} catch (Throwable $e) {
|
||||
error_log('[' . date('c') . '] Mitgliedsantrag-Versand fehlgeschlagen: ' . $e->getMessage() . "\n", 3, STORAGE_PATH . '/logs/mail.log');
|
||||
$respond(false, 'mail');
|
||||
}
|
||||
|
||||
// Mail 2 (Eingangsbestätigung an Antragsteller) — optional, Fehler nicht an Besucher leaken.
|
||||
$welcome = $data['welcome_mail'] ?? null;
|
||||
if (is_array($welcome) && !empty($welcome['subject']) && !empty($welcome['body'])) {
|
||||
try {
|
||||
$confirm = $buildMailer();
|
||||
$confirm->setFrom($smtp['from'], $smtp['from_name']);
|
||||
$confirm->addAddress($email, $firstName . ' ' . $lastName);
|
||||
$confirm->Subject = $welcome['subject'];
|
||||
$confirm->Body = $welcome['body'];
|
||||
$confirm->send();
|
||||
} catch (Throwable $e) {
|
||||
error_log('[' . date('c') . '] Bestätigungsmail fehlgeschlagen: ' . $e->getMessage() . "\n", 3, STORAGE_PATH . '/logs/mail.log');
|
||||
}
|
||||
}
|
||||
|
||||
$respond(true);
|
||||
@@ -20,22 +20,25 @@ if ($groups === []) {
|
||||
<div class="container">
|
||||
<h2><?= e($title) ?></h2>
|
||||
<ul class="age-groups__grid">
|
||||
<?php foreach ($groups as $g): ?>
|
||||
<li class="age-group">
|
||||
<?php foreach ($groups as $g): $upcoming = !empty($g['upcoming']); ?>
|
||||
<li class="age-group<?= $upcoming ? ' age-group--upcoming' : '' ?>">
|
||||
<div class="age-group__media">
|
||||
<?php if (!empty($g['photo']['base'])): ?>
|
||||
<?php component('img', ['image' => $g['photo'], 'sizes' => '(max-width: 600px) 100vw, 320px', 'class' => 'age-group__img']); ?>
|
||||
<?php component('img', ['image' => $g['photo'], 'sizes' => '(max-width: 600px) 100vw, (max-width: 992px) 50vw, 560px', 'class' => 'age-group__img']); ?>
|
||||
<?php else: ?>
|
||||
<img class="age-group__img" src="<?= e(asset('img/persons/platzhalter.jpg')) ?>" alt="" width="400" height="300" loading="lazy" decoding="async">
|
||||
<img class="age-group__img" src="<?= e(asset('img/persons/platzhalter-spieler.jpg')) ?>" alt="" width="480" height="1047" loading="lazy" decoding="async">
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($g['code'])): ?>
|
||||
<span class="age-group__badge" aria-hidden="true"><?= e($g['code']) ?></span>
|
||||
<?php if ($upcoming): ?>
|
||||
<span class="age-group__soon">In Entstehung</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="age-group__body">
|
||||
<h3 class="age-group__name"><?= e($g['name']) ?></h3>
|
||||
<?php if (!empty($g['training'])): ?>
|
||||
<p class="age-group__training"><?= icon('clock', 'age-group__icon') ?><span><?= e($g['training']) ?></span></p>
|
||||
<p class="age-group__training">
|
||||
<?= icon('clock', 'age-group__icon') ?>
|
||||
<span><?php foreach ((array) $g['training'] as $i => $t): ?><?= $i ? '<br>' : '' ?><?= e($t) ?><?php endforeach; ?></span>
|
||||
</p>
|
||||
<?php elseif (!empty($g['note'])): ?>
|
||||
<p class="age-group__training text-muted"><?= e($g['note']) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* CTA-Button. Props: $cta array{label: string, slug: string, style?: 'primary'|'outline'}
|
||||
* CTA-Button. Props: $cta array{label: string, slug: string,
|
||||
* style?: 'primary'|'outline', size?: 'sm'|'lg'}
|
||||
*/
|
||||
$style = $cta['style'] ?? 'primary';
|
||||
$size = $cta['size'] ?? null;
|
||||
$class = 'btn'
|
||||
. ($style === 'outline' ? ' btn--outline' : '')
|
||||
. ($size === 'lg' ? ' btn--lg' : ($size === 'sm' ? ' btn--sm' : ''));
|
||||
?>
|
||||
<a class="btn<?= $style === 'outline' ? ' btn--outline' : '' ?>" href="<?= e(url($cta['slug'])) ?>"><?= e($cta['label']) ?></a>
|
||||
<a class="<?= e($class) ?>" href="<?= e(url($cta['slug'])) ?>"><?= e($cta['label']) ?></a>
|
||||
|
||||
39
app/components/department-choice.php
Normal file
39
app/components/department-choice.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Abteilungs-Auswahl: gleichwertige Karten, die auf Anker derselben Seite springen.
|
||||
* Props:
|
||||
* $choice array{title?, intro?, items: [{kicker?, title, text, label, anchor}]}
|
||||
* $anchor string (optional, Sektions-ID)
|
||||
* Bewusst eigenes <a href="#…">-Markup (nicht die cta-Komponente): cta() schickt
|
||||
* jeden Slug durch url() und würde aus "#anker" ein "/#anker" (Startseite) machen.
|
||||
*/
|
||||
$anchor = $anchor ?? null;
|
||||
if (empty($choice['items'])) {
|
||||
return;
|
||||
}
|
||||
?>
|
||||
<section class="section department-choice"<?= $anchor ? ' id="' . e($anchor) . '"' : '' ?>>
|
||||
<div class="container">
|
||||
<?php if (!empty($choice['title'])): ?>
|
||||
<h2 class="department-choice__title"><?= e($choice['title']) ?></h2>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($choice['intro'])): ?>
|
||||
<p class="department-choice__intro"><?= e($choice['intro']) ?></p>
|
||||
<?php endif; ?>
|
||||
<ul class="department-choice__grid" role="list">
|
||||
<?php foreach ($choice['items'] as $item): ?>
|
||||
<li class="department-choice__card">
|
||||
<h3 class="department-choice__heading"><?= e($item['title']) ?></h3>
|
||||
<?php if (!empty($item['subtitle'])): ?>
|
||||
<p class="department-choice__subtitle"><?= e($item['subtitle']) ?></p>
|
||||
<?php endif; ?>
|
||||
<p class="department-choice__text"><?= e($item['text']) ?></p>
|
||||
<a class="btn department-choice__btn" href="#<?= e($item['anchor']) ?>"><?= e($item['label']) ?><?= icon('arrow-right', 'department-choice__arrow') ?></a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
29
app/components/fee-table.php
Normal file
29
app/components/fee-table.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Beitrags-/Preistabelle. Props:
|
||||
* $fees array{title?, rows: [{label, price}], note?}
|
||||
* Semantische Definitionsliste (Begriff → Betrag), responsiv ohne <table>-Layout-Zwang.
|
||||
*/
|
||||
if (empty($fees['rows'])) {
|
||||
return;
|
||||
}
|
||||
?>
|
||||
<div class="fee-table">
|
||||
<?php if (!empty($fees['title'])): ?>
|
||||
<h3 class="fee-table__title"><?= e($fees['title']) ?></h3>
|
||||
<?php endif; ?>
|
||||
<dl class="fee-table__list">
|
||||
<?php foreach ($fees['rows'] as $row): ?>
|
||||
<div class="fee-table__row">
|
||||
<dt class="fee-table__label"><?= e($row['label']) ?></dt>
|
||||
<dd class="fee-table__price"><?= e($row['price']) ?></dd>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</dl>
|
||||
<?php if (!empty($fees['note'])): ?>
|
||||
<p class="fee-table__note"><?= e($fees['note']) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -13,20 +13,28 @@ $nav = json_load('navigation');
|
||||
$socialLabels = ['instagram' => 'Instagram', 'facebook' => 'Facebook', 'youtube' => 'YouTube'];
|
||||
$socialIcons = ['instagram' => 'instagram', 'facebook' => 'facebook', 'youtube' => 'youtube'];
|
||||
|
||||
// Hauptnav flach ausrollen (Unterpunkte wie Matchcenter einreihen), Anker-Links raus.
|
||||
$footerNav = [];
|
||||
// Hauptnav in Spalten gruppieren: jeder Hauptpunkt mit Unterpunkten wird eine
|
||||
// Footer-Spalte (Heading verlinkt die Übersicht, darunter die Kinder). Standalone-
|
||||
// Hauptpunkte (ohne Kinder) und CTAs sammeln sich als „Mehr“-Spalte. Anker-Links raus.
|
||||
$sections = [];
|
||||
$extraLinks = [];
|
||||
foreach ($nav['main'] as $item) {
|
||||
if (str_starts_with($item['slug'], '#')) {
|
||||
continue;
|
||||
}
|
||||
$footerNav[] = ['label' => $item['label'], 'slug' => $item['slug']];
|
||||
$children = [];
|
||||
foreach ($item['children'] ?? [] as $child) {
|
||||
if ($child['slug'] !== $item['slug']) { // „Übersicht“ dupliziert nur den Eltern-Slug
|
||||
$footerNav[] = $child;
|
||||
if ($child['slug'] !== $item['slug']) { // „Übersicht“ dupliziert nur den Eltern-Slug = Heading
|
||||
$children[] = $child;
|
||||
}
|
||||
}
|
||||
if ($children) {
|
||||
$sections[] = ['label' => $item['label'], 'slug' => $item['slug'], 'links' => $children];
|
||||
} else {
|
||||
$extraLinks[] = ['label' => $item['label'], 'slug' => $item['slug']];
|
||||
}
|
||||
}
|
||||
$footerNav = array_merge($footerNav, $nav['ctas'] ?? []);
|
||||
$extraLinks = array_merge($extraLinks, $nav['ctas'] ?? []);
|
||||
?>
|
||||
<footer class="site-footer">
|
||||
<div class="container site-footer__grid">
|
||||
@@ -45,14 +53,18 @@ $footerNav = array_merge($footerNav, $nav['ctas'] ?? []);
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<nav aria-label="Footer-Navigation">
|
||||
<h2 class="site-footer__heading">Seiten</h2>
|
||||
<?php foreach ($sections as $section): ?>
|
||||
<nav aria-label="<?= e($section['label']) ?>">
|
||||
<h2 class="site-footer__heading">
|
||||
<a href="<?= e(url($section['slug'])) ?>"><?= e($section['label']) ?></a>
|
||||
</h2>
|
||||
<ul class="site-footer__list">
|
||||
<?php foreach ($footerNav as $item): ?>
|
||||
<?php foreach ($section['links'] as $item): ?>
|
||||
<li><a href="<?= e(url($item['slug'])) ?>"><?= e($item['label']) ?></a></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</nav>
|
||||
<?php endforeach; ?>
|
||||
<div>
|
||||
<h2 class="site-footer__heading">Kontakt</h2>
|
||||
<address class="site-footer__address">
|
||||
@@ -67,19 +79,23 @@ $footerNav = array_merge($footerNav, $nav['ctas'] ?? []);
|
||||
<a href="mailto:<?= e($club['email']) ?>"><?= e($club['email']) ?></a>
|
||||
</p>
|
||||
</address>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="site-footer__heading">Rechtliches</h2>
|
||||
<ul class="site-footer__list">
|
||||
<?php foreach ($nav['legal'] as $item): ?>
|
||||
<?php if ($extraLinks): ?>
|
||||
<ul class="site-footer__list site-footer__list--extra">
|
||||
<?php foreach ($extraLinks as $item): ?>
|
||||
<li><a href="<?= e(url($item['slug'])) ?>"><?= e($item['label']) ?></a></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="site-footer__bottom">
|
||||
<div class="container site-footer__bottom-inner">
|
||||
<p>© <?= e(date('Y')) ?> <?= e($club['legal_name']) ?></p>
|
||||
<ul class="site-footer__legal" aria-label="Rechtliches">
|
||||
<?php foreach ($nav['legal'] as $item): ?>
|
||||
<li><a href="<?= e(url($item['slug'])) ?>"><?= e($item['label']) ?></a></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<p class="site-footer__memberships">Mitglied in: <?= e(implode(' · ', $club['memberships'])) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ $nav = json_load('navigation');
|
||||
<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" aria-hidden="true"><?= e($club['name']) ?></span>
|
||||
</a>
|
||||
<?php component('nav', ['current' => $current]); ?>
|
||||
<?php if (!empty($nav['ctas'])): ?>
|
||||
|
||||
113
app/components/membership-form.php
Normal file
113
app/components/membership-form.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Mitglieds-Antrag Fußball (schlank, ohne Bankdaten — die kommen auf dem Papierantrag).
|
||||
* Props:
|
||||
* $fee_options array Beitragsklassen als Strings (Whitelist – muss zur Action passen)
|
||||
* Felder rendern ausschließlich über component('form-field') — siehe CLAUDE.md.
|
||||
* Liest Flash-Status aus Query-Params (?sent=1 / ?error=…) — PRG-Muster.
|
||||
* Spam-Schutz: Honeypot "website" + signierter Timestamp "ft" (form_token()).
|
||||
*/
|
||||
$club = json_load('club');
|
||||
$fee_options = $fee_options ?? [];
|
||||
|
||||
$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' => 'Dein Antrag konnte gerade nicht versendet werden. Bitte versuche es später erneut oder schreib uns direkt an ' . $club['email'] . '.',
|
||||
];
|
||||
|
||||
$f = [
|
||||
'first_name' => ['type' => 'text', 'name' => 'first_name', 'id' => 'mm-first-name', 'label' => 'Vorname', 'required' => true, 'autocomplete' => 'given-name', 'maxlength' => 100],
|
||||
'last_name' => ['type' => 'text', 'name' => 'last_name', 'id' => 'mm-last-name', 'label' => 'Nachname', 'required' => true, 'autocomplete' => 'family-name', 'maxlength' => 100],
|
||||
'birth_date' => ['type' => 'date', 'name' => 'birth_date', 'id' => 'mm-birth-date', 'label' => 'Geboren am', 'required' => true, 'autocomplete' => 'bday'],
|
||||
'phone' => ['type' => 'tel', 'name' => 'phone', 'id' => 'mm-phone', 'label' => 'Telefonnummer', 'required' => true, 'autocomplete' => 'tel', 'maxlength' => 50],
|
||||
'street' => ['type' => 'text', 'name' => 'street', 'id' => 'mm-street', 'label' => 'Straße & Hausnummer', 'required' => true, 'autocomplete' => 'street-address', 'maxlength' => 200],
|
||||
'zip' => ['type' => 'text', 'name' => 'zip', 'id' => 'mm-zip', 'label' => 'Postleitzahl', 'required' => true, 'autocomplete' => 'postal-code', 'maxlength' => 10],
|
||||
'city' => ['type' => 'text', 'name' => 'city', 'id' => 'mm-city', 'label' => 'Wohnort', 'required' => true, 'autocomplete' => 'address-level2', 'maxlength' => 100],
|
||||
'email' => ['type' => 'email', 'name' => 'email', 'id' => 'mm-email', 'label' => 'E-Mail', 'required' => true, 'autocomplete' => 'email', 'maxlength' => 200],
|
||||
'fee_class' => ['type' => 'select', 'name' => 'fee_class', 'id' => 'mm-fee-class', 'label' => 'Beitragsklasse', 'required' => true, 'options' => $fee_options, 'placeholder' => 'Bitte wählen'],
|
||||
'statute' => [
|
||||
'type' => 'checkbox', 'name' => 'statute', 'id' => 'mm-statute', 'required' => true,
|
||||
'label' => 'Mit der Beitrittserklärung erkenne ich die Satzung des TSV 1908 Kulmbach e. V. an. Bei Kindern unter 18 Jahren ist die Unterschrift eines Erziehungsberechtigten erforderlich.',
|
||||
'label_html' => 'Mit der Beitrittserklärung erkenne ich die Satzung des TSV 1908 Kulmbach e. V. an. Bei Kindern unter 18 Jahren ist die Unterschrift eines Erziehungsberechtigten erforderlich.',
|
||||
],
|
||||
'privacy' => [
|
||||
'type' => 'checkbox', 'name' => 'privacy', 'id' => 'mm-privacy', 'required' => true,
|
||||
'label' => 'Ich stimme den Datenschutzbestimmungen zu.',
|
||||
'label_html' => 'Ich stimme den <a href="' . e(url('datenschutz')) . '">Datenschutzbestimmungen</a> zu.',
|
||||
],
|
||||
];
|
||||
?>
|
||||
<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 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>
|
||||
<?php elseif ($error !== null): ?>
|
||||
<p class="form__error"><?= e($errorMessages[$error] ?? $errorMessages['mail']) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<p class="form__note">Pflichtfelder sind mit <span class="form__required" aria-hidden="true">*</span><span class="visually-hidden">Stern</span> markiert.</p>
|
||||
|
||||
<fieldset class="form__group">
|
||||
<legend class="form__legend">Zur Person</legend>
|
||||
<div class="form__group-body">
|
||||
<div class="form__row">
|
||||
<?php component('form-field', ['field' => $f['first_name']]); ?>
|
||||
<?php component('form-field', ['field' => $f['last_name']]); ?>
|
||||
</div>
|
||||
<?php component('form-field', ['field' => $f['birth_date']]); ?>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="form__group">
|
||||
<legend class="form__legend">Adresse</legend>
|
||||
<div class="form__group-body">
|
||||
<?php component('form-field', ['field' => $f['street']]); ?>
|
||||
<div class="form__row">
|
||||
<?php component('form-field', ['field' => $f['zip']]); ?>
|
||||
<?php component('form-field', ['field' => $f['city']]); ?>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="form__group">
|
||||
<legend class="form__legend">Kontakt</legend>
|
||||
<div class="form__group-body">
|
||||
<div class="form__row">
|
||||
<?php component('form-field', ['field' => $f['phone']]); ?>
|
||||
<?php component('form-field', ['field' => $f['email']]); ?>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="form__group">
|
||||
<legend class="form__legend">Mitgliedschaft</legend>
|
||||
<div class="form__group-body">
|
||||
<?php component('form-field', ['field' => $f['fee_class']]); ?>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="form__group">
|
||||
<legend class="form__legend">Bestätigung</legend>
|
||||
<div class="form__group-body">
|
||||
<?php component('form-field', ['field' => $f['statute']]); ?>
|
||||
<?php component('form-field', ['field' => $f['privacy']]); ?>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<?php /* Honeypot — für Menschen unsichtbar, Bots füllen es aus */ ?>
|
||||
<div class="visually-hidden" aria-hidden="true">
|
||||
<label for="mm-website">Website (bitte leer lassen)</label>
|
||||
<input type="text" id="mm-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">Antrag absenden</button>
|
||||
</p>
|
||||
</form>
|
||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Visitenkarte für eine Person. Props:
|
||||
* $person array{department, name, phone?, email?, image?}
|
||||
* $person array{department, name, phone?, email?, image?, whatsapp?}
|
||||
* Ohne image greift der Platzhalter-Avatar (img/persons/platzhalter.jpg).
|
||||
* Kontakt-Link: phone → tel:, sonst email → mailto:.
|
||||
* Optional zusätzlich WhatsApp (wa.me) bei phone + whatsapp=true (opt-in pro Person).
|
||||
*/
|
||||
$image = $person['image'] ?? null;
|
||||
$src = $image ?: 'img/persons/platzhalter.jpg';
|
||||
@@ -22,6 +23,10 @@ if (!empty($person['phone'])) {
|
||||
} else {
|
||||
$contactHref = null;
|
||||
}
|
||||
|
||||
$waHref = (!empty($person['whatsapp']) && !empty($person['phone']))
|
||||
? 'https://wa.me/' . preg_replace('/\D/', '', $person['phone'])
|
||||
: null;
|
||||
?>
|
||||
<article class="person-card">
|
||||
<div class="person-card__media">
|
||||
@@ -36,6 +41,11 @@ if (!empty($person['phone'])) {
|
||||
<a class="person-card__contact" href="<?= e($contactHref) ?>">
|
||||
<?= icon($contactIcon) ?><span><?= e($contactLabel) ?></span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php if ($waHref !== null): ?>
|
||||
<a class="person-card__contact" href="<?= e($waHref) ?>" target="_blank" rel="noopener">
|
||||
<?= icon('whatsapp') ?><span>WhatsApp</span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
63
app/pages/ansprechpartner.php
Normal file
63
app/pages/ansprechpartner.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Ansprechpartner — Hero (Vereinsgelände) → zwei zusammengefasste person-grid-
|
||||
* Sektionen: Fußball (Herren/Damen/Jugend) und Turnen, aus data/ansprechpartner.json.
|
||||
*/
|
||||
$page = json_load('ansprechpartner');
|
||||
|
||||
// Sektionen aus dem "group"-Feld der Personen ableiten (Single Source): eine
|
||||
// Sektion je Gruppe (Fußball, Turnen), darin die jeweiligen Abteilungen/Disziplinen
|
||||
// als Karten-Überschrift. Reihenfolge = JSON-Reihenfolge.
|
||||
$groups = [];
|
||||
foreach ($page['persons'] ?? [] as $p) {
|
||||
$dept = $p['department'] ?? '';
|
||||
if ($dept === '') {
|
||||
continue;
|
||||
}
|
||||
$label = $p['group'] ?? $dept;
|
||||
if (!in_array($dept, $groups[$label] ?? [], true)) {
|
||||
$groups[$label][] = $dept;
|
||||
}
|
||||
}
|
||||
|
||||
// Eindeutiger Abschnitts-Anker aus dem Bereichsnamen (kein Slug-Helper im Projekt).
|
||||
$anchor_slug = static function (string $s): string {
|
||||
$s = strtolower(strtr($s, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']));
|
||||
return trim((string) preg_replace('/[^a-z0-9]+/', '-', $s), '-');
|
||||
};
|
||||
|
||||
$meta = [
|
||||
'title' => 'Ansprechpartner',
|
||||
'description' => 'Ansprechpartner und Kontaktwege beim TSV 1908 Kulmbach – Geschäftsstelle, Turnabteilung und Vorstand auf einen Blick.',
|
||||
'schema' => page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Der Verein', 'slug' => 'verein'],
|
||||
['name' => 'Ansprechpartner', 'slug' => 'ansprechpartner'],
|
||||
]),
|
||||
];
|
||||
component('hero', ['hero' => [
|
||||
'compact' => true,
|
||||
'kicker' => 'Der Verein',
|
||||
'title' => 'Ansprechpartner',
|
||||
'text' => $page['intro'] ?? '',
|
||||
'image' => [
|
||||
'base' => 'img/pages/vereinsgelaende',
|
||||
'widths' => [1000, 1600],
|
||||
'alt' => '',
|
||||
],
|
||||
]]);
|
||||
|
||||
$i = 0;
|
||||
foreach ($groups as $label => $depts):
|
||||
component('person-grid', [
|
||||
'departments' => $depts,
|
||||
'title' => $label,
|
||||
'intro' => '',
|
||||
'anchor' => 'kontakt-' . $anchor_slug($label),
|
||||
'tinted' => ($i % 2 === 0),
|
||||
]);
|
||||
$i++;
|
||||
endforeach;
|
||||
@@ -26,5 +26,9 @@ foreach ($home['sections'] as $section) {
|
||||
|
||||
component('stats', ['stats' => $home['stats']]);
|
||||
component('partner-grid', ['intro' => $home['partners']]);
|
||||
component('person-grid');
|
||||
// Auf der Startseite bewusst nur die Haupt-Ansprechpartner je Bereich; die
|
||||
// vollständige Turn-Disziplin-Liste lebt auf /ansprechpartner.
|
||||
component('person-grid', [
|
||||
'departments' => ['Herrenfußball', 'Damenfußball', 'Jugendfußball', 'Erwachsenenturnen'],
|
||||
]);
|
||||
component('contact-form', ['intro' => $home['contact']]);
|
||||
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
/**
|
||||
* Jugendfußball-Übersichtsseite. Inhalt: data/jugend.json.
|
||||
* Komposition: Hero → Intro-Bento (Bild, Mitmachen, Spielort, Altersklassen) →
|
||||
* Altersklassen-Karten → Ansprechpartner → CTA → FAQ.
|
||||
* Altersklassen-Karten → FAQ + Ansprechpartner (faq-contact, Slider bei mehreren).
|
||||
*/
|
||||
$page = json_load('jugend');
|
||||
|
||||
@@ -13,10 +13,17 @@ $club = json_load('club');
|
||||
$addr = $club['address'] ?? [];
|
||||
$mapsQuery = rawurlencode(trim(($addr['venue'] ?? '') . ', ' . ($addr['street'] ?? '') . ', ' . ($addr['zip'] ?? '') . ' ' . ($addr['city'] ?? ''), ', '));
|
||||
|
||||
// Ansprechpartner Jugendfußball aus der Single-Source (mehrere → Slider in faq-contact).
|
||||
$contacts = array_values(array_filter(
|
||||
json_load('ansprechpartner')['persons'] ?? [],
|
||||
static fn (array $p): bool => ($p['department'] ?? '') === 'Jugendfußball'
|
||||
));
|
||||
|
||||
$meta = [
|
||||
'title' => 'Jugendfußball in Kulmbach',
|
||||
'description' => 'Jugendfußball in Kulmbach – entdecke unsere Mannschaften von der G- bis zur C-Jugend, Betreuer und Trainingszeiten. Probiere Fußball in familiärer Atmosphäre aus.',
|
||||
'og_image' => 'img/pages/jugendfussball-hero-1440.jpg',
|
||||
'scripts' => count($contacts) > 1 ? ['contact-slider.js'] : [],
|
||||
'schema' => page_schema(
|
||||
[
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
@@ -61,20 +68,10 @@ component('hero', ['hero' => $page['hero']]);
|
||||
<?php
|
||||
component('age-groups', ['groups' => $page['groups'] ?? []]);
|
||||
|
||||
component('person-grid', [
|
||||
'departments' => ['Jugendfußball'],
|
||||
'title' => 'Ansprechpartner Jugendfußball',
|
||||
'intro' => 'Fragen zum Schnuppertraining oder zur passenden Altersgruppe? Melde dich direkt.',
|
||||
'anchor' => 'kontakt',
|
||||
component('faq-contact', [
|
||||
'faq' => $page['faq'] ?? [],
|
||||
'faqIntro' => 'Ruf uns direkt an oder schreib uns unkompliziert per WhatsApp – wir beantworten alle Fragen rund um Schnuppertraining und die passende Altersgruppe.',
|
||||
'persons' => $contacts,
|
||||
'contactTitle' => '',
|
||||
'anchor' => 'kontakt',
|
||||
]);
|
||||
|
||||
component('cta-band', ['band' => [
|
||||
'title' => 'Neugierig geworden?',
|
||||
'text' => 'Komm mit deinem Kind zum kostenlosen Schnuppertraining – ganz unverbindlich.',
|
||||
'ctas' => [
|
||||
['label' => 'Zum Ansprechpartner', 'slug' => 'jugendfussball#kontakt', 'style' => 'primary'],
|
||||
['label' => 'Mitglied werden', 'slug' => 'mitglied-werden', 'style' => 'outline'],
|
||||
],
|
||||
]]);
|
||||
|
||||
component('faq', ['faq' => $page['faq'] ?? []]);
|
||||
|
||||
82
app/pages/mitglied-werden.php
Normal file
82
app/pages/mitglied-werden.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Mitglied werden (Hub). Inhalt: data/mitglied-werden.json + data/club.json.
|
||||
* Struktur: Hero → Abteilungs-Auswahl → Info-Band → Fußball (Beiträge + Online-Formular)
|
||||
* → Turnen (Hinweise + Beiträge + PDF) → FAQ.
|
||||
*/
|
||||
$page = json_load('mitglied-werden');
|
||||
$club = json_load('club');
|
||||
|
||||
// Beitragsklassen für das Formular aus der Beitragstabelle ableiten (Single Source).
|
||||
// Muss identisch in app/actions/membership-submit.php gebildet werden (Whitelist).
|
||||
$feeOptions = array_map(
|
||||
static fn (array $r): string => $r['label'] . ' — ' . $r['price'],
|
||||
$page['fussball']['fees']['rows'] ?? []
|
||||
);
|
||||
|
||||
$meta = [
|
||||
'title' => 'Mitglied werden',
|
||||
'description' => 'Mitglied werden beim TSV 08 Kulmbach: Fußball bequem online anmelden oder Turnen per Aufnahmeantrag. Alle Beiträge, Aufnahme und Infos für deinen Vereinsbeitritt in Kulmbach.',
|
||||
'og_image' => 'img/hero/hero-1600.jpg',
|
||||
'scripts' => ['form.js'],
|
||||
'schema' => page_schema(
|
||||
[
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Mitglied werden', 'slug' => 'mitglied-werden'],
|
||||
],
|
||||
$page['faq'] ?? []
|
||||
),
|
||||
];
|
||||
|
||||
component('hero', ['hero' => $page['hero']]);
|
||||
|
||||
component('department-choice', ['choice' => $page['choice'], 'anchor' => 'abteilung']);
|
||||
?>
|
||||
<section class="section membership-signup" id="anmeldung-fussball">
|
||||
<div class="container membership-signup__inner">
|
||||
<header class="membership-signup__head">
|
||||
<h2><?= e($page['fussball']['title']) ?></h2>
|
||||
<p class="membership-signup__intro"><?= e($page['fussball']['intro']) ?></p>
|
||||
</header>
|
||||
<div class="membership-card">
|
||||
<?php component('fee-table', ['fees' => $page['fussball']['fees']]); ?>
|
||||
<?php component('membership-form', ['fee_options' => $feeOptions]); ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section section--tinted membership-signup" id="anmeldung-turnen">
|
||||
<div class="container membership-signup__inner">
|
||||
<header class="membership-signup__head">
|
||||
<h2><?= e($page['turnen']['title']) ?></h2>
|
||||
<p class="membership-signup__intro"><?= e($page['turnen']['intro']) ?></p>
|
||||
</header>
|
||||
<?php if (!empty($page['turnen']['notes'])): ?>
|
||||
<ul class="membership-notes" role="list">
|
||||
<?php foreach ($page['turnen']['notes'] as $note): ?>
|
||||
<li class="membership-notes__item">
|
||||
<?php if (!empty($note['icon'])): ?>
|
||||
<span class="membership-notes__icon"><?= icon($note['icon']) ?></span>
|
||||
<?php endif; ?>
|
||||
<div class="membership-notes__body">
|
||||
<h3 class="membership-notes__title"><?= e($note['title']) ?></h3>
|
||||
<p><?= e($note['text']) ?></p>
|
||||
</div>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
<div class="membership-download">
|
||||
<a class="btn membership-download__btn" href="<?= e(asset($page['turnen']['download']['file'])) ?>" download>
|
||||
<?= icon('download', 'membership-download__icon') ?><?= e($page['turnen']['download']['label']) ?>
|
||||
</a>
|
||||
<p class="membership-download__hint"><?= e($page['turnen']['download']['hint']) ?></p>
|
||||
<p class="membership-download__contact">Fragen zur Turnabteilung? Schreib an <a href="mailto:<?= e($club['email_turnen']) ?>"><?= e($club['email_turnen']) ?></a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
component('faq', ['faq' => $page['faq'] ?? []]);
|
||||
46
app/pages/partner-werden.php
Normal file
46
app/pages/partner-werden.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Partner werden — Platzhalter/Stub für Sponsoring/Partnerschaft. Pakete und
|
||||
* Konditionen folgen; Kontaktweg über data/club.json (Single Source of Truth).
|
||||
*/
|
||||
$club = json_load('club');
|
||||
|
||||
$meta = [
|
||||
'title' => 'Partner werden',
|
||||
'description' => 'Werde Partner oder Sponsor des TSV 1908 Kulmbach und unterstütze den Sport im Katzbachtal – so kommst du mit uns ins Gespräch.',
|
||||
'schema' => page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Der Verein', 'slug' => 'verein'],
|
||||
['name' => 'Partner werden', 'slug' => 'partner-werden'],
|
||||
]),
|
||||
];
|
||||
?>
|
||||
<section class="section legal">
|
||||
<div class="container">
|
||||
<div class="legal__head">
|
||||
<p class="legal__kicker">Der Verein</p>
|
||||
<h1>Partner werden</h1>
|
||||
<p class="legal__lead">Unterstütze den TSV 08 Kulmbach und werde sichtbar – als Partner mitten im Vereinsleben.</p>
|
||||
</div>
|
||||
|
||||
<div class="legal__body">
|
||||
<section>
|
||||
<h2>Sponsoring & Partnerschaft</h2>
|
||||
<p>Ob Bandenwerbung, Trikotsponsoring oder Unterstützung einzelner Abteilungen – wir stellen dir gern die passenden Möglichkeiten vor. Eine Übersicht der Partnerpakete und Konditionen ist in Vorbereitung.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Kontakt aufnehmen</h2>
|
||||
<div class="legal__card">
|
||||
<p>
|
||||
<strong><?= e($club['name']) ?></strong><br>
|
||||
E-Mail: <a href="mailto:<?= e($club['email']) ?>"><?= e($club['email']) ?></a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
46
app/pages/sportheimbuchung.php
Normal file
46
app/pages/sportheimbuchung.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Sportheimbuchung — Platzhalter/Stub. Inhalt (Verfügbarkeiten, Buchungsablauf,
|
||||
* Konditionen) folgt; Kontaktweg über data/club.json (Single Source of Truth).
|
||||
*/
|
||||
$club = json_load('club');
|
||||
|
||||
$meta = [
|
||||
'title' => 'Sportheim mieten & buchen',
|
||||
'description' => 'Das Sportheim des TSV 1908 Kulmbach für Feiern und Veranstaltungen mieten – Ansprechpartner und Buchungsablauf.',
|
||||
'schema' => page_schema([
|
||||
['name' => 'Startseite', 'slug' => ''],
|
||||
['name' => 'Der Verein', 'slug' => 'verein'],
|
||||
['name' => 'Sportheimbuchung', 'slug' => 'sportheimbuchung'],
|
||||
]),
|
||||
];
|
||||
?>
|
||||
<section class="section legal">
|
||||
<div class="container">
|
||||
<div class="legal__head">
|
||||
<p class="legal__kicker">Der Verein</p>
|
||||
<h1>Sportheim mieten & buchen</h1>
|
||||
<p class="legal__lead">Unser Sportheim auf der <?= e($club['address']['venue']) ?> lässt sich für Feiern und Veranstaltungen mieten.</p>
|
||||
</div>
|
||||
|
||||
<div class="legal__body">
|
||||
<section>
|
||||
<h2>Buchung & Verfügbarkeit</h2>
|
||||
<p>Die Online-Buchung mit Belegungskalender und Konditionen befindet sich gerade in Vorbereitung. Bis dahin nimm bitte direkt Kontakt mit uns auf – wir klären Termin, Ausstattung und Preise unkompliziert mit dir.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Ansprechpartner</h2>
|
||||
<div class="legal__card">
|
||||
<p>
|
||||
<strong><?= e($club['name']) ?></strong><br>
|
||||
E-Mail: <a href="mailto:<?= e($club['email']) ?>"><?= e($club['email']) ?></a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -25,6 +25,10 @@ return [
|
||||
'turnen/wettkampfturnen' => ['file' => 'turnen-wettkampfturnen.php', 'sitemap' => true],
|
||||
'verein' => ['file' => 'verein.php', 'sitemap' => true],
|
||||
'historie' => ['file' => 'historie.php', 'sitemap' => true],
|
||||
'sportheimbuchung' => ['file' => 'sportheimbuchung.php', 'sitemap' => true],
|
||||
'ansprechpartner' => ['file' => 'ansprechpartner.php', 'sitemap' => true],
|
||||
'partner-werden' => ['file' => 'partner-werden.php', 'sitemap' => true],
|
||||
'mitglied-werden' => ['file' => 'mitglied-werden.php', 'sitemap' => true],
|
||||
'impressum' => ['file' => 'impressum.php', 'sitemap' => true],
|
||||
'datenschutz' => ['file' => 'datenschutz.php', 'sitemap' => true],
|
||||
'404' => ['file' => '404.php', 'sitemap' => false],
|
||||
|
||||
Reference in New Issue
Block a user