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:
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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
24
app/pages/kontakt.php
Normal 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']);
|
||||||
@@ -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
13
data/kontakt.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
]
|
]
|
||||||
|
|||||||
BIN
public/assets/img/pages/vereinsgelaende-640.jpg
Normal file
BIN
public/assets/img/pages/vereinsgelaende-640.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
Reference in New Issue
Block a user