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 && ( +

+ {fields.map((f) => ( +
+ + +
+ ))} +
+ )} +