diff --git a/README.md b/README.md
index eb37c12..c755280 100644
--- a/README.md
+++ b/README.md
@@ -32,10 +32,13 @@ Diese Regeln sind nicht kosmetisch – die Datenschutzerklärung sagt sie zu:
`next/font` zur Build-Zeit selbst gehostet.
- **kein Storage**: kein localStorage, kein sessionStorage, keine Cookies –
deshalb auch kein Cookie-Banner.
-- **Kontaktformular mit Serverversand**, kein Newsletter. Abschnitt 06 sendet an
- `/api/contact` beim Hoster; die Anfrage landet im Postfach
+- **Kontaktformular mit Serverversand**, kein Newsletter. Abschnitt 06 der
+ Startseite, die Messeseite und die Terminanfrage am Fuß von Trainings und
+ Keynotes senden an `/api/contact` beim Hoster; die Anfrage landet im Postfach
`info@anelia-wolf.com` im Ordner `web-anfragen` (kein Formulardienst eines
- Dritten). Alle übrigen Kontaktwege laufen weiter über `mailto:` und Telefon.
+ Dritten). Die Terminanfrage schickt zusätzlich `topic` (Betreff) und
+ `details` (Wunschtermin usw.) mit. Alle übrigen Kontaktwege laufen weiter über
+ `mailto:` und Telefon.
Wer eine dieser Zusagen ändert, muss `src/content/legal.ts` (Ziffer 2, 3 und 6)
mit ändern.
diff --git a/src/app/en/services/keynotes/page.tsx b/src/app/en/services/keynotes/page.tsx
index 77946b0..41df7dd 100644
--- a/src/app/en/services/keynotes/page.tsx
+++ b/src/app/en/services/keynotes/page.tsx
@@ -1,7 +1,8 @@
import type { Metadata } from "next";
import { KeynotesBody } from "@/components/pages/KeynotesBody";
+import { homeEn } from "@/content/en/home";
import { keynotesEn, talkLabelsEn } from "@/content/en/keynotes";
-import { alternatesFor, OG_LOCALE } from "@/lib/i18n";
+import { alternatesFor, OG_LOCALE, routesByLang } from "@/lib/i18n";
import { site } from "@/lib/site";
export const metadata: Metadata = {
@@ -18,5 +19,12 @@ export const metadata: Metadata = {
/** /en/services/keynotes/ – layout in , copy in src/content/en/keynotes.ts. */
export default function EnKeynotesPage() {
- return ;
+ return (
+
+ );
}
diff --git a/src/app/en/services/trainings/page.tsx b/src/app/en/services/trainings/page.tsx
index 5815cb1..65818fc 100644
--- a/src/app/en/services/trainings/page.tsx
+++ b/src/app/en/services/trainings/page.tsx
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { TrainingsBody } from "@/components/pages/TrainingsBody";
import { homeEn } from "@/content/en/home";
import { trainingsEn } from "@/content/en/trainings";
-import { alternatesFor, OG_LOCALE } from "@/lib/i18n";
+import { alternatesFor, OG_LOCALE, routesByLang } from "@/lib/i18n";
import { site } from "@/lib/site";
export const metadata: Metadata = {
@@ -19,5 +19,12 @@ export const metadata: Metadata = {
/** /en/services/trainings/ – layout in , copy in src/content/en/trainings.ts. */
export default function EnTrainingsPage() {
- return ;
+ return (
+
+ );
}
diff --git a/src/components/home/ProcessContact.tsx b/src/components/home/ProcessContact.tsx
index d7c2378..c91cd8c 100644
--- a/src/components/home/ProcessContact.tsx
+++ b/src/components/home/ProcessContact.tsx
@@ -5,7 +5,7 @@ import { Reveal } from "@/components/motion/Reveal";
import { Rule } from "@/components/motion/Rule";
import { ContactLinks } from "@/components/ui/ContactLinks";
import { Eyebrow, EYEBROW_TEXT } from "@/components/ui/Eyebrow";
-import { ContactForm, type ContactCoupon } from "@/components/ui/ContactForm";
+import { ContactForm, type ContactCoupon, type ContactField } from "@/components/ui/ContactForm";
/* H2 on ink: Bodoni, `clamp(2.75rem, 1.6rem + 4.4vw, 6rem)`, ivory (spec §6.7). */
const H2 =
@@ -29,9 +29,20 @@ export interface ProcessContactProps {
total?: string;
/** Vorteil, der schon an der Anfrage hängt (Messeseite). Ohne Angabe entfällt er. */
coupon?: ContactCoupon;
+ /** Zusatzfelder der Terminanfrage (Leistungsseiten). */
+ fields?: readonly ContactField[];
+ /** Anliegen der Seite – wird zum Betreff der Mail. */
+ topic?: string;
}
-export function ProcessContact({ content: contact = contactDe, privacyHref, total, coupon }: ProcessContactProps = {}) {
+export function ProcessContact({
+ content: contact = contactDe,
+ privacyHref,
+ total,
+ coupon,
+ fields,
+ topic,
+}: ProcessContactProps = {}) {
return (
/* `data-sale` ist der Haken, an dem das rote Reduziert-Fähnchen
aufhängt – die Komponente hängt global in und kann von dort nicht
@@ -65,7 +76,7 @@ export function ProcessContact({ content: contact = contactDe, privacyHref, tota
Es verschickt nichts selbst, sondern übergibt eine fertige E-Mail an das
Mailprogramm – siehe den Kopf von ContactForm.tsx. */}
-
+
{/* Contact block: under the Ablauf up to 1280 (the CTA button is 354 px
@@ -76,10 +87,14 @@ export function ProcessContact({ content: contact = contactDe, privacyHref, tota
- {/* Sign-off above the colophon (the Footer follows globally). */}
-
- {contact.signoff}
-
+ {/* Sign-off above the colophon (the Footer follows globally). Leer = entfällt:
+ Auf den Leistungsseiten stünde der Claim sonst direkt über dem Fuß, der ihn
+ ebenfalls trägt. */}
+ {contact.signoff && (
+
+ {contact.signoff}
+
+ )}
);
diff --git a/src/components/pages/KeynotesBody.tsx b/src/components/pages/KeynotesBody.tsx
index e5ec3cd..89eba79 100644
--- a/src/components/pages/KeynotesBody.tsx
+++ b/src/components/pages/KeynotesBody.tsx
@@ -5,15 +5,14 @@ import { Occasions } from "@/components/services/keynotes/Occasions";
import { SectionHeading } from "@/components/services/SectionHeading";
import { SignatureTalk, type TalkLabels } from "@/components/services/keynotes/SignatureTalk";
import { SpeakerProfile } from "@/components/services/keynotes/SpeakerProfile";
-import { CtaBand } from "@/components/ui/CtaBand";
-import { FactsStrip } from "@/components/ui/FactsStrip";
+import { ProcessContact } from "@/components/home/ProcessContact";
import { Faq } from "@/components/ui/Faq";
-import { Interruption } from "@/components/ui/Interruption";
import { Ledger } from "@/components/ui/Ledger";
import { Reveal } from "@/components/motion/Reveal";
import { talkLabels as talkLabelsDe, type KeynotesContent } from "@/content/keynotes";
+import { contact as contactDe, type HomeContent } from "@/content/home";
import { getPhotos, getSpeakerPdf } from "@/lib/assets";
-import { flags } from "@/lib/site";
+import { flags, routes } from "@/lib/site";
/** Content column of every light section: cols 3–12 on desktop. */
const BODY = "col-span-12 lg:col-span-10 lg:col-start-3";
@@ -26,6 +25,10 @@ export interface KeynotesBodyProps {
content: KeynotesContent;
/** Zeilenbeschriftungen im Vortragsblock – in der Sprache der Seite. */
talkLabels?: TalkLabels;
+ /** Formular-Texte (Feldnamen, Rückmeldungen, Kontaktzeilen) in der Sprache der Seite. */
+ contact?: HomeContent["contact"];
+ /** Ziel des Datenschutz-Links im Formular. */
+ privacyHref?: string;
}
/**
@@ -33,18 +36,22 @@ export interface KeynotesBodyProps {
* „die Bühnenseite“). Server Component: Foto und PDF werden zur Bauzeit über lib/assets
* geprüft. Kein – template.tsx rendert `main#inhalt`.
*
- * Reihenfolge: Hero (ink, optionales Bühnenfoto) → 01 Der Vortrag → Interruption (ink, auf
- * oberster Ebene, damit sie ihren eigenen Grund malt) → 02 Anlässe → 03 Publikum →
- * 04 Für Veranstalter (Speaker-Facts, Termine, PDF-Zeile nur bei gelieferter Datei) →
- * 05 FAQ (Flag) → Kontakt-Band (ink).
+ * Reihenfolge: Hero (ink, optionales Bühnenfoto, Fakten inkl. Technik) → 01 Der Vortrag
+ * (mit Terminen und – nur bei gelieferter Datei – der PDF-Zeile) → 02 Anlässe →
+ * 03 Publikum → 04 FAQ (Flag) → Terminanfrage (ink, Formular, zählt mit).
*/
-export function KeynotesBody({ content: keynotes, talkLabels = talkLabelsDe }: KeynotesBodyProps) {
+export function KeynotesBody({
+ content: keynotes,
+ talkLabels = talkLabelsDe,
+ contact = contactDe,
+ privacyHref = routes.datenschutz,
+}: KeynotesBodyProps) {
const { stage } = getPhotos();
const speakerPdf = flags.showSpeakerPdf ? getSpeakerPdf() : null;
const showFaq = flags.showFaq && keynotes.faq.items.length > 0;
const total = showFaq ? "05" : "04";
- const { talk, interruption, occasions, takeaways, speakerFacts, termine, veranstalter, faq, band } = keynotes;
+ const { talk, occasions, takeaways, termine, faq, anfrage } = keynotes;
return (
<>
@@ -58,18 +65,23 @@ export function KeynotesBody({ content: keynotes, talkLabels = talkLabelsDe }: K
+ {/* Wo der Vortrag als Nächstes läuft – direkt unter dem Vortrag statt in einem
+ eigenen Abschnitt, der sonst nur noch aus dieser Tafel bestünde. */}
+
+
+
+ {/* Spec §7.4: die Zeile erscheint nur, wenn die PDF-Datei geliefert wurde. */}
+ {speakerPdf && (
+
+
+
+ )}
-
- {/* 100 svh is a stage on desktop; on a phone it would be ~70 % empty. */}
-
-
-
@@ -84,30 +96,8 @@ export function KeynotesBody({ content: keynotes, talkLabels = talkLabelsDe }: K
-
-
-
-
-
-
-
-
-
-
- {/* Spec §7.4: die Zeile erscheint nur, wenn die PDF-Datei geliefert wurde. */}
- {speakerPdf && (
-
-
-
- )}
-
-
{showFaq && (
-
+
@@ -116,7 +106,23 @@ export function KeynotesBody({ content: keynotes, talkLabels = talkLabelsDe }: K
)}
-
+ {/* Terminanfrage statt Kontakt-Band: derselbe Block wie auf der Startseite, ohne
+ den Claim darunter (der steht direkt danach im Fuß). */}
+
>
);
}
diff --git a/src/components/pages/TrainingsBody.tsx b/src/components/pages/TrainingsBody.tsx
index 44f3471..8684f9b 100644
--- a/src/components/pages/TrainingsBody.tsx
+++ b/src/components/pages/TrainingsBody.tsx
@@ -4,14 +4,12 @@ import { ServiceLayout } from "@/components/services/ServiceLayout";
import { TrainingsSituations } from "@/components/services/trainings/TrainingsSituations";
import { TrainingsModules } from "@/components/services/trainings/TrainingsModules";
import { TrainingsMethod } from "@/components/services/trainings/TrainingsMethod";
-import { TrainingsGoals } from "@/components/services/trainings/TrainingsGoals";
-import { TrainingsRahmen } from "@/components/services/trainings/TrainingsRahmen";
import { TrainingsAblauf } from "@/components/services/trainings/TrainingsAblauf";
import { TrainingsFaq } from "@/components/services/trainings/TrainingsFaq";
-import { TrainingsBand } from "@/components/services/trainings/TrainingsBand";
+import { ProcessContact } from "@/components/home/ProcessContact";
import type { TrainingsContent } from "@/content/trainings";
-import type { HomeContent } from "@/content/home";
-import { flags } from "@/lib/site";
+import { contact as contactDe, type HomeContent } from "@/content/home";
+import { flags, routes } from "@/lib/site";
const pad = (n: number) => String(n).padStart(2, "0");
@@ -19,6 +17,10 @@ export interface TrainingsBodyProps {
content: TrainingsContent;
/** „Über Anelia“-Randspalte im Abschnitt „Wie ich arbeite“ – in der Sprache der Seite. */
marginalia: HomeContent["about"]["marginalia"];
+ /** Formular-Texte (Feldnamen, Rückmeldungen, Kontaktzeilen) in der Sprache der Seite. */
+ contact?: HomeContent["contact"];
+ /** Ziel des Datenschutz-Links im Formular. */
+ privacyHref?: string;
}
/**
@@ -26,8 +28,16 @@ export interface TrainingsBodyProps {
* Die „Angebots“-Seite, am wenigsten Bewegung: nur , , – kein Pin,
* kein Draw, kein Blur, kein Foto (das Bühnenfoto gehört zu den Keynotes, §7.1).
* Kein : template.tsx liefert `main#inhalt`.
+ *
+ * Reihenfolge: Hero → Ausgangslage → Module → Wie ich arbeite → Ablauf → FAQ (Flag) →
+ * Terminanfrage (ink, Formular). Die Terminanfrage zählt als letzter Abschnitt mit.
*/
-export function TrainingsBody({ content: trainings, marginalia }: TrainingsBodyProps) {
+export function TrainingsBody({
+ content: trainings,
+ marginalia,
+ contact = contactDe,
+ privacyHref = routes.datenschutz,
+}: TrainingsBodyProps) {
/* Helle Abschnitte in Lesereihenfolge; FAQ nur mit `flags.showFaq` (Spec §7.1.6). */
const sections = [
{ id: trainings.situations.id, label: trainings.situations.eyebrow, body: },
@@ -37,14 +47,13 @@ export function TrainingsBody({ content: trainings, marginalia }: TrainingsBodyP
label: trainings.how.eyebrow,
body: ,
},
- { id: trainings.goals.id, label: trainings.goals.eyebrow, body: },
- { id: trainings.rahmen.id, label: trainings.rahmen.eyebrow, body: },
{ id: trainings.ablauf.id, label: trainings.ablauf.eyebrow, body: },
...(flags.showFaq && trainings.faq.items.length > 0
? [{ id: trainings.faq.id, label: trainings.faq.eyebrow, body: }]
: []),
];
- const total = pad(sections.length);
+ const total = pad(sections.length + 1);
+ const { anfrage } = trainings;
return (
<>
@@ -56,7 +65,23 @@ export function TrainingsBody({ content: trainings, marginalia }: TrainingsBodyP
))}
-
+ {/* Terminanfrage statt Kontakt-Band: derselbe Block wie auf der Startseite, ohne
+ den Claim darunter (der steht direkt danach im Fuß). */}
+
>
);
}
diff --git a/src/components/services/trainings/TrainingsAblauf.tsx b/src/components/services/trainings/TrainingsAblauf.tsx
index 9663ecb..5ed89ee 100644
--- a/src/components/services/trainings/TrainingsAblauf.tsx
+++ b/src/components/services/trainings/TrainingsAblauf.tsx
@@ -1,27 +1,20 @@
-import { Reveal } from "@/components/motion/Reveal";
-import { Marginalia } from "@/components/ui/Marginalia";
import { Steps } from "@/components/ui/Steps";
import { trainings as trainingsDe, type TrainingsContent } from "@/content/trainings";
import { SectionHeading } from "@/components/services/SectionHeading";
/**
- * Ablauf – four numbered rows (Bedarfsgespräch → Follow-up nach 30 Tagen),
- * cols 3–8, plus the „Was du bekommst“ marginalia in cols 10–12 (sticky on
- * desktop) so the spread reads like coaching’s Ablauf.
+ * Ablauf – four numbered rows (Bedarfsgespräch → Follow-up nach 30 Tagen), cols 3–8.
+ * Die Randspalte „Was du bekommst“ ist am 02.10.2026 entfallen: Sie wiederholte die
+ * Schritte (Konzept, 30 Tage, Follow-up) Wort für Wort.
*/
export function TrainingsAblauf({ content = trainingsDe }: { content?: TrainingsContent }) {
- const { eyebrow, steps, marginalia } = content.ablauf;
+ const { eyebrow, steps } = content.ablauf;
return (
<>
-
-
-
-
-
>
);
}
diff --git a/src/components/services/trainings/TrainingsBand.tsx b/src/components/services/trainings/TrainingsBand.tsx
deleted file mode 100644
index 57d8217..0000000
--- a/src/components/services/trainings/TrainingsBand.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import { CtaBand } from "@/components/ui/CtaBand";
-import { site } from "@/lib/site";
-import { trainings as trainingsDe, type TrainingsContent } from "@/content/trainings";
-
-/**
- * Kontakt band „Sprechen wir über dein Team.“ – same primary CTA as the hero.
- * Phone prominent: the number sits in the CTA micro line, in addition to the
- * large „Ruf an“ tel link that renders on the right.
- */
-export function TrainingsBand({ content = trainingsDe }: { content?: TrainingsContent }) {
- const { h2, cta, micro } = content.band;
- return ;
-}
-
-export default TrainingsBand;
diff --git a/src/components/services/trainings/TrainingsGoals.tsx b/src/components/services/trainings/TrainingsGoals.tsx
deleted file mode 100644
index 24b4008..0000000
--- a/src/components/services/trainings/TrainingsGoals.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { Ledger } from "@/components/ui/Ledger";
-import { trainings as trainingsDe, type TrainingsContent } from "@/content/trainings";
-import { SectionHeading } from "@/components/services/SectionHeading";
-
-/** „Typische Ziele“ – goals (not promises) as three hairline ledger lines, cols 3–8. */
-export function TrainingsGoals({ content = trainingsDe }: { content?: TrainingsContent }) {
- const { eyebrow, rows } = content.goals;
- return (
- <>
-
-
-
-
- >
- );
-}
-
-export default TrainingsGoals;
diff --git a/src/components/services/trainings/TrainingsRahmen.tsx b/src/components/services/trainings/TrainingsRahmen.tsx
deleted file mode 100644
index 236c0fd..0000000
--- a/src/components/services/trainings/TrainingsRahmen.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { Reveal } from "@/components/motion/Reveal";
-import { SectionHeading } from "@/components/services/SectionHeading";
-import { Facts } from "@/components/ui/Facts";
-import { trainings as trainingsDe, type TrainingsContent } from "@/content/trainings";
-
-/**
- * Rahmen – bone block cols 3–10 (1 px `--line`, padded) with the two-column
- * mono facts table (Format · Sprache · Gruppengröße · Vorbereitung).
- * Placeholder rows follow `flags.showPlaceholders` inside .
- * No page-level grid override – the shared table stacks label above value
- * below `sm` on every page, so the Rahmen block is the same object here and
- * on /leistungen/coaching/.
- *
- * The chapter carries a real `
` like every other one: is a
- * plain `
`, so this section used to be unreachable by heading navigation.
- */
-export function TrainingsRahmen({ content = trainingsDe }: { content?: TrainingsContent }) {
- const { eyebrow, rows } = content.rahmen;
- return (
- <>
-
-
-
-
- >
- );
-}
-
-export default TrainingsRahmen;
diff --git a/src/components/ui/ContactForm.tsx b/src/components/ui/ContactForm.tsx
index 4b94e98..6619e11 100644
--- a/src/components/ui/ContactForm.tsx
+++ b/src/components/ui/ContactForm.tsx
@@ -60,6 +60,18 @@ export interface ContactCoupon {
line: string;
}
+/**
+ * Ein zusätzliches, freiwilliges Feld der Terminanfrage (z. B. Wunschtermin).
+ * `label` steht über dem Feld in der Sprache der Seite; `mailLabel` beschriftet den
+ * Wert in der Mail an Anelia – auf Deutsch, auch wenn die Anfrage von /en/ kommt.
+ */
+export interface ContactField {
+ name: string;
+ label: string;
+ mailLabel: string;
+ autoComplete?: string;
+}
+
type Status = "idle" | "sending" | "sent" | "error";
export function ContactForm({
@@ -67,6 +79,8 @@ export function ContactForm({
content: form = contactDe.form,
privacyHref = routes.datenschutz,
coupon,
+ fields = [],
+ topic,
}: {
className?: string;
/** Ziel des Datenschutz-Links – in der englischen Fassung /en/privacy/. */
@@ -75,6 +89,13 @@ export function ContactForm({
content?: HomeContent["contact"]["form"];
/** Vorteil, der schon an der Anfrage hängt. Ohne Angabe entfällt das Feld. */
coupon?: ContactCoupon;
+ /** Zusätzliche freiwillige Felder (Terminanfrage der Leistungsseiten). */
+ fields?: readonly ContactField[];
+ /**
+ * Anliegen der Seite, z. B. „Terminanfrage Training“. `/api/contact` macht daraus
+ * den Betreff, damit die Anfrage im Postfach ohne Öffnen einzuordnen ist.
+ */
+ topic?: string;
}) {
const uid = useId();
const [status, setStatus] = useState("idle");
@@ -94,12 +115,19 @@ export function ContactForm({
er im Postfach unter dem, was der Absender geschrieben hat, und nicht davor. */
const written = value("message");
+ /* Nur ausgefüllte Zusatzfelder reisen mit – in der Mail steht keine leere Zeile. */
+ const details = fields
+ .map((f) => ({ label: f.mailLabel, value: value(f.name) }))
+ .filter((d) => d.value);
+
const payload = {
name: value("name"),
email: value("email"),
company: value("company"),
message: coupon ? `${written}\n\n${coupon.line}` : written,
page: typeof window === "undefined" ? "" : window.location.pathname,
+ ...(topic ? { topic } : {}),
+ ...(details.length ? { details } : {}),
// Honeypot: Menschen sehen das Feld nicht, Bots füllen es aus.
website: value("website"),
};
@@ -120,13 +148,14 @@ export function ContactForm({
`${form.fields.name}: ${payload.name}`,
`${form.fields.email}: ${payload.email}`,
payload.company ? `${form.fields.company}: ${payload.company}` : null,
+ ...details.map((d) => `${d.label}: ${d.value}`),
"",
payload.message,
]
.filter((line) => line !== null)
.join("\r\n");
setMailtoHref(
- `mailto:${site.email}?subject=${encodeURIComponent(form.subject)}&body=${encodeURIComponent(body)}`,
+ `mailto:${site.email}?subject=${encodeURIComponent(topic ?? form.subject)}&body=${encodeURIComponent(body)}`,
);
setStatus("error");
}
@@ -198,6 +227,25 @@ export function ContactForm({
+ {fields.length > 0 && (
+