Trainings + Keynotes: Terminanfrage statt Kontakt-Band, Doppeltes raus

Beide Leistungsseiten enden jetzt mit der Terminanfrage: dasselbe Formular wie
auf der Startseite (an /api/contact), mit zwei freiwilligen Feldern –
Trainings: Wunschtermin/Zeitraum und Teamgröße, Keynotes: Datum und
Veranstaltung & Ort. Das Anliegen („Terminanfrage Training“ / „… Keynote“)
geht als `topic` mit und wird beim Hoster zum Mail-Betreff, die Felder als
`details` in die Mail. Der Knopf im Hero („Training anfragen“, „Keynote
anfragen“) springt dorthin statt ein Mailprogramm zu öffnen.

Entfallen, weil es schon auf der Seite stand:
- Trainings: „Typische Ziele“ (Spiegel der drei Bremsen), der Rahmen-Abschnitt
  (= Hero-Fakten), die Randspalte im Ablauf (wiederholte die Schritte), die FAQ
  zur Teilnehmerzahl; Subline ohne die Format-/Sprach-Wiederholung.
- Keynotes: die Unterbrechung „Mut zum Erfolg.“ (= H1 und Vortragstitel), die
  Speaker-Facts (= Hero-Fakten; „Technik“ steht jetzt dort), die FAQ „Auf
  Englisch?“; die Termine hängen direkt unter dem Vortrag.

Datenschutzerklärung Ziffer 2 und 5 (DE/EN): mehrere Formulare, die neuen
freiwilligen Felder. Ungenutzt und entfernt: TrainingsGoals, TrainingsRahmen,
TrainingsBand, FactsStrip, Interruption, `mailto` in site.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-02 13:07:00 +00:00
1 parent a37e48e4ef
commit f825e74222
20 files changed
+263 -395

No files matched your search

+6 -3
View File
@@ -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.
+10 -2
View File
@@ -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 <KeynotesBody/>, copy in src/content/en/keynotes.ts. */
export default function EnKeynotesPage() {
return <KeynotesBody content={keynotesEn} talkLabels={talkLabelsEn} />;
return (
<KeynotesBody
content={keynotesEn}
talkLabels={talkLabelsEn}
contact={homeEn.contact}
privacyHref={routesByLang.en.datenschutz}
/>
);
}
+9 -2
View File
@@ -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 <TrainingsBody/>, copy in src/content/en/trainings.ts. */
export default function EnTrainingsPage() {
return <TrainingsBody content={trainingsEn} marginalia={homeEn.about.marginalia} />;
return (
<TrainingsBody
content={trainingsEn}
marginalia={homeEn.about.marginalia}
contact={homeEn.contact}
privacyHref={routesByLang.en.datenschutz}
/>
);
}
+19 -4
View File
@@ -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 <ScrollProgress/> das rote Reduziert-Fähnchen
aufhängt – die Komponente hängt global in <Shell/> 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. */}
<Reveal className="col-span-12 mt-12 lg:col-span-10 lg:col-start-3 lg:mt-14 xl:col-span-6">
<ContactForm content={contact.form} privacyHref={privacyHref} coupon={coupon} />
<ContactForm content={contact.form} privacyHref={privacyHref} coupon={coupon} fields={fields} topic={topic} />
</Reveal>
{/* 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
</div>
</div>
{/* Sign-off above the colophon (the Footer follows globally). */}
{/* 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 && (
<Reveal as="p" className={`col-span-12 mt-20 lg:col-span-10 lg:col-start-3 lg:mt-32 ${EYEBROW_TEXT} text-gold`}>
{contact.signoff}
</Reveal>
)}
</Section>
</div>
);
+50 -44
View File
@@ -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 <main> – 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
<div className={`${BODY} mt-16`}>
<SignatureTalk rows={talk.rows} labels={talkLabels} />
</div>
</Section>
</ServiceLayout>
{/* 100 svh is a stage on desktop; on a phone it would be ~70 % empty. */}
<Interruption
id={interruption.id}
text={interruption.text}
mono={interruption.mono}
className="min-h-[70svh] lg:min-h-svh"
{/* 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. */}
<div id={termine.id} className={`${BODY} mt-16`}>
<Ledger variant="termine" rows={termine.rows} heading={termine.heading} />
</div>
{/* Spec §7.4: die Zeile erscheint nur, wenn die PDF-Datei geliefert wurde. */}
{speakerPdf && (
<div className={`${BODY} mt-16`}>
<SpeakerProfile
label={keynotes.speakerPdf.label}
text={keynotes.speakerPdf.text}
href={speakerPdf}
/>
</div>
)}
</Section>
<ServiceLayout>
<Section id={occasions.id} theme="light" numeral="02" total={total} label={occasions.eyebrow}>
<SectionHeading eyebrow={occasions.eyebrow} h2={occasions.h2} className={HEAD} />
<div className={`${BODY} mt-16`}>
@@ -84,30 +96,8 @@ export function KeynotesBody({ content: keynotes, talkLabels = talkLabelsDe }: K
</div>
</Section>
<Section id={veranstalter.id} theme="light" numeral="04" total={total} label={veranstalter.eyebrow}>
<SectionHeading eyebrow={veranstalter.eyebrow} h2={veranstalter.h2} className={HEAD} />
<Reveal className={`${BODY} mt-16`}>
<div id={speakerFacts.id}>
<FactsStrip items={speakerFacts.items} />
</div>
</Reveal>
<div id={termine.id} className={`${BODY} mt-16`}>
<Ledger variant="termine" rows={termine.rows} heading={termine.heading} />
</div>
{/* Spec §7.4: die Zeile erscheint nur, wenn die PDF-Datei geliefert wurde. */}
{speakerPdf && (
<div className={`${BODY} mt-16`}>
<SpeakerProfile
label={veranstalter.speakerPdf.label}
text={veranstalter.speakerPdf.text}
href={speakerPdf}
/>
</div>
)}
</Section>
{showFaq && (
<Section id={faq.id} theme="light" numeral="05" total={total} label={faq.eyebrow}>
<Section id={faq.id} theme="light" numeral="04" total={total} label={faq.eyebrow}>
<SectionHeading eyebrow={faq.eyebrow} className={HEAD} />
<Reveal className={`${FAQ_COL} mt-12`}>
<Faq items={faq.items} />
@@ -116,7 +106,23 @@ export function KeynotesBody({ content: keynotes, talkLabels = talkLabelsDe }: K
)}
</ServiceLayout>
<CtaBand h2={band.h2} cta={band.cta} micro={band.micro} />
{/* Terminanfrage statt Kontakt-Band: derselbe Block wie auf der Startseite, ohne
den Claim darunter (der steht direkt danach im Fuß). */}
<ProcessContact
content={{
...contact,
id: anfrage.id,
numeral: total,
eyebrow: anfrage.eyebrow,
h2: anfrage.h2,
signoff: "",
form: { ...contact.form, subject: anfrage.topic },
}}
privacyHref={privacyHref}
total={total}
fields={anfrage.fields}
topic={anfrage.topic}
/>
</>
);
}
+35 -10
View File
@@ -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 <Rule/>, <Lines/>, <Reveal/> – kein Pin,
* kein Draw, kein Blur, kein Foto (das Bühnenfoto gehört zu den Keynotes, §7.1).
* Kein <main>: 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: <TrainingsSituations content={trainings} /> },
@@ -37,14 +47,13 @@ export function TrainingsBody({ content: trainings, marginalia }: TrainingsBodyP
label: trainings.how.eyebrow,
body: <TrainingsMethod content={trainings} marginalia={marginalia} />,
},
{ id: trainings.goals.id, label: trainings.goals.eyebrow, body: <TrainingsGoals content={trainings} /> },
{ id: trainings.rahmen.id, label: trainings.rahmen.eyebrow, body: <TrainingsRahmen content={trainings} /> },
{ id: trainings.ablauf.id, label: trainings.ablauf.eyebrow, body: <TrainingsAblauf content={trainings} /> },
...(flags.showFaq && trainings.faq.items.length > 0
? [{ id: trainings.faq.id, label: trainings.faq.eyebrow, body: <TrainingsFaq content={trainings} /> }]
: []),
];
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
</Section>
))}
</ServiceLayout>
<TrainingsBand content={trainings} />
{/* Terminanfrage statt Kontakt-Band: derselbe Block wie auf der Startseite, ohne
den Claim darunter (der steht direkt danach im Fuß). */}
<ProcessContact
content={{
...contact,
id: anfrage.id,
numeral: total,
eyebrow: anfrage.eyebrow,
h2: anfrage.h2,
signoff: "",
form: { ...contact.form, subject: anfrage.topic },
}}
privacyHref={privacyHref}
total={total}
fields={anfrage.fields}
topic={anfrage.topic}
/>
</>
);
}
@@ -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 (
<>
<SectionHeading eyebrow={eyebrow} className="col-span-12 lg:col-span-6 lg:col-start-3" />
<div className="col-span-12 mt-10 lg:col-span-6 lg:col-start-3">
<Steps items={steps} />
</div>
<div className="col-span-12 mt-12 lg:col-span-3 lg:col-start-10 lg:mt-10">
<Reveal delay={0.2}>
<Marginalia title={marginalia.title} items={marginalia.items} sticky />
</Reveal>
</div>
</>
);
}
@@ -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 <ContactLinks/> renders on the right.
*/
export function TrainingsBand({ content = trainingsDe }: { content?: TrainingsContent }) {
const { h2, cta, micro } = content.band;
return <CtaBand h2={h2} cta={cta} micro={`${micro} · ${site.phoneDisplay}`} />;
}
export default TrainingsBand;
@@ -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 (
<>
<SectionHeading eyebrow={eyebrow} className="col-span-12 lg:col-span-6 lg:col-start-3" />
<div className="col-span-12 mt-10 lg:col-span-6 lg:col-start-3">
<Ledger variant="goals" rows={rows} />
</div>
</>
);
}
export default TrainingsGoals;
@@ -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 <Facts/>.
* 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 `<h2>` like every other one: <Facts title> is a
* plain `<p>`, so this section used to be unreachable by heading navigation.
*/
export function TrainingsRahmen({ content = trainingsDe }: { content?: TrainingsContent }) {
const { eyebrow, rows } = content.rahmen;
return (
<>
<SectionHeading eyebrow={eyebrow} className="col-span-12 lg:col-span-6 lg:col-start-3" />
<Reveal className="col-span-12 mt-10 lg:col-span-8 lg:col-start-3">
<Facts variant="table" items={rows} />
</Reveal>
</>
);
}
export default TrainingsRahmen;
+49 -1
View File
@@ -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<Status>("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({
<input id={field("company")} name="company" type="text" autoComplete="organization" className={input} />
</div>
{fields.length > 0 && (
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
{fields.map((f) => (
<div key={f.name}>
<label htmlFor={field(f.name)} className={label}>
{f.label}
</label>
<input
id={field(f.name)}
name={f.name}
type="text"
autoComplete={f.autoComplete ?? "off"}
className={input}
/>
</div>
))}
</div>
)}
<div>
<label htmlFor={field("message")} className={label}>
{form.fields.message}
-39
View File
@@ -1,39 +0,0 @@
import { cn } from "@/lib/utils";
import type { FactItem } from "@/content/home";
import { PlaceholderText, visibleFacts } from "./Facts";
export interface FactsStripProps {
items: readonly FactItem[];
className?: string;
}
/**
* Speaker facts: one full-width row of equal cells separated by 1 px
* vertical hairlines. Each cell = DM Mono label (muted) + Jost value.
* Wraps to 2 / 3 columns below 1024 px.
*/
export function FactsStrip({ items, className }: FactsStripProps) {
const rows = visibleFacts(items);
if (!rows.length) return null;
return (
<dl
className={cn(
"grid grid-cols-2 border-y border-line sm:grid-cols-3 lg:grid-cols-6",
"[&>div]:border-l [&>div]:border-line [&>div:first-child]:border-l-0",
"[&>div:nth-child(2n+1)]:max-sm:border-l-0 [&>div:nth-child(3n+1)]:sm:max-lg:border-l-0",
className,
)}
>
{rows.map((f) => (
<div key={f.label} className="flex flex-col gap-2 px-5 py-6 lg:px-6 lg:py-7">
<dt className="font-mono text-xs tracking-[0.06em] text-fg-muted">{f.label}</dt>
<dd className="font-sans text-[1.0625rem] leading-snug text-fg">
<PlaceholderText text={f.value} />
</dd>
</div>
))}
</dl>
);
}
export default FactsStrip;
-50
View File
@@ -1,50 +0,0 @@
import { cn } from "@/lib/utils";
import { Container } from "@/components/layout/Container";
import { GRID } from "@/components/layout/Section";
import { Lines } from "@/components/motion/Lines";
import { Reveal } from "@/components/motion/Reveal";
export interface InterruptionProps {
/** „Mut zum Erfolg.“ */
text: string;
/** DM Mono line below, e.g. „Keynote · herCAREER Expo 2026 · 23.10. · 10:30 · Auditorium 2“. */
mono?: string;
id?: string;
className?: string;
}
/**
* Typographic interruption (keynote page): 100svh ink, one giant Bodoni line
* `clamp(4rem, 2rem + 12vw, 14rem)` in cols 2–12, mono meta in gold below.
* Background stays transparent – <body> paints the ground.
*/
export function Interruption({ text, mono, id, className }: InterruptionProps) {
return (
<section
id={id}
data-theme="dark"
aria-label={text}
className={cn("relative flex min-h-svh items-center bg-transparent py-24 text-fg", className)}
>
<Container className="w-full">
<div className={GRID}>
<div className="col-span-12 lg:col-start-2 lg:col-span-11">
<Lines
as="p"
className="font-display text-[clamp(4rem,2rem+12vw,14rem)] font-normal leading-[0.94] tracking-[-0.015em] text-ivory [text-wrap:balance]"
>
{text}
</Lines>
{mono && (
<Reveal as="p" delay={0.3} className="mt-8 font-mono text-xs tracking-[0.06em] tabular-nums text-gold">
{mono}
</Reveal>
)}
</div>
</div>
</Container>
</section>
);
}
export default Interruption;
+14 -34
View File
@@ -3,7 +3,7 @@
* The talk title follows Anelia's own English herCAREER listing:
* „The WOLF Principle – Courage to Succeed!“.
*/
import { mailto } from "@/lib/site";
import { routesByLang } from "@/lib/i18n";
import { assertNoPlaceholders, collectStrings, ph } from "@/lib/legal";
import type { KeynotesContent } from "@/content/keynotes";
import { homeEn } from "./home";
@@ -26,14 +26,15 @@ export const keynotesEn: KeynotesContent = {
h1: { before: "Courage to ", em: "succeed", after: "." },
h1GoldEm: true,
subline:
"Keynotes on the WOLF Principle for company events, kick-offs and conferences. An impulse that stays with the room – in German or English, live or online.",
cta: { label: "Request a keynote", href: mailto.keynote },
"Keynotes on the WOLF Principle for company events, kick-offs and conferences. An impulse that stays with the room.",
cta: { label: "Request a keynote", href: `${routesByLang.en.keynotes}#terminanfrage` },
secondary: { label: "Speaker profile (PDF)", href: "/speaker-profil.pdf", external: true },
facts: [
{ label: "Length", value: "20–60 minutes" },
{ label: "Format", value: "Live, online, hybrid" },
{ label: "Language", value: "German / English" },
{ label: "Travel", value: "Germany-wide, Europe on request" },
{ label: "Tech", value: "Headset, slides optional" },
],
alt: "Anelia Wolf on stage",
caption: `Anelia Wolf · Photo: ${ph("Name der Fotografin")}`,
@@ -55,12 +56,6 @@ export const keynotesEn: KeynotesContent = {
],
},
interruption: {
id: "mut",
text: "Courage to succeed.",
mono: "Keynote · herCAREER Expo 2026 · 23 Oct · 10:30 · Auditorium 2",
},
occasions: {
id: "anlaesse",
eyebrow: "Occasions",
@@ -92,26 +87,9 @@ export const keynotesEn: KeynotesContent = {
],
},
speakerFacts: {
id: "speaker-facts",
items: [
{ label: "Languages", value: "DE / EN" },
{ label: "Length", value: "20–60 min" },
{ label: "Format", value: "live, online, hybrid" },
{ label: "Tech", value: "Headset, slides optional" },
{ label: "Travel", value: "Germany-wide, Europe on request" },
{ label: "Enquiry", value: "no obligation" },
],
},
termine: homeEn.termine,
veranstalter: {
id: "veranstalter",
eyebrow: "For event hosts",
h2: { before: "Everything you need up front.", em: "", after: "" },
speakerPdf: { label: "Speaker profile (PDF)", text: "Short bio, topics, photos, tech", href: "/speaker-profil.pdf" },
},
faq: {
id: "faq",
@@ -125,10 +103,6 @@ export const keynotesEn: KeynotesContent = {
q: "Can it be combined with a workshop?",
a: "Yes. The keynote works alongside a workshop or training – on the same day or as a follow-up.",
},
{
q: "In English?",
a: "Yes. I speak in German or English – live, online or hybrid.",
},
{
q: "Is there video or are there references?",
a: "On request. Get in touch and I will send you material on the talk.",
@@ -136,10 +110,16 @@ export const keynotesEn: KeynotesContent = {
],
},
band: {
h2: { before: "Bring the WOLF Principle to your stage.", em: "", after: "" },
cta: { label: "Request a keynote", href: mailto.keynote },
micro: "No obligation · reply in German or English",
anfrage: {
id: "terminanfrage",
eyebrow: "Request a date",
h2: { before: "Let's find a ", em: "date", after: "." },
/* Betreff und Feldnamen der Mail bleiben deutsch – sie gehen an Anelia. */
topic: "Terminanfrage Keynote (englische Seite)",
fields: [
{ name: "datum", label: "Date of the event", mailLabel: "Datum" },
{ name: "veranstaltung", label: "Event & venue", mailLabel: "Veranstaltung & Ort" },
],
},
};
+3 -3
View File
@@ -160,10 +160,10 @@ export const datenschutzEn: LegalPage = {
"– sets no cookies and stores no information on your device (no local storage, no session storage),",
"– uses no analytics, tracking or marketing services (no Google Analytics, no pixels, no fingerprinting),",
"– embeds no third-party content (no external fonts, no videos, no maps, no social media plugins),",
"– sends the contact form via the hosting provider's server directly to my email inbox (section 5); no third-party form service is involved,",
"– sends the contact forms via the hosting provider's server directly to my email inbox (section 5); no third-party form service is involved,",
"– contains no newsletter.",
"All fonts, scripts and images are delivered from the server of the hosting provider named below. A cookie banner is therefore not required.",
"Personal data is processed in three cases only: when the pages are technically retrieved (server log files, section 3), when you contact me by email or telephone (section 4) and when you submit the contact form (section 5).",
"Personal data is processed in three cases only: when the pages are technically retrieved (server log files, section 3), when you contact me by email or telephone (section 4) and when you submit a contact form (section 5).",
],
},
{
@@ -210,7 +210,7 @@ export const datenschutzEn: LegalPage = {
{
h2: "5. Contact form",
paragraphs: [
"When you submit the contact form on the home page, the data you enter (name, email address, optionally your company and the content of your message) is transmitted in encrypted form to the hosting provider's server (section 3) and delivered from there as an email to my inbox. The email also records the time of sending and the page from which the form was submitted. No third-party form service is involved, and no cookies are set.",
"When you submit a contact form on this website (home page, service pages, trade fair page), the data you enter (name, email address, optionally your company, for a date request on the service pages optionally also your preferred date, team size or event date, event and venue, and the content of your message) is transmitted in encrypted form to the hosting provider's server (section 3) and delivered from there as an email to my inbox. The email also records the time of sending and the page from which the form was submitted. No third-party form service is involved, and no cookies are set.",
"Purpose: receiving and answering your enquiry.",
"Legal basis: Art. 6 (1) (b) GDPR where your enquiry is aimed at entering into or performing a contract; otherwise Art. 6 (1) (f) GDPR – legitimate interest in answering enquiries.",
"Storage period: the hosting provider's server does not store the form data permanently; it delivers it immediately as an email. The periods set out in section 4 then apply to that email.",
+14 -41
View File
@@ -2,7 +2,7 @@
* English copy of /en/services/trainings/. Same shape as src/content/trainings.ts.
* The B2B nouns stay concrete – this page has to read like an offer, not a brochure.
*/
import { mailto } from "@/lib/site";
import { routesByLang } from "@/lib/i18n";
import { assertNoPlaceholders, collectStrings, ph } from "@/lib/legal";
import type { TrainingsContent } from "@/content/trainings";
@@ -21,8 +21,8 @@ export const trainingsEn: TrainingsContent = {
eyebrow: "For companies & teams",
h1: { before: "Sales with ", em: "personality", after: "." },
subline:
"B2B sales training and leadership workshops that hold up in the working week, not just in the seminar room. In house, online, in German or English.",
cta: { label: "Request training", href: mailto.training, micro: "No obligation" },
"B2B sales training and leadership workshops that hold up in the working week, not just in the seminar room.",
cta: { label: "Request training", href: `${routesByLang.en.trainings}#terminanfrage`, micro: "No obligation" },
facts: [
{ label: "Format", value: "In house or online" },
{ label: "Length", value: "1–2 days or a series" },
@@ -81,44 +81,15 @@ export const trainingsEn: TrainingsContent = {
],
},
goals: {
id: "ziele",
eyebrow: "Typical goals",
rows: [
{ text: "More qualified first conversations" },
{ text: "Key accounts grown systematically" },
{ text: "Clearer leadership inside the sales team" },
],
},
rahmen: {
id: "rahmen",
eyebrow: "Practicalities",
rows: [
{ label: "Format", value: "In-house workshop 1–2 days / training series / online" },
{ label: "Language", value: "DE/EN" },
{ label: "Group size", value: ph("Platzhalter"), placeholder: true },
{ label: "Preparation", value: "Scoping call, behaviour assessments optional" },
],
},
ablauf: {
id: "ablauf",
eyebrow: "How it runs",
steps: [
{ numeral: "01", title: "Scoping call", text: "" },
{ numeral: "02", title: "Concept", text: "" },
{ numeral: "02", title: "A concept on one page", text: "" },
{ numeral: "03", title: "Training", text: "" },
{ numeral: "04", title: "Follow-up after 30 days", text: "" },
],
marginalia: {
title: "What you take away",
items: [
{ value: "A concept on one page" },
{ value: "Commitments for 30 days" },
{ value: "A follow-up conversation" },
],
},
},
faq: {
@@ -129,10 +100,6 @@ export const trainingsEn: TrainingsContent = {
q: "In house or external?",
a: "In house at your company, or online. An external venue is possible on request.",
},
{
q: "How many participants?",
a: "We agree the group size in the scoping call – small enough that everyone gets to speak.",
},
{
q: "Also for leaders without a sales role?",
a: "Yes. The Leadership & team dynamics module is aimed at sales leadership, team leads and management – a sales role of your own is not a requirement.",
@@ -144,10 +111,16 @@ export const trainingsEn: TrainingsContent = {
],
},
band: {
h2: { before: "Let's talk about your team.", em: "", after: "" },
cta: { label: "Request training", href: mailto.training },
micro: "No obligation",
anfrage: {
id: "terminanfrage",
eyebrow: "Request a date",
h2: { before: "Let's find a ", em: "date", after: "." },
/* Betreff und Feldnamen der Mail bleiben deutsch – sie gehen an Anelia. */
topic: "Terminanfrage Training (englische Seite)",
fields: [
{ name: "wunschtermin", label: "Preferred date or period", mailLabel: "Wunschtermin" },
{ name: "teamgroesse", label: "Team size", mailLabel: "Teamgröße" },
],
},
};
+24 -39
View File
@@ -1,10 +1,15 @@
/**
* /leistungen/keynotes/ – Keynotes & Speaking (Spec §7.4). Copy 1:1, Du-Form.
* Entschlackt am 02.10.2026: Die typografische Unterbrechung („Mut zum Erfolg.“ – stand
* schon als H1 und im Vortragstitel), die Speaker-Facts (wiederholten die Hero-Fakten;
* nur „Technik“ war neu und steht jetzt dort) und die FAQ „Auf Englisch?“ sind
* entfallen. Die Termine hängen unter dem Vortrag. Statt des Kontakt-Bands mit
* mailto-Knopf schließt die Seite mit der Terminanfrage (Formular).
* Zweiter Vortrag („Unlock your Sales-DNA“) bleibt weg (flags.showSecondTalk: false).
* Speaker-Profil-Zeile nur mit flags.showSpeakerPdf bzw. vorhandener Datei (assets.getSpeakerPdf()).
* FAQ-Antworten: kurz, aus den Spec-Fakten abgeleitet (Spec A.8).
*/
import { mailto } from "@/lib/site";
import { routes } from "@/lib/site";
import type { Widen } from "./widen";
import { assertNoPlaceholders, collectStrings, ph } from "@/lib/legal";
import type { ColumnItem, Cta, FactItem, FaqItem, GoalRow, Headline } from "./home";
@@ -39,8 +44,9 @@ export const keynotes = {
h1: { before: "Mut zum ", em: "Erfolg", after: "." } satisfies Headline,
h1GoldEm: true,
subline:
"Keynotes nach dem WOLF-Prinzip für Firmen-Events, Kick-offs und Kongresse. Ein Impuls, der nachwirkt – auf Deutsch oder Englisch, live oder online.",
cta: { label: "Keynote anfragen", href: mailto.keynote } satisfies Cta,
"Keynotes nach dem WOLF-Prinzip für Firmen-Events, Kick-offs und Kongresse. Ein Impuls, der nachwirkt.",
/* Springt zur Terminanfrage am Fuß der Seite (vorher: mailto). */
cta: { label: "Keynote anfragen", href: `${routes.keynotes}#terminanfrage` } satisfies Cta,
/** Nur rendern, wenn die PDF-Datei existiert (assets.getSpeakerPdf()). */
secondary: { label: "Speaker-Profil (PDF)", href: "/speaker-profil.pdf", external: true },
facts: [
@@ -48,6 +54,7 @@ export const keynotes = {
{ label: "Format", value: "Live, online, hybrid" },
{ label: "Sprache", value: "Deutsch / Englisch" },
{ label: "Ort", value: "Deutschlandweit, Europa auf Anfrage" },
{ label: "Technik", value: "Headset, Präsentation optional" },
] satisfies readonly FactItem[],
alt: "Anelia Wolf auf der Bühne",
caption: `Anelia Wolf · Foto: ${ph("Name der Fotografin")}`,
@@ -70,17 +77,6 @@ export const keynotes = {
] satisfies readonly TalkRow[],
},
/* Spec §7.4 wörtlich. Die vorherige Fassung („Ein Impuls, der nachwirkt.“) stand nicht
in Abschnitt A und brach zugleich Spec §0 Kritik #4: der Satz darf EINMAL pro
Keynote-Seite vorkommen und stand dann in Subline, Meta-Description und in der
Unterbrechung – die eine große typografische Pause wiederholte, was 400 px darüber
schon zu lesen war. */
interruption: {
id: "mut",
text: "Mut zum Erfolg.",
mono: "Keynote · herCAREER Expo 2026 · 23.10. · 10:30 · Auditorium 2",
},
occasions: {
id: "anlaesse",
eyebrow: "Anlässe",
@@ -113,26 +109,10 @@ export const keynotes = {
] satisfies readonly GoalRow[],
},
speakerFacts: {
id: "speaker-facts",
items: [
{ label: "Sprachen", value: "DE / EN" },
{ label: "Dauer", value: "20–60 Min." },
{ label: "Format", value: "live, online, hybrid" },
{ label: "Technik", value: "Headset, Präsentation optional" },
{ label: "Reise", value: "deutschlandweit, Europa auf Anfrage" },
{ label: "Anfrage", value: "unverbindlich" },
] satisfies readonly FactItem[],
},
termine,
veranstalter: {
id: "veranstalter",
eyebrow: "Für Veranstalter",
h2: { before: "Alles Wichtige vorab.", em: "", after: "" } satisfies Headline,
/** Zeile unter dem Vortrag – nur, wenn die PDF-Datei geliefert wurde (assets.getSpeakerPdf()). */
speakerPdf: { label: "Speaker-Profil (PDF)", text: "Kurzvita, Themen, Fotos, Technik", href: "/speaker-profil.pdf" },
},
faq: {
id: "faq",
@@ -146,10 +126,6 @@ export const keynotes = {
q: "Auch als Workshop nach der Keynote?",
a: "Ja. Die Keynote lässt sich mit einem Workshop oder Training kombinieren – am selben Tag oder als Folgetermin.",
},
{
q: "Auf Englisch?",
a: "Ja. Keynotes halte ich auf Deutsch oder Englisch – live, online oder hybrid.",
},
{
q: "Gibt es Video oder Referenzen?",
a: "Auf Anfrage. Sprich mich an, dann schicke ich dir Unterlagen zum Vortrag.",
@@ -157,10 +133,19 @@ export const keynotes = {
] satisfies readonly FaqItem[],
},
band: {
h2: { before: "Hol das WOLF-Prinzip auf deine Bühne.", em: "", after: "" } satisfies Headline,
cta: { label: "Keynote anfragen", href: mailto.keynote } satisfies Cta,
micro: "Unverbindlich · Antwort auf Deutsch oder Englisch",
/**
* Terminanfrage (ink) – das Formular der Startseite mit zwei freiwilligen Feldern.
* `topic` wird zum Mail-Betreff; `mailLabel` beschriftet die Felder in der Mail.
*/
anfrage: {
id: "terminanfrage",
eyebrow: "Terminanfrage",
h2: { before: "Lass uns einen ", em: "Termin", after: " finden." } satisfies Headline,
topic: "Terminanfrage Keynote",
fields: [
{ name: "datum", label: "Datum der Veranstaltung", mailLabel: "Datum" },
{ name: "veranstaltung", label: "Veranstaltung & Ort", mailLabel: "Veranstaltung & Ort" },
],
},
} as const;
+3 -3
View File
@@ -165,10 +165,10 @@ export const datenschutz: LegalPage = {
"– setzt keine Cookies und speichert keine Informationen in Ihrem Endgerät (kein Local Storage, kein Session Storage),",
"– verwendet keine Analyse-, Tracking- oder Marketing-Dienste (kein Google Analytics, keine Pixel, kein Fingerprinting),",
"– bindet keine Inhalte von Drittanbietern ein (keine externen Schriftarten, keine Videos, keine Karten, keine Social-Media-Plugins),",
"– verschickt das Kontaktformular über den Server des Hosters unmittelbar an mein E-Mail-Postfach (Ziffer 5); ein Formularanbieter eines Dritten ist nicht eingebunden,",
"– verschickt die Kontaktformulare über den Server des Hosters unmittelbar an mein E-Mail-Postfach (Ziffer 5); ein Formularanbieter eines Dritten ist nicht eingebunden,",
"– enthält keinen Newsletter.",
"Alle Schriftarten, Skripte und Bilder werden vom Server des unten genannten Hosting-Anbieters ausgeliefert. Ein Cookie-Banner ist deshalb nicht erforderlich.",
"Personenbezogene Daten werden nur in drei Fällen verarbeitet: beim technischen Abruf der Seiten (Server-Logfiles, Ziffer 3), wenn Sie mich per E-Mail oder Telefon kontaktieren (Ziffer 4) und wenn Sie das Kontaktformular absenden (Ziffer 5).",
"Personenbezogene Daten werden nur in drei Fällen verarbeitet: beim technischen Abruf der Seiten (Server-Logfiles, Ziffer 3), wenn Sie mich per E-Mail oder Telefon kontaktieren (Ziffer 4) und wenn Sie ein Kontaktformular absenden (Ziffer 5).",
],
},
{
@@ -218,7 +218,7 @@ export const datenschutz: LegalPage = {
{
h2: "5. Kontaktformular",
paragraphs: [
"Wenn Sie das Kontaktformular auf der Startseite absenden, werden die von Ihnen eingegebenen Daten (Name, E-Mail-Adresse, freiwillig Ihre Firma sowie der Inhalt Ihrer Nachricht) verschlüsselt an den Server des Hosters (Ziffer 3) übertragen und von dort als E-Mail an mein Postfach zugestellt. In der E-Mail werden zusätzlich der Zeitpunkt des Absendens und die Unterseite vermerkt, von der aus Sie das Formular abgeschickt haben. Ein Formularanbieter eines Dritten ist nicht eingebunden; Cookies werden dabei nicht gesetzt.",
"Wenn Sie ein Kontaktformular dieser Website absenden (Startseite, Leistungsseiten, Messeseite), werden die von Ihnen eingegebenen Daten (Name, E-Mail-Adresse, freiwillig Ihre Firma, bei einer Terminanfrage auf den Leistungsseiten freiwillig auch Wunschtermin, Teamgröße bzw. Datum, Veranstaltung und Ort, sowie der Inhalt Ihrer Nachricht) verschlüsselt an den Server des Hosters (Ziffer 3) übertragen und von dort als E-Mail an mein Postfach zugestellt. In der E-Mail werden zusätzlich der Zeitpunkt des Absendens und die Unterseite vermerkt, von der aus Sie das Formular abgeschickt haben. Ein Formularanbieter eines Dritten ist nicht eingebunden; Cookies werden dabei nicht gesetzt.",
"Zweck: Entgegennahme und Beantwortung Ihrer Anfrage.",
"Rechtsgrundlage: Art. 6 Abs. 1 lit. b DSGVO, soweit Ihre Anfrage auf den Abschluss oder die Durchführung eines Vertrags gerichtet ist; im Übrigen Art. 6 Abs. 1 lit. f DSGVO – berechtigtes Interesse an der Beantwortung von Anfragen.",
"Speicherdauer: Der Server des Hosters legt die Formulardaten nicht dauerhaft ab, sondern stellt sie unmittelbar als E-Mail zu. Für diese E-Mail gelten anschließend dieselben Fristen wie in Ziffer 4.",
+23 -42
View File
@@ -1,12 +1,16 @@
/**
* /leistungen/trainings/ – Trainings & Workshops (Spec §7.3). Copy 1:1, Du-Form.
* Entschlackt am 02.10.2026: „Typische Ziele“ (Spiegel der drei Bremsen), der
* Rahmen-Abschnitt (stand schon im Hero), die Randspalte im Ablauf (wiederholte die
* Schritte) und die FAQ zur Teilnehmerzahl sind entfallen; statt des Kontakt-Bands
* mit mailto-Knopf schließt die Seite mit der Terminanfrage (Formular).
* „Antwort innerhalb von 24 Stunden“ bleibt weg (flags.show24hPromise: false).
* FAQ-Antworten: kurz, aus den Spec-Fakten abgeleitet; Preise „auf Anfrage“ (Spec A.8).
*/
import { mailto } from "@/lib/site";
import { routes } from "@/lib/site";
import type { Widen } from "./widen";
import { assertNoPlaceholders, collectStrings, ph } from "@/lib/legal";
import type { Cta, FactItem, FaqItem, GoalRow, Headline, MarginaliaItem, SituationRow, StepItem } from "./home";
import type { Cta, FactItem, FaqItem, Headline, SituationRow, StepItem } from "./home";
/** Kopfzeile der Modul-Tabelle – von <ModuleTable/> importiert. */
export const moduleLabels = { module: "Modul", goal: "Ziel", contents: "Inhalte", audience: "Für wen", /** Only ever announced – the meta column carries no visible header. */ meta: "Format" } as const;
@@ -34,8 +38,9 @@ export const trainings = {
eyebrow: "Für Unternehmen & Teams",
h1: { before: "Vertrieb mit ", em: "Persönlichkeit", after: "." } satisfies Headline,
subline:
"B2B-Vertriebstrainings und Leadership-Workshops, die im Alltag funktionieren – nicht nur im Seminarraum. Inhouse, online, auf Deutsch oder Englisch.",
cta: { label: "Training anfragen", href: mailto.training, micro: "Unverbindlich" } satisfies Cta,
"B2B-Vertriebstrainings und Leadership-Workshops, die im Alltag funktionieren – nicht nur im Seminarraum.",
/* Springt zur Terminanfrage am Fuß der Seite (vorher: mailto). */
cta: { label: "Training anfragen", href: `${routes.trainings}#terminanfrage`, micro: "Unverbindlich" } satisfies Cta,
facts: [
{ label: "Format", value: "Inhouse oder online" },
{ label: "Dauer", value: "1–2 Tage oder Reihe" },
@@ -95,44 +100,15 @@ export const trainings = {
],
},
goals: {
id: "ziele",
eyebrow: "Typische Ziele",
rows: [
{ text: "Mehr qualifizierte Erstgespräche" },
{ text: "Systematischer Bestandskundenausbau" },
{ text: "Klarere Führung im Vertriebsteam" },
] satisfies readonly GoalRow[],
},
rahmen: {
id: "rahmen",
eyebrow: "Rahmen",
rows: [
{ label: "Format", value: "Inhouse-Workshop 1–2 Tage / Trainingsreihe / Online" },
{ label: "Sprache", value: "DE/EN" },
{ label: "Gruppengröße", value: ph("Platzhalter"), placeholder: true },
{ label: "Vorbereitung", value: "Bedarfsgespräch, optional Verhaltensanalysen" },
] satisfies readonly FactItem[],
},
ablauf: {
id: "ablauf",
eyebrow: "Ablauf",
steps: [
{ numeral: "01", title: "Bedarfsgespräch", text: "" },
{ numeral: "02", title: "Konzept", text: "" },
{ numeral: "02", title: "Konzept auf einer Seite", text: "" },
{ numeral: "03", title: "Training", text: "" },
{ numeral: "04", title: "Follow-up nach 30 Tagen", text: "" },
] satisfies readonly StepItem[],
marginalia: {
title: "Was du bekommst",
items: [
{ value: "Konzept auf einer Seite" },
{ value: "Vereinbarungen für 30 Tage" },
{ value: "Follow-up-Gespräch" },
] satisfies readonly MarginaliaItem[],
},
},
faq: {
@@ -143,10 +119,6 @@ export const trainings = {
q: "Inhouse oder extern?",
a: "Inhouse bei dir im Unternehmen oder online. Ein externer Veranstaltungsort ist auf Anfrage möglich.",
},
{
q: "Wie viele Teilnehmende?",
a: "Die Gruppengröße stimmen wir im Bedarfsgespräch ab – so, dass jede und jeder im Training zu Wort kommt.",
},
{
q: "Auch für Führungskräfte ohne Vertriebsrolle?",
a: "Ja. Das Modul Leadership & Teamdynamik richtet sich an Vertriebsleitung, Teamleitung und Geschäftsführung – eine eigene Vertriebsrolle ist keine Voraussetzung.",
@@ -158,10 +130,19 @@ export const trainings = {
] satisfies readonly FaqItem[],
},
band: {
h2: { before: "Sprechen wir über dein Team.", em: "", after: "" } satisfies Headline,
cta: { label: "Training anfragen", href: mailto.training } satisfies Cta,
micro: "Unverbindlich",
/**
* Terminanfrage (ink) – das Formular der Startseite mit zwei freiwilligen Feldern.
* `topic` wird zum Mail-Betreff; `mailLabel` beschriftet die Felder in der Mail.
*/
anfrage: {
id: "terminanfrage",
eyebrow: "Terminanfrage",
h2: { before: "Lass uns einen ", em: "Termin", after: " finden." } satisfies Headline,
topic: "Terminanfrage Training",
fields: [
{ name: "wunschtermin", label: "Wunschtermin oder Zeitraum", mailLabel: "Wunschtermin" },
{ name: "teamgroesse", label: "Teamgröße", mailLabel: "Teamgröße" },
],
},
} as const;
-5
View File
@@ -84,8 +84,3 @@ export const routes = {
} as const;
export type Route = (typeof routes)[keyof typeof routes];
export const mailto = {
training: "mailto:info@anelia-wolf.com?subject=Training%20anfragen",
keynote: "mailto:info@anelia-wolf.com?subject=Keynote%20anfragen",
} as const;