Kontaktseite: eigene /kontakt mit Hero + Formular

Neue /kontakt-Seite (Hero + wiederverwendete contact-form-Komponente, ohne
Intro-Block). Behebt die bisher ins 404 laufenden "Kontakt aufnehmen"-CTAs.

- contact-form: optionales $intro + Rücksprung-Slug $return (Hidden-Feld)
- contact-submit: PRG-Redirect zur absendenden Seite, whitelist-validiert
  gegen routes.php (kein Open-Redirect)
- Verlinkt im Footer-Rechtsbereich und im Menü "Der Verein"

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGzc6GhWhmLJt1jC2q1SRZ
This commit is contained in:
2026-06-20 21:14:28 +02:00
parent 23778324c7
commit 08f5a1ae9b
7 changed files with 63 additions and 6 deletions

View File

@@ -4,25 +4,33 @@ declare(strict_types=1);
/** /**
* POST /kontakt-senden — Kontaktformular validieren und via Brevo SMTP versenden. * POST /kontakt-senden — Kontaktformular validieren und via Brevo SMTP versenden.
* Antwort: PRG-Redirect zu /#kontakt (?sent=1 | ?error=…) oder JSON bei fetch (form.js). * Antwort: PRG-Redirect zurück zur absendenden Seite (#kontakt, ?sent=1 | ?error=…)
* oder JSON bei fetch (form.js). Das Formular kommt von / wie auch von /kontakt.
*/ */
use PHPMailer\PHPMailer\PHPMailer; use PHPMailer\PHPMailer\PHPMailer;
$field = static fn (string $key): string => trim((string) ($_POST[$key] ?? ''));
// Rücksprungziel gegen die Slug-Whitelist (Single Source of Truth) prüfen — kein Open-Redirect.
$return = $field('return');
$routes = require APP_PATH . '/routes.php';
if (!array_key_exists($return, $routes)) {
$return = '';
}
$wantsJson = str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json'); $wantsJson = str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json');
$respond = static function (bool $ok, string $error = '') use ($wantsJson): never { $respond = static function (bool $ok, string $error = '') use ($wantsJson, $return): never {
if ($wantsJson) { if ($wantsJson) {
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => $ok, 'error' => $error ?: null]); echo json_encode(['ok' => $ok, 'error' => $error ?: null]);
} else { } else {
header('Location: ' . url('') . ($ok ? '?sent=1' : '?error=' . $error) . '#kontakt', true, 303); header('Location: ' . url($return) . ($ok ? '?sent=1' : '?error=' . $error) . '#kontakt', true, 303);
} }
exit; exit;
}; };
$field = static fn (string $key): string => trim((string) ($_POST[$key] ?? ''));
$route = 'kontakt'; $route = 'kontakt';
$token = $field('ft'); $token = $field('ft');

View File

@@ -4,12 +4,18 @@ declare(strict_types=1);
/** /**
* Kontaktformular (Felder wie auf der alten Seite). Props: * Kontaktformular (Felder wie auf der alten Seite). Props:
* $intro array{title, text} * $intro array{title, text}|null — optionaler Intro-Block (Überschrift, Teaser, Direkt-Mail).
* Weglassen → nur das Formular (z. B. auf /kontakt, wo der Hero den Kontext liefert).
* $return string optional — Slug der absendenden Seite (Default '' = Startseite). Die Action
* leitet nach PRG hierher zurück (whitelist-validiert), damit /kontakt nicht auf die
* Startseite springt.
* Felder rendern ausschließlich über component('form-field') — siehe CLAUDE.md. * Felder rendern ausschließlich über component('form-field') — siehe CLAUDE.md.
* Liest Flash-Status aus Query-Params (?sent=1 / ?error=...) — PRG-Muster. * Liest Flash-Status aus Query-Params (?sent=1 / ?error=...) — PRG-Muster.
* Spam-Schutz: Honeypot "company_url" + signierter Timestamp "ft" (form_token()). * Spam-Schutz: Honeypot "company_url" + signierter Timestamp "ft" (form_token()).
*/ */
$club = json_load('club'); $club = json_load('club');
$return = $return ?? '';
$intro = $intro ?? null;
$interests = ['Herrenfußball', 'Damenfußball', 'Jugendfußball', 'Turnen', 'Sonstiges']; $interests = ['Herrenfußball', 'Damenfußball', 'Jugendfußball', 'Turnen', 'Sonstiges'];
@@ -37,11 +43,13 @@ $f = [
?> ?>
<section class="section contact" id="kontakt"> <section class="section contact" id="kontakt">
<div class="container contact__inner"> <div class="container contact__inner">
<?php if (!empty($intro)): ?>
<div class="contact__intro"> <div class="contact__intro">
<h2><?= e($intro['title']) ?></h2> <h2><?= e($intro['title']) ?></h2>
<p><?= e($intro['text']) ?></p> <p><?= e($intro['text']) ?></p>
<p class="text-muted">Oder direkt per Mail an <a href="mailto:<?= e($club['email']) ?>"><?= e($club['email']) ?></a></p> <p class="text-muted">Oder direkt per Mail an <a href="mailto:<?= e($club['email']) ?>"><?= e($club['email']) ?></a></p>
</div> </div>
<?php endif; ?>
<form class="form" method="post" action="<?= e(url('kontakt-senden')) ?>" novalidate> <form class="form" method="post" action="<?= e(url('kontakt-senden')) ?>" novalidate>
<div class="form__status" role="status" aria-live="polite" tabindex="-1" data-form-status> <div class="form__status" role="status" aria-live="polite" tabindex="-1" data-form-status>
<?php if ($sent): ?> <?php if ($sent): ?>
@@ -73,6 +81,7 @@ $f = [
<input type="text" id="contact-company-url" name="company_url" tabindex="-1" autocomplete="off"> <input type="text" id="contact-company-url" name="company_url" tabindex="-1" autocomplete="off">
</div> </div>
<input type="hidden" name="ft" value="<?= e(form_token()) ?>"> <input type="hidden" name="ft" value="<?= e(form_token()) ?>">
<input type="hidden" name="return" value="<?= e($return) ?>">
<p class="form__submit"> <p class="form__submit">
<button class="btn" type="submit">Nachricht senden</button> <button class="btn" type="submit">Nachricht senden</button>

24
app/pages/kontakt.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
/**
* Kontaktseite — Hero + Kontaktformular (dieselbe Komponente wie auf der Startseite).
* Inhalte: data/kontakt.json (Single Source of Truth).
*/
$page = json_load('kontakt');
$meta = [
'title' => 'Kontakt',
'description' => 'Kontakt zum TSV 08 Kulmbach: Schreib uns über das Formular zu Fußball, Turnen oder einem allgemeinen Anliegen. Wir melden uns so schnell wie möglich.',
'og_image' => 'img/pages/vereinsgelaende-1600.jpg',
'scripts' => ['form.js'],
'schema' => page_schema([
['name' => 'Startseite', 'slug' => ''],
['name' => 'Kontakt', 'slug' => 'kontakt'],
]),
];
component('hero', ['hero' => $page['hero']]);
// Kein Intro-Block: Der Hero liefert bereits Überschrift + Teaser, hier folgt nur das Formular.
component('contact-form', ['return' => 'kontakt']);

View File

@@ -28,6 +28,7 @@ return [
'historie' => ['file' => 'historie.php', 'sitemap' => true], 'historie' => ['file' => 'historie.php', 'sitemap' => true],
'sportheimbuchung' => ['file' => 'sportheimbuchung.php', 'sitemap' => true], 'sportheimbuchung' => ['file' => 'sportheimbuchung.php', 'sitemap' => true],
'ansprechpartner' => ['file' => 'ansprechpartner.php', 'sitemap' => true], 'ansprechpartner' => ['file' => 'ansprechpartner.php', 'sitemap' => true],
'kontakt' => ['file' => 'kontakt.php', 'sitemap' => true],
'partner-werden' => ['file' => 'partner-werden.php', 'sitemap' => true], 'partner-werden' => ['file' => 'partner-werden.php', 'sitemap' => true],
'mitglied-werden' => ['file' => 'mitglied-werden.php', 'sitemap' => true], 'mitglied-werden' => ['file' => 'mitglied-werden.php', 'sitemap' => true],
'impressum' => ['file' => 'impressum.php', 'sitemap' => true], 'impressum' => ['file' => 'impressum.php', 'sitemap' => true],

13
data/kontakt.json Normal file
View File

@@ -0,0 +1,13 @@
{
"hero": {
"compact": true,
"align": "bottom-left",
"title": "Kontakt",
"text": "Fragen zu unseren Abteilungen, Lust mitzumachen oder ein Anliegen an den Verein? Schreib uns wir melden uns so schnell wie möglich bei dir.",
"image": {
"base": "img/pages/vereinsgelaende",
"widths": [640, 1000, 1600],
"alt": "Vereinsgelände des TSV 08 Kulmbach im Katzbachtal"
}
}
}

View File

@@ -31,6 +31,7 @@
"children": [ "children": [
{ "label": "Übersicht", "slug": "verein" }, { "label": "Übersicht", "slug": "verein" },
{ "label": "Ansprechpartner", "slug": "ansprechpartner" }, { "label": "Ansprechpartner", "slug": "ansprechpartner" },
{ "label": "Kontakt", "slug": "kontakt" },
{ "label": "Historie", "slug": "historie" }, { "label": "Historie", "slug": "historie" },
{ "label": "Sportheimbuchung", "slug": "sportheimbuchung", "separator": true }, { "label": "Sportheimbuchung", "slug": "sportheimbuchung", "separator": true },
{ "label": "Partner werden", "slug": "partner-werden" } { "label": "Partner werden", "slug": "partner-werden" }
@@ -42,6 +43,7 @@
{ "label": "Mitglied werden", "slug": "mitglied-werden", "style": "primary" } { "label": "Mitglied werden", "slug": "mitglied-werden", "style": "primary" }
], ],
"legal": [ "legal": [
{ "label": "Kontakt", "slug": "kontakt" },
{ "label": "Impressum", "slug": "impressum" }, { "label": "Impressum", "slug": "impressum" },
{ "label": "Datenschutz", "slug": "datenschutz" } { "label": "Datenschutz", "slug": "datenschutz" }
] ]

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB