Compare commits

...
9 Commits
Author SHA1 Message Date
knsoandClaude Opus 5.5 c3351c9a6d persolog-Zertifikat als drittes Abzeichen
Anelias persolog-Zertifizierung (Persönlichkeits-Modell, abgeschlossen am
09.10.2026) steht in „Über Anelia“ zwischen Greator Persönlichkeitscoach
und Greator Speaker – nach Datum, das neueste oben. Gleicher Lorbeerkranz,
Hover und Vorschau-Overlay. Im Kranz steht das persolog-Emblem, aus dem
PDF freigestellt (Außenfläche per Floodfill, Kanten gegen Weiß entmischt,
® weggelassen), Farbe und Form unverändert.

Drei Abzeichen sind länger als Text, Werdegang und Marginalien zusammen.
Damit die Zeilen 1–3 nicht mitwachsen, reicht die Spalte ohne Foto über
eine vierte Zeile mit `1fr` (neues Section-Prop `gridClassName`) – der
Überhang landet unten, die Abstände links bleiben wie live.

Der Abzeichen-Titel bricht mit `text-wrap: balance` um, also
„persolog® / Persönlichkeits-Modell“ statt nach dem Bindestrich.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:10:45 +00:00
knsoandClaude Opus 5.5 1e7b84b27c Persönlichkeitscoach-Zertifikat über Greator Speaker
Anelias neues Greator-Zertifikat (Persönlichkeitscoach, 09.10.2026) steht
in „Über Anelia“ über dem Speaker-Abzeichen, im selben Lorbeerkranz mit
freigestelltem Siegel, Hover und Vorschau-Overlay. Das Siegel ist aus dem
PDF freigestellt (Kreis, 150-dpi-Raster), Farbe und Form unverändert.

getCertificate() wird zu getCertificates(): eine Liste, das neueste oben.
Ein einzelnes Abzeichen klebt weiter auf Höhe der „04“. Zwei passen dort
nicht ins Bild, also steht der Stapel still und beginnt auf Höhe der
Eyebrow. Unter lg stehen sie nebeneinander, auf dem Handy untereinander.

Der Abzeichen-Titel darf umbrechen (Zeilenhöhe 1,4, Ränder ausgeglichen;
einzeilig unverändert). Der Dialog-Titel trennt Silben, damit
„Persönlichkeitscoach“ auf dem Handy „Schließen“ nicht aus dem Bild
schiebt. Jeder Dialog bekommt seine eigene Titel-ID (useId).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:01:12 +00:00
knsoandClaude Opus 5.5 30e9151de4 Über Anelia mit B2C, WOLF-Prinzip englisch mit „impact“
Über Anelia (Anelias Wunsch): Sie hat einen Store im B2C-Verkauf geleitet.
Absatz 1 sagt das jetzt („dann die Leitung eines Stores im B2C-Verkauf – im
Premium-Segment“), und im Werdegang heißt die Zeile „B2C“ statt „Premium“
und steht so direkt über „B2B“. Englisch entsprechend.

WOLF-Prinzip englisch: „Assured presence“ → „Assured impact“ in der Formel der
Startseite und im Vortragstext der Keynotes-Seite – wie im Slogan
„Personality · Impact · Success“ und in der Headline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:54:54 +00:00
knsoandClaude Opus 5.5 6fdd7df1f8 Anelias Feedback: Slogan auf der Naht, ihre Headline, HR auf der Trainings-Seite
Slogan: „Personality · Impact · Success“ steht nicht mehr im Hero unter dem
Monogramm, sondern als dunkle Plakette mit Goldrand mittig auf der Naht zwischen
Cover und Abschnitt 01 – auf beiden Sprachfassungen englisch (lang="en"). Neue
optionale `lead`-Stelle in <Section/>; Inhalt unter `positioning.slogan`. Das
Monogramm hat ab lg wieder seine alte Größe, das Cover ist 2.5 rem kürzer als
der Viewport, damit die Plakette schon auf dem ersten Bildschirm ganz zu sehen
ist. Unter lg hält `pb-10` den Kennenlern-Knopf frei von der halben Plakette.

Headline: Anelias Wortlaut „Mit starker Persönlichkeit und souveräner Wirkung
zum Erfolg!“ – drei Betonungen, Gold-Verlauf nur auf „Erfolg“. Englisch:
„Strong personality, assured impact – your way to success!“. AGENTS.md und
globals.css beschreiben die Trias-Ausnahme jetzt mit dem Slogan auf der Naht.

Trainings: viertes Modul „HR-Teambuilding & Konfliktlösung“ aus ihrer Nachricht
(Dauer offen, Text braucht ihre Freigabe – CONTENT_TODO), vierte Bremse,
Subline und Meta-Beschreibung mit HR; „Konflikte“ wandert aus dem Leadership-
Modul ins HR-Modul. Messeseite (Themenzeile 02) und „Für wen“ auf der
Startseite ziehen mit. Die englische Modultabelle zeigt jetzt englische
Spaltenköpfe (`modules.labels`, „Audience“ passt einzeilig) statt
„Modul | Ziel | Für wen“.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:51:33 +00:00
knsoandClaude Opus 5.5 f825e74222 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>
2026-10-02 13:07:00 +00:00
JUSNandClaude Opus 5 a37e48e4ef Messeseite: „EN“ führt zur englischen Messeseite, nicht zur Startseite
Auf her-career.anelia-wolf.com liegt die Seite unter „/“. usePathname()
meldet dort „/“, und counterpart() leitete daraus folgerichtig die
englische STARTSEITE ab – wer am Stand gescannt und dann auf „EN“ getippt
hat, landete auf anelia-wolf.com/en/. Der Umschalter fragt jetzt den
hreflang-Verweis im Kopf des Dokuments: Der hängt an der Seite, nicht an
der Adresse, unter der sie ausgeliefert wird.

Am selben Wurzelproblem: Der Sprachwechsel ist wegen der zwei
Wurzel-Layouts ohnehin ein neues Dokument. Den macht jetzt der Browser
statt router.push(), und über die Sprachgrenze wird nicht mehr vorgeladen
– auf der Subdomain scheiterte der Vorabruf an CORS, mehrfach pro
Seitenaufbau.

Dazu zwei Sachen am Kopf der Messeseite: Das Monogramm steht hinter dem
Schlusspunkt der Begrüßung, 0,783 em hoch (die Oberlänge der Playfair
Display, im Browser gemessen) und auf der Grundlinie – und dafür nicht
mehr in der Kopfzeile, wo es sonst zweimal auf einem Bildschirm stünde.
Es steht dort dekorativ, sonst wandert der Text seines <title> über
SplitText ins aria-label der Überschrift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 21:17:09 +02:00
knsoandClaude Opus 5 ecf66c7340 Die Trias im Cover: Claim unter dem Monogramm, neue Headline
Unter dem großen Zeichen steht jetzt der Claim „Persönlichkeit · Wirkung ·
Erfolg“ (englisch „Personality · Impact · Success“), rechtsbündig auf
dessen Kante, in der Kapitälchen-Zeile der Seite. Das Trennzeichen setzt
der Hero, wie im Ledger – im Inhalt stehen nur die drei Wörter.

Das Monogramm schrumpft dafür von 45vh auf 40vh, mobil von 14 auf 12svh:
so kostet der Claim keine Höhe, das Cover bleibt bei min-h-svh. Der
Nackenstrich endet nicht mehr an der Folio-Haarlinie, sondern an der
Oberkante des Claims (20 px bei 1440×900, 1 px Luft). Der Ursprung der
Haarlinie bleibt derselbe Punkt unter dem V – nachgemessen: --handoff-x
957+151 = 1108, transform-origin der Regel 72+1036 = 1108. Die Vertikale
liest sich Nacken → Strich → Claim → Haarlinie.

Die H1 heißt „Mit deiner Persönlichkeit entwickelst du deine Wirkung –
und deinen Erfolg.“ Sie betont drei Wörter statt einem, weil sie den
Claim wiederholt; den Gold-Verlauf trägt weiterhin genau eines. Dafür
gibt es Headline.parts, und em-plain nimmt den übrigen das Gold wieder ab
(Regel in globals.css, Ausnahme in AGENTS.md notiert).

Die Größe ist gemessen, nicht geschätzt. Playfair bei 100 px: „Mit deiner
Persönlichkeit“ = 1033 px, „entwickelst du deine Wirkung –“ = 1315 px.
max-w-[10.9em] liegt zwischen beiden, damit bricht die Headline auf jeder
Schriftgröße in dieselben vier Zeilen – der Umbruch hängt nicht mehr an
der Fensterbreite. Der Clamp bleibt zusätzlich von der Fensterhöhe
gedeckelt (22vh − 120px), für 800- und 768-px-Laptops.

HeadlinePart.tail klammert Wort und Satzzeichen in ein white-space:
nowrap. Nötig, weil SplitText die Zeilen selbst neu aufbaut: es
normalisiert U+00A0 zu einem gewöhnlichen Leerzeichen und darf an jeder
Elementgrenze umbrechen. Ohne die Klammer fing der Gedankenstrich im
Englischen eine Zeile an, mit einem Punkt statt des Strichs derselbe
Fehler. Ein Margin verschiebt den Umbruch nur, nowrap nimmt ihn weg.

02 Trainings & Workshops nimmt HR auf: „Leadership & HR mit
Persönlichkeit – für Führungskräfte und Teams.“ Die Stichworte tragen
HR-Teambuilding & Konfliktlösung und Kommunikation & Teamgeist – es geht
darum, dass ein Team sich besser kennenlernt und versteht. Bei 1280 px
bleiben 76 px Luft zur Link-Spalte.

Geprüft bei 390/1024/1180/1440/1600 px in beiden Sprachen: kein
Gedankenstrich am Zeilenanfang, kein waagerechter Überlauf, Cover trifft
die Fensterhöhe. Kein Build gelaufen – der Dev-Server einer anderen
Sitzung hält .next; geprüft ist tsc --noEmit und eslint.

Offen und nicht Teil dieses Commits: src/content/coaching.ts sichert
seinen Gedankenstrich noch mit U+00A0 ab, was aus demselben Grund nicht
wirkt. Dort fällt der Umbruch derzeit zufällig richtig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 20:56:09 +02:00
JUSNandClaude Opus 5 389b3b9d71 Greator-Zertifikat klebt rechts neben Über Anelia
Gegenstück zur klebenden „04“: gleiche Höhe (calc(50vh − .45em)),
gleiche Zeilen, nur am anderen Rand. Im goldenen Lorbeerkranz steht
das Greator-TheKey-Siegel, darunter Haarlinie, Beschreibung, Datum.

Das Siegel ist aus dem Zertifikat freigestellt – das PDF hat keine
eingebettete Logodatei, die ganze Seite ist ein JPEG. Farbe und Form
bleiben unangetastet: eine fremde Marke wird nicht ins Gold der Seite
umgefärbt. Größe 58 % der Kranzbreite; die Öffnung gäbe bis 70 % her,
an den SVG-Pfaden nachgemessen.

Der Klick öffnet eine Vorschau nach dem Muster des Overlay-Menüs:
Escape schließt, Klick auf den Grund schließt, Fokus bleibt drin,
Lenis hält an, der Rest der Seite wird inert. Bewusst ein Bild und
kein eingebettetes PDF – ein iframe stünde quer zur Zusage in der
Datenschutzerklärung. Ausgeliefert wird ein echter Link aufs PDF,
den erst das JS zum Overlay aufwertet.

Nebenbei: in der Foto-Variante der Sektion lagen Werdegang-Ledger
und Marginalie beide in Zeile 2 übereinander. Da kein Porträt in
public/photos/ liegt, ist das nie aufgefallen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 20:13:08 +02:00
knso c09f674653 QR Web
Handy ansicht und website ansicht für den Qr gemacht
2026-09-10 20:06:42 +02:00
62 changed files with 1899 additions and 595 deletions

No files matched your search

+7
View File
@@ -25,6 +25,13 @@ schreibt den Block neu, dieser Teil bleibt).
- Betonungswort einer Headline: genau ein `<em>`. Kursiv, und auf `data-theme="dark"` - Betonungswort einer Headline: genau ein `<em>`. Kursiv, und auf `data-theme="dark"`
zusätzlich golden – das macht eine Regel in `globals.css`, nicht die Komponente. zusätzlich golden – das macht eine Regel in `globals.css`, nicht die Komponente.
Auf hellem Grund bleibt es rein typografisch (Gold-Verlauf auf Elfenbein = 2,4:1). Auf hellem Grund bleibt es rein typografisch (Gold-Verlauf auf Elfenbein = 2,4:1).
Einzige Ausnahme: eine Headline, die eine Aufzählung IST – die Trias der Startseite
(„Mit starker *Persönlichkeit* und souveräner *Wirkung* zum *Erfolg*!“, Anelias
Wortlaut), weil sie den Slogan „Personality · Impact · Success“ auf der Naht darunter
aufnimmt. Dann trägt jedes Wort ein `<em>`, den Gold-Verlauf aber weiterhin nur
eines; die übrigen bekommen `em-plain` (`Headline.parts[].gold` im Inhalt steuert das).
- Der Slogan steht NICHT im Hero, sondern als Plakette auf der Naht Cover → 01
(`positioning.slogan`, `<SloganPlate/>` in Positioning.tsx; Entscheidung 02.10.2026).
- Goldene Schrift nur auf `[data-theme="dark"]`; auf Ivory gilt `--gold-deep`. - Goldene Schrift nur auf `[data-theme="dark"]`; auf Ivory gilt `--gold-deep`.
- Sichtbare Copy lebt ausschließlich in `src/content/*.ts`, nie im JSX. - Sichtbare Copy lebt ausschließlich in `src/content/*.ts`, nie im JSX.
+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. `next/font` zur Build-Zeit selbst gehostet.
- **kein Storage**: kein localStorage, kein sessionStorage, keine Cookies – - **kein Storage**: kein localStorage, kein sessionStorage, keine Cookies –
deshalb auch kein Cookie-Banner. deshalb auch kein Cookie-Banner.
- **Kontaktformular mit Serverversand**, kein Newsletter. Abschnitt 06 sendet an - **Kontaktformular mit Serverversand**, kein Newsletter. Abschnitt 06 der
`/api/contact` beim Hoster; die Anfrage landet im Postfach 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 `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) Wer eine dieser Zusagen ändert, muss `src/content/legal.ts` (Ziffer 2, 3 und 6)
mit ändern. mit ändern.
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

+10 -2
View File
@@ -1,7 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { KeynotesBody } from "@/components/pages/KeynotesBody"; import { KeynotesBody } from "@/components/pages/KeynotesBody";
import { homeEn } from "@/content/en/home";
import { keynotesEn, talkLabelsEn } from "@/content/en/keynotes"; 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"; import { site } from "@/lib/site";
export const metadata: Metadata = { 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. */ /** /en/services/keynotes/ – layout in <KeynotesBody/>, copy in src/content/en/keynotes.ts. */
export default function EnKeynotesPage() { 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 { TrainingsBody } from "@/components/pages/TrainingsBody";
import { homeEn } from "@/content/en/home"; import { homeEn } from "@/content/en/home";
import { trainingsEn } from "@/content/en/trainings"; 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"; import { site } from "@/lib/site";
export const metadata: Metadata = { 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. */ /** /en/services/trainings/ – layout in <TrainingsBody/>, copy in src/content/en/trainings.ts. */
export default function EnTrainingsPage() { 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}
/>
);
} }
+57
View File
@@ -30,6 +30,13 @@
--gold-700: #a37a2c; --gold-700: #a37a2c;
--gold-900: #6e501a; --gold-900: #6e501a;
/* Rabatt-Rot. Die einzige Farbe der Seite außerhalb von Tinte, Elfenbein, Bein und
Gold – und zwar, weil Rot „reduziert“ bedeutet und Gold das nicht sagen kann.
Warm und tief gewählt, damit sie neben dem Gold nicht kreischt. Sie hat genau
eine Aufgabe: das Reduziert-Fähnchen am Scroll-Fortschritt (<ScrollProgress/>).
Elfenbein darauf: 5,5:1. */
--sale: #b3321f;
/* Gradients (fixed light direction: top-left) */ /* Gradients (fixed light direction: top-left) */
--gold-gradient: linear-gradient(135deg, #f4e5b8 0%, #e6c97a 22%, #c9a24d 48%, #a37a2c 74%, #6e501a 100%); --gold-gradient: linear-gradient(135deg, #f4e5b8 0%, #e6c97a 22%, #c9a24d 48%, #a37a2c 74%, #6e501a 100%);
--gold-gradient-text: linear-gradient(100deg, #f4e5b8 0%, #e6c97a 32%, #c9a24d 62%, #a37a2c 100%); --gold-gradient-text: linear-gradient(100deg, #f4e5b8 0%, #e6c97a 32%, #c9a24d 62%, #a37a2c 100%);
@@ -99,6 +106,7 @@
--color-gold-200: var(--gold-200); --color-gold-200: var(--gold-200);
--color-gold-700: var(--gold-700); --color-gold-700: var(--gold-700);
--color-gold-900: var(--gold-900); --color-gold-900: var(--gold-900);
--color-sale: var(--sale);
--color-grey-warm: var(--grey-warm); --color-grey-warm: var(--grey-warm);
--color-grey-deep: var(--grey-deep); --color-grey-deep: var(--grey-deep);
--color-line-dark: var(--line-dark); --color-line-dark: var(--line-dark);
@@ -199,6 +207,44 @@ body:has(main #messe-special) header [data-header-cta] {
display: none; display: none;
} }
/* Derselbe Erkennungsweg für den Kollaborationszusatz „× herCAREER“ neben der
Wortmarke – nur umgekehrt: Er ist überall aus und geht auf der Messeseite an.
Anelia arbeitet FÜR die herCAREER; auf dem Impressum oder den Leistungsseiten
hätte eine fremde Marke im Kopf nichts zu suchen. */
header [data-collab] {
display: none;
}
body:has(main #messe-special) header [data-collab] {
display: inline-flex;
}
/* Auf der Messeseite trägt der Kopf kein Monogramm mehr (Kundenwunsch, 10.09.2026).
Seit die Marke hinter dem Schlusspunkt der Begrüßung steht (`h1Mark` in
src/components/services/ServiceHero.tsx), stand sie auf demselben Bildschirm
zweimal – oben klein im Kopf, darunter groß in der Überschrift. Eine der beiden
musste weg, und die große ist die, die gemeint ist.
Bis dahin galt dieselbe Regel schon unter 640 px, aus einem zweiten Grund: Mit
Monogramm begann „ANELIA WOLF“ um 48 px eingerückt, während das Malzeichen darunter
an der Seitenkante saß – zwei Zeilen, die an keiner Kante fluchteten. Ohne Monogramm
beginnen beide links auf derselben Linie und lesen sich als eine Marke.
Nur der Kopf der Messeseite: Der Fuß behält sein Monogramm, alle anderen Seiten auch.
Die Hülle („Umblättern“) ebenfalls – sie liegt über der ganzen Seite, nicht in der Zeile. */
body:has(main #messe-special) header .wordmark__mark {
display: none;
}
@media (max-width: 639px) {
/* Die Laufweite von 0.22em auf 0.15em. „ANELIA WOLF“ hat zehn Buchstabenlücken;
bei 15 px sind das 33 px allein an Sperrung, und die fehlten, damit die Zeile
„ANELIA WOLF × herCAREER“ neben Sprachumschalter und Menü auf 335 px passt.
Nur hier – auf allen anderen Seiten und ab 640 px bleibt die Wortmarke, wie sie ist. */
body:has(main #messe-special) header .wordmark__name {
letter-spacing: 0.15em;
}
}
/* Legal pages (Spec A/D6): „Datenschutzerklärung“ is one 20-character word and /* Legal pages (Spec A/D6): „Datenschutzerklärung“ is one 20-character word and
at 44 px it is wider than the 375 px column – it widened the whole document. at 44 px it is wider than the 375 px column – it widened the whole document.
Scoped here because the rule belongs to src/components/legal/LegalPage.tsx, Scoped here because the rule belongs to src/components/legal/LegalPage.tsx,
@@ -246,6 +292,17 @@ svg {
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
} }
/* Ausnahme für eine Headline, die eine Aufzählung IST – die Trias der Startseite
(„Persönlichkeit … Wirkung … Erfolg“) betont drei Wörter, weil sie den Slogan auf
der Naht darunter aufnimmt. Gold bleibt trotzdem rationiert: den Verlauf trägt genau
das letzte Wort, die übrigen bleiben rein kursiv. Gesetzt über `parts[].gold` im
Inhalt, nicht in der Komponente. */
[data-theme="dark"] .font-display em.em-plain {
color: inherit;
background: none;
-webkit-text-fill-color: currentColor;
}
/* Hairlines */ /* Hairlines */
.rule-gold { height: 1px; background: var(--gold-hairline); } .rule-gold { height: 1px; background: var(--gold-hairline); }
[data-theme="light"] .rule-gold { background: var(--gold-hairline-light); } [data-theme="light"] .rule-gold { background: var(--gold-hairline-light); }
+41 -4
View File
@@ -25,6 +25,18 @@ export interface BrandMarkProps {
handoff?: boolean; handoff?: boolean;
/** Accessible name. */ /** Accessible name. */
title?: string; title?: string;
/**
* Die Marke steht nur als Bild da, ohne Namen: kein `<title>`, kein `role`/`aria-label`,
* stattdessen `aria-hidden`. Nötig überall dort, wo sie INNERHALB von Text sitzt – in der
* H1 der Messeseite etwa. Zwei Gründe, beide handfest:
*
* 1. Der Name stünde mitten im Satz, den ein Screenreader vorliest.
* 2. Der Text eines `<title>`-Elements zählt zum `textContent` seines Vorfahren, und
* <Lines/> baut mit SplitText (`aria: "auto"`) daraus das aria-label der Überschrift.
* Ohne diesen Schalter hieß sie „Schön, dass Sie gescannt haben. Anelia Wolf –
* Monogramm“ – ein `aria-hidden` am umgebenden <span> ändert daran nichts.
*/
decorative?: boolean;
} }
const ID_CLASS: Record<string, string> = { const ID_CLASS: Record<string, string> = {
@@ -40,7 +52,9 @@ function escapeAttr(s: string): string {
function prepareSvg( function prepareSvg(
raw: string, raw: string,
{ className, idPrefix, variant, handoff, title }: Required<Pick<BrandMarkProps, "variant" | "handoff" | "title">> & { className, idPrefix, variant, handoff, title, decorative }: Required<
Pick<BrandMarkProps, "variant" | "handoff" | "title" | "decorative">
> &
Pick<BrandMarkProps, "className" | "idPrefix">, Pick<BrandMarkProps, "className" | "idPrefix">,
): string { ): string {
/* The file comes from a non-technical client via public/brand/ and is injected with /* The file comes from a non-technical client via public/brand/ and is injected with
@@ -90,6 +104,10 @@ function prepareSvg(
return `<path${a}${selfClose}>`; return `<path${a}${selfClose}>`;
}); });
/* Der Name der Marke, wo sie keinen tragen soll – siehe `decorative`. Das <title>
muss WEG, nicht nur versteckt: Es zählt zum textContent der Überschrift. */
if (decorative) svg = svg.replace(/<title\b[\s\S]*?<\/title>/gi, "");
svg = svg.replace(/<svg\b([^>]*)>/, (_, attrs: string) => { svg = svg.replace(/<svg\b([^>]*)>/, (_, attrs: string) => {
let a = attrs.replace(/\s(width|height)="[^"]*"/g, ""); let a = attrs.replace(/\s(width|height)="[^"]*"/g, "");
if (/\bclass="/.test(a)) { if (/\bclass="/.test(a)) {
@@ -97,6 +115,11 @@ function prepareSvg(
} else if (className) { } else if (className) {
a += ` class="${className}"`; a += ` class="${className}"`;
} }
if (decorative) {
a = a.replace(/\srole="[^"]*"/gi, "").replace(/\saria-label="[^"]*"/gi, "");
a += ' aria-hidden="true"';
return `<svg${a}>`;
}
if (!/\brole=/.test(a)) a += ' role="img"'; if (!/\brole=/.test(a)) a += ' role="img"';
if (!/\baria-label=/.test(a)) a += ` aria-label="${escapeAttr(title)}"`; if (!/\baria-label=/.test(a)) a += ` aria-label="${escapeAttr(title)}"`;
return `<svg${a}>`; return `<svg${a}>`;
@@ -111,16 +134,30 @@ export function BrandMark({
variant = "full", variant = "full",
handoff = false, handoff = false,
title = "Anelia Wolf – Monogramm", title = "Anelia Wolf – Monogramm",
decorative = false,
}: BrandMarkProps) { }: BrandMarkProps) {
const raw = getLogoSvg(); const raw = getLogoSvg();
if (raw) { if (raw) {
const html = prepareSvg(raw, { className, idPrefix, variant, handoff, title }); const html = prepareSvg(raw, { className, idPrefix, variant, handoff, title, decorative });
return <span className="contents" dangerouslySetInnerHTML={{ __html: html }} />; return <span className="contents" dangerouslySetInnerHTML={{ __html: html }} />;
} }
/* Ersatzmarke, wenn public/brand/logo-mark.svg fehlt. <WolfMark/> wird nicht angefasst
(Projektregel), deshalb bekommt sie hier einen leeren Namen – dann steht in ihrem
<title> kein Text, der in die Überschrift wandern könnte – und die Hülle nimmt sie
aus dem Vorlesetext. */
const fallbackTitle = decorative ? "" : title;
const hide = decorative ? { "aria-hidden": true as const } : {};
if (variant === "v-only") { if (variant === "v-only") {
return ( return (
<span className="contents [&_.wolfmark-wolf]:hidden"> <span className="contents [&_.wolfmark-wolf]:hidden" {...hide}>
<WolfMark className={className} idPrefix={idPrefix} title={title} /> <WolfMark className={className} idPrefix={idPrefix} title={fallbackTitle} />
</span>
);
}
if (decorative) {
return (
<span className="contents" {...hide}>
<WolfMark className={className} idPrefix={idPrefix} title={fallbackTitle} />
</span> </span>
); );
} }
+45
View File
@@ -0,0 +1,45 @@
import { cn } from "@/lib/utils";
import { HerCareerMark } from "./HerCareerMark";
export interface CollabProps {
/** Was ein Screenreader hört – der Zusatz ist eine Angabe, kein Bild ohne Inhalt. */
label: string;
className?: string;
}
/**
* Kollaborationszusatz neben der Wortmarke: `× herCAREER`.
*
* Das Malzeichen ist die übliche Kurzschrift für „zusammen mit“ (Marke × Marke)
* und trägt hier die ganze Aussage – deshalb ist es dekorativ und der Sinn steht
* im `aria-label` der Gruppe, nicht in einem Zeichen, das ein Screenreader als
* „mal“ vorlesen würde.
*
* Sichtbar ist der Zusatz nur auf der Messeseite; eingeschaltet wird er über
* `[data-collab]` in globals.css, weil der Kopf global in <Shell/> hängt und von
* dort die Route nicht kennt. Anelia arbeitet FÜR die herCAREER – sie ist nicht
* dauerhaft mit ihr verbunden, und auf dem Impressum hätte die fremde Marke
* nichts zu suchen.
*/
export function Collab({ label, className }: CollabProps) {
return (
<span
data-collab=""
role="img"
aria-label={label}
className={cn("items-center gap-[6px] text-fg sm:gap-2.5", className)}
>
{/* Etwas kleiner als die Marke daneben und gedämpft: Das Malzeichen verbindet,
es soll nicht mit einer der beiden Marken um Aufmerksamkeit streiten. */}
<span aria-hidden="true" className="font-sans text-[0.75rem] font-light leading-none text-fg-muted">
×
</span>
{/* Zweizeilige Marke: Die Gesamthöhe muss über der Versalhöhe von „ANELIA WOLF“
daneben liegen, sonst ist jede der beiden Zeilen nur halb so groß wie die
Wortmarke und der Zusatz wird unleserlich. */}
<HerCareerMark className="h-[18px] w-auto sm:h-[22px]" />
</span>
);
}
export default Collab;
+43
View File
@@ -0,0 +1,43 @@
/**
* Wortmarke der herCAREER Expo – Kollaborationszusatz im Kopf der Messeseite.
*
* Aus `herCAREER_Logo-1.svg` (Illustrator-Export) übernommen, mit drei
* Eingriffen, ohne die sie hier nicht funktioniert:
*
* 1. Die Ebene „Hilfslinien“ ist raus. Sie enthielt ein Rechteck über die
* ganze Artboard-Fläche mit `stroke: #fff` – auf dem tintenfarbenen Kopf
* hätte die Marke in einem weißen Kasten gestanden.
* 2. `fill="currentColor"` statt der Illustrator-Vorgabe Schwarz: Auf dunklem
* Grund war die Marke sonst unsichtbar, und so nimmt sie die Schriftfarbe
* ihrer Umgebung an – hell im Kopf, tinte im geöffneten Menü.
* 3. Die viewBox ist auf die Zeichnung beschnitten (vorher 0 0 1424.7 702.5
* mit rund 12 % Luft oben und unten). Nur so lässt sich die Höhe optisch
* auf die Wortmarke daneben abstimmen; der Abstand ringsum kommt aus dem
* Layout, nicht aus der Datei.
*
* Die Pfade selbst sind unverändert – es ist eine fremde Marke.
*/
export function HerCareerMark({ className }: { className?: string }) {
return (
<svg
viewBox="68.5 82.9 1273.3 536.8"
fill="currentColor"
aria-hidden="true"
focusable="false"
className={className}
>
<path d="M175.7,182.1c-33.2,0-60.8,27.6-60.8,65.6v107.2h-32V82.9h32v105.2c9.6-23.6,35.2-38,65.6-38,45.2,0,77.6,34.8,77.6,83.2v121.6h-32v-119.2c0-31.2-20.4-53.6-50.4-53.6h0Z" />
<path d="M279.7,255.7c0-61.2,40-105.6,96-105.6s94.4,46.4,94.4,110.4,0,3.6-.4,6.8h-154.8c4.8,35.6,29.6,60.4,62.4,60.4s50.4-16.8,55.2-40l36,11.2c-8.8,35.2-46.4,60.8-91.2,60.8s-97.6-43.6-97.6-104h0ZM435.7,236.1c-6.4-35.2-32.8-54.8-61.6-54.8s-52.4,22-58.4,54.8h120Z" />
<path d="M494.9,154.9h27.2l4,33.2c9.6-23.2,28.8-38,52-38s15.6,2,22.4,4.8l-1.6,37.6c-8-4.8-17.6-6.4-24-6.4-28,0-48,22.4-48,53.6v115.2h-32v-200h0Z" />
<path d="M166.9,619.7c-54.8,0-98.4-42-98.4-104.8s42.4-104.8,96.8-104.8,82.4,29.6,90.4,70.4l-35.2,11.2c-3.2-28.8-26-50.4-55.2-50.4s-62.4,30.4-62.4,73.6,27.2,72.8,62.4,72.8,54.4-20.8,56.8-52.8l36.8,11.2c-7.2,43.2-45.6,73.6-92,73.6Z" />
<path d="M323.7,506.1l61.6-11.2c7.6-1.2,12.8-5.6,12.8-11.2v-1.6c0-25.2-18.4-43.2-44-43.2s-44,20-44.8,48l-35.2-5.6c4-41.2,36.4-71.2,79.2-71.2s76.8,28.4,76.8,72v92.8l.8,40h-26.4l-5.2-32.8c-11.6,23.2-35.6,37.6-67.6,37.6s-64-24-64-57.6,19.2-49.2,56-56h0ZM384.5,522.1l-52.8,9.6c-19.2,3.6-28.8,15.6-28.8,30.4s12.8,26.4,35.2,26.4c33.2,0,60-24.8,60-56.8v-12.8c-2.8,1.2-8,2-13.6,3.2h0Z" />
<path d="M462.9,414.9h27.2l4,33.2c9.6-23.2,28.8-38,52-38s15.6,2,22.4,4.8l-1.6,37.6c-8-4.8-17.6-6.4-24-6.4-28,0-48,22.4-48,53.6v115.2h-32v-200h0Z" />
<path d="M570.9,515.7c0-61.2,40-105.6,96-105.6s94.4,46.4,94.4,110.4,0,3.6-.4,6.8h-154.8c4.8,35.6,29.6,60.4,62.4,60.4s50.4-16.8,55.2-40l36,11.2c-8.8,35.2-46.4,60.8-91.2,60.8s-97.6-43.6-97.6-104h0ZM726.9,496.1c-6.4-35.2-32.8-54.8-61.6-54.8s-52.4,22-58.4,54.8h120Z" />
<path d="M774.9,515.7c0-61.2,40-105.6,96-105.6s94.4,46.4,94.4,110.4,0,3.6-.4,6.8h-154.8c4.8,35.6,29.6,60.4,62.4,60.4s50.4-16.8,55.2-40l36,11.2c-8.8,35.2-46.4,60.8-91.2,60.8s-97.6-43.6-97.6-104h0ZM930.9,496.1c-6.4-35.2-32.8-54.8-61.6-54.8s-52.4,22-58.4,54.8h120Z" />
<path d="M990.4,415h27.2l4,33.2c9.6-23.2,28.8-38,52-38s15.6,2,22.4,4.8l-1.6,37.6c-8-4.8-17.6-6.4-24-6.4-28,0-48,22.4-48,53.6v115.2h-32v-200.1h0Z" />
<circle cx="1237" cy="514.9" r="104.8" />
</svg>
);
}
export default HerCareerMark;
+50 -5
View File
@@ -1,5 +1,6 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { flags } from "@/lib/site"; import { flags } from "@/lib/site";
import type { CertificateAsset } from "@/lib/assets";
import { about as aboutDe, type HomeContent } from "@/content/home"; import { about as aboutDe, type HomeContent } from "@/content/home";
import { Section } from "@/components/layout/Section"; import { Section } from "@/components/layout/Section";
import { Lines } from "@/components/motion/Lines"; import { Lines } from "@/components/motion/Lines";
@@ -9,12 +10,15 @@ import { Eyebrow } from "@/components/ui/Eyebrow";
import { Ledger } from "@/components/ui/Ledger"; import { Ledger } from "@/components/ui/Ledger";
import { Marginalia } from "@/components/ui/Marginalia"; import { Marginalia } from "@/components/ui/Marginalia";
import { Portrait } from "@/components/ui/Portrait"; import { Portrait } from "@/components/ui/Portrait";
import { Certificate } from "@/components/ui/Certificate";
export interface AboutProps { export interface AboutProps {
/** `getPhotos().portrait` – `null` renders the documented variant B (no photo). */ /** `getPhotos().portrait` – `null` renders the documented variant B (no photo). */
photo: string | null; photo: string | null;
/** Inhalt der Sektion. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */ /** Inhalt der Sektion. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */
content?: HomeContent["about"]; content?: HomeContent["about"];
/** `getCertificates()` – leer lässt die Sektion wie vorher rendern. */
certificates?: readonly CertificateAsset[];
} }
/* H2 on ivory: Playfair 400, max 14ch (spec §3.2 / §6.5). */ /* H2 on ivory: Playfair 400, max 14ch (spec §3.2 / §6.5). */
@@ -29,11 +33,14 @@ const BODY = "max-w-[36rem] font-sans text-[1.125rem] leading-[1.6] text-fg [hyp
* in view while reading, the Werdegang as a ledger, marginalia with * in view while reading, the Werdegang as a ledger, marginalia with
* verifiable facts. No counters, no logos. * verifiable facts. No counters, no logos.
* *
* With photo: Portrait cols 3–6 (sticky) · text + ledger cols 7–12 · marginalia cols 10–12. * With photo: Portrait cols 3–6 (sticky) · text + ledger cols 7–12 · marginalia and
* Without: text cols 3–8 · ledger cols 3–9 · marginalia under the ledger * certificates stacked under the ledger (cols 7–12) – the text already
* (no monogram vignette – see below). * owns the outer columns there, so nothing can stick to the right edge.
* Without: text cols 3–8 · ledger cols 3–9 · marginalia under the ledger ·
* certificates in cols 10–12, a single one sticky (no monogram vignette –
* see below).
*/ */
export function About({ photo, content: about = aboutDe }: AboutProps) { export function About({ photo, content: about = aboutDe, certificates = [] }: AboutProps) {
const paragraphs = about.paragraphs.map((p, i) => const paragraphs = about.paragraphs.map((p, i) =>
i === about.paragraphs.length - 1 && flags.showTwins ? `${p} ${about.twins}` : p, i === about.paragraphs.length - 1 && flags.showTwins ? `${p} ${about.twins}` : p,
); );
@@ -48,6 +55,11 @@ export function About({ photo, content: about = aboutDe }: AboutProps) {
numeral={about.numeral} numeral={about.numeral}
label={about.eyebrow} label={about.eyebrow}
className="py-[clamp(4.5rem,10vh,8rem)] md:py-[clamp(7rem,16vh,14rem)]" className="py-[clamp(4.5rem,10vh,8rem)] md:py-[clamp(7rem,16vh,14rem)]"
/* Ohne Foto reicht die Zertifikatsspalte über eine vierte, sonst leere Zeile.
Weil die `1fr` ist, landet alles, was der Stapel länger ist als Text,
Werdegang und Marginalien, dort – Zeilen 1–3 behalten ihre Höhe, die
Abstände dazwischen wachsen nicht mit. */
gridClassName={cn(!hasPhoto && "lg:grid-rows-[auto_auto_auto_1fr]")}
> >
{hasPhoto && ( {hasPhoto && (
<div className="col-span-12 mb-14 lg:col-span-4 lg:col-start-3 lg:mb-0"> <div className="col-span-12 mb-14 lg:col-span-4 lg:col-start-3 lg:mb-0">
@@ -92,11 +104,44 @@ export function About({ photo, content: about = aboutDe }: AboutProps) {
<Reveal <Reveal
className={cn( className={cn(
"col-span-12 mt-12", "col-span-12 mt-12",
hasPhoto ? "lg:col-span-3 lg:col-start-10 lg:row-start-2" : "lg:col-span-7 lg:col-start-3 lg:row-start-3", hasPhoto ? "lg:col-span-6 lg:col-start-7 lg:row-start-3" : "lg:col-span-7 lg:col-start-3 lg:row-start-3",
)} )}
> >
<Marginalia items={about.marginalia} /> <Marginalia items={about.marginalia} />
</Reveal> </Reveal>
{/* Ein einzelnes Zertifikat ist das Gegenstück zur klebenden „04“: gleiche
Höhe (`calc(50vh − .45em)`), gleiche Zeilen, nur am anderen Rand. Mehrere
Abzeichen (je rund 24 rem) passen dort nicht mehr ins Bild – dann steht
der Stapel still und beginnt oben auf Höhe der Eyebrow, das neueste
zuerst. Mit Foto reicht der Text bis Spalte 12 – dort gäbe es keine freie
Gutter, also stehen sie dann still unter dem Werdegang. */}
{certificates.length > 0 && (
<Reveal
className={cn(
"col-span-12 mt-14",
hasPhoto
? "lg:col-span-4 lg:col-start-7 lg:row-start-4"
: "lg:col-span-3 lg:col-start-10 lg:row-span-4 lg:row-start-1 lg:mt-0",
)}
>
<div
className={cn(
"flex flex-wrap gap-x-12 gap-y-16 lg:flex-col",
!hasPhoto && certificates.length === 1 && "lg:sticky lg:top-[calc(50vh_-_0.45em)]",
)}
>
{certificates.map((asset) => (
<Certificate
key={asset.key}
content={about.certificates[asset.key]}
asset={asset}
className="w-full"
/>
))}
</div>
</Reveal>
)}
</Section> </Section>
</div> </div>
); );
+68 -31
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { Fragment, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { flags } from "@/lib/site"; import { flags } from "@/lib/site";
import { gsap, MM } from "@/lib/gsap"; import { gsap, MM } from "@/lib/gsap";
@@ -26,7 +26,8 @@ export interface HeroProps {
} }
/* Load choreography (spec §6.1), seconds from mount. Masthead hairlines 0 / 0.15 are /* Load choreography (spec §6.1), seconds from mount. Masthead hairlines 0 / 0.15 are
Masthead's own; the folio hairline waits for the neck stroke (draw 0.7 + handoff 1.2–1.4). */ Masthead's own; the folio hairline waits for the neck stroke (draw 0.7 + handoff 1.2–1.4).
Danach erscheint der Slogan auf der Naht (<Positioning/>, Verzögerung 2.3). */
const T = { lines: 0.3, door: 0.5, draw: 0.7, cta: 1.2, folio: 2.0 } as const; const T = { lines: 0.3, door: 0.5, draw: 0.7, cta: 1.2, folio: 2.0 } as const;
/** Fallback length of the handoff hairline until it is measured (px). */ /** Fallback length of the handoff hairline until it is measured (px). */
@@ -99,15 +100,22 @@ function markBottomY(markWrap: HTMLElement, pts: { x: number; y: number }[]): nu
} }
/** /**
* Cover (ink, ≥ 100svh). Rows: (leer) · 1fr · H1 · Subline + CTA · Folio. * Cover (ink, ≥ 100svh − 2.5rem). Rows: (leer) · 1fr · H1 · Subline + CTA · Folio.
* Die Mastkopf-Zeile wurde am 06.09.2026 auf Kundenwunsch gestrichen; die erste * Die Mastkopf-Zeile wurde am 06.09.2026 auf Kundenwunsch gestrichen; die erste
* Rasterzeile bleibt leer, damit die übrigen Zeilenzuweisungen unverändert gelten. * Rasterzeile bleibt leer, damit die übrigen Zeilenzuweisungen unverändert gelten.
* Variant A (photo): portrait cols 8–12 bleeding to the page edge with scrims, * Variant A (photo): portrait cols 8–12 bleeding to the page edge with scrims,
* monogram 28vh in cols 6–8 overlapping its left edge, neck handoff into the * monogram 28vh in cols 6–8 overlapping its left edge, neck handoff into the
* folio hairline, which draws outward from that x. Variant B (no photo): * folio hairline, which draws outward from that x. Variant B (no photo):
* monogram 56vh cols 7–12, right-aligned, same handoff. Mobile: masthead, * monogram 45vh cols 7–12, right-aligned, same handoff. Mobile: masthead,
* monogram 14svh, H1, CTAs, portrait 3:4 (≤ 48svh) – no folio row (it costs * monogram 12svh, H1, CTAs, portrait 3:4 (≤ 48svh) – no folio row (it costs 69 px
* 69 px the phone cover does not have; Positionierung carries „01 / 06“). * the phone cover does not have; Positionierung carries „01 / 06“).
*
* Der Slogan („Personality · Impact · Success“) steht seit dem 02.10.2026 nicht mehr
* hier unter dem Monogramm, sondern als Plakette auf der Naht zu Abschnitt 01
* (<Positioning/>). Damit sie schon auf dem ersten Bildschirm ganz zu sehen ist, ist
* das Cover 2.5 rem kürzer als der Viewport – die Plakette ragt halb hinein. Unter lg
* ist der CTA der letzte Block über der Naht: `pb-10` hält ihn frei von der halben
* Plakette (ohne lag sie auf kleinen Handys 8–14 px auf dem Knopf).
*/ */
export function Hero({ mark, photo, content: hero = heroDe }: HeroProps) { export function Hero({ mark, photo, content: hero = heroDe }: HeroProps) {
const root = useRef<HTMLDivElement>(null); const root = useRef<HTMLDivElement>(null);
@@ -181,7 +189,7 @@ export function Hero({ mark, photo, content: hero = heroDe }: HeroProps) {
<Section <Section
id={hero.id} id={hero.id}
theme="dark" theme="dark"
className="grid min-h-svh pt-[calc(var(--header-h,7.5rem)+0.75rem)] pb-0 lg:pt-[calc(var(--header-h,7.5rem)+0.5rem)] [&>div]:grid" className="grid min-h-[calc(100svh-2.5rem)] pt-[calc(var(--header-h,7.5rem)+0.75rem)] pb-10 lg:pb-0 lg:pt-[calc(var(--header-h,7.5rem)+0.5rem)] [&>div]:grid"
> >
{/* From lg the FOURTH row (subline + CTA) is the flexible one, not the second: {/* From lg the FOURTH row (subline + CTA) is the flexible one, not the second:
the slack has to sit between the CTA block and the folio, otherwise a the slack has to sit between the CTA block and the folio, otherwise a
@@ -226,30 +234,33 @@ export function Hero({ mark, photo, content: hero = heroDe }: HeroProps) {
</div> </div>
)} )}
{/* Monogram: bottom-aligned to the folio hairline; the neck hands off into it. */} {/* Monogramm: unten an der Folio-Haarlinie ausgerichtet; der Nackenstrich
läuft vom V bis auf die Haarlinie. */}
<div <div
ref={markWrap} ref={markWrap}
className={cn( className={cn(
"relative z-[1] col-span-12 row-start-2 mt-6 h-[14svh] text-ivory lg:row-start-2 lg:row-end-5 lg:mt-0 lg:mb-4 lg:self-end", "relative z-[1] col-span-12 row-start-2 mt-6 flex flex-col text-ivory lg:row-start-2 lg:row-end-5 lg:mt-0 lg:mb-4 lg:self-end",
"max-lg:[&_span[aria-hidden=true]]:hidden", "max-lg:[&_span[aria-hidden=true]]:hidden",
/* WolfMark's 7-unit stroke is ~18 px at hero size – far from the /* WolfMark's 7-unit stroke is ~18 px at hero size – far from the
spec's hairline mark. Overridden here, not in WolfMark.tsx. */ spec's hairline mark. Overridden here, not in WolfMark.tsx. */
"[&_.wolfmark-v]:[stroke-width:3] [&_.wolfmark-wolf]:[stroke-width:2.6]", "[&_.wolfmark-v]:[stroke-width:3] [&_.wolfmark-wolf]:[stroke-width:2.6]",
hasPhoto hasPhoto ? "lg:col-span-3 lg:col-start-6 lg:items-start" : "lg:col-span-6 lg:col-start-7 lg:items-end",
? "lg:col-span-3 lg:col-start-6 lg:h-[28vh] lg:justify-self-start"
: "lg:col-span-6 lg:col-start-7 lg:h-[min(45vh,24vw)] lg:justify-self-end",
)} )}
> >
<DrawMark {/* Ab lg wieder die Größe von vor dem Claim (45vh / 28vh). Am Handy bleiben es
play="load" 12svh: Der Platz geht an den Abstand über der Slogan-Plakette. */}
sheen <div className={cn("h-[12svh]", hasPhoto ? "lg:h-[28vh]" : "lg:h-[min(45vh,24vw)]")}>
handoff <DrawMark
handoffLength={handoffLength} play="load"
delay={T.draw} sheen
className="block h-full [&_svg]:h-full [&_svg]:w-auto" handoff
> handoffLength={handoffLength}
{mark} delay={T.draw}
</DrawMark> className="block h-full [&_svg]:h-full [&_svg]:w-auto"
>
{mark}
</DrawMark>
</div>
</div> </div>
{/* H1 */} {/* H1 */}
@@ -257,25 +268,51 @@ export function Hero({ mark, photo, content: hero = heroDe }: HeroProps) {
ref={h1} ref={h1}
className="relative z-[2] col-span-12 row-start-3 mt-6 lg:col-span-8 lg:col-start-1 lg:row-start-3 lg:mt-0 lg:self-end" className="relative z-[2] col-span-12 row-start-3 mt-6 lg:col-span-8 lg:col-start-1 lg:row-start-3 lg:mt-0 lg:self-end"
> >
{/* The H1 is capped by viewport HEIGHT as well as width: `26vh - 108px` {/* `max-w-[10.9em]` wurde für die Vorgänger-Headline gemessen und passt auch
is neutral at 1440×900 (the cover's reference frame) and pulls the für Anelias Satz (02.10.2026, gemessen): Desktop DE und EN je drei Zeilen
three Bodoni lines back on 800/768 px laptops, where the vw term („Mit starker Persönlichkeit | und souveräner | Wirkung zum Erfolg!“),
alone grows faster than the fold shrinks. */} 390 px DE vier, EN drei, 360 px DE fünf. Die erste Zeile ist mit ≈ 10.8 em
knapp unter der Grenze – rendert ein Browser sie breiter, rutscht
„Persönlichkeit“ in Zeile 2, was ebenso sauber bricht (`text-wrap:balance`).
Der Clamp bleibt zusätzlich von der Fenster-HÖHE gedeckelt: `22vh - 120px`
ist bei 1440×900 neutral und zieht die Zeilen auf 800/768-px-Laptops
zurück, wo der vw-Term schneller wächst als der Bildschirm schrumpft. */}
<Lines <Lines
as="h1" as="h1"
play="load" play="load"
delay={T.lines} delay={T.lines}
stagger={0.09} stagger={0.09}
className="max-w-[12ch] font-display text-[clamp(3rem,min(1.4rem_+_7.4vw,26vh_-_108px),8rem)] font-normal leading-[0.94] tracking-[-0.015em] text-ivory [text-wrap:balance]" className="max-w-[10.9em] font-display text-[clamp(2.5rem,min(0.5rem_+_4.9vw,22vh_-_120px),5.25rem)] font-normal leading-[0.94] tracking-[-0.015em] text-ivory [text-wrap:balance]"
> >
{hero.h1.before} {hero.h1.parts ? (
<em className="italic">{hero.h1.em}</em> /* Mehr als eine Betonung: kursiv trägt jedes Teil, den Gold-Verlauf
{hero.h1.after} genau eines – `em-plain` nimmt ihn den übrigen wieder ab. */
hero.h1.parts.map((part, i) => {
if (!part.em) return <Fragment key={i}>{part.text}</Fragment>;
const word = <em className={cn("italic", !part.gold && "em-plain")}>{part.text}</em>;
/* `tail` klammert Wort und Satzzeichen zusammen – siehe `HeadlinePart.tail`. */
return part.tail ? (
<span key={i} className="whitespace-nowrap">
{word}
{part.tail}
</span>
) : (
<Fragment key={i}>{word}</Fragment>
);
})
) : (
<>
{hero.h1.before}
<em className="italic">{hero.h1.em}</em>
{hero.h1.after}
</>
)}
</Lines> </Lines>
</div> </div>
{/* Subline + CTA. Vertical rhythm from lg is viewport-relative so the cover {/* Subline + CTA. Vertical rhythm from lg is viewport-relative so the cover
keeps fitting `min-h-svh` at 768–900 px viewport heights; the row runs keeps fitting its min height (100svh − 2.5rem) at 768–900 px viewport heights; the row runs
cols 1–8 below 1440 so button + text link stay on one line there too. At cols 1–8 below 1440 so button + text link stay on one line there too. At
1440×900 – the cover's reference frame – the original 24/32 px rhythm is 1440×900 – the cover's reference frame – the original 24/32 px rhythm is
restored, which also keeps the headline clear of the monogram. */} restored, which also keeps the headline clear of the monogram. */}
+46 -2
View File
@@ -1,10 +1,11 @@
import { Fragment } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { positioning as positioningDe, type HomeContent } from "@/content/home"; import { positioning as positioningDe, type HomeContent } from "@/content/home";
import { Section } from "@/components/layout/Section"; import { Section } from "@/components/layout/Section";
import { Lines } from "@/components/motion/Lines"; import { Lines } from "@/components/motion/Lines";
import { Reveal, RevealGroup, RevealItem } from "@/components/motion/Reveal"; import { Reveal, RevealGroup, RevealItem } from "@/components/motion/Reveal";
import { Rule } from "@/components/motion/Rule"; import { Rule } from "@/components/motion/Rule";
import { Eyebrow } from "@/components/ui/Eyebrow"; import { Eyebrow, EYEBROW_TEXT } from "@/components/ui/Eyebrow";
/** H2 on ivory: Playfair 400, 36 → 76 px, one italic word max. */ /** H2 on ivory: Playfair 400, 36 → 76 px, one italic word max. */
export const H2 = export const H2 =
@@ -12,6 +13,48 @@ export const H2 =
const MONO = "font-mono text-xs tracking-[0.06em] tabular-nums"; const MONO = "font-mono text-xs tracking-[0.06em] tabular-nums";
/**
* Slogan-Plakette auf der Naht Cover → 01 (Entscheidung 02.10.2026: nicht im Hero,
* sondern „im Übergang“, in einer Zeile). Eine Schrift kann nicht über der Kante die
* Farbe wechseln – deshalb trägt die Plakette ihren eigenen Ink-Grund mit Goldrand:
* Die obere Hälfte verschmilzt mit dem Cover, die untere liegt als Etikett auf Ivory,
* die goldene Naht-Haarlinie läuft dahinter durch. Gold nur auf Dunkel (AGENTS.md).
*
* Aufbau: Die Hülle steht mit Höhe 0 auf der Oberkante und zentriert; das mittlere
* Element hebt die Plakette um die halbe Höhe an (statisch); `<Reveal/>` animiert
* innen nur opacity + y – so streiten Transform und GSAP nicht um dasselbe Element.
* `data-theme="dark"` in einer hellen Sektion malt keinen Grund (globals.css), daher
* `bg-ink` ausdrücklich.
*/
function SloganPlate({ words, lang }: { words: readonly string[]; lang: string }) {
return (
/* `items-start`: ohne streckt Flex das Kind auf die Höhe 0 der Hülle, und das
Anheben um die halbe Höhe liefe ins Leere. */
<div className="pointer-events-none absolute inset-x-0 top-0 z-[3] flex h-0 items-start justify-center px-3">
<div className="-translate-y-1/2">
<Reveal play="load" delay={2.3} y={8}>
<p
lang={lang}
data-theme="dark"
className={cn(
EYEBROW_TEXT,
"whitespace-nowrap border border-gold/60 bg-ink px-5 py-3 leading-[1.5] text-ivory/80 sm:px-7",
"max-[359px]:tracking-[0.18em]",
)}
>
{words.map((word, i) => (
<Fragment key={word}>
{i > 0 && <span className="mx-[0.7em] text-gold">·</span>}
{word}
</Fragment>
))}
</p>
</Reveal>
</div>
</div>
);
}
/** /**
* 01 · Positionierung (ivory, spec §6.2). Numeral cols 1–2 (sticky), eyebrow + * 01 · Positionierung (ivory, spec §6.2). Numeral cols 1–2 (sticky), eyebrow +
* H2 + editorial paragraph + the three question rows cols 3–9 * H2 + editorial paragraph + the three question rows cols 3–9
@@ -24,7 +67,7 @@ export interface PositioningProps {
} }
export function Positioning({ content: positioning = positioningDe }: PositioningProps = {}) { export function Positioning({ content: positioning = positioningDe }: PositioningProps = {}) {
const { numeral, eyebrow, h2, paragraph, rows } = positioning; const { numeral, eyebrow, h2, paragraph, rows, slogan } = positioning;
return ( return (
<Section <Section
@@ -33,6 +76,7 @@ export function Positioning({ content: positioning = positioningDe }: Positionin
numeral={numeral} numeral={numeral}
label={eyebrow} label={eyebrow}
className="py-[clamp(72px,10vh,140px)] md:py-[clamp(112px,16vh,224px)]" className="py-[clamp(72px,10vh,140px)] md:py-[clamp(112px,16vh,224px)]"
lead={<SloganPlate words={slogan.words} lang={slogan.lang} />}
> >
<div className="col-span-12 lg:col-span-7 lg:col-start-3 lg:row-start-1"> <div className="col-span-12 lg:col-span-7 lg:col-start-3 lg:row-start-1">
<Reveal> <Reveal>
+33 -8
View File
@@ -5,7 +5,7 @@ import { Reveal } from "@/components/motion/Reveal";
import { Rule } from "@/components/motion/Rule"; import { Rule } from "@/components/motion/Rule";
import { ContactLinks } from "@/components/ui/ContactLinks"; import { ContactLinks } from "@/components/ui/ContactLinks";
import { Eyebrow, EYEBROW_TEXT } from "@/components/ui/Eyebrow"; import { Eyebrow, EYEBROW_TEXT } from "@/components/ui/Eyebrow";
import { ContactForm } 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). */ /* H2 on ink: Bodoni, `clamp(2.75rem, 1.6rem + 4.4vw, 6rem)`, ivory (spec §6.7). */
const H2 = const H2 =
@@ -27,11 +27,32 @@ export interface ProcessContactProps {
privacyHref?: string; privacyHref?: string;
/** Nenner des Abschnittszählers („04 / 04“). Die Startseite zählt ohne Nenner. */ /** Nenner des Abschnittszählers („04 / 04“). Die Startseite zählt ohne Nenner. */
total?: string; 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 }: ProcessContactProps = {}) { export function ProcessContact({
content: contact = contactDe,
privacyHref,
total,
coupon,
fields,
topic,
}: ProcessContactProps = {}) {
return ( return (
<div data-theme="dark" className="relative bg-transparent text-fg"> /* `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
wissen, ob diese Seite einen Gutschein trägt. */
<div
data-theme="dark"
data-sale={coupon?.tag.value}
data-sale-stub={coupon?.tag.stub}
className="relative bg-transparent text-fg"
>
<Rule variant="gold" /> <Rule variant="gold" />
<Section <Section
id={contact.id} id={contact.id}
@@ -55,7 +76,7 @@ export function ProcessContact({ content: contact = contactDe, privacyHref, tota
Es verschickt nichts selbst, sondern übergibt eine fertige E-Mail an das Es verschickt nichts selbst, sondern übergibt eine fertige E-Mail an das
Mailprogramm – siehe den Kopf von ContactForm.tsx. */} 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"> <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} /> <ContactForm content={contact.form} privacyHref={privacyHref} coupon={coupon} fields={fields} topic={topic} />
</Reveal> </Reveal>
{/* Contact block: under the Ablauf up to 1280 (the CTA button is 354 px {/* Contact block: under the Ablauf up to 1280 (the CTA button is 354 px
@@ -66,10 +87,14 @@ export function ProcessContact({ content: contact = contactDe, privacyHref, tota
</div> </div>
</div> </div>
{/* Sign-off above the colophon (the Footer follows globally). */} {/* Sign-off above the colophon (the Footer follows globally). Leer = entfällt:
<Reveal as="p" className={`col-span-12 mt-20 lg:col-span-10 lg:col-start-3 lg:mt-32 ${EYEBROW_TEXT} text-gold`}> Auf den Leistungsseiten stünde der Claim sonst direkt über dem Fuß, der ihn
{contact.signoff} ebenfalls trägt. */}
</Reveal> {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> </Section>
</div> </div>
); );
+19 -2
View File
@@ -7,6 +7,7 @@ import { flags, legal as legalDe, nav as navDe } from "@/lib/site";
import { routesByLang, type Lang } from "@/lib/i18n"; import { routesByLang, type Lang } from "@/lib/i18n";
import { chrome as chromeDe, type Chrome } from "@/content/chrome"; import { chrome as chromeDe, type Chrome } from "@/content/chrome";
import { Wordmark } from "@/components/brand/Wordmark"; import { Wordmark } from "@/components/brand/Wordmark";
import { Collab } from "@/components/brand/Collab";
import { TransitionLink } from "@/components/motion/TransitionLink"; import { TransitionLink } from "@/components/motion/TransitionLink";
import { subscribe } from "@/components/motion/transition-store"; import { subscribe } from "@/components/motion/transition-store";
import { Container } from "./Container"; import { Container } from "./Container";
@@ -108,9 +109,25 @@ export function Header({ mark, chrome = chromeDe, nav = navDe, legal = legalDe,
> >
<Container> <Container>
<div className="grid h-[60px] grid-cols-12 items-center gap-x-[clamp(16px,2vw,32px)] lg:h-[72px]"> {/* Unter 640 px eine Flex-Zeile statt des 12-Spalten-Rasters. Grund ist Arithmetik:
<div data-menu-inert="" className="col-span-6 flex min-w-0 items-center xl:col-span-3"> Bei 375 px stehen 335 px zur Verfügung, und elf Spaltenlücken à 16 px fressen
davon 176 – für „ANELIA WOLF × herCAREER“ neben Sprachumschalter und Menü
blieb in zwei Sechstel-Zellen kein Platz. Ohne Raster bekommt jede Gruppe
ihre natürliche Breite, `justify-between` schiebt sie auseinander. Für die
anderen Seiten ändert das nichts: Dort stehen unter 640 px ohnehin nur diese
zwei Gruppen in der Zeile, links und rechts.
`min-h` statt `h`, damit die Zeile notfalls wachsen darf; `--header-h` misst
der ResizeObserver weiter unten am Element. */}
<div className="flex min-h-[60px] items-center justify-between gap-3 py-2 sm:grid sm:grid-cols-12 sm:gap-x-[clamp(16px,2vw,32px)] lg:min-h-[72px] lg:py-0">
{/* Wortmarke und Kollaborationszusatz stehen auf jeder Breite nebeneinander.
Der Zusatz steht AUSSERHALB des Wortmarken-Links: Er führt nicht zur
Startseite, er ist eine Angabe. */}
<div
data-menu-inert=""
className="flex min-w-0 items-center gap-2 sm:col-span-6 sm:gap-4 xl:col-span-3"
>
<Wordmark size="nav" href={routesByLang[lang].home} mark={mark} chrome={chrome} /> <Wordmark size="nav" href={routesByLang[lang].home} mark={mark} chrome={chrome} />
<Collab label={chrome.collab.ariaLabel} />
</div> </div>
<nav data-menu-inert="" aria-label={chrome.header.navAriaLabel} className="hidden items-center justify-center gap-6 xl:col-span-5 xl:flex xl:gap-4 wide:gap-6 2xl:gap-8"> <nav data-menu-inert="" aria-label={chrome.header.navAriaLabel} className="hidden items-center justify-center gap-6 xl:col-span-5 xl:flex xl:gap-4 wide:gap-6 2xl:gap-8">
+45 -3
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useSyncExternalStore } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { counterpart, langSwitch, otherLang, type Lang } from "@/lib/i18n"; import { counterpart, langSwitch, otherLang, type Lang } from "@/lib/i18n";
import { TransitionLink } from "@/components/motion/TransitionLink"; import { TransitionLink } from "@/components/motion/TransitionLink";
@@ -19,11 +20,44 @@ export interface LangSwitchProps {
const BASE = const BASE =
"inline-flex items-center gap-1.5 font-mono text-[11px] uppercase tracking-[0.14em] transition-colors duration-300"; "inline-flex items-center gap-1.5 font-mono text-[11px] uppercase tracking-[0.14em] transition-colors duration-300";
/**
* Der Pfad des Gegenstücks, wie ihn das Dokument SELBST nennt.
*
* `<link rel="alternate" hreflang="…">` steht im Kopf jeder zweisprachigen Seite
* (`alternatesFor()` in src/lib/i18n.ts) und hängt an der Seite, nicht an der Adresse,
* unter der sie ausgeliefert wird. Genau darin liegt der Unterschied auf der Messeseite:
* her-career.anelia-wolf.com liefert dieselbe Datei unter „/“ aus
* (docs/SUBDOMAIN-HERCAREER.md). `usePathname()` sagt dort „/“, und aus „/“ leitet
* `counterpart()` folgerichtig die englische STARTSEITE ab – wer am Stand gescannt und
* dann auf „EN“ getippt hat, landete auf anelia-wolf.com/en/ statt auf der englischen
* Messeseite. Der hreflang-Verweis im Kopf derselben Datei zeigt dagegen auf
* /en/her-career/, egal unter welcher Adresse sie liegt.
*
* Übernommen wird nur der PFAD: relativ bleibt der Link auf dem Host, auf dem er
* angeklickt wurde, und die Subdomain schickt ihn mit ihrem 301 auf die Hauptdomain –
* so, wie sie es mit jedem anderen Pfad auch tut. Die absolute Adresse zu übernehmen
* würde in der Entwicklung auf die Live-Site zeigen.
*/
function alternatePath(target: Lang): string | null {
const el = document.querySelector<HTMLLinkElement>(`link[rel="alternate"][hreflang="${target}"]`);
if (!el) return null;
try {
const url = new URL(el.href, window.location.href);
return `${url.pathname}${url.hash}`;
} catch {
return null;
}
}
/** Der Kopf des Dokuments ändert sich nicht, solange das Dokument steht. */
const noSubscribe = () => () => {};
/** /**
* Sprachumschalter im Kopf. Steht an der Stelle, an der vorher „Kontakt“ stand. * Sprachumschalter im Kopf. Steht an der Stelle, an der vorher „Kontakt“ stand.
* *
* Zielt auf das Gegenstück derselben Seite (`counterpart()`), nicht pauschal auf die * Zielt auf das Gegenstück derselben Seite, nicht pauschal auf die Startseite: erst
* Startseite. * `counterpart()` aus dem Pfad, nach dem ersten Rendern der hreflang-Verweis des
* Dokuments (`alternatePath()` – die Begründung steht dort).
* *
* Zur Flagge: Eine Flagge steht für ein Land, nicht für eine Sprache – deshalb steht * Zur Flagge: Eine Flagge steht für ein Land, nicht für eine Sprache – deshalb steht
* neben ihr immer das Sprachkürzel, und der zugängliche Name nennt die Sprache * neben ihr immer das Sprachkürzel, und der zugängliche Name nennt die Sprache
@@ -32,7 +66,15 @@ const BASE =
export function LangSwitch({ lang, className, variant = "pair" }: LangSwitchProps) { export function LangSwitch({ lang, className, variant = "pair" }: LangSwitchProps) {
const pathname = usePathname() ?? "/"; const pathname = usePathname() ?? "/";
const other = otherLang(lang); const other = otherLang(lang);
const href = counterpart(pathname, other); /* Zwei Quellen, absichtlich: Beim Vorrendern gibt es kein Dokument, das man fragen
könnte – da trägt der Pfad die Antwort. Im Browser steht der hreflang-Verweis im
Kopf, und der weiß es auch dann noch, wenn der Pfad nichts mehr hergibt. Auf allen
Adressen außer der Messe-Subdomain kommt bei beiden dasselbe heraus. */
const href = useSyncExternalStore(
noSubscribe,
() => alternatePath(other) ?? counterpart(pathname, other),
() => counterpart(pathname, other),
);
/* Kompakt: Die aktive Sprache steht nicht da – sie steht ja im Dokument. Gezeigt wird, /* Kompakt: Die aktive Sprache steht nicht da – sie steht ja im Dokument. Gezeigt wird,
wohin es geht. Die negativen Ränder heben das Tippziel auf 54 × 42 px, ohne die wohin es geht. Die negativen Ränder heben das Tippziel auf 54 × 42 px, ohne die
+12 -1
View File
@@ -23,6 +23,14 @@ export interface SectionProps {
space?: "chapter" | "block"; space?: "chapter" | "block";
className?: string; className?: string;
as?: "section" | "div" | "article"; as?: "section" | "div" | "article";
/**
* Sitzt außerhalb von Container und Raster direkt an der Oberkante – für etwas, das
* auf der Naht zum Block darüber liegt (Slogan-Plakette der Startseite). Die Sektion
* ist `relative`, das Kind positioniert sich selbst.
*/
lead?: ReactNode;
/** Zusätzliche Klassen für das 12-Spalten-Raster selbst (z. B. eine Zeilenvorlage). */
gridClassName?: string;
children: ReactNode; children: ReactNode;
} }
@@ -51,6 +59,8 @@ export function Section({
space = "chapter", space = "chapter",
className, className,
as = "section", as = "section",
lead,
gridClassName,
children, children,
}: SectionProps) { }: SectionProps) {
const Tag = as; const Tag = as;
@@ -65,8 +75,9 @@ export function Section({
className, className,
)} )}
> >
{lead}
<Container className="relative"> <Container className="relative">
<div className={GRID}> <div className={cn(GRID, gridClassName)}>
{numeral && ( {numeral && (
<SectionGutter numeral={numeral} total={total} label={label ?? ""} mobileMeta={mobileMeta} /> <SectionGutter numeral={numeral} total={total} label={label ?? ""} mobileMeta={mobileMeta} />
)} )}
+9 -1
View File
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState, type ReactNode } from "react";
import { gsap, EASE } from "@/lib/gsap"; import { gsap, EASE } from "@/lib/gsap";
import { WolfMark } from "@/components/brand/WolfMark"; import { WolfMark } from "@/components/brand/WolfMark";
import { chrome as chromeDe, type Chrome } from "@/content/chrome"; import { chrome as chromeDe, type Chrome } from "@/content/chrome";
import { crossesLangRoot } from "@/lib/i18n";
import { endTransition, setTransitionPhase, subscribe } from "./transition-store"; import { endTransition, setTransitionPhase, subscribe } from "./transition-store";
export interface CurtainProps { export interface CurtainProps {
@@ -49,6 +50,13 @@ export function Curtain({ children, chrome = chromeDe }: CurtainProps) {
onComplete: () => { onComplete: () => {
phase.current = "pushed"; phase.current = "pushed";
setTransitionPhase("pushed"); setTransitionPhase("pushed");
/* Sprachwechsel: den Dokumentwechsel macht der Browser, nicht der Router
(Begründung in crossesLangRoot(), src/lib/i18n.ts). Die Hülle bleibt
liegen, bis das neue Dokument da ist – sie gehört zu diesem hier. */
if (crossesLangRoot(pathname, href)) {
window.location.assign(href);
return;
}
router.push(href, { scroll: false }); router.push(href, { scroll: false });
window.clearTimeout(timer.current); window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => { timer.current = window.setTimeout(() => {
@@ -57,7 +65,7 @@ export function Curtain({ children, chrome = chromeDe }: CurtainProps) {
}, },
}); });
}); });
}, [router, chrome.curtain.fallbackLabel]); }, [router, pathname, chrome.curtain.fallbackLabel]);
// Route changed → the sheet continues upward and resets. // Route changed → the sheet continues upward and resets.
useEffect(() => { useEffect(() => {
+100 -12
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useLayoutEffect, useRef } from "react"; import { useLayoutEffect, useRef } from "react";
import { gsap, EASE, MM } from "@/lib/gsap"; import { gsap, ScrollTrigger, EASE, MM } from "@/lib/gsap";
/** /**
* 1 px `--accent` line directly under the header (the header writes * 1 px `--accent` line directly under the header (the header writes
@@ -9,34 +9,122 @@ import { gsap, EASE, MM } from "@/lib/gsap";
* *
* Scrubbed by ScrollTrigger, not by a second animation runtime: this was the * Scrubbed by ScrollTrigger, not by a second animation runtime: this was the
* only reason `useScroll` (framer-motion) shipped on every route. * only reason `useScroll` (framer-motion) shipped on every route.
*
* ---------------------------------------------------------------------------
* Reduziert-Fähnchen
*
* Trägt eine Seite einen Gutschein, hängt an der Spitze des Balkens ein rotes
* Fähnchen mit dem Nachlass – dort, wo der Leser seine eigene Position abliest.
* Es blendet sich erst ein, wenn der Abschnitt mit dem Formular ins Bild kommt:
* Bis dahin sagt der Balken „so weit bist du“, ab dort zusätzlich „und hier wird
* er eingelöst“.
*
* Diese Komponente hängt in <Shell/> und läuft auf JEDER Seite – auf dem
* Impressum darf kein „−20 %“ kleben. Statt einen Zustand durch das Layout zu
* fädeln (was der App-Router nicht hergibt), liest sie den Text aus dem DOM:
* <ProcessContact/> schreibt ihn als `data-sale` an den Kontaktabschnitt, wenn
* ein Gutschein übergeben wurde. Denselben Weg geht schon die Regel in
* globals.css, die den Kopf-CTA auf der Messeseite ausblendet.
*/ */
export function ScrollProgress() { export function ScrollProgress() {
const bar = useRef<HTMLDivElement>(null); const bar = useRef<HTMLDivElement>(null);
const tag = useRef<HTMLDivElement>(null);
useLayoutEffect(() => { useLayoutEffect(() => {
const el = bar.current; const el = bar.current;
if (!el) return; if (!el) return;
const mm = gsap.matchMedia(); const mm = gsap.matchMedia();
mm.add({ reduce: MM.reduce, ok: MM.ok }, () => { mm.add({ reduce: MM.reduce, ok: MM.ok }, (ctx) => {
/* Reduced motion too: the bar reports position, it does not animate for effect – /* Reduced motion too: the bar reports position, it does not animate for effect –
the scrub follows the reader's own scrolling either way. */ the scrub follows the reader's own scrolling either way. */
/* Die Strecke wird gemessen, nicht aus einem Element abgeleitet.
Vorher stand hier `trigger: document.documentElement` mit „top top“ bis
„bottom bottom“. <html> trägt aber `h-full`, ist also genau einen Bildschirm
hoch – Anfang und Ende fielen zusammen, die Strecke war null und der Balken
blieb auf 0. Auf dem Desktop fiel das nicht auf, weil Lenis dort seine eigene
Position meldet; auf Mobil ist Lenis aus (SmoothScroll.tsx überlässt Touch dem
nativen Scrollen), und der Balken zeichnete überhaupt nicht.
`maxScroll` fragt den tatsächlichen Scrollweg ab, `invalidateOnRefresh` rechnet
ihn neu, wenn die Seite ihre Höhe ändert. */
const scrub = {
start: 0,
end: () => ScrollTrigger.maxScroll(window),
scrub: true,
invalidateOnRefresh: true,
} as const;
gsap.set(el, { scaleX: 0, transformOrigin: "0 50%" }); gsap.set(el, { scaleX: 0, transformOrigin: "0 50%" });
gsap.to(el, { gsap.to(el, { scaleX: 1, ease: EASE.none, scrollTrigger: scrub });
scaleX: 1,
ease: EASE.none, /* Das Fähnchen steht immer im Dokument und wird hier beschriftet, statt seinen
scrollTrigger: { trigger: document.documentElement, start: "top top", end: "bottom bottom", scrub: true }, Text durch React-State zu schicken: Ein `setState` im Effekt wäre ein zweiter
Durchlauf für eine Angabe, die sich nach dem ersten Rendern nie mehr ändert
(und react-hooks verbietet es zu Recht). Ohne `data-sale` bleibt es leer und
durchsichtig – auf dem Impressum klebt kein „−20 %“. */
const flag = tag.current;
const anchor = document.querySelector<HTMLElement>("main [data-sale]");
if (!flag || !anchor?.dataset.sale) return;
const [value, stub] = flag.querySelectorAll<HTMLElement>("[data-cell]");
value.textContent = anchor.dataset.sale;
stub.textContent = anchor.dataset.saleStub ?? "";
/* Der Balken selbst wird über `scaleX` gezogen – ein Kind darin würde
mitgestaucht. Das Fähnchen läuft deshalb eigenständig über dieselbe
Strecke; `xPercent: -100` legt seine rechte Kante auf die Spitze, damit
es am Ende nicht über den Fensterrand hinausragt. Der Endwert ist eine
Funktion, `invalidateOnRefresh` rechnet ihn beim Größenwechsel neu. */
gsap.set(flag, { opacity: 0, xPercent: -100 });
gsap.fromTo(
flag,
{ x: 0 },
{ x: () => window.innerWidth, ease: EASE.none, scrollTrigger: { ...scrub } },
);
/* Nur `opacity` – nichts, was Fokus oder Tab-Reihenfolge anfasst. Bei
reduzierter Bewegung ohne Überblendung, aber mit demselben Ergebnis. */
const fade = ctx.conditions?.reduce ? 0 : 0.4;
ScrollTrigger.create({
trigger: anchor,
start: "top 90%",
onEnter: () => gsap.to(flag, { opacity: 1, duration: fade, ease: EASE.out }),
onLeaveBack: () => gsap.to(flag, { opacity: 0, duration: fade, ease: EASE.out }),
}); });
}); });
return () => mm.revert(); return () => mm.revert();
}, []); }, []);
return ( return (
<div <>
ref={bar} <div
aria-hidden="true" ref={bar}
className="pointer-events-none fixed inset-x-0 z-50 h-px origin-left scale-x-0 bg-accent transition-opacity duration-300 [[data-menu-open]_&]:opacity-0" aria-hidden="true"
style={{ top: "var(--header-h, 60px)" }} className="pointer-events-none fixed inset-x-0 z-50 h-px origin-left scale-x-0 bg-accent transition-opacity duration-300 [[data-menu-open]_&]:opacity-0"
/> style={{ top: "var(--header-h, 60px)" }}
/>
{/* Ein Gutschein im Kleinstformat: runde Ecken wie der große, und quer durch die
Mitte dieselbe Reißkante – hier hochkant, weil das Fähnchen breiter als hoch
ist. Rechts hinter der Perforation sitzt der Abriss.
Anders als beim großen Gutschein sind es keine gestanzten Kerben, sondern nur
die gestrichelte Linie: Eine Kerbe muss den Grund dahinter zeigen, und dieses
Fähnchen schwebt über der Seite – mal über Tinte, mal über Elfenbein. Eine
Kerbe in fester Farbe wäre auf einem von beiden Gründen ein Fleck.
Rot, weil Rot reduziert heißt und Gold das nicht sagen kann. `aria-hidden` wie
der Balken: Dass der Vorteil an der Anfrage hängt, steht als Text im Formular
darunter, nicht nur in dieser Farbe. Leer und durchsichtig, solange die Seite
keinen Gutschein trägt. */}
<div
ref={tag}
aria-hidden="true"
className="pointer-events-none fixed left-0 z-50 flex -translate-y-1/2 items-stretch overflow-hidden rounded-[5px] bg-sale font-mono text-[10px] uppercase leading-none tracking-[0.12em] text-ivory opacity-0 [[data-menu-open]_&]:!opacity-0"
style={{ top: "var(--header-h, 60px)" }}
>
<span data-cell="" className="px-2 py-[5px]" />
<span className="border-l border-dashed border-ivory/55" />
<span data-cell="" className="px-[7px] py-[5px]" />
</div>
</>
); );
} }
+22 -2
View File
@@ -4,6 +4,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import type { ComponentProps, MouseEvent } from "react"; import type { ComponentProps, MouseEvent } from "react";
import { getLenis, hasJs, prefersReducedMotion } from "@/lib/gsap"; import { getLenis, hasJs, prefersReducedMotion } from "@/lib/gsap";
import { crossesLangRoot } from "@/lib/i18n";
import { isTransitioning, startTransition } from "./transition-store"; import { isTransitioning, startTransition } from "./transition-store";
type NextLinkProps = ComponentProps<typeof Link>; type NextLinkProps = ComponentProps<typeof Link>;
@@ -54,8 +55,13 @@ export function scrollToHash(hash: string, immediate = false) {
* modifier/middle clicks, external targets, same-page anchors (smooth * modifier/middle clicks, external targets, same-page anchors (smooth
* scroll instead), reduced motion, and while a transition is running. * scroll instead), reduced motion, and while a transition is running.
*/ */
export function TransitionLink({ href, label, onClick, scroll, ...rest }: TransitionLinkProps) { export function TransitionLink({ href, label, onClick, scroll, prefetch, ...rest }: TransitionLinkProps) {
const pathname = usePathname(); const pathname = usePathname();
/* Für einen Sprachwechsel gibt es keine Route zum Vorladen – er ist ein neues
Dokument (crossesLangRoot(), src/lib/i18n.ts). Auf her-career.anelia-wolf.com
scheitert der Vorabruf zusätzlich an der Herkunft: Der Pfad geht per 301 auf die
Hauptdomain, und der Abruf stirbt an CORS – mehrfach pro Seitenaufbau. */
const crossesLang = crossesLangRoot(pathname, href);
const handleClick = (e: MouseEvent<HTMLAnchorElement>) => { const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {
onClick?.(e); onClick?.(e);
@@ -79,13 +85,27 @@ export function TransitionLink({ href, label, onClick, scroll, ...rest }: Transi
return; return;
} }
/* Sprachwechsel: immer ein neues Dokument, nie ein Router-Sprung (Begründung in
crossesLangRoot(), src/lib/i18n.ts). Mit Bewegung legt sich erst die Hülle
darüber – den Wechsel selbst macht dann <Curtain/>; ohne Bewegung geht es
direkt. Beides ist ein echter Seitenaufbau, kein router.push(). */
if (crossesLang) {
e.preventDefault();
if (hasJs() && !prefersReducedMotion()) {
startTransition(href, label); // ein zweiter Klick läuft ins Leere – das ist gewollt
return;
}
window.location.assign(href);
return;
}
if (!hasJs() || prefersReducedMotion()) return; // plain client navigation if (!hasJs() || prefersReducedMotion()) return; // plain client navigation
e.preventDefault(); e.preventDefault();
if (isTransitioning()) return; // ignore double clicks if (isTransitioning()) return; // ignore double clicks
startTransition(href, label); startTransition(href, label);
}; };
return <Link href={href} scroll={scroll ?? false} onClick={handleClick} {...rest} />; return <Link href={href} scroll={scroll ?? false} prefetch={crossesLang ? false : prefetch} onClick={handleClick} {...rest} />;
} }
export default TransitionLink; export default TransitionLink;
+51 -30
View File
@@ -1,13 +1,12 @@
import { BrandMark } from "@/components/brand/BrandMark";
import { Section } from "@/components/layout/Section"; import { Section } from "@/components/layout/Section";
import { ServiceHero } from "@/components/services/ServiceHero"; import { ServiceHero } from "@/components/services/ServiceHero";
import { ServiceLayout } from "@/components/services/ServiceLayout"; import { ServiceLayout } from "@/components/services/ServiceLayout";
import { SectionHeading } from "@/components/services/SectionHeading"; import { SectionHeading } from "@/components/services/SectionHeading";
import { ProcessContact } from "@/components/home/ProcessContact"; import { ProcessContact } from "@/components/home/ProcessContact";
import { Facts } from "@/components/ui/Facts"; import { Coupon } from "@/components/ui/Coupon";
import { Ledger } from "@/components/ui/Ledger"; import { Ledger } from "@/components/ui/Ledger";
import { Reveal } from "@/components/motion/Reveal";
import type { HerCareerContent } from "@/content/her-career"; import type { HerCareerContent } from "@/content/her-career";
import { flags } from "@/lib/site";
/** Content column of every light section: cols 3–12 on desktop – as on the service pages. */ /** Content column of every light section: cols 3–12 on desktop – as on the service pages. */
const BODY = "col-span-12 lg:col-span-10 lg:col-start-3"; const BODY = "col-span-12 lg:col-span-10 lg:col-start-3";
@@ -23,31 +22,63 @@ export interface HerCareerBodyProps {
* QR-Code der Rückwandgrafik (Adresse: her-career.anelia-wolf.com, Auslieferung: * QR-Code der Rückwandgrafik (Adresse: her-career.anelia-wolf.com, Auslieferung:
* docs/SUBDOMAIN-HERCAREER.md). * docs/SUBDOMAIN-HERCAREER.md).
* *
* Kurz gehalten, weil hier jemand mit dem Handy am Stand steht: Hero (ink) → * Kurz gehalten, weil hier jemand mit dem Handy am Stand steht: Hero mit dem
* 01 Messe-Special → 02 Themen → 03 Auf der Messe (nur `flags.showHerCareer`) → * Gutschein (ink) → 01 Messe-Special → 02 Themen → 03 Kontakt-Band (ink). Alle
* Kontakt-Band (ink). Alle weiterführenden Links liegen als absolute Adressen in * weiterführenden Links liegen als absolute Adressen in der Inhaltsdatei und führen
* der Inhaltsdatei und führen auf die Hauptdomain. Kein <main> – das rendert * auf die Hauptdomain. Kein <main> – das rendert template.tsx.
* template.tsx.
*/ */
export function HerCareerBody({ content }: HerCareerBodyProps) { export function HerCareerBody({ content }: HerCareerBodyProps) {
const { special, themen, messe, contact } = content; const { special, themen, contact } = content;
const showFair = flags.showHerCareer; const { coupon, formNotice } = special;
/* Der Kontaktblock zählt mit – er ist der letzte nummerierte Abschnitt der Seite. */ /* Der Hinweis im Formular wird aus dem Gutschein oben abgeleitet, nicht daneben
const total = showFair ? "04" : "03"; noch einmal getippt: „−20 % · Stichwort herCAREER“ und die Zeile, die mit der
Nachricht ins Postfach geht. Ändert sich der Nachlass, ändert er sich an einer
Stelle. */
const contactCoupon = {
eyebrow: formNotice.eyebrow,
value: `${coupon.rate} ${coupon.unit} · ${coupon.code.label} ${coupon.code.value}`,
state: formNotice.state,
tag: { value: coupon.rate, stub: coupon.unit },
hint: formNotice.hint,
line: `${formNotice.eyebrow} ${coupon.code.value}: ${coupon.rate} ${coupon.unit} ${coupon.on}.`,
};
/* Der Kontaktblock zählt mit – er ist der letzte nummerierte Abschnitt der Seite.
Der Termine-Ledger („Auf der Messe“) ist am 10.09.2026 entfallen (Kundenwunsch);
damit hat die Seite drei Abschnitte, und `flags.showHerCareer` steuert hier nichts
mehr – nur noch die Meta-Beschreibung in der Route. */
const total = "03";
return ( return (
<> <>
{/* Ohne Fakten-Stapel: der Hero trägt Headline, Subline und den einen Knopf. */} {/* Weder Fakten-Stapel noch Subline: <ServiceHero/> stellt die Begrüßung ohne `subline`
<ServiceHero content={content} /> eine Stufe größer und über die Spalten 3–11. Der Gutschein hängt als Slot direkt
darunter – wer den Code gescannt hat, sieht den Nachlass ohne einen Scroll an einer
Sektionsüberschrift vorbei. Die Breite ist begrenzt: über die vollen neun Spalten
gezogen wäre er kein Gutschein mehr, sondern ein Banner. */}
<ServiceHero
content={content}
/* Das Monogramm steht hinter dem Schlusspunkt der Begrüßung – die einzige
Stelle der Seite, an der Marke und Satz eine Zeile teilen. Größe und
Grundlinie regelt <ServiceHero/>; `idPrefix`, weil dieselbe Marke auf der
Seite noch dreimal vorkommt (Kopf, Hülle, Fuß) und die Verlaufs-ids sonst
kollidieren. */
h1Mark={<BrandMark idPrefix="hc-h1" decorative />}
>
<Coupon content={coupon} className="max-w-[34rem]" />
</ServiceHero>
<ServiceLayout> <ServiceLayout>
{/* 01 Messe-Special – der Grund, warum jemand den Code gescannt hat. */} {/* 01 Messe-Special – was Firmen zum Gutschein oben im Hero obendrauf bekommen.
Wie eingelöst wird, steht im Abriss des Gutscheins („Stichwort herCAREER“);
der Weg dorthin ist das Formular in Sektion 04. */}
<Section id={special.id} theme="light" numeral="01" total={total} label={special.eyebrow}> <Section id={special.id} theme="light" numeral="01" total={total} label={special.eyebrow}>
<SectionHeading eyebrow={special.eyebrow} h2={special.h2} className={HEAD} /> <SectionHeading eyebrow={special.bonus.eyebrow} h2={special.bonus.h2} className={HEAD} />
<Reveal className={`${BODY} mt-12 lg:mt-16`}>
<Facts variant="table" title={special.title} items={special.rows} /> <div className={`${BODY} mt-12 lg:mt-16`}>
</Reveal> <Ledger variant="werdegang" rows={special.bonus.rows} />
</div>
</Section> </Section>
{/* 02 Themen – die vier Blöcke der Rückwand, verlinkt auf die Hauptdomain. */} {/* 02 Themen – die vier Blöcke der Rückwand, verlinkt auf die Hauptdomain. */}
@@ -57,20 +88,10 @@ export function HerCareerBody({ content }: HerCareerBodyProps) {
<Ledger variant="toc" rows={themen.rows} /> <Ledger variant="toc" rows={themen.rows} />
</div> </div>
</Section> </Section>
{/* 03 Auf der Messe – derselbe Termine-Ledger wie auf der Startseite, eine Quelle. */}
{showFair && (
<Section id={messe.id} theme="light" numeral="03" total={total} label={messe.eyebrow}>
<SectionHeading eyebrow={messe.eyebrow} className={HEAD} />
<div className={`${BODY} mt-12 lg:mt-16`}>
<Ledger variant="termine" rows={messe.termine.rows} heading={messe.termine.heading} />
</div>
</Section>
)}
</ServiceLayout> </ServiceLayout>
{/* Kontakt: derselbe Block wie Sektion 06 der Startseite, mit dem Formular. */} {/* Kontakt: derselbe Block wie Sektion 06 der Startseite, mit dem Formular. */}
<ProcessContact content={contact} privacyHref={content.privacyHref} total={total} /> <ProcessContact content={contact} privacyHref={content.privacyHref} total={total} coupon={contactCoupon} />
</> </>
); );
} }
+2 -2
View File
@@ -7,7 +7,7 @@ import { About } from "@/components/home/About";
import { Audience } from "@/components/home/Audience"; import { Audience } from "@/components/home/Audience";
import { ProcessContact } from "@/components/home/ProcessContact"; import { ProcessContact } from "@/components/home/ProcessContact";
import type { HomeContent } from "@/content/home"; import type { HomeContent } from "@/content/home";
import { getPhotos } from "@/lib/assets"; import { getCertificates, getPhotos } from "@/lib/assets";
import { routesByLang, type Lang } from "@/lib/i18n"; import { routesByLang, type Lang } from "@/lib/i18n";
import { rootCopy } from "@/lib/metadata"; import { rootCopy } from "@/lib/metadata";
import { site } from "@/lib/site"; import { site } from "@/lib/site";
@@ -37,7 +37,7 @@ export function HomeBody({ lang, content }: { lang: Lang; content: HomeContent }
<Positioning content={content.positioning} /> <Positioning content={content.positioning} />
<ServicesIndex content={content.services} /> <ServicesIndex content={content.services} />
<WolfPrinciple mark={<BrandMark idPrefix="prinzip" />} content={content.principle} /> <WolfPrinciple mark={<BrandMark idPrefix="prinzip" />} content={content.principle} />
<About photo={aboutPhoto} content={content.about} /> <About photo={aboutPhoto} content={content.about} certificates={getCertificates()} />
<Audience content={content.audience} /> <Audience content={content.audience} />
<ProcessContact content={content.contact} privacyHref={routesByLang[lang].datenschutz} /> <ProcessContact content={content.contact} privacyHref={routesByLang[lang].datenschutz} />
</> </>
+50 -44
View File
@@ -5,15 +5,14 @@ import { Occasions } from "@/components/services/keynotes/Occasions";
import { SectionHeading } from "@/components/services/SectionHeading"; import { SectionHeading } from "@/components/services/SectionHeading";
import { SignatureTalk, type TalkLabels } from "@/components/services/keynotes/SignatureTalk"; import { SignatureTalk, type TalkLabels } from "@/components/services/keynotes/SignatureTalk";
import { SpeakerProfile } from "@/components/services/keynotes/SpeakerProfile"; import { SpeakerProfile } from "@/components/services/keynotes/SpeakerProfile";
import { CtaBand } from "@/components/ui/CtaBand"; import { ProcessContact } from "@/components/home/ProcessContact";
import { FactsStrip } from "@/components/ui/FactsStrip";
import { Faq } from "@/components/ui/Faq"; import { Faq } from "@/components/ui/Faq";
import { Interruption } from "@/components/ui/Interruption";
import { Ledger } from "@/components/ui/Ledger"; import { Ledger } from "@/components/ui/Ledger";
import { Reveal } from "@/components/motion/Reveal"; import { Reveal } from "@/components/motion/Reveal";
import { talkLabels as talkLabelsDe, type KeynotesContent } from "@/content/keynotes"; 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 { 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. */ /** Content column of every light section: cols 3–12 on desktop. */
const BODY = "col-span-12 lg:col-span-10 lg:col-start-3"; const BODY = "col-span-12 lg:col-span-10 lg:col-start-3";
@@ -26,6 +25,10 @@ export interface KeynotesBodyProps {
content: KeynotesContent; content: KeynotesContent;
/** Zeilenbeschriftungen im Vortragsblock – in der Sprache der Seite. */ /** Zeilenbeschriftungen im Vortragsblock – in der Sprache der Seite. */
talkLabels?: TalkLabels; 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 * „die Bühnenseite“). Server Component: Foto und PDF werden zur Bauzeit über lib/assets
* geprüft. Kein <main> – template.tsx rendert `main#inhalt`. * geprüft. Kein <main> – template.tsx rendert `main#inhalt`.
* *
* Reihenfolge: Hero (ink, optionales Bühnenfoto) → 01 Der Vortrag → Interruption (ink, auf * Reihenfolge: Hero (ink, optionales Bühnenfoto, Fakten inkl. Technik) → 01 Der Vortrag
* oberster Ebene, damit sie ihren eigenen Grund malt) → 02 Anlässe → 03 Publikum → * (mit Terminen und – nur bei gelieferter Datei – der PDF-Zeile) → 02 Anlässe →
* 04 Für Veranstalter (Speaker-Facts, Termine, PDF-Zeile nur bei gelieferter Datei) → * 03 Publikum → 04 FAQ (Flag) → Terminanfrage (ink, Formular, zählt mit).
* 05 FAQ (Flag) → Kontakt-Band (ink).
*/ */
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 { stage } = getPhotos();
const speakerPdf = flags.showSpeakerPdf ? getSpeakerPdf() : null; const speakerPdf = flags.showSpeakerPdf ? getSpeakerPdf() : null;
const showFaq = flags.showFaq && keynotes.faq.items.length > 0; const showFaq = flags.showFaq && keynotes.faq.items.length > 0;
const total = showFaq ? "05" : "04"; 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 ( return (
<> <>
@@ -58,18 +65,23 @@ export function KeynotesBody({ content: keynotes, talkLabels = talkLabelsDe }: K
<div className={`${BODY} mt-16`}> <div className={`${BODY} mt-16`}>
<SignatureTalk rows={talk.rows} labels={talkLabels} /> <SignatureTalk rows={talk.rows} labels={talkLabels} />
</div> </div>
{/* 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> </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"
/>
<ServiceLayout>
<Section id={occasions.id} theme="light" numeral="02" total={total} label={occasions.eyebrow}> <Section id={occasions.id} theme="light" numeral="02" total={total} label={occasions.eyebrow}>
<SectionHeading eyebrow={occasions.eyebrow} h2={occasions.h2} className={HEAD} /> <SectionHeading eyebrow={occasions.eyebrow} h2={occasions.h2} className={HEAD} />
<div className={`${BODY} mt-16`}> <div className={`${BODY} mt-16`}>
@@ -84,30 +96,8 @@ export function KeynotesBody({ content: keynotes, talkLabels = talkLabelsDe }: K
</div> </div>
</Section> </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 && ( {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} /> <SectionHeading eyebrow={faq.eyebrow} className={HEAD} />
<Reveal className={`${FAQ_COL} mt-12`}> <Reveal className={`${FAQ_COL} mt-12`}>
<Faq items={faq.items} /> <Faq items={faq.items} />
@@ -116,7 +106,23 @@ export function KeynotesBody({ content: keynotes, talkLabels = talkLabelsDe }: K
)} )}
</ServiceLayout> </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 { TrainingsSituations } from "@/components/services/trainings/TrainingsSituations";
import { TrainingsModules } from "@/components/services/trainings/TrainingsModules"; import { TrainingsModules } from "@/components/services/trainings/TrainingsModules";
import { TrainingsMethod } from "@/components/services/trainings/TrainingsMethod"; 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 { TrainingsAblauf } from "@/components/services/trainings/TrainingsAblauf";
import { TrainingsFaq } from "@/components/services/trainings/TrainingsFaq"; 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 { TrainingsContent } from "@/content/trainings";
import type { HomeContent } from "@/content/home"; import { contact as contactDe, type HomeContent } from "@/content/home";
import { flags } from "@/lib/site"; import { flags, routes } from "@/lib/site";
const pad = (n: number) => String(n).padStart(2, "0"); const pad = (n: number) => String(n).padStart(2, "0");
@@ -19,6 +17,10 @@ export interface TrainingsBodyProps {
content: TrainingsContent; content: TrainingsContent;
/** „Über Anelia“-Randspalte im Abschnitt „Wie ich arbeite“ – in der Sprache der Seite. */ /** „Über Anelia“-Randspalte im Abschnitt „Wie ich arbeite“ – in der Sprache der Seite. */
marginalia: HomeContent["about"]["marginalia"]; 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, * 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 Draw, kein Blur, kein Foto (das Bühnenfoto gehört zu den Keynotes, §7.1).
* Kein <main>: template.tsx liefert `main#inhalt`. * 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). */ /* Helle Abschnitte in Lesereihenfolge; FAQ nur mit `flags.showFaq` (Spec §7.1.6). */
const sections = [ const sections = [
{ id: trainings.situations.id, label: trainings.situations.eyebrow, body: <TrainingsSituations content={trainings} /> }, { 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, label: trainings.how.eyebrow,
body: <TrainingsMethod content={trainings} marginalia={marginalia} />, 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} /> }, { id: trainings.ablauf.id, label: trainings.ablauf.eyebrow, body: <TrainingsAblauf content={trainings} /> },
...(flags.showFaq && trainings.faq.items.length > 0 ...(flags.showFaq && trainings.faq.items.length > 0
? [{ id: trainings.faq.id, label: trainings.faq.eyebrow, body: <TrainingsFaq content={trainings} /> }] ? [{ 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 ( return (
<> <>
@@ -56,7 +65,23 @@ export function TrainingsBody({ content: trainings, marginalia }: TrainingsBodyP
</Section> </Section>
))} ))}
</ServiceLayout> </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}
/>
</> </>
); );
} }
+67 -7
View File
@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Container } from "@/components/layout/Container"; import { Container } from "@/components/layout/Container";
import { Folio } from "@/components/layout/Folio"; import { Folio } from "@/components/layout/Folio";
@@ -18,7 +19,8 @@ export interface ServiceHeroContent {
h1: Headline; h1: Headline;
/** The italic word may also carry `.gold-text` (keynotes – the page's one gradient word). */ /** The italic word may also carry `.gold-text` (keynotes – the page's one gradient word). */
h1GoldEm?: boolean; h1GoldEm?: boolean;
subline: string; /** Fließtext unter der Headline. Ohne Angabe trägt die Headline den Hero allein. */
subline?: string;
/** Haupt-Knopf. Ohne Angabe trägt der Hero nur Headline und Subline. */ /** Haupt-Knopf. Ohne Angabe trägt der Hero nur Headline und Subline. */
cta?: Cta; cta?: Cta;
/** Fakten-Stapel oben rechts. Ohne Angabe (und ohne Foto) entfällt die Spalte. */ /** Fakten-Stapel oben rechts. Ohne Angabe (und ohne Foto) entfällt die Spalte. */
@@ -34,6 +36,18 @@ export interface ServiceHeroProps {
content: ServiceHeroContent; content: ServiceHeroContent;
/** Stage photo (keynotes) – `null`/undefined = no photo. */ /** Stage photo (keynotes) – `null`/undefined = no photo. */
photo?: string | null; photo?: string | null;
/**
* Steht in derselben Spalte direkt unter Headline, Subline und Knopf.
* Auf der Messeseite hängt hier der Gutschein: Wer den Code gescannt hat,
* soll den Nachlass sehen, ohne erst an einer Sektionsüberschrift vorbei
* zu scrollen.
*/
children?: ReactNode;
/**
* Steht als letztes Zeichen in der H1, hinter dem Schlusspunkt. Auf der Messeseite
* hängt hier das Monogramm – die Begrüßung endet mit der Marke, zu der sie gehört.
*/
h1Mark?: ReactNode;
className?: string; className?: string;
} }
@@ -41,8 +55,12 @@ export interface ServiceHeroProps {
* Service-page hero (ink, ≥ 60svh): mono numeral, H1 with one italic word, * Service-page hero (ink, ≥ 60svh): mono numeral, H1 with one italic word,
* subline, the page's primary CTA, facts stack top-right, folio with the next * subline, the page's primary CTA, facts stack top-right, folio with the next
* section. Header monogram only – no hero draw. * section. Header monogram only – no hero draw.
*
* Ohne `subline` steht die Headline allein: sie erbt dann den Platz, den der
* Fließtext gehalten hat – eine Stufe größer und über die Spalten 3–11 statt
* 3–9. So trägt die Messeseite ihren Hero mit einem einzigen Satz.
*/ */
export function ServiceHero({ content, photo, className }: ServiceHeroProps) { export function ServiceHero({ content, photo, children, h1Mark, className }: ServiceHeroProps) {
/* `masthead` und `eyebrow` stehen weiter in den Inhaltsdateien, werden aber seit /* `masthead` und `eyebrow` stehen weiter in den Inhaltsdateien, werden aber seit
dem 06.09.2026 nicht mehr gerendert (Kundenwunsch): die Kopfzeile trug dieselben dem 06.09.2026 nicht mehr gerendert (Kundenwunsch): die Kopfzeile trug dieselben
Angaben doppelt, und über der H1 stand faktisch die Überschrift ein zweites Mal. */ Angaben doppelt, und über der H1 stand faktisch die Überschrift ein zweites Mal. */
@@ -51,6 +69,11 @@ export function ServiceHero({ content, photo, className }: ServiceHeroProps) {
/* Die Messeseite trägt weder Fakten noch Foto – dann gibt es die rechte Spalte /* Die Messeseite trägt weder Fakten noch Foto – dann gibt es die rechte Spalte
nicht, statt eine leere Reveal-Hülle in die Spalten 10–12 zu stellen. */ nicht, statt eine leere Reveal-Hülle in die Spalten 10–12 zu stellen. */
const hasAside = hasPhoto || Boolean(facts?.length); const hasAside = hasPhoto || Boolean(facts?.length);
/* Ohne Fließtext steht die Headline allein im Hero – dann bekommt sie dessen
Platz, statt dass der Hero um die fehlende Zeile zusammenfällt. Der Slot
darunter ändert daran nichts: Auf der Messeseite ist die große Begrüßung
ausdrücklich gewollt, der Gutschein hängt sich unter sie. */
const solo = !subline;
return ( return (
<section <section
@@ -71,25 +94,62 @@ export function ServiceHero({ content, photo, className }: ServiceHeroProps) {
{numeral} {numeral}
</Reveal> </Reveal>
<div className={cn("col-span-12 flex flex-col lg:col-start-3", hasPhoto ? "lg:col-span-5" : "lg:col-span-7")}> <div
className={cn(
"col-span-12 flex flex-col lg:col-start-3",
hasPhoto ? "lg:col-span-5" : solo ? "lg:col-span-9" : "lg:col-span-7",
)}
>
<Lines <Lines
as="h1" as="h1"
play="load" play="load"
delay={0.35} delay={0.35}
className="max-w-[14ch] font-display text-[clamp(2.75rem,1.4rem+5.4vw,7rem)] font-normal leading-[0.96] tracking-[-0.015em] text-ivory [text-wrap:balance]" className={cn(
"max-w-[14ch] font-display font-normal leading-[0.96] tracking-[-0.015em] text-ivory [text-wrap:balance]",
solo ? "text-[clamp(3.25rem,1.6rem+7vw,9rem)]" : "text-[clamp(2.75rem,1.4rem+5.4vw,7rem)]",
)}
> >
{h1.before} {h1.before}
{h1.em && <em className={cn("italic", h1GoldEm && "gold-text")}>{h1.em}</em>} {h1.em && <em className={cn("italic", h1GoldEm && "gold-text")}>{h1.em}</em>}
{h1.after} {h1.after}
{h1Mark && (
/* Auf der Grundlinie, hinter dem Punkt, 0,783 em hoch: Das ist die
Oberlänge der Playfair Display (im Browser an „h“ und „b“ gemessen;
die Versalhöhe liegt bei 0,719 em) – die Marke wird damit genau so
hoch, wie die Schrift dieser Zeile maximal wird, und keinen Punkt
höher. Die viewBox der Marke sitzt eng auf der Zeichnung, deshalb
ist Kastenhöhe gleich sichtbare Höhe.
Zwischen Punkt und Marke steht KEIN Leerzeichen im Markup – der
Abstand kommt aus `ml`, damit an dieser Stelle keine Umbruchstelle
entsteht und die Marke nicht allein in eine letzte Zeile rutscht.
Dekorativ (`aria-hidden`): Die Marke steht im Kopf schon mit Namen.
In der Überschrift stünde er ein zweites Mal – und zwar mitten im
Satz, den ein Screenreader vorliest. */
<span
aria-hidden="true"
className="ml-[0.22em] inline-block align-baseline [&_svg]:h-[0.783em] [&_svg]:w-auto"
>
{h1Mark}
</span>
)}
</Lines> </Lines>
<Reveal play="load" delay={1.0} as="p" className="mt-10 max-w-[40ch] font-sans text-xl font-light leading-[1.45] text-fg-muted"> {subline && (
{subline} <Reveal play="load" delay={1.0} as="p" className="mt-10 max-w-[40ch] font-sans text-xl font-light leading-[1.45] text-fg-muted">
</Reveal> {subline}
</Reveal>
)}
{cta && ( {cta && (
<Reveal play="load" delay={1.2} className="mt-8"> <Reveal play="load" delay={1.2} className="mt-8">
<Button {...cta} /> <Button {...cta} />
</Reveal> </Reveal>
)} )}
{children && (
<Reveal play="load" delay={1.0} className="mt-12 lg:mt-14">
{children}
</Reveal>
)}
</div> </div>
{hasAside && ( {hasAside && (
@@ -1,27 +1,20 @@
import { Reveal } from "@/components/motion/Reveal";
import { Marginalia } from "@/components/ui/Marginalia";
import { Steps } from "@/components/ui/Steps"; import { Steps } from "@/components/ui/Steps";
import { trainings as trainingsDe, type TrainingsContent } from "@/content/trainings"; import { trainings as trainingsDe, type TrainingsContent } from "@/content/trainings";
import { SectionHeading } from "@/components/services/SectionHeading"; import { SectionHeading } from "@/components/services/SectionHeading";
/** /**
* Ablauf – four numbered rows (Bedarfsgespräch → Follow-up nach 30 Tagen), * Ablauf – four numbered rows (Bedarfsgespräch → Follow-up nach 30 Tagen), cols 3–8.
* cols 3–8, plus the „Was du bekommst“ marginalia in cols 10–12 (sticky on * Die Randspalte „Was du bekommst“ ist am 02.10.2026 entfallen: Sie wiederholte die
* desktop) so the spread reads like coaching’s Ablauf. * Schritte (Konzept, 30 Tage, Follow-up) Wort für Wort.
*/ */
export function TrainingsAblauf({ content = trainingsDe }: { content?: TrainingsContent }) { export function TrainingsAblauf({ content = trainingsDe }: { content?: TrainingsContent }) {
const { eyebrow, steps, marginalia } = content.ablauf; const { eyebrow, steps } = content.ablauf;
return ( return (
<> <>
<SectionHeading eyebrow={eyebrow} className="col-span-12 lg:col-span-6 lg:col-start-3" /> <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"> <div className="col-span-12 mt-10 lg:col-span-6 lg:col-start-3">
<Steps items={steps} /> <Steps items={steps} />
</div> </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;
@@ -26,17 +26,17 @@ const MODULE_TABLE_FIX = [
].join(" "); ].join(" ");
/** /**
* Leistungsbausteine „Drei Module.“ – ledger table cols 3–12: header row * Leistungsbausteine („Vier Module.“) – ledger table cols 3–12: header row
* „Modul | Ziel | Für wen“, one native `<details>` per module (+ → ×), * „Modul | Ziel | Für wen“, one native `<details>` per module (+ → ×),
* opening to the hairline-separated „Inhalte“ list. * opening to the hairline-separated „Inhalte“ list.
*/ */
export function TrainingsModules({ content = trainingsDe }: { content?: TrainingsContent }) { export function TrainingsModules({ content = trainingsDe }: { content?: TrainingsContent }) {
const { eyebrow, h2, items } = content.modules; const { eyebrow, h2, items, labels } = content.modules;
return ( return (
<> <>
<SectionHeading eyebrow={eyebrow} h2={h2} className="col-span-12 lg:col-span-6 lg:col-start-3" /> <SectionHeading eyebrow={eyebrow} h2={h2} className="col-span-12 lg:col-span-6 lg:col-start-3" />
<Reveal className="col-span-12 mt-12 lg:col-span-8 lg:col-start-3 lg:mt-16"> <Reveal className="col-span-12 mt-12 lg:col-span-8 lg:col-start-3 lg:mt-16">
<ModuleTable modules={items} className={MODULE_TABLE_FIX} /> <ModuleTable modules={items} labels={labels} className={MODULE_TABLE_FIX} />
</Reveal> </Reveal>
</> </>
); );
@@ -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;
@@ -2,7 +2,7 @@ import { Ledger } from "@/components/ui/Ledger";
import { trainings as trainingsDe, type TrainingsContent } from "@/content/trainings"; import { trainings as trainingsDe, type TrainingsContent } from "@/content/trainings";
import { SectionHeading } from "@/components/services/SectionHeading"; import { SectionHeading } from "@/components/services/SectionHeading";
/** Ausgangslage „Drei Bremsen.“ – H2 cols 3–8, situations ledger cols 3–11 (rows stagger in via Reveal). */ /** Ausgangslage „Vier Bremsen.“ – H2 cols 3–8, situations ledger cols 3–11 (rows stagger in via Reveal). */
export function TrainingsSituations({ content = trainingsDe }: { content?: TrainingsContent }) { export function TrainingsSituations({ content = trainingsDe }: { content?: TrainingsContent }) {
const { eyebrow, h2, rows } = content.situations; const { eyebrow, h2, rows } = content.situations;
return ( return (
+102
View File
@@ -0,0 +1,102 @@
"use client";
import { useState, type MouseEvent } from "react";
import { cn } from "@/lib/utils";
import type { CertificateAsset } from "@/lib/assets";
import type { CertificateCopy } from "@/content/home";
import { LaurelWreath } from "./LaurelWreath";
import { CertificateDialog } from "./CertificateDialog";
export interface CertificateProps {
content: CertificateCopy;
asset: CertificateAsset;
className?: string;
}
/**
* Das Zertifikats-Abzeichen der Sektion „Über Anelia“ – Lorbeerkranz in Gold,
* darunter Haarlinie, Beschreibung, Datum. Kein Fettdruck, Radius 0, kein
* Schatten; die Display-Schrift kommt hier gar nicht vor, weil sie unter 28 px
* nicht gesetzt werden darf und in diese Spalte nichts Größeres passt.
*
* Ausgeliefert wird ein echter Link auf das PDF: ohne JavaScript, beim
* Rechtsklick und beim Öffnen im neuen Tab landet man beim Dokument. Erst das
* JS wertet den Klick zum Overlay auf.
*/
export function Certificate({ content, asset, className }: CertificateProps) {
const [open, setOpen] = useState(false);
const onClick = (e: MouseEvent<HTMLAnchorElement>) => {
/* Neues Fenster/Tab bleibt neues Fenster/Tab – nur der einfache Klick
wird abgefangen. */
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
e.preventDefault();
setOpen(true);
};
return (
<div className={cn("max-w-[16rem]", className)}>
<a
href={asset.pdf}
target="_blank"
rel="noopener noreferrer"
onClick={onClick}
className="group block text-fg"
>
<span className="relative block aspect-[892.77/688.08] w-full">
<LaurelWreath className="absolute inset-0 h-full w-full text-gold-700 transition-colors duration-500 ease-[var(--ease-out-quart)] group-hover:text-gold-900 group-focus-visible:text-gold-900 [[data-theme=dark]_&]:text-gold" />
<span className="absolute inset-0 flex items-center justify-center">
{/* Aus dem Zertifikat freigestellt, unverändert in Farbe und Form: eine
fremde Marke wird nicht ins Gold der Seite umgefärbt. `alt=""`, weil
der Text direkt darunter dasselbe sagt. */}
{/* eslint-disable-next-line @next/next/no-img-element -- static export, no optimiser */}
<img
src={asset.seal}
alt=""
width={asset.sealSize}
height={asset.sealSize}
/* 58 % der Kranzbreite. Die Öffnung gäbe bis 70 % her (im Browser an den
Pfaden nachgemessen), der Rest ist Luft, damit das Siegel nicht an den
Blättern klebt. */
className="block h-auto w-[58%]"
/>
</span>
</span>
{/* Zeilenhöhe 1,4 statt 1, damit ein umbrechender Titel („Greator
Persönlichkeitscoach“) nicht zusammenklebt. Die Ränder davor und danach
sind um den halben Durchschuss (0,15 rem) kürzer – einzeilig steht alles
wie zuvor mit `leading-none`, `mt-6` und `mt-5`. `balance`, damit
„persolog® / Persönlichkeits-Modell“ umbricht statt nach dem Bindestrich. */}
<span className="mt-[1.35rem] block font-mono text-xs uppercase leading-[1.4] tracking-[0.18em] text-accent [text-wrap:balance]">
{content.title}
</span>
<span aria-hidden="true" className="rule-gold mt-[1.1rem] block h-px w-full" />
<span className="mt-5 block font-sans text-[0.875rem] leading-[1.5] text-fg [hyphens:auto]">
{content.description}
</span>
<span className="mt-3 block font-mono text-[0.6875rem] tracking-[0.06em] tabular-nums text-fg-muted">
{content.date}
</span>
<span className="mt-5 inline-flex items-baseline gap-2 font-sans text-[0.8125rem] font-medium uppercase leading-none tracking-[0.2em] text-accent">
<span className="bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat pb-1 transition-[background-size] duration-[400ms] ease-[var(--ease-out-quart)] group-hover:bg-[length:100%_1px] group-focus-visible:bg-[length:100%_1px]">
{content.cta}
</span>
<span
aria-hidden="true"
className="transition-transform duration-300 ease-[var(--ease-out-quart)] group-hover:translate-x-2"
>
→
</span>
</span>
</a>
<CertificateDialog open={open} onClose={() => setOpen(false)} content={content} asset={asset} />
</div>
);
}
export default Certificate;
+206
View File
@@ -0,0 +1,206 @@
"use client";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { CertificateAsset } from "@/lib/assets";
import type { CertificateCopy } from "@/content/home";
import { gsap, DUR, EASE, MM, getLenis, refreshSoon } from "@/lib/gsap";
/**
* Kopf, Blatt und Fußzeile teilen sich eine Breite, sonst stehen sie versetzt.
* Auf dem Desktop richtet sie sich nach der Höhe: das Blatt soll ganz ins Bild
* passen (0,706 = 989 ÷ 1400, das Seitenverhältnis des Scans).
*/
const COLUMN = "w-full max-w-[46rem] lg:max-w-[min(46rem,calc((100svh-16rem)*0.706))]";
export interface CertificateDialogProps {
open: boolean;
onClose: () => void;
content: CertificateCopy;
asset: CertificateAsset;
}
/**
* Vorschau des Zertifikats: dunkler Grund, Seite 1 als Bild, darunter der Weg
* zum PDF. Bewusst ein Bild und kein eingebettetes PDF – ein `<object>`/iframe
* hätte den Viewer des Browsers mitgebracht (auf dem Handy unzuverlässig) und
* stünde quer zur Zusage in der Datenschutzerklärung.
*
* Verhalten wie beim Overlay-Menü, dessen Muster diese Komponente übernimmt:
* Escape schließt, Klick auf den Grund schließt, Fokus bleibt im Dialog,
* Lenis hält an, der Rest der Seite wird `inert`, am Ende bekommt das
* auslösende Element seinen Fokus zurück.
*
* Gerendert wird in `document.body` – der Auslöser sitzt in `main`, und `main`
* ist ja gerade das, was hier `inert` gesetzt wird. Ohne den Portal läge der
* Dialog in seinem eigenen toten Bereich.
*/
export function CertificateDialog({ open, onClose, content, asset }: CertificateDialogProps) {
/* Die Überschrift benennt den Dialog (`aria-labelledby`) – je Zertifikat ein Dialog. */
const titleId = useId();
const panel = useRef<HTMLDivElement>(null);
const onCloseRef = useRef(onClose);
/* Der Dialog muss seinen Ausblend-Tween überleben – gleiche Lösung wie im
Overlay-Menü: `exiting` hält ihn im DOM, bis GSAP fertig ist. */
const [exiting, setExiting] = useState(false);
const [prevOpen, setPrevOpen] = useState(open);
if (prevOpen !== open) {
setPrevOpen(open);
setExiting(!open);
}
const mounted = open || exiting;
useEffect(() => {
onCloseRef.current = onClose;
});
/* Nur `opacity` – der Dialog enthält Links, und alles mit `visibility` oder
`autoAlpha` nähme sie aus der Tab-Reihenfolge (Projektregel). */
useLayoutEffect(() => {
const el = panel.current;
if (!mounted || !el) return;
const mm = gsap.matchMedia();
mm.add({ reduce: MM.reduce, ok: MM.ok }, (ctx) => {
const reduce = Boolean(ctx.conditions?.reduce);
if (open) {
gsap.set(el, { opacity: reduce ? 1 : 0 });
if (reduce) return;
gsap.to(el, { opacity: 1, duration: DUR.micro, ease: EASE.out });
return;
}
gsap.to(el, {
opacity: 0,
duration: reduce ? 0 : DUR.micro,
ease: EASE.inOut,
onComplete: () => setExiting(false),
});
});
return () => mm.revert();
}, [open, mounted]);
useEffect(() => {
if (!open) return;
const lenis = getLenis();
lenis?.stop();
const previous = document.activeElement as HTMLElement | null;
const others = Array.from(document.querySelectorAll<HTMLElement>("main, header, footer"));
others.forEach((el) => {
el.inert = true;
});
const focusables = () =>
Array.from(panel.current?.querySelectorAll<HTMLElement>("a[href], button:not([disabled])") ?? []);
const t = window.setTimeout(() => focusables()[0]?.focus(), 50);
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCloseRef.current();
return;
}
if (e.key !== "Tab") return;
const items = focusables();
if (!items.length) return;
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement as HTMLElement | null;
const inside = active ? items.includes(active) : false;
if (e.shiftKey && (active === first || !inside)) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && (active === last || !inside)) {
e.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", onKey);
return () => {
window.clearTimeout(t);
document.removeEventListener("keydown", onKey);
others.forEach((el) => {
el.inert = false;
});
lenis?.start();
previous?.focus?.();
refreshSoon();
};
}, [open]);
if (!mounted) return null;
return createPortal(
<div
ref={panel}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
data-theme="dark"
data-lenis-prevent=""
onClick={(e) => {
/* Nur der Grund schließt – ein Klick ins Blatt darf nicht durchschlagen. */
if (e.target === e.currentTarget) onCloseRef.current();
}}
className="fixed inset-0 z-50 flex flex-col items-center overflow-y-auto overscroll-contain bg-ink px-5 py-[calc(var(--header-h,60px)/2+16px)] text-fg lg:px-10"
>
<div className={`mb-8 flex items-start justify-between gap-6 ${COLUMN}`}>
{/* `min-w-0` + Silbentrennung: „Persönlichkeitscoach“ ist auf dem Handy
breiter als der Platz neben „Schließen“ und schöbe den Knopf sonst
aus dem Bild. */}
<div className="min-w-0">
<p className="font-mono text-xs uppercase tracking-[0.12em] text-accent">{content.eyebrow}</p>
{/* Playfair hat hier den Platz für seine 28-px-Untergrenze – im Abzeichen selbst nicht. */}
<h2
id={titleId}
className="mt-3 font-display text-[clamp(1.75rem,1.2rem+1vw,2.25rem)] font-normal leading-none tracking-[-0.01em] text-fg [hyphens:auto]"
>
{content.title}
</h2>
</div>
<button
type="button"
onClick={() => onCloseRef.current()}
className="-my-3 shrink-0 py-3 font-mono text-xs uppercase tracking-[0.2em] text-fg-muted transition-colors duration-300 hover:text-gold focus-visible:text-gold"
>
{content.close} <span aria-hidden="true">✕</span>
</button>
</div>
<figure className={`m-0 flex flex-col ${COLUMN}`}>
{/* eslint-disable-next-line @next/next/no-img-element -- static export, no optimiser */}
<img
src={asset.image}
alt={content.imageAlt}
width={asset.width}
height={asset.height}
className="block h-auto w-full border border-line"
/>
<figcaption className="mt-6 flex w-full flex-col gap-4 sm:flex-row sm:items-baseline sm:justify-between">
<span className="font-mono text-xs tracking-[0.06em] text-fg-muted">{content.dialogMeta}</span>
<a
href={asset.pdf}
target="_blank"
rel="noopener noreferrer"
className="group/tl -my-4 inline-flex shrink-0 items-baseline gap-2 py-4 font-sans text-[0.8125rem] font-medium uppercase leading-none tracking-[0.2em] text-accent"
>
<span className="bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat pb-1 transition-[background-size] duration-[400ms] ease-[var(--ease-out-quart)] group-hover/tl:bg-[length:100%_1px] group-focus-visible/tl:bg-[length:100%_1px]">
{content.pdfLabel}
</span>
<span
aria-hidden="true"
className="transition-transform duration-300 ease-[var(--ease-out-quart)] group-hover/tl:translate-x-2"
>
↗
</span>
</a>
</figcaption>
</figure>
</div>,
document.body,
);
}
export default CertificateDialog;
+113 -2
View File
@@ -5,6 +5,7 @@ import { cn } from "@/lib/utils";
import { site, routes } from "@/lib/site"; import { site, routes } from "@/lib/site";
import { contact as contactDe, type HomeContent } from "@/content/home"; import { contact as contactDe, type HomeContent } from "@/content/home";
import { BUTTON_BASE, BUTTON_PRIMARY } from "@/components/ui/Button"; import { BUTTON_BASE, BUTTON_PRIMARY } from "@/components/ui/Button";
import { COUPON_SURFACE, CouponPerforation } from "@/components/ui/Coupon";
import { TextLink } from "@/components/ui/TextLink"; import { TextLink } from "@/components/ui/TextLink";
/** /**
@@ -26,18 +27,75 @@ import { TextLink } from "@/components/ui/TextLink";
/** Endpunkt beim Hoster – gleiche Domain, deshalb ohne CORS-Vorspiel. */ /** Endpunkt beim Hoster – gleiche Domain, deshalb ohne CORS-Vorspiel. */
const ENDPOINT = "/api/contact"; const ENDPOINT = "/api/contact";
/**
* Ein Vorteil, der schon an der Anfrage hängt (Messeseite: der gescannte Gutschein).
* Das Formular zeigt ihn als bereits ausgefülltes, gesperrtes Feld über den anderen –
* es gibt nichts abzutippen, kein Stichwort einzutragen.
*/
export interface ContactCoupon {
/** Mono-Caps über dem Wert, z. B. „Messe-Special“. */
eyebrow: string;
/** Der Vorteil in einer Zeile, z. B. „−20 % · Stichwort herCAREER“. */
value: string;
/** Zustand rechts oben, z. B. „Hinterlegt“. */
state: string;
/**
* Das rote Reduziert-Fähnchen am Scroll-Fortschritt, zweigeteilt wie ein Ticket:
* links der Betrag, rechts hinter der Perforation der Abriss („−20“ | „%“).
* <ProcessContact/> schreibt beides als `data-sale` und `data-sale-stub` an den
* Abschnitt; <ScrollProgress/> liest es von dort. Wenige Zeichen je Hälfte – das
* Fähnchen ist rund 20 px hoch.
*/
tag: { value: string; stub: string };
/** Ein Satz darunter: was der Besucher jetzt noch tun muss. */
hint: string;
/**
* Diese Zeile wird der Nachricht angehängt.
*
* Ohne sie wäre der Hinweis eine Behauptung, die nur auf dem Bildschirm gilt:
* `/api/contact` liegt beim Hoster und nicht in diesem Repo, also ist nicht
* nachprüfbar, ob ein zusätzliches Feld im Postfach ankommt. Die Nachricht kommt
* sicher an – deshalb reist der Vorteil in ihr mit.
*/
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"; type Status = "idle" | "sending" | "sent" | "error";
export function ContactForm({ export function ContactForm({
className, className,
content: form = contactDe.form, content: form = contactDe.form,
privacyHref = routes.datenschutz, privacyHref = routes.datenschutz,
coupon,
fields = [],
topic,
}: { }: {
className?: string; className?: string;
/** Ziel des Datenschutz-Links – in der englischen Fassung /en/privacy/. */ /** Ziel des Datenschutz-Links – in der englischen Fassung /en/privacy/. */
privacyHref?: string; privacyHref?: string;
/** Inhalt. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */ /** Inhalt. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */
content?: HomeContent["contact"]["form"]; 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 uid = useId();
const [status, setStatus] = useState<Status>("idle"); const [status, setStatus] = useState<Status>("idle");
@@ -53,12 +111,23 @@ export function ContactForm({
const data = new FormData(formEl); const data = new FormData(formEl);
const value = (key: string) => String(data.get(key) ?? "").trim(); const value = (key: string) => String(data.get(key) ?? "").trim();
/* Der Vorteil hängt unter der Nachricht, durch eine Leerzeile abgesetzt – so steht
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 = { const payload = {
name: value("name"), name: value("name"),
email: value("email"), email: value("email"),
company: value("company"), company: value("company"),
message: value("message"), message: coupon ? `${written}\n\n${coupon.line}` : written,
page: typeof window === "undefined" ? "" : window.location.pathname, page: typeof window === "undefined" ? "" : window.location.pathname,
...(topic ? { topic } : {}),
...(details.length ? { details } : {}),
// Honeypot: Menschen sehen das Feld nicht, Bots füllen es aus. // Honeypot: Menschen sehen das Feld nicht, Bots füllen es aus.
website: value("website"), website: value("website"),
}; };
@@ -79,13 +148,14 @@ export function ContactForm({
`${form.fields.name}: ${payload.name}`, `${form.fields.name}: ${payload.name}`,
`${form.fields.email}: ${payload.email}`, `${form.fields.email}: ${payload.email}`,
payload.company ? `${form.fields.company}: ${payload.company}` : null, payload.company ? `${form.fields.company}: ${payload.company}` : null,
...details.map((d) => `${d.label}: ${d.value}`),
"", "",
payload.message, payload.message,
] ]
.filter((line) => line !== null) .filter((line) => line !== null)
.join("\r\n"); .join("\r\n");
setMailtoHref( setMailtoHref(
`mailto:${site.email}?subject=${encodeURIComponent(form.subject)}&body=${encodeURIComponent(body)}`, `mailto:${site.email}?subject=${encodeURIComponent(topic ?? form.subject)}&body=${encodeURIComponent(body)}`,
); );
setStatus("error"); setStatus("error");
} }
@@ -113,6 +183,28 @@ export function ContactForm({
return ( return (
<form onSubmit={submit} noValidate={false} className={cn("flex flex-col gap-8", className)}> <form onSubmit={submit} noValidate={false} className={cn("flex flex-col gap-8", className)}>
{coupon && (
/* Kein <input disabled>: Ein gesperrtes Feld ist für Screenreader ein Feld, das
man nicht bedienen kann – hier gibt es aber gar nichts zu bedienen. Deshalb
steht der Vorteil als Text da, in der Form eines ausgefüllten Feldes. */
<div className={COUPON_SURFACE}>
<div className="px-5 py-5 sm:px-6">
<div className="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-2">
<p className="font-mono text-[11px] uppercase tracking-[0.16em] text-gold-900">{coupon.eyebrow}</p>
<p className="font-mono text-[11px] uppercase tracking-[0.16em] text-gold-900">
<span aria-hidden="true">✓ </span>
{coupon.state}
</p>
</div>
<p className="mt-2 font-sans text-[1.0625rem] leading-snug">{coupon.value}</p>
</div>
<CouponPerforation />
<p className="max-w-[46ch] px-5 py-4 font-mono text-[11px] leading-relaxed tracking-[0.04em] text-gold-900 sm:px-6">
{coupon.hint}
</p>
</div>
)}
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
<div> <div>
<label htmlFor={field("name")} className={label}> <label htmlFor={field("name")} className={label}>
@@ -135,6 +227,25 @@ export function ContactForm({
<input id={field("company")} name="company" type="text" autoComplete="organization" className={input} /> <input id={field("company")} name="company" type="text" autoComplete="organization" className={input} />
</div> </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> <div>
<label htmlFor={field("message")} className={label}> <label htmlFor={field("message")} className={label}>
{form.fields.message} {form.fields.message}
+129
View File
@@ -0,0 +1,129 @@
import { cn } from "@/lib/utils";
export interface CouponContent {
/** Mono-Caps über dem Satz, z. B. „Ihr Messe-Special“. */
kicker: string;
/** Der Nachlass in Display-Ziffern, z. B. „−20“. */
rate: string;
/** Einheit neben der Zahl, z. B. „%“. */
unit: string;
/** Worauf er gilt – der eine Satz, der den Gutschein trägt. */
on: string;
/** Was drinsteckt. Eine Zeile je Leistung, mit goldenem Strich davor. */
includes: readonly string[];
/** Frist und Stichwort im Abriss unter der Perforation. */
valid: { label: string; value: string };
code: { label: string; value: string };
}
export interface CouponProps {
content: CouponContent;
className?: string;
}
const MONO = "font-mono text-xs tracking-[0.06em] tabular-nums";
/**
* Der Grund eines Gutscheins: blasses Gold, goldene Haarlinie, runde Ecken.
*
* Exportiert, weil zwei Bauteile denselben Gegenstand darstellen – der große
* Gutschein im Hero und der kleine Hinweis über dem Kontaktformular, dass der
* Vorteil schon anhängt. Sie müssen gleich aussehen, also gibt es die Werte
* einmal. Die runden Ecken sind die ausdrückliche Ausnahme von „Radius 0“
* (Kundenwunsch 10.09.2026) und gelten nur für diese beiden Stellen.
*/
export const COUPON_SURFACE = "rounded-[14px] border border-gold-700 bg-gold-100 text-ink";
/**
* Die Reißkante: gestrichelte Haarlinie über die volle Breite, an beiden Enden
* eine Kerbe. Die Kerben sind auf die Ecke gestellte Quadrate in der Grundfarbe
* des umgebenden Abschnitts (`bg-bg`) – sie sehen aus wie gestanzt und kommen
* ohne einen einzigen Eckenradius aus. Auf hellem Grund elfenbein, auf dunklem
* tintenschwarz, ganz von selbst.
*/
export function CouponPerforation() {
return (
<div aria-hidden="true" className="relative border-t border-dashed border-gold-700">
<span className="absolute left-0 top-0 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rotate-45 bg-bg" />
<span className="absolute right-0 top-0 h-3.5 w-3.5 translate-x-1/2 -translate-y-1/2 rotate-45 bg-bg" />
</div>
);
}
/**
* Der Messe-Gutschein (/her-career/, Sektion 01).
*
* Er soll als Gutschein erkennbar sein, ohne aus dem Haus-Stil zu fallen –
* also kein abgerundetes Ticket mit Schatten und Fremdschrift, sondern die
* drei Merkmale, die ein Gutschein wirklich braucht, in der Sprache der Seite:
*
* 0. Runde Ecken. Die einzige Stelle der Website mit einem Eckenradius
* (Projektregel ist sonst Radius 0, ausdrückliche Ausnahme vom 10.09.2026):
* Der Gutschein stellt einen Gegenstand dar, kein Interface-Element, und
* ein Gutschein mit scharfen Ecken sieht aus wie eine Tabelle. Der Radius
* bleibt auf dieses eine Bauteil beschränkt – Knöpfe, Rahmen und Tafeln
* ringsum sind weiter eckig.
* 1. Ein eigener Grund. `--gold-100` ist der blasse Goldton der Palette –
* bis hierher nirgends als Fläche benutzt und damit frei für genau das
* eine Objekt, das sich vom Elfenbein abheben soll. Die Schrift darauf ist
* Tinte, nicht Gold: Gold auf Gold liest sich nicht, und goldene Schrift
* bleibt ohnehin dem dunklen Grund vorbehalten.
* 2. Eine Reißkante. Gestrichelte Haarlinie über die volle Breite, an beiden
* Enden eine Kerbe. Die Kerben sind auf die Ecke gestellte Quadrate in der
* Grundfarbe der Sektion – gestanzt aussehende Löcher ohne einen einzigen
* Eckenradius.
* 3. Ein Abriss. Unter der Kante stehen Frist und Stichwort, das Stichwort im
* eigenen Kästchen wie ein Stempelfeld.
*
* Der Block steht immer auf `data-theme="light"`; die Kerben nehmen mit `bg-bg`
* den Grund der Sektion an.
*/
export function Coupon({ content, className }: CouponProps) {
const { kicker, rate, unit, on, includes, valid, code } = content;
return (
<div className={cn("relative", COUPON_SURFACE, className)}>
<div className="p-7 sm:p-12">
<p className={cn(MONO, "uppercase tracking-[0.16em] text-gold-900")}>{kicker}</p>
{/* Zahl und Einheit auf einer Grundlinie; die Einheit misst sich an der Zahl. */}
<p className="mt-5 flex items-baseline gap-[0.1em] font-display font-normal leading-[0.82] tracking-[-0.02em] [font-variant-numeric:lining-nums]">
<span className="text-[clamp(4.5rem,2.2rem+9vw,8.5rem)]">{rate}</span>
<span className="text-[clamp(1.75rem,1rem+3vw,3rem)]">{unit}</span>
</p>
<p className="mt-6 max-w-[22ch] font-display text-[clamp(1.75rem,1.35rem+1.5vw,2.75rem)] font-normal leading-[1.08] tracking-[-0.01em] [text-wrap:balance]">
{on}
</p>
{/* Dieselben Schienen wie <Facts variant="stack"/>: Strich | Text. */}
<ul className={cn(MONO, "mt-9 grid gap-y-3")}>
{includes.map((item) => (
<li key={item} className="grid grid-cols-[1.5rem_1fr] gap-x-3">
<span aria-hidden="true" className="mt-[0.55em] h-px w-6 self-start bg-gold-900" />
<span>{item}</span>
</li>
))}
</ul>
</div>
<CouponPerforation />
{/* Der Abriss: Frist links, Stichwort rechts im Stempelfeld. */}
<dl className={cn(MONO, "flex flex-wrap items-center justify-between gap-x-10 gap-y-4 p-7 sm:px-12 sm:py-8")}>
<div className="flex items-baseline gap-2">
<dt className="text-gold-900">{valid.label}</dt>
<dd>{valid.value}</dd>
</div>
<div className="flex items-center gap-3">
<dt className="text-gold-900">{code.label}</dt>
{/* Kein `uppercase`: „herCAREER“ ist gemischt geschrieben, Versalien
machten daraus ein „HERCAREER“, das die Messe so nicht führt. */}
<dd className="rounded-[6px] border border-gold-700 px-3 py-1.5 tracking-[0.14em]">{code.value}</dd>
</div>
</dl>
</div>
);
}
export default Coupon;
-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;
+32
View File
@@ -0,0 +1,32 @@
import { cn } from "@/lib/utils";
/**
* Lorbeerkranz, zwei Hälften, `fill: currentColor` – die Farbe kommt also von
* der Klasse am Element, nicht aus dem Pfad. Rein dekorativ (`aria-hidden`):
* was der Kranz bedeutet, steht als Text daneben.
*
* Inline und nicht als `<img src>`, weil das Projekt keine externen Requests
* macht und ein Inline-SVG die Goldfarbe des jeweiligen Grundes erbt.
*/
export function LaurelWreath({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 892.77 688.08"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
focusable="false"
className={cn("fill-current", className)}
>
<path d={RIGHT} />
<path d={LEFT} />
</svg>
);
}
const RIGHT =
"M892.6,358.7c-2.45,21.69-26.03,75.09-50.59,78.41-2.61.35-8.8-.53-10.21.3-.39.23-4.32,6.79-4.75,7.74-6.88,15.27-11.04,42.49-17.43,60.57-2.66,7.51-9.87,21.35-11.03,27.98-.1.58-.26,1.65.51,1.5,6.6-6.88,8.6-17.3,13.33-25.67,13.94-24.66,43.3-43.5,72.17-42.32-2.33,14.6-10.5,29.43-19.18,41.31-12.47,17.06-30.45,32.62-53.23,29.6-7.56-1-6.85-5.3-14.59,1.58-6.35,5.64-9.71,15.53-14.19,22.81-9.91,16.12-20.19,32.01-32.8,46.2,4.66.89,8.76-7.66,11.97-11.03,11.14-11.72,21.81-19.57,38.02-22.98,15.67-3.3,37.45-3.14,52.02,4.03-12.24,25.25-57.46,51.32-84.7,41.67-7.61-2.7-12.94-10.13-22.06-6.43-18.34,18.87-38.7,35.14-61.25,48.75,4.97,1.57,9.28-3.53,13.32-5.68,20.73-11.01,32.74-14.63,56.5-9.15,13.85,3.19,26.55,9.91,38.17,17.82-19.09,21.89-75.53,31.43-97.49,11.5-3.79-3.44-5.4-10.28-10.95-11.04-9.07-1.24-22.07,6.8-31.04,10.07-3.69,1.34-27.01,9.51-28.52,7.99.06-2.6-.8-5.29,1.57-6.93,1.5-1.04,17.65-4.87,21.68-6.32,8.48-3.06,22.88-8.38,30.46-12.54,9.12-5.01,2.78-21.66,2.3-29.72-1.18-19.76,7.83-43.47,20.48-58.5,1.79-2.13,19.39-19.37,21.51-16.98-2.82,28.58.11,60.88-19.03,84.46-2.69,3.03-6.49,6.06-10.06,7.94-4.12,2.16-9.31.65-8.9,7.59,1.27,1.23,11.02-5.38,12.91-6.58,10.31-6.56,22.01-15.72,31.1-23.9,2.59-2.34,19.93-19.11,20.44-20.46.23-.63-.39-11.92-.68-13.33-1.18-5.83-6.95-13.96-8.5-21.5-3.96-19.28-2.28-43.2,4.96-61.49,3.45-8.71,9.01-17.74,15.27-24.73,2.9,17.5,8.39,36.08,10.37,53.62,1.87,16.52.69,35.79-10.9,48.86-4.62,5.21-9.96,5.2-7.97,14.52,13.17-15.31,24.53-32.58,34.16-50.33,1.99-3.66,9.61-17.17,9.7-20.13.33-10.18-12.65-21.57-17.05-30.87-11.72-24.78-14.73-58.69-5.28-84.63.71-1.96,1.42-5.12,3.44-6.04,12.22,32.23,38.76,63.84,24.79,100.27-2.31,6.03-7.68,8.74-4.76,15.73,1.54-.25,1.89-1.87,2.47-3.02,7.87-15.49,14.88-42.87,18.84-60.16,1.87-8.16,6.12-21.11,2.85-28.5-4.29-9.69-11.33-11.15-18.64-17.35-24.62-20.88-33.84-58.75-31.52-89.97,1.97-.51,1.93.81,2.81,1.68,20.75,20.47,53.85,55.45,49.12,86.75-.44,2.9-3.52,7.02-3.71,9.23s2.82,4.83,2.79,7.33c1.42,1.39,1.82-.07,1.99-1.48.8-6.86,1.5-14.15,1.99-21.03,1.05-14.96,1.76-33.12,1.05-48.03-.45-9.51-.5-18.45-7.5-25.5-4.23-4.26-9.74-3.95-14.91-6.09-28.89-12.01-48.06-49.12-49.88-79.13-.07-1.11-2.03-1.97.73-1.73,23.64,16.34,59.89,35.61,63.41,67.59.81,7.39-3.03,17.42,5.1,20.39-.84-15.5-4.12-31.76-7.48-47.01-1.91-8.66-8.25-37.76-12.65-43.35-6.39-8.12-14.14-3.87-22.25-4.75-21.82-2.37-43.12-23.21-53.78-41.22-3.02-5.1-7.72-13-7.34-18.66,29.71,10.05,66.39,12.92,75.07,48.93,1.39,5.76-1.51,11.75,7.44,11.05-9.45-25.34-21.53-49.48-34.15-73.34-9.49-11.1-18.29-1.46-29.31-.62-24.24,1.85-49.97-15.79-62.53-35.54,12.16-1.78,27.18-2.45,39.53-1.53,14.47,1.07,29.13,6.2,36.65,19.35,3.12,5.45,2.93,14.34,10.8,11.68-7.94-13.11-17.24-26.37-27.04-38.45-2.54-3.14-13.54-16.92-15.98-18.02-7.78-3.53-11.89,2.2-17.3,4.66-22.22,10.12-48.27,3.4-65.69-12.67-1.12-1.03-7.02-6.35-5.98-7.51,17.62-2.11,36.48-11.68,54.24-7.75,8.57,1.89,16.8,6.91,22.19,13.81,2.88,3.69.83,6.42,7.06,6.94,1.49.12,2.92.63,2.49-1.49-8.92-8.7-17.38-18.05-26.98-26.03-3.66-3.04-13.47-11.25-17.29-12.71-4.55-1.73-10.25-.33-15.11-.89-27.28-3.12-48.71-29.1-57.58-53.4,1.55-2.18,1.45-.91,2.6-.65,29.66,6.66,68.48,16.71,70.87,53.16,21.14,14.84,39.62,33.01,57,52,.62-8.81-9.38-14.38-14.48-20.52-19.56-23.59-25.5-58.58-24.02-88.47.68-.79,10.44,7.02,11.52,7.97,20.16,17.57,37.02,49.29,34.52,76.57-1,10.9-9.52,15.89-4.29,28.21,1.81,4.26,14.83,18.08,18.66,23.34,9.6,13.17,19.55,27.31,26.6,41.91,1.62.09,2.24.49,1.98-1.47-.48-3.57-9.92-18.62-12.17-23.85-11.51-26.81-14.45-60.89-2.88-88.23.37-.88.81-2.87,2.06-1.96,9.94,12.98,16.87,28.86,21.49,44.52,5.8,19.65,9.83,40.98.04,60.03-1.45,2.82-4.93,5.59-5.48,8.51-2.09,11.08,6.07,21.31,10.12,30.78,7.21,16.81,14.1,33.95,18.83,51.67,1.58-.05,1.97-4.44,1.92-5.42-.27-5.28-7.25-19.63-8.64-27.37-4.67-26.05-2.53-50.65,10.53-73.88,1.45-2.57,4.8-8.51,6.73-10.28,1.69-1.54,1.69-.41,2.66,1.23,12.28,20.79,17.41,81.23,2.78,101.21-2.35,3.21-9.25,7.51-10.45,9.55-.39.66-3.05,12.36-3.11,13.43-.35,5.98,7.73,33.4,9.25,42.34,2.02,11.93,3.48,24.11,4.32,36.18,2.19-.58,1.95-2.71,2.05-4.46.4-6.52-1.93-15.33-2.11-22-.88-33.13,13.53-64.28,38.54-85.52,1.65-.03,3.41,14.23,3.55,16.43,1.54,24.35-4.14,74.1-26.52,88.57-6.99,4.52-12.38,3.46-14.29,13.71-2.29,12.31,3.05,32.46.81,45.81l-4.03,40.46c4.58-3.93,3.21-12.03,4.24-17.25,6.58-33.49,28.21-64.47,60.75-76.75-.37,4.65.51,9.94,0,14.5Z";
const LEFT =
"M122.6,347.7c-1.7,17.7-14.14,47.55-26.48,60.52-5.79,6.09-14.83,9.27-20.05,15.95-7.2,9.23-5.11,14.49-3.18,25.23,4.49,25.1,12.63,50.47,22.72,73.79,1.68-.31,2.16-5.36,2.02-6.44-.24-1.76-4.97-6.9-6.22-9.86-8.94-21.17-.97-44.77,8.01-64.37l18.18-35.83c12.17,28.27,10.36,66.34-4.83,93.18-4.82,8.52-16.69,18.39-14.52,29.1.83,4.09,6.01,13.72,8.18,17.9,9.33,17.95,21.89,36.15,35.16,51.33,1.78-8.72-2.17-7.86-6.5-12.99-25.68-30.41-7.81-69.51-2-104.01,20.14,23.8,27.35,62.58,18.27,92.26-3.14,10.27-9.76,17.88-6.25,29.71,18.74,19.75,39.22,38.23,63.47,51.02.89-8.43-5.47-6.33-10.34-9.15-4.61-2.67-12.11-11.32-15.06-15.94-13.97-21.88-9.34-50.93-13.57-75.43,4.11-.29,10.26,4.82,13.45,7.55,18.34,15.69,31.07,45.29,29.51,69.46-.49,7.59-5.43,18.51-.47,25.5,1.35,1.9,21.37,10.31,25.22,11.78,9.94,3.8,20.08,6.73,30.28,9.72l.98,7.51c-7.54-2.18-15.1-4.36-22.51-6.98-7.88-2.79-24.11-11.27-30.95-12.04-11.89-1.35-11.52,5.99-19.05,12-20.85,16.65-61.75,9.86-83.5-2.46-2.07-1.18-13.19-7.73-11.54-10.53,21.13-14.47,48.69-24.76,74.34-17.26,10.95,3.2,20.18,11.91,29.19,14.81,1.02.33,5.04,1.53,5.02-.51-11.82-6.39-24.42-14.2-34.91-22.59-6.02-4.82-23.27-23.82-27.5-25.5-9.8-3.9-9.91.63-16.9,4.26-26.89,13.94-71.69-11.42-87.08-34.28-1.05-1.55-3.6-3.96-2.17-5.96,3.81-5.34,36.79-5.58,43.94-4.81,18.64,1.99,33.72,11.44,46.14,24.86,3.15,3.4,6.26,11.64,11.48,10.52-10.1-11.12-19.65-23.71-27.41-36.58-5-8.3-11.58-25.16-17.59-31.41-7.79-8.1-10.21-3.34-18.5-2.52-24.7,2.44-44.75-17.93-56.91-37.07-6.3-9.93-12.8-22.08-13.59-33.92,33.98-.41,60.36,19.86,75.52,48.98,3.34,6.42,4.42,14.83,10.47,19.02-6.28-16.37-13.32-32.34-18.26-49.23s-6.28-33.8-14.89-48.61c-33.08,1.59-49.47-37.67-57.35-64.16-2.91-9.81-5.26-18.73-3.5-28.99,26.83,9.2,52.19,40.65,58.53,67.96,1.93,8.33.98,19.55,6.46,26.03-5.71-27.36-4-55.47-3.25-83.24-.33-2.46-2.64-10.16-4.27-11.73-1.84-1.78-8.46-3.44-11.41-5.59-21.82-15.94-28.32-68.11-24.94-92.82.26-1.88,2.02-11.61,3.38-11.6,24.77,21.31,40.05,54.58,38.49,87.47-.37,7.9-3.74,17.25-1.49,24.51,1.87-1.18,1.21-2.88,1.51-4.49,2.56-13.9,3.69-28.54,6.31-42.69,1.26-6.78,7.5-26.71,7.24-31.33-.05-.87-2.77-11.88-3.12-12.42-.93-1.43-6.38-5.09-8.41-7.59-17.02-20.94-12.69-82.05.77-104.18.86-1.41,1.09-3.01,2.65-1.25,3.37,3.78,8.96,15.02,11.03,19.97,8.44,20.2,10.18,39.73,6.24,61.21-1.57,8.58-8.23,23.74-8.7,30.39-.09,1.31.19,5.98,1.96,6.38,2.2-9.93,5.59-20.68,9.26-30.24,4.82-12.57,15.43-28.92,18.49-40.51.76-2.89,1.78-9.95,1.23-12.72-.47-2.38-6.17-8.59-7.75-12.24-9.36-21.65.56-56.02,9.6-76.95.98-2.26,12.81-26.07,15.16-24.32,12.15,28.6,10.64,62.39-1.81,90.67-2.46,5.58-10.57,17.48-11.2,21.81-.14.96-.63,3.81,1.01,3.51,4.3-10.16,10.59-19.63,16.79-28.71,6.7-9.82,24.02-28.29,27.92-37.08,5.11-11.53.33-12.38-2.74-21.66-7.05-21.37,8.26-54.53,22.07-71.01,3.38-4.04,17.33-17.9,21.49-19.52,1.73-.68,1.35.43,1.49,1.47,3.89,29.17-8.37,67.53-27.03,89.99-4.6,5.54-11.32,8.61-11.97,16.52,1.97.46,2.37-1.01,3.41-2.08,16.97-17.57,33.36-36.17,54.38-49.11-.07-35.38,43.89-46.99,71.78-53.74,1.38-.33,1.84.04,1.42,1.42-5.53,18.05-25.93,42.44-43.72,49.28-12.29,4.72-24.31,2.03-34.57,8.43-14.24,8.89-27.32,24.04-38.69,36.31,6.82,2.69,8.09-6.05,11.95-10.05,18.99-19.63,50.96-6.74,73.53-2.45,1.16.29.47,1.31.03,2.01-2.75,4.35-16.7,13.95-21.64,16.36-13.41,6.54-31.14,9.11-45.24,3.5-7.37-2.94-11.36-9.47-20.93-6.66-4.55,1.34-25.58,28.93-29.52,34.48-3.29,4.64-12.58,17.34-14.17,21.83-1,2.82,5.58-.56,6-1,.9-.94,3.53-9.1,5.75-12.25,5.76-8.16,20.75-17.27,30.73-17.27h43l1.06,2.03c-13.36,19.94-41.02,38.11-65.81,34.71-10.24-1.41-16.8-10.17-26.17,1.35-8.68,10.66-28.7,55.97-32.13,69.87-.26,1.06-1.13,3.41.04,4.04,6.8-3.28,4.54-9.25,5.94-15.06,7.5-31,50.07-37.7,75.55-45.94-6.63,24.9-36.5,58.71-63.45,60.04-7.64.37-14.17-2.72-20.55,4.45-2.66,2.99-6.55,17.32-7.91,22.09-3.07,10.75-5.58,22.38-7.6,33.4-2.1,11.43-4.64,23.48-3.98,35.02,6.42-4.26,3.24-5.92,3.5-10.5,2.2-39.31,32.77-57.91,63.47-76.54.76-.46,1.75-1.99,2.53-.47-3.3,29.92-22.05,68.78-51.31,80.69-4.12,1.68-8.25,1.66-12.01,3.99-1.75,1.09-6.98,6.87-7.46,8.54-1.18,4.12-2.01,16.84-2.25,21.75-1.13,22.55,1.18,45.14,3.05,67.54,1.37,1.8,4.44-5.33,4.55-5.94.14-.79-4.39-11.57-4.45-15.45-.46-30.71,29.69-61.31,49.92-82.09.96-.98,1.3-1.88,2.98-1.52-.55,7.91.75,16.71,0,24.5Z";
export default LaurelWreath;
+10 -8
View File
@@ -8,6 +8,8 @@ export type { ModuleItem };
export interface ModuleTableProps { export interface ModuleTableProps {
modules: readonly ModuleItem[]; modules: readonly ModuleItem[];
/** Spalten- und Zellbeschriftungen in der Sprache der Seite. Ohne Angabe deutsch. */
labels?: { module: string; goal: string; contents: string; audience: string; meta: string };
className?: string; className?: string;
} }
@@ -21,14 +23,14 @@ const HEAD = cn(MONO, "uppercase tracking-[0.12em] text-accent");
* (Bodoni 30 px title, Jost goal + audience, mono meta). Opens to the * (Bodoni 30 px title, Jost goal + audience, mono meta). Opens to the
* hairline-separated „Inhalte“ list; „+“ rotates to „ד via CSS. * hairline-separated „Inhalte“ list; „+“ rotates to „ד via CSS.
*/ */
export function ModuleTable({ modules, className }: ModuleTableProps) { export function ModuleTable({ modules, labels = moduleLabels, className }: ModuleTableProps) {
const cols = "grid grid-cols-[1fr_auto] gap-x-6 gap-y-3 lg:grid-cols-[minmax(0,3.5fr)_minmax(0,3fr)_minmax(0,2fr)_minmax(0,2.2fr)_2rem] lg:gap-x-8"; const cols = "grid grid-cols-[1fr_auto] gap-x-6 gap-y-3 lg:grid-cols-[minmax(0,3.5fr)_minmax(0,3fr)_minmax(0,2fr)_minmax(0,2.2fr)_2rem] lg:gap-x-8";
return ( return (
<div className={cn("w-full", className)}> <div className={cn("w-full", className)}>
<div className={cn(cols, "hidden border-b border-line pb-3 lg:grid")} aria-hidden="true"> <div className={cn(cols, "hidden border-b border-line pb-3 lg:grid")} aria-hidden="true">
<span className={HEAD}>{moduleLabels.module}</span> <span className={HEAD}>{labels.module}</span>
<span className={HEAD}>{moduleLabels.goal}</span> <span className={HEAD}>{labels.goal}</span>
<span className={HEAD}>{moduleLabels.audience}</span> <span className={HEAD}>{labels.audience}</span>
<span /> <span />
<span /> <span />
</div> </div>
@@ -47,21 +49,21 @@ export function ModuleTable({ modules, className }: ModuleTableProps) {
would be announced as unlabelled text. The label stays in the DOM at every would be announced as unlabelled text. The label stays in the DOM at every
width and is only visually suppressed where the header row takes over. */} width and is only visually suppressed where the header row takes over. */}
<span className="col-span-2 font-sans text-[1.0625rem] leading-snug text-fg lg:col-span-1"> <span className="col-span-2 font-sans text-[1.0625rem] leading-snug text-fg lg:col-span-1">
<span className={cn(HEAD, "mr-3 lg:sr-only")}>{moduleLabels.goal}</span> <span className={cn(HEAD, "mr-3 lg:sr-only")}>{labels.goal}</span>
{m.goal} {m.goal}
</span> </span>
<span className="col-span-2 font-sans text-[1.0625rem] leading-snug text-fg lg:col-span-1"> <span className="col-span-2 font-sans text-[1.0625rem] leading-snug text-fg lg:col-span-1">
<span className={cn(HEAD, "mr-3 lg:sr-only")}>{moduleLabels.audience}</span> <span className={cn(HEAD, "mr-3 lg:sr-only")}>{labels.audience}</span>
{m.audience} {m.audience}
</span> </span>
<span className={cn(MONO, "col-span-2 tabular-nums text-fg-muted lg:col-span-1 lg:text-right")}> <span className={cn(MONO, "col-span-2 tabular-nums text-fg-muted lg:col-span-1 lg:text-right")}>
{/* The meta column has no visible header at any width. */} {/* The meta column has no visible header at any width. */}
<span className="sr-only">{moduleLabels.meta}</span> <span className="sr-only">{labels.meta}</span>
<MetaLines text={m.meta} /> <MetaLines text={m.meta} />
</span> </span>
</summary> </summary>
<div className="pb-8 lg:grid lg:grid-cols-[minmax(0,3fr)_minmax(0,7fr)_2rem] lg:gap-x-8"> <div className="pb-8 lg:grid lg:grid-cols-[minmax(0,3fr)_minmax(0,7fr)_2rem] lg:gap-x-8">
<span className={cn(HEAD, "mb-3 block lg:mb-0")}>{moduleLabels.contents}</span> <span className={cn(HEAD, "mb-3 block lg:mb-0")}>{labels.contents}</span>
<ul className="border-t border-line"> <ul className="border-t border-line">
{m.contents.map((line) => ( {m.contents.map((line) => (
<li key={line} className="border-b border-line py-3 font-sans text-[1.0625rem] leading-snug text-fg"> <li key={line} className="border-b border-line py-3 font-sans text-[1.0625rem] leading-snug text-fg">
+5
View File
@@ -18,6 +18,11 @@ export const chrome = {
sub: "Sales Mentoring & Potential Development", sub: "Sales Mentoring & Potential Development",
ariaLabel: "Anelia Wolf – Startseite", ariaLabel: "Anelia Wolf – Startseite",
}, },
/* Kollaborationszusatz neben der Wortmarke. Sichtbar ist nur „× herCAREER“;
was das Malzeichen meint, hört ein Screenreader hier ausgeschrieben. */
collab: {
ariaLabel: "In Zusammenarbeit mit der herCAREER Expo",
},
/* Der Sprunglink ist das erste fokussierbare Element im Dokument – er muss die /* Der Sprunglink ist das erste fokussierbare Element im Dokument – er muss die
Sprache der Seite sprechen, sonst liest ihn ein Screenreader deutsch auf einer Sprache der Seite sprechen, sonst liest ihn ein Screenreader deutsch auf einer
englischen Seite vor. */ englischen Seite vor. */
+3
View File
@@ -26,6 +26,9 @@ export const chromeEn: Chrome = {
sub: "Sales Mentoring & Potential Development", sub: "Sales Mentoring & Potential Development",
ariaLabel: "Anelia Wolf – home", ariaLabel: "Anelia Wolf – home",
}, },
collab: {
ariaLabel: "In collaboration with herCAREER Expo",
},
skipLink: "Skip to content", skipLink: "Skip to content",
header: { header: {
navAriaLabel: "Main navigation", navAriaLabel: "Main navigation",
+35 -20
View File
@@ -12,7 +12,7 @@
*/ */
import { site } from "@/lib/site"; import { site } from "@/lib/site";
import { routesByLang } from "@/lib/i18n"; import { routesByLang } from "@/lib/i18n";
import { assertNoPlaceholders, collectStrings, ph } from "@/lib/legal"; import { assertNoPlaceholders, collectStrings } from "@/lib/legal";
import type { HerCareerContent } from "@/content/her-career"; import type { HerCareerContent } from "@/content/her-career";
import { homeEn } from "./home"; import { homeEn } from "./home";
@@ -36,21 +36,42 @@ export const herCareerEn: HerCareerContent = {
lang: "German & English", lang: "German & English",
}, },
eyebrow: "herCAREER Expo 2026 · MOC Munich", eyebrow: "herCAREER Expo 2026 · MOC Munich",
h1: { before: "Good to have you ", em: "here", after: "." }, h1: { before: "Thanks for ", em: "scanning", after: "." },
subline: /* No hero subline – see the German file: whoever scanned the code knows they
"You scanned the code on the stand wall. Here is what we talk about at the booth – the fair special, my three topics and the shortest way to a conversation.", scanned it. The greeting carries the hero on its own, one size up. */
folio: { next: { label: "Fair special", href: "#messe-special" } }, folio: { next: { label: "Fair special", href: "#messe-special" } },
special: { special: {
id: "messe-special", id: "messe-special",
eyebrow: "Fair special", eyebrow: "Fair special",
h2: { before: "Your advantage.", em: "", after: "" }, coupon: {
title: "The special", kicker: "Your fair special",
rows: [ rate: "−20",
{ label: "The offer", value: ph("Messe-Special: Was bekommt, wer den Code scannt?") }, unit: "%",
{ label: "Valid until", value: ph("Frist, z. B. Anfragen bis 30.11.2026") }, on: "on personality & behaviour analyses",
{ label: "How to claim", value: ph("Bedingung, z. B. Stichwort in der Anfrage") }, includes: ["Profile analysis", "45 min 1:1 debrief coaching"],
], valid: { label: "Valid until", value: "6 November 2026" },
code: { label: "Keyword", value: "herCAREER" },
},
bonus: {
eyebrow: "At no extra cost",
h2: { before: "Companies get this ", em: "on top", after: "." },
rows: [
{
keyword: "Keynote Speaking",
text: "A 25-minute Q&A session straight after the talk – your audience leaves with answers, not just with impulses.",
},
{
keyword: "Trainings & Workshops",
text: "An exclusive 45-minute 1:1 deep-dive coaching with the manager: how to steer what the team learned and make it stick.",
},
],
},
formNotice: {
eyebrow: "Fair special",
state: "Applied",
hint: "Already attached to this enquiry. Just fill in the rest.",
},
}, },
themen: { themen: {
@@ -70,8 +91,8 @@ export const herCareerEn: HerCareerContent = {
numeral: "02", numeral: "02",
title: "Training & Workshops", title: "Training & Workshops",
oneLiner: oneLiner:
"B2B sales training: revenue growth through new business and existing customers, communication that fits the type across the team.", "B2B sales training and HR team building: revenue growth through new business and existing customers, team spirit and conflict resolution in the team.",
keywords: ["New business", "Existing customers", "Team communication"], keywords: ["New business", "Existing customers", "HR team building & conflict resolution"],
link: { label: "See training", href: main(r.trainings) }, link: { label: "See training", href: main(r.trainings) },
}, },
{ {
@@ -84,17 +105,11 @@ export const herCareerEn: HerCareerContent = {
], ],
}, },
messe: {
id: "auf-der-messe",
eyebrow: "At the fair",
termine: homeEn.termine,
},
privacyHref: main(r.datenschutz), privacyHref: main(r.datenschutz),
contact: { contact: {
...homeEn.contact, ...homeEn.contact,
numeral: "04", numeral: "03",
h2: { before: "Let us keep ", em: "talking", after: "." }, h2: { before: "Let us keep ", em: "talking", after: "." },
form: { ...homeEn.contact.form, subject: "herCAREER Expo 2026", note: "", privacy: "" }, form: { ...homeEn.contact.form, subject: "herCAREER Expo 2026", note: "", privacy: "" },
}, },
+62 -8
View File
@@ -21,7 +21,21 @@ export const homeEn: HomeContent = {
center: "Sales Mentoring & Potential Development", center: "Sales Mentoring & Potential Development",
lang: "German & English", lang: "German & English",
}, },
h1: { before: "Who you are decides how you ", em: "land", after: "." }, /* English rendering of Anelia's German line „Mit starker Persönlichkeit und
souveräner Wirkung zum Erfolg!“ – same three emphases, gold on „success“ only. */
h1: {
before: "Strong personality, assured impact – your way to ",
em: "success",
after: "!",
parts: [
{ text: "Strong " },
{ text: "personality", em: true, tail: "," },
{ text: " assured " },
{ text: "impact", em: true, tail: " –" },
{ text: " your way to " },
{ text: "success", em: true, gold: true, tail: "!" },
],
},
subline: subline:
"Welcome. Whether as a speaker at your company event, in one-to-one coaching or as a trainer for your team – I am glad to hear what you need.", "Welcome. Whether as a speaker at your company event, in one-to-one coaching or as a trainer for your team – I am glad to hear what you need.",
cta: { cta: {
@@ -37,6 +51,7 @@ export const homeEn: HomeContent = {
/* ---------- 01 Positioning (ivory) ---------- */ /* ---------- 01 Positioning (ivory) ---------- */
positioning: { positioning: {
id: "positionierung", id: "positionierung",
slogan: { words: ["Personality", "Impact", "Success"], lang: "en" },
numeral: "01", numeral: "01",
eyebrow: "Positioning", eyebrow: "Positioning",
h2: { before: "Three questions. One principle.", em: "", after: "" }, h2: { before: "Three questions. One principle.", em: "", after: "" },
@@ -67,8 +82,8 @@ export const homeEn: HomeContent = {
{ {
numeral: "02", numeral: "02",
title: "Training & Workshops", title: "Training & Workshops",
oneLiner: "Sales with personality – for teams and their leaders.", oneLiner: "Leadership & HR with personality – for leaders and their teams.",
keywords: ["B2B sales training", "New and existing customers", "Leadership & team dynamics"], keywords: ["B2B sales training", "HR team building & conflict resolution", "Communication & team spirit"],
link: { label: "See training", href: r.trainings }, link: { label: "See training", href: r.trainings },
}, },
{ {
@@ -90,7 +105,8 @@ export const homeEn: HomeContent = {
opLabels: { "+": "plus ", "=": "equals " }, opLabels: { "+": "plus ", "=": "equals " },
lines: [ lines: [
{ text: "Strong personality" }, { text: "Strong personality" },
{ op: "+", text: "Assured presence" }, /* „impact“ wie im Slogan und in der Headline (Entscheidung 02.10.2026). */
{ op: "+", text: "Assured impact" },
{ op: "=", text: "Maximum ", em: "success", goldEm: true }, { op: "=", text: "Maximum ", em: "success", goldEm: true },
], ],
link: { label: "The WOLF Principle on stage", href: r.keynotes }, link: { label: "The WOLF Principle on stage", href: r.keynotes },
@@ -104,14 +120,14 @@ export const homeEn: HomeContent = {
eyebrow: "About Anelia", eyebrow: "About Anelia",
h2: { before: "I am out selling myself. Every week.", em: "", after: "" }, h2: { before: "I am out selling myself. Every week.", em: "", after: "" },
paragraphs: [ paragraphs: [
"My father was a trader. I saw early on how a conversation changes a decision – and that people buy from people. Later came training as a dental technician, running a department, then sales and store management in the premium segment.", "My father was a trader. I saw early on how a conversation changes a decision – and that people buy from people. Later came training as a dental technician, running a department, then running a store in B2C retail – in the premium segment.",
"Today I work in B2B field sales: cold calling, new business, key accounts. Alongside that I work with people and teams as a coach, trainer and keynote speaker – in German and English. What I pass on, I did myself the week before.", "Today I work in B2B field sales: cold calling, new business, key accounts. Alongside that I work with people and teams as a coach, trainer and keynote speaker – in German and English. What I pass on, I did myself the week before.",
], ],
twins: "And at home two sixteen-year-old twins put my negotiating to the test daily.", twins: "And at home two sixteen-year-old twins put my negotiating to the test daily.",
werdegang: [ werdegang: [
{ keyword: "Roots", text: "Sales conversations in my father's business" }, { keyword: "Roots", text: "Sales conversations in my father's business" },
{ keyword: "Precision", text: "Dental technician, department manager" }, { keyword: "Precision", text: "Dental technician, department manager" },
{ keyword: "Premium", text: "Sales & store management in the premium segment" }, { keyword: "B2C", text: "Store manager & sales in the premium segment" },
{ keyword: "B2B", text: "Field sales: cold calling, new business, key accounts" }, { keyword: "B2B", text: "Field sales: cold calling, new business, key accounts" },
{ keyword: "Stage", text: "Keynote speaker, trainer & coach · herCAREER Expo 2026" }, { keyword: "Stage", text: "Keynote speaker, trainer & coach · herCAREER Expo 2026" },
], ],
@@ -122,6 +138,44 @@ export const homeEn: HomeContent = {
{ value: "LinkedIn", href: site.linkedin, external: true }, { value: "LinkedIn", href: site.linkedin, external: true },
{ value: "German · English" }, { value: "German · English" },
], ],
certificates: {
persoenlichkeitscoach: {
title: "Greator Personality Coach",
description: "Certified graduate of the Greator Personality Coach programme",
date: "October 2026",
cta: "View certificate",
eyebrow: "Certificate",
dialogMeta: "Greator · issued 9 October 2026",
imageAlt:
"Certificate: Anelia Wolf has successfully completed the theoretical and practical parts of the three-month Greator Personality Coach programme.",
pdfLabel: "Open PDF",
close: "Close",
},
persolog: {
title: "persolog® Personality Model",
description: "Certified trainer for the persolog® Personality Model",
date: "October 2026",
cta: "View certificate",
eyebrow: "Certificate",
dialogMeta: "persolog · completed 9 October 2026",
imageAlt:
"Certificate: Anelia Wolf successfully completed the persolog® Personality Model certification on 9 October 2026 and is entitled to call herself a certified trainer for the persolog® Personality Model.",
pdfLabel: "Open PDF",
close: "Close",
},
speaker: {
title: "Greator Speaker",
description: "Certified graduate of the Greator Speaker programme",
date: "May 2026",
cta: "View certificate",
eyebrow: "Certificate",
dialogMeta: "Greator TheKey · issued 16 May 2026",
imageAlt:
"Certificate: Anelia Wolf has successfully completed the certified Greator Speaker programme.",
pdfLabel: "Open PDF",
close: "Close",
},
},
folio: { current: "05", total: "06" }, folio: { current: "05", total: "06" },
}, },
@@ -134,11 +188,11 @@ export const homeEn: HomeContent = {
columns: [ columns: [
{ {
label: "Companies", label: "Companies",
title: "Sales teams & their leaders", title: "Sales teams, leaders & HR",
lines: [ lines: [
"New business that becomes predictable", "New business that becomes predictable",
"Existing customers that grow instead of drifting away", "Existing customers that grow instead of drifting away",
"Leadership and team dynamics in sales", "Team spirit & conflict resolution – with leaders and HR",
], ],
link: { label: "See training", href: r.trainings }, link: { label: "See training", href: r.trainings },
}, },
+16 -36
View File
@@ -3,7 +3,7 @@
* The talk title follows Anelia's own English herCAREER listing: * The talk title follows Anelia's own English herCAREER listing:
* „The WOLF Principle – Courage to Succeed!“. * „The WOLF Principle – Courage to Succeed!“.
*/ */
import { mailto } from "@/lib/site"; import { routesByLang } from "@/lib/i18n";
import { assertNoPlaceholders, collectStrings, ph } from "@/lib/legal"; import { assertNoPlaceholders, collectStrings, ph } from "@/lib/legal";
import type { KeynotesContent } from "@/content/keynotes"; import type { KeynotesContent } from "@/content/keynotes";
import { homeEn } from "./home"; import { homeEn } from "./home";
@@ -26,14 +26,15 @@ export const keynotesEn: KeynotesContent = {
h1: { before: "Courage to ", em: "succeed", after: "." }, h1: { before: "Courage to ", em: "succeed", after: "." },
h1GoldEm: true, h1GoldEm: true,
subline: 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.", "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: mailto.keynote }, cta: { label: "Request a keynote", href: `${routesByLang.en.keynotes}#terminanfrage` },
secondary: { label: "Speaker profile (PDF)", href: "/speaker-profil.pdf", external: true }, secondary: { label: "Speaker profile (PDF)", href: "/speaker-profil.pdf", external: true },
facts: [ facts: [
{ label: "Length", value: "20–60 minutes" }, { label: "Length", value: "20–60 minutes" },
{ label: "Format", value: "Live, online, hybrid" }, { label: "Format", value: "Live, online, hybrid" },
{ label: "Language", value: "German / English" }, { label: "Language", value: "German / English" },
{ label: "Travel", value: "Germany-wide, Europe on request" }, { label: "Travel", value: "Germany-wide, Europe on request" },
{ label: "Tech", value: "Headset, slides optional" },
], ],
alt: "Anelia Wolf on stage", alt: "Anelia Wolf on stage",
caption: `Anelia Wolf · Photo: ${ph("Name der Fotografin")}`, caption: `Anelia Wolf · Photo: ${ph("Name der Fotografin")}`,
@@ -48,19 +49,13 @@ export const keynotesEn: KeynotesContent = {
numeral: "01", numeral: "01",
title: "The WOLF Principle – Courage to Succeed!", title: "The WOLF Principle – Courage to Succeed!",
message: message:
"Strong personality, assured presence, maximum success: how you recognise your potential and use it with confidence – in life and in business.", "Strong personality, assured impact, maximum success: how you recognise your potential and use it with confidence – in life and in business.",
audience: "Employees, sales teams, leaders, career audiences", audience: "Employees, sales teams, leaders, career audiences",
meta: "20–60 min · DE/EN · live & online", meta: "20–60 min · DE/EN · live & online",
}, },
], ],
}, },
interruption: {
id: "mut",
text: "Courage to succeed.",
mono: "Keynote · herCAREER Expo 2026 · 23 Oct · 10:30 · Auditorium 2",
},
occasions: { occasions: {
id: "anlaesse", id: "anlaesse",
eyebrow: "Occasions", 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, termine: homeEn.termine,
veranstalter: { speakerPdf: { label: "Speaker profile (PDF)", text: "Short bio, topics, photos, tech", href: "/speaker-profil.pdf" },
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: { faq: {
id: "faq", id: "faq",
@@ -125,10 +103,6 @@ export const keynotesEn: KeynotesContent = {
q: "Can it be combined with a workshop?", 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.", 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?", q: "Is there video or are there references?",
a: "On request. Get in touch and I will send you material on the talk.", a: "On request. Get in touch and I will send you material on the talk.",
@@ -136,10 +110,16 @@ export const keynotesEn: KeynotesContent = {
], ],
}, },
band: { anfrage: {
h2: { before: "Bring the WOLF Principle to your stage.", em: "", after: "" }, id: "terminanfrage",
cta: { label: "Request a keynote", href: mailto.keynote }, eyebrow: "Request a date",
micro: "No obligation · reply in German or English", 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),", "– 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),", "– 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),", "– 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.", "– 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.", "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", h2: "5. Contact form",
paragraphs: [ 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.", "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.", "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.", "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.",
+28 -46
View File
@@ -2,7 +2,7 @@
* English copy of /en/services/trainings/. Same shape as src/content/trainings.ts. * 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. * 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 { assertNoPlaceholders, collectStrings, ph } from "@/lib/legal";
import type { TrainingsContent } from "@/content/trainings"; import type { TrainingsContent } from "@/content/trainings";
@@ -10,7 +10,7 @@ export const trainingsEn: TrainingsContent = {
meta: { meta: {
title: "Training & Workshops", title: "Training & Workshops",
description: description:
"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. Anelia Wolf, trainer.", "B2B sales training, leadership workshops and HR team building that hold up in the working week, not just in the seminar room. In house, online, in German or English. Anelia Wolf, trainer.",
}, },
numeral: "02", numeral: "02",
masthead: { masthead: {
@@ -21,31 +21,33 @@ export const trainingsEn: TrainingsContent = {
eyebrow: "For companies & teams", eyebrow: "For companies & teams",
h1: { before: "Sales with ", em: "personality", after: "." }, h1: { before: "Sales with ", em: "personality", after: "." },
subline: 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.", "B2B sales training, leadership workshops and HR team building that hold up in the working week, not just in the seminar room.",
cta: { label: "Request training", href: mailto.training, micro: "No obligation" }, cta: { label: "Request training", href: `${routesByLang.en.trainings}#terminanfrage`, micro: "No obligation" },
facts: [ facts: [
{ label: "Format", value: "In house or online" }, { label: "Format", value: "In house or online" },
{ label: "Length", value: "1–2 days or a series" }, { label: "Length", value: "1–2 days or a series" },
{ label: "Language", value: "German / English" }, { label: "Language", value: "German / English" },
{ label: "Group size", value: ph("Platzhalter"), placeholder: true }, { label: "Group size", value: ph("Platzhalter"), placeholder: true },
], ],
folio: { next: { label: "Three brakes", href: "#ausgangslage" } }, folio: { next: { label: "Four brakes", href: "#ausgangslage" } },
situations: { situations: {
id: "ausgangslage", id: "ausgangslage",
eyebrow: "Starting point", eyebrow: "Starting point",
h2: { before: "Three brakes.", em: "", after: "" }, h2: { before: "Four brakes.", em: "", after: "" },
rows: [ rows: [
{ numeral: "01", text: "New business has stalled – and nobody enjoys calling cold." }, { numeral: "01", text: "New business has stalled – and nobody enjoys calling cold." },
{ numeral: "02", text: "Existing customers are looked after, but never grown." }, { numeral: "02", text: "Existing customers are looked after, but never grown." },
{ numeral: "03", text: "Leadership and team dynamics slow sales down instead of carrying it." }, { numeral: "03", text: "Leadership and team dynamics slow sales down instead of carrying it." },
{ numeral: "04", text: "People in the team talk past each other – conflicts linger and team spirit is missing." },
], ],
}, },
modules: { modules: {
id: "module", id: "module",
eyebrow: "What you get", eyebrow: "What you get",
h2: { before: "Three modules.", em: "", after: "" }, h2: { before: "Four modules.", em: "", after: "" },
labels: { module: "Module", goal: "Goal", contents: "What it covers", audience: "Audience", meta: "Format" },
items: [ items: [
{ {
title: "B2B sales training", title: "B2B sales training",
@@ -64,10 +66,17 @@ export const trainingsEn: TrainingsContent = {
{ {
title: "Leadership & team dynamics", title: "Leadership & team dynamics",
goal: "Leadership that makes sales teams stronger.", goal: "Leadership that makes sales teams stronger.",
contents: ["Behaviour profiles in the team", "Roles", "Feedback", "Conflict", "Setting targets"], contents: ["Behaviour profiles in the team", "Roles", "Feedback", "Setting targets"],
audience: "Sales leadership, team leads, management", audience: "Sales leadership, team leads, management",
meta: "One-day workshop · on site · DE/EN", meta: "One-day workshop · on site · DE/EN",
}, },
{
title: "HR team building & conflict resolution",
goal: "A team that knows and understands each other – and resolves conflict itself.",
contents: ["Understanding yourself and the other person", "Communication within the team", "Building team spirit", "Resolving conflict"],
audience: "Teams, leaders and HR",
meta: `Workshop ${ph("Dauer")} · in house · DE/EN`,
},
], ],
}, },
@@ -81,44 +90,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: { ablauf: {
id: "ablauf", id: "ablauf",
eyebrow: "How it runs", eyebrow: "How it runs",
steps: [ steps: [
{ numeral: "01", title: "Scoping call", text: "" }, { 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: "03", title: "Training", text: "" },
{ numeral: "04", title: "Follow-up after 30 days", 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: { faq: {
@@ -129,10 +109,6 @@ export const trainingsEn: TrainingsContent = {
q: "In house or external?", q: "In house or external?",
a: "In house at your company, or online. An external venue is possible on request.", 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?", 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.", 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 +120,16 @@ export const trainingsEn: TrainingsContent = {
], ],
}, },
band: { anfrage: {
h2: { before: "Let's talk about your team.", em: "", after: "" }, id: "terminanfrage",
cta: { label: "Request training", href: mailto.training }, eyebrow: "Request a date",
micro: "No obligation", 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" },
],
}, },
}; };
+65 -30
View File
@@ -18,9 +18,9 @@
*/ */
import { routes, site } from "@/lib/site"; import { routes, site } from "@/lib/site";
import type { Widen } from "./widen"; import type { Widen } from "./widen";
import { assertNoPlaceholders, collectStrings, ph } from "@/lib/legal"; import { assertNoPlaceholders, collectStrings } from "@/lib/legal";
import type { FactItem, Headline, TocRow } from "./home"; import type { Headline, TocRow, WerdegangRow } from "./home";
import { contact as contactHome, termine } from "./home"; import { contact as contactHome } from "./home";
/** /**
* Absolute Adresse auf der Hauptdomain. Die Seite wird unter zwei Hosts * Absolute Adresse auf der Hauptdomain. Die Seite wird unter zwei Hosts
@@ -47,14 +47,15 @@ export const herCareer = {
lang: "Deutsch & Englisch", lang: "Deutsch & Englisch",
}, },
eyebrow: "herCAREER Expo 2026 · MOC München", eyebrow: "herCAREER Expo 2026 · MOC München",
h1: { before: "Schön, dass du ", em: "hier", after: " bist." } satisfies Headline, h1: { before: "Schön, dass Sie ", em: "gescannt", after: " haben." } satisfies Headline,
subline: /* Keine Subline im Hero (Kundenwunsch, 10.09.2026): Wer den Code gescannt hat, weiß,
"Du hast den Code an der Rückwand gescannt. Hier steht, worüber wir am Stand sprechen – das Messe-Special, meine drei Themen und der kürzeste Weg zu einem Gespräch.", dass er ihn gescannt hat – der Satz erklärte dem Besucher seine eigene Herkunft.
Die Begrüßung trägt den Hero jetzt allein; <ServiceHero/> stellt sie ohne `subline`
eine Stufe größer und über die Spalten 3–11. */
/* Kein Knopf im Hero (Kundenwunsch, 07.09.2026): Die eine Aufforderung steht im /* Kein Knopf im Hero (Kundenwunsch, 07.09.2026): Die eine Aufforderung steht im
Kontakt-Band am Fuß der Seite – dort mit Mail-Betreff „herCAREER Expo 2026“. */ Kontakt-Band am Fuß der Seite – dort mit Mail-Betreff „herCAREER Expo 2026“. */
/* Kein Fakten-Stapel im Hero (Kundenwunsch, 07.09.2026): Stand, Messetermin und /* Kein Fakten-Stapel im Hero (Kundenwunsch, 07.09.2026): Stand, Messetermin und
Themen standen dort ein zweites Mal – Standnummer und Termin trägt der Themen standen dort ein zweites Mal. */
Termine-Ledger weiter unten, die Themen der Abschnitt „Worüber wir reden“. */
folio: { next: { label: "Messe-Special", href: "#messe-special" } }, folio: { next: { label: "Messe-Special", href: "#messe-special" } },
/* ---------- 01 Messe-Special (ivory) ---------- */ /* ---------- 01 Messe-Special (ivory) ---------- */
@@ -62,15 +63,58 @@ export const herCareer = {
special: { special: {
id: "messe-special", id: "messe-special",
eyebrow: "Messe-Special", eyebrow: "Messe-Special",
h2: { before: "Dein Vorteil.", em: "", after: "" } satisfies Headline, /* Keine eigene H2: Seit der Gutschein im Hero hängt, steht in dieser Sektion nur
title: "Das Special", noch, was obendrauf kommt – „Ihr Vorteil.“ hätte einen Abschnitt überschrieben,
/* Wortlaut kommt von Anelia (docs/OFFENE-DATEN.md, Punkt 2.7). Bis dahin stehen die in dem der Vorteil nicht mehr vorkommt. Die Überschrift der Sektion ist darum die
Zeilen sichtbar als „[…]“ auf der Seite, und der Build-Guard hält den Launch-Flip an. */ des Bonus-Blocks; `eyebrow` bleibt der Laufkopf in der Randspalte und das Ziel
rows: [ des Folio-Links im Hero. */
{ label: "Das Angebot", value: ph("Messe-Special: Was bekommt, wer den Code scannt?") },
{ label: "Gültig bis", value: ph("Frist, z. B. Anfragen bis 30.11.2026") }, /**
{ label: "So einlösen", value: ph("Bedingung, z. B. Stichwort in der Anfrage") }, * Der Gutschein – Wortlaut von Anelia (10.09.2026); er löst die drei Platzhalter
] satisfies readonly FactItem[], * ab, die hier bis dahin als „[…]“ standen. Er hängt im Hero direkt unter der
* Begrüßung, nicht in dieser Sektion; gestaltet wird er von <Coupon/>.
*/
coupon: {
kicker: "Ihr Messe-Special",
/* Echtes Minus (U+2212), kein Bindestrich: nur das steht in der Display-Schrift
auf Ziffernbreite und damit auf einer Höhe mit der 2 und der 0. */
rate: "−20",
unit: "%",
on: "auf Persönlichkeits- & Verhaltensanalysen",
includes: ["Profilanalyse", "45 Min. 1:1-Auswertungscoaching"],
/* 14 Tage nach Messeschluss (23.10.2026) – ausgeschrieben, damit die Frist auch
dann noch stimmt, wenn die Seite jemand später aufruft. */
valid: { label: "Gültig bis", value: "6. November 2026" },
code: { label: "Stichwort", value: "herCAREER" },
},
/** Zwei Zusatzleistungen für Firmen – ohne Aufpreis. Trägt die H2 der Sektion. */
bonus: {
eyebrow: "Ohne Aufpreis",
h2: { before: "Für Firmen gibt es ", em: "obendrauf", after: "." } satisfies Headline,
rows: [
{
keyword: "Keynote Speaking",
text: "Eine 25-minütige Q&A-Session im Anschluss an den Vortrag – Ihr Publikum bleibt mit Antworten zurück, nicht nur mit Impulsen.",
},
{
keyword: "Trainings & Workshops",
text: "Ein exklusives, 45-minütiges 1:1-Deep-Dive-Coaching mit der Führungskraft: wie sie das Gelernte im Team langfristig steuert und verankert.",
},
] satisfies readonly WerdegangRow[],
},
/**
* Signal im Kontaktformular (Sektion 03): Der Gutschein hängt schon an der
* Anfrage, es gibt kein Stichwort abzutippen. Wert und Nachrichtenzeile leitet
* <HerCareerBody/> aus `coupon` oben ab – der Wortlaut des Vorteils steht nur
* an einer Stelle und kann hier nicht auseinanderlaufen.
*/
formNotice: {
eyebrow: "Messe-Special",
state: "Hinterlegt",
hint: "Hängt schon an dieser Anfrage. Bitte nur noch den Rest ausfüllen.",
},
}, },
/* ---------- 02 Themen (ivory) ---------- */ /* ---------- 02 Themen (ivory) ---------- */
@@ -92,8 +136,8 @@ export const herCareer = {
numeral: "02", numeral: "02",
title: "Trainings & Workshops", title: "Trainings & Workshops",
oneLiner: oneLiner:
"B2B-Vertriebstraining: Umsatzwachstum durch Neukundengewinnung und Bestandskundenaufbau, typgerechte Kommunikation im Team.", "B2B-Vertriebstraining und HR-Teambuilding: Umsatzwachstum durch Neu- und Bestandskunden, Teamgeist und Konfliktlösung im Team.",
keywords: ["Neukundengewinnung", "Bestandskunden", "Teamkommunikation"], keywords: ["Neukundengewinnung", "Bestandskunden", "HR-Teambuilding & Konfliktlösung"],
link: { label: "Zu den Trainings", href: main(routes.trainings) }, link: { label: "Zu den Trainings", href: main(routes.trainings) },
}, },
{ {
@@ -107,16 +151,7 @@ export const herCareer = {
] satisfies readonly TocRow[], ] satisfies readonly TocRow[],
}, },
/* ---------- 03 Auf der Messe (ivory) – nur mit flags.showHerCareer ---------- */ /* ---------- 03 Kontakt (ink) ---------- */
messe: {
id: "auf-der-messe",
eyebrow: "Auf der Messe",
/* Eine Quelle für beide Seiten: derselbe Ledger steht auf der Startseite. */
termine,
},
/* ---------- 04 Kontakt (ink) ---------- */
/** Ziel des Datenschutz-Links im Formular – absolut, wie jeder Link von hier weg. */ /** Ziel des Datenschutz-Links im Formular – absolut, wie jeder Link von hier weg. */
privacyHref: main(routes.datenschutz), privacyHref: main(routes.datenschutz),
@@ -130,7 +165,7 @@ export const herCareer = {
*/ */
contact: { contact: {
...contactHome, ...contactHome,
numeral: "04", numeral: "03",
h2: { before: "Lass uns ", em: "weiterreden", after: "." } satisfies Headline, h2: { before: "Lass uns ", em: "weiterreden", after: "." } satisfies Headline,
/* Hinweiszeile und Datenschutz-Link entfallen hier (Kundenwunsch, 07.09.2026). /* Hinweiszeile und Datenschutz-Link entfallen hier (Kundenwunsch, 07.09.2026).
Rückmeldung und Rückfall-Link beim Absenden bleiben. ACHTUNG: Seit dem Umbau Rückmeldung und Rückfall-Link beim Absenden bleiben. ACHTUNG: Seit dem Umbau
+107 -7
View File
@@ -8,10 +8,37 @@ import { assertNoPlaceholders, collectStrings, ph } from "@/lib/legal";
/* ---------- gemeinsame Typen (werden von allen content/*.ts genutzt) ---------- */ /* ---------- gemeinsame Typen (werden von allen content/*.ts genutzt) ---------- */
/**
* Ein Stück einer Headline. `em` setzt kursiv, `gold` zusätzlich den Verlauf –
* der greift ohnehin nur auf `data-theme="dark"` (Regel in globals.css).
*/
export interface HeadlinePart {
text: string;
em?: boolean;
gold?: boolean;
/**
* Satzzeichen, das untrennbar an `text` hängt – Gedankenstrich, Punkt. Wird mit
* dem Wort in ein `white-space: nowrap` gesetzt, inklusive führendem Leerzeichen.
*
* Nötig, weil SplitText die Zeilen selbst neu aufbaut und dabei an jeder
* Elementgrenze umbrechen darf: ohne die Klammer landete „–“ bzw. „.“ hinter
* einem `<em>` am Zeilenanfang. Ein geschütztes Leerzeichen hilft nicht,
* SplitText normalisiert es zu einem gewöhnlichen; ein Margin auch nicht, der
* verschiebt den Umbruch nur. `nowrap` nimmt dem Browser die Stelle wirklich weg.
*/
tail?: string;
}
export interface Headline { export interface Headline {
before: string; before: string;
em: string; em: string;
after: string; after: string;
/**
* Headline mit mehr als einer Betonung – nur für Aufzählungen wie die Trias
* der Startseite. Wird bevorzugt gerendert; `before`/`em`/`after` bleiben als
* einfache Fassung stehen (Schrift-Test, Fallback). Gold trägt genau ein Teil.
*/
parts?: readonly HeadlinePart[];
} }
export interface Cta { export interface Cta {
@@ -93,6 +120,24 @@ export interface FaqItem {
/* ---------- Hero (Cover, ink) ---------- */ /* ---------- Hero (Cover, ink) ---------- */
/* Hero-Headline – Wortlaut von Anelia (Kundenwunsch, freigegeben 02.10.2026). Drei
Betonungen, den Gold-Verlauf trägt nur „Erfolg“ (Trias-Ausnahme in AGENTS.md).
Ausdrücklich als `Headline` getippt: Die englische Fassung braucht andere
`parts`-Formen (Komma als `tail`), `Widen<typeof hero>` übernähme sonst die deutschen. */
const heroH1: Headline = {
before: "Mit starker Persönlichkeit und souveräner Wirkung zum ",
em: "Erfolg",
after: "!",
parts: [
{ text: "Mit starker " },
{ text: "Persönlichkeit", em: true },
{ text: " und souveräner " },
{ text: "Wirkung", em: true },
{ text: " zum " },
{ text: "Erfolg", em: true, gold: true, tail: "!" },
],
};
export const hero = { export const hero = {
id: "hero", id: "hero",
masthead: { masthead: {
@@ -100,7 +145,7 @@ export const hero = {
center: "Sales Mentoring & Potential Development", center: "Sales Mentoring & Potential Development",
lang: "Deutsch & Englisch", lang: "Deutsch & Englisch",
}, },
h1: { before: "Wer du bist, entscheidet, wie du ", em: "wirkst", after: "." } satisfies Headline, h1: heroH1,
subline: subline:
"Herzlich willkommen. Ob als Speakerin für dein Firmenevent, im 1:1 Coaching oder als Trainerin für dein Team – gerne bin ich für dein Anliegen da.", "Herzlich willkommen. Ob als Speakerin für dein Firmenevent, im 1:1 Coaching oder als Trainerin für dein Team – gerne bin ich für dein Anliegen da.",
cta: { cta: {
@@ -117,6 +162,13 @@ export const hero = {
export const positioning = { export const positioning = {
id: "positionierung", id: "positionierung",
/**
* Slogan-Plakette auf der Naht zwischen Cover (ink) und diesem Abschnitt – nicht im
* Hero (Entscheidung 02.10.2026). Auf beiden Sprachfassungen englisch, wie Anelia
* ihn formuliert hat; `lang` sagt das dem Screenreader. Das Trennzeichen setzt die
* Komponente.
*/
slogan: { words: ["Personality", "Impact", "Success"], lang: "en" },
numeral: "01", numeral: "01",
eyebrow: "Positionierung", eyebrow: "Positionierung",
h2: { before: "Drei Fragen. Ein Prinzip.", em: "", after: "" } satisfies Headline, h2: { before: "Drei Fragen. Ein Prinzip.", em: "", after: "" } satisfies Headline,
@@ -148,8 +200,8 @@ export const services = {
{ {
numeral: "02", numeral: "02",
title: "Trainings & Workshops", title: "Trainings & Workshops",
oneLiner: "Vertrieb mit Persönlichkeit – für Teams und Führungskräfte.", oneLiner: "Leadership & HR mit Persönlichkeit – für Führungskräfte und Teams.",
keywords: ["B2B-Vertriebstraining", "Neukunden & Bestandskunden", "Leadership & Teamdynamik"], keywords: ["B2B-Vertriebstraining", "HR-Teambuilding & Konfliktlösung", "Kommunikation & Teamgeist"],
link: { label: "Zu den Trainings", href: routes.trainings }, link: { label: "Zu den Trainings", href: routes.trainings },
}, },
{ {
@@ -189,7 +241,7 @@ export const about = {
eyebrow: "Über Anelia", eyebrow: "Über Anelia",
h2: { before: "Ich stehe selbst im Vertrieb. Jede Woche.", em: "", after: "" } satisfies Headline, h2: { before: "Ich stehe selbst im Vertrieb. Jede Woche.", em: "", after: "" } satisfies Headline,
paragraphs: [ paragraphs: [
"Mein Vater war Händler. Ich habe früh gesehen, wie ein Gespräch eine Entscheidung verändert – und dass Menschen von Menschen kaufen. Später kam die Ausbildung zur Zahntechnikerin, die Abteilungsleitung, dann Verkauf und Store Management im Premium-Segment.", "Mein Vater war Händler. Ich habe früh gesehen, wie ein Gespräch eine Entscheidung verändert – und dass Menschen von Menschen kaufen. Später kam die Ausbildung zur Zahntechnikerin, die Abteilungsleitung, dann die Leitung eines Stores im B2C-Verkauf – im Premium-Segment.",
"Heute arbeite ich im B2B-Außendienst: Kaltakquise, Neukunden, Bestandskunden. Parallel begleite ich Menschen und Teams als Coach, Trainerin und Keynote Speakerin – auf Deutsch und Englisch. Was ich weitergebe, habe ich in der Woche davor selbst gemacht.", "Heute arbeite ich im B2B-Außendienst: Kaltakquise, Neukunden, Bestandskunden. Parallel begleite ich Menschen und Teams als Coach, Trainerin und Keynote Speakerin – auf Deutsch und Englisch. Was ich weitergebe, habe ich in der Woche davor selbst gemacht.",
], ],
/** Nur mit `flags.showTwins` an Absatz 2 anhängen (mit Leerzeichen). */ /** Nur mit `flags.showTwins` an Absatz 2 anhängen (mit Leerzeichen). */
@@ -197,7 +249,9 @@ export const about = {
werdegang: [ werdegang: [
{ keyword: "Prägung", text: "Verkaufsgespräche im Betrieb meines Vaters" }, { keyword: "Prägung", text: "Verkaufsgespräche im Betrieb meines Vaters" },
{ keyword: "Präzision", text: "Zahntechnikerin, Abteilungsleiterin" }, { keyword: "Präzision", text: "Zahntechnikerin, Abteilungsleiterin" },
{ keyword: "Premium", text: "Verkauf & Store Management im Premium-Segment" }, /* „B2C“ statt „Premium“ (Anelias Wunsch, 02.10.2026): Sie hat den Store geleitet –
und so steht B2C direkt über B2B. */
{ keyword: "B2C", text: "Store-Leitung & Verkauf im Premium-Segment" },
{ keyword: "B2B", text: "Außendienst: Kaltakquise, Neu- & Bestandskunden" }, { keyword: "B2B", text: "Außendienst: Kaltakquise, Neu- & Bestandskunden" },
{ keyword: "Bühne", text: "Keynote Speakerin, Trainerin & Coach · herCAREER Expo 2026" }, { keyword: "Bühne", text: "Keynote Speakerin, Trainerin & Coach · herCAREER Expo 2026" },
] satisfies readonly WerdegangRow[], ] satisfies readonly WerdegangRow[],
@@ -208,6 +262,50 @@ export const about = {
{ value: "LinkedIn", href: site.linkedin, external: true }, { value: "LinkedIn", href: site.linkedin, external: true },
{ value: "Deutsch · Englisch" }, { value: "Deutsch · Englisch" },
] satisfies readonly MarginaliaItem[], ] satisfies readonly MarginaliaItem[],
/* Die Abzeichen in der rechten Gutter – Gegenstück zur klebenden „04“ links.
`seal` steht IM Lorbeerkranz, alles darunter ist die Bildunterschrift.
Schlüssel wie in `getCertificates()` (src/lib/assets.ts), dort auch die Reihenfolge. */
certificates: {
persoenlichkeitscoach: {
title: "Greator Persönlichkeitscoach",
description: "Zertifizierte Absolventin der Ausbildung zum Greator Persönlichkeitscoach",
date: "Oktober 2026",
cta: "Zertifikat ansehen",
/* Overlay */
eyebrow: "Zertifikat",
dialogMeta: "Greator · Ausstellungsdatum 9. Oktober 2026",
imageAlt:
"Zertifikat: Anelia Wolf hat den theoretischen und praktischen Teil der dreimonatigen Ausbildung zum Greator Persönlichkeitscoach erfolgreich abgeschlossen.",
pdfLabel: "PDF öffnen",
close: "Schließen",
},
persolog: {
title: "persolog® Persönlichkeits-Modell",
description: "Zertifizierte Trainerin für das persolog® Persönlichkeits-Modell",
date: "Oktober 2026",
cta: "Zertifikat ansehen",
/* Overlay */
eyebrow: "Zertifikat",
dialogMeta: "persolog · Abschluss am 9. Oktober 2026",
imageAlt:
"Zertifikat: Anelia Wolf hat die Zertifizierung zum persolog® Persönlichkeits-Modell am 9. Oktober 2026 erfolgreich abgeschlossen und ist berechtigt, die Bezeichnung „Zertifizierte Trainerin für das persolog® Persönlichkeits-Modell“ zu führen.",
pdfLabel: "PDF öffnen",
close: "Schließen",
},
speaker: {
title: "Greator Speaker",
description: "Zertifizierte Absolventin der Greator Speaker Ausbildung",
date: "Mai 2026",
cta: "Zertifikat ansehen",
/* Overlay */
eyebrow: "Zertifikat",
dialogMeta: "Greator TheKey · Ausstellungsdatum 16. Mai 2026",
imageAlt:
"Zertifikat: Anelia Wolf hat die Ausbildung zum zertifizierten Greator Speaker erfolgreich abgeschlossen.",
pdfLabel: "PDF öffnen",
close: "Schließen",
},
},
folio: { current: "05", total: "06" }, folio: { current: "05", total: "06" },
} as const; } as const;
@@ -242,11 +340,11 @@ export const audience = {
columns: [ columns: [
{ {
label: "Unternehmen", label: "Unternehmen",
title: "Vertriebsteams & Führungskräfte", title: "Vertriebsteams, Führung & HR",
lines: [ lines: [
"Neukundengewinnung, die planbar wird", "Neukundengewinnung, die planbar wird",
"Bestandskunden, die wachsen statt abwandern", "Bestandskunden, die wachsen statt abwandern",
"Führung und Teamdynamik im Vertrieb", "Teamgeist & Konfliktlösung – mit Führung und HR",
], ],
link: { label: "Zu den Trainings", href: routes.trainings }, link: { label: "Zu den Trainings", href: routes.trainings },
}, },
@@ -319,3 +417,5 @@ export const home = { hero, positioning, services, principle, about, audience, t
assertNoPlaceholders("src/content/home.ts", collectStrings(home)); assertNoPlaceholders("src/content/home.ts", collectStrings(home));
/** Struktur, nicht Wortlaut – die englische Fassung erfüllt denselben Typ. */ /** Struktur, nicht Wortlaut – die englische Fassung erfüllt denselben Typ. */
export type HomeContent = Widen<typeof home>; export type HomeContent = Widen<typeof home>;
/** Texte des Zertifikat-Abzeichens – Abzeichen und Overlay teilen sie sich. */
export type CertificateCopy = HomeContent["about"]["certificates"]["speaker"];
+25 -40
View File
@@ -1,10 +1,15 @@
/** /**
* /leistungen/keynotes/ – Keynotes & Speaking (Spec §7.4). Copy 1:1, Du-Form. * /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). * Zweiter Vortrag („Unlock your Sales-DNA“) bleibt weg (flags.showSecondTalk: false).
* Speaker-Profil-Zeile nur mit flags.showSpeakerPdf bzw. vorhandener Datei (assets.getSpeakerPdf()). * Speaker-Profil-Zeile nur mit flags.showSpeakerPdf bzw. vorhandener Datei (assets.getSpeakerPdf()).
* FAQ-Antworten: kurz, aus den Spec-Fakten abgeleitet (Spec A.8). * 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 type { Widen } from "./widen";
import { assertNoPlaceholders, collectStrings, ph } from "@/lib/legal"; import { assertNoPlaceholders, collectStrings, ph } from "@/lib/legal";
import type { ColumnItem, Cta, FactItem, FaqItem, GoalRow, Headline } from "./home"; 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, h1: { before: "Mut zum ", em: "Erfolg", after: "." } satisfies Headline,
h1GoldEm: true, h1GoldEm: true,
subline: 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.", "Keynotes nach dem WOLF-Prinzip für Firmen-Events, Kick-offs und Kongresse. Ein Impuls, der nachwirkt.",
cta: { label: "Keynote anfragen", href: mailto.keynote } satisfies Cta, /* 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()). */ /** Nur rendern, wenn die PDF-Datei existiert (assets.getSpeakerPdf()). */
secondary: { label: "Speaker-Profil (PDF)", href: "/speaker-profil.pdf", external: true }, secondary: { label: "Speaker-Profil (PDF)", href: "/speaker-profil.pdf", external: true },
facts: [ facts: [
@@ -48,6 +54,7 @@ export const keynotes = {
{ label: "Format", value: "Live, online, hybrid" }, { label: "Format", value: "Live, online, hybrid" },
{ label: "Sprache", value: "Deutsch / Englisch" }, { label: "Sprache", value: "Deutsch / Englisch" },
{ label: "Ort", value: "Deutschlandweit, Europa auf Anfrage" }, { label: "Ort", value: "Deutschlandweit, Europa auf Anfrage" },
{ label: "Technik", value: "Headset, Präsentation optional" },
] satisfies readonly FactItem[], ] satisfies readonly FactItem[],
alt: "Anelia Wolf auf der Bühne", alt: "Anelia Wolf auf der Bühne",
caption: `Anelia Wolf · Foto: ${ph("Name der Fotografin")}`, caption: `Anelia Wolf · Foto: ${ph("Name der Fotografin")}`,
@@ -70,17 +77,6 @@ export const keynotes = {
] satisfies readonly TalkRow[], ] 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: { occasions: {
id: "anlaesse", id: "anlaesse",
eyebrow: "Anlässe", eyebrow: "Anlässe",
@@ -113,26 +109,10 @@ export const keynotes = {
] satisfies readonly GoalRow[], ] 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, termine,
veranstalter: { /** Zeile unter dem Vortrag – nur, wenn die PDF-Datei geliefert wurde (assets.getSpeakerPdf()). */
id: "veranstalter", speakerPdf: { label: "Speaker-Profil (PDF)", text: "Kurzvita, Themen, Fotos, Technik", href: "/speaker-profil.pdf" },
eyebrow: "Für Veranstalter",
h2: { before: "Alles Wichtige vorab.", em: "", after: "" } satisfies Headline,
speakerPdf: { label: "Speaker-Profil (PDF)", text: "Kurzvita, Themen, Fotos, Technik", href: "/speaker-profil.pdf" },
},
faq: { faq: {
id: "faq", id: "faq",
@@ -146,10 +126,6 @@ export const keynotes = {
q: "Auch als Workshop nach der Keynote?", 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.", 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?", q: "Gibt es Video oder Referenzen?",
a: "Auf Anfrage. Sprich mich an, dann schicke ich dir Unterlagen zum Vortrag.", a: "Auf Anfrage. Sprich mich an, dann schicke ich dir Unterlagen zum Vortrag.",
@@ -157,10 +133,19 @@ export const keynotes = {
] satisfies readonly FaqItem[], ] satisfies readonly FaqItem[],
}, },
band: { /**
h2: { before: "Hol das WOLF-Prinzip auf deine Bühne.", em: "", after: "" } satisfies Headline, * Terminanfrage (ink) – das Formular der Startseite mit zwei freiwilligen Feldern.
cta: { label: "Keynote anfragen", href: mailto.keynote } satisfies Cta, * `topic` wird zum Mail-Betreff; `mailLabel` beschriftet die Felder in der Mail.
micro: "Unverbindlich · Antwort auf Deutsch oder Englisch", */
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; } 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),", "– 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),", "– 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),", "– 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.", "– 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.", "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", h2: "5. Kontaktformular",
paragraphs: [ 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.", "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.", "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.", "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.",
+51 -48
View File
@@ -1,14 +1,25 @@
/** /**
* /leistungen/trainings/ – Trainings & Workshops (Spec §7.3). Copy 1:1, Du-Form. * /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).
* HR dazu am 02.10.2026 (Anelias Wunsch, wie Zeile 02 der Startseite): viertes Modul
* „HR-Teambuilding & Konfliktlösung“, vierte Bremse, Subline und Beschreibung. Der
* Modultext ist aus ihrer Nachricht formuliert und braucht ihre Freigabe (CONTENT_TODO).
* „Antwort innerhalb von 24 Stunden“ bleibt weg (flags.show24hPromise: false). * „Antwort innerhalb von 24 Stunden“ bleibt weg (flags.show24hPromise: false).
* FAQ-Antworten: kurz, aus den Spec-Fakten abgeleitet; Preise „auf Anfrage“ (Spec A.8). * 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 type { Widen } from "./widen";
import { assertNoPlaceholders, collectStrings, ph } from "@/lib/legal"; 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. */ /**
* Kopfzeile der Modul-Tabelle (deutsch). Liegt zusätzlich als `modules.labels` im
* Inhalt, damit die englische Seite ihre eigenen Köpfe bekommt – bis 02.10.2026 las
* <ModuleTable/> nur diese Konstante, und /en/ zeigte „Modul | Ziel | Für wen“.
*/
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; 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;
export interface ModuleItem { export interface ModuleItem {
@@ -23,7 +34,7 @@ export const trainings = {
meta: { meta: {
title: "Trainings & Workshops", title: "Trainings & Workshops",
description: description:
"B2B-Vertriebstrainings und Leadership-Workshops, die im Alltag funktionieren – nicht nur im Seminarraum. Inhouse, online, auf Deutsch oder Englisch. Anelia Wolf, Trainerin.", "B2B-Vertriebstrainings, Leadership-Workshops und HR-Teambuilding, die im Alltag funktionieren – nicht nur im Seminarraum. Inhouse, online, auf Deutsch oder Englisch. Anelia Wolf, Trainerin.",
}, },
numeral: "02", numeral: "02",
masthead: { masthead: {
@@ -34,31 +45,34 @@ export const trainings = {
eyebrow: "Für Unternehmen & Teams", eyebrow: "Für Unternehmen & Teams",
h1: { before: "Vertrieb mit ", em: "Persönlichkeit", after: "." } satisfies Headline, h1: { before: "Vertrieb mit ", em: "Persönlichkeit", after: "." } satisfies Headline,
subline: subline:
"B2B-Vertriebstrainings und Leadership-Workshops, die im Alltag funktionieren – nicht nur im Seminarraum. Inhouse, online, auf Deutsch oder Englisch.", "B2B-Vertriebstrainings, Leadership-Workshops und HR-Teambuilding, die im Alltag funktionieren – nicht nur im Seminarraum.",
cta: { label: "Training anfragen", href: mailto.training, micro: "Unverbindlich" } satisfies Cta, /* Springt zur Terminanfrage am Fuß der Seite (vorher: mailto). */
cta: { label: "Training anfragen", href: `${routes.trainings}#terminanfrage`, micro: "Unverbindlich" } satisfies Cta,
facts: [ facts: [
{ label: "Format", value: "Inhouse oder online" }, { label: "Format", value: "Inhouse oder online" },
{ label: "Dauer", value: "1–2 Tage oder Reihe" }, { label: "Dauer", value: "1–2 Tage oder Reihe" },
{ label: "Sprache", value: "Deutsch / Englisch" }, { label: "Sprache", value: "Deutsch / Englisch" },
{ label: "Gruppengröße", value: ph("Platzhalter"), placeholder: true }, { label: "Gruppengröße", value: ph("Platzhalter"), placeholder: true },
] satisfies readonly FactItem[], ] satisfies readonly FactItem[],
folio: { next: { label: "Drei Bremsen", href: "#ausgangslage" } }, folio: { next: { label: "Vier Bremsen", href: "#ausgangslage" } },
situations: { situations: {
id: "ausgangslage", id: "ausgangslage",
eyebrow: "Ausgangslage", eyebrow: "Ausgangslage",
h2: { before: "Drei Bremsen.", em: "", after: "" } satisfies Headline, h2: { before: "Vier Bremsen.", em: "", after: "" } satisfies Headline,
rows: [ rows: [
{ numeral: "01", text: "Die Neukundengewinnung stockt – und niemand ruft gern kalt an." }, { numeral: "01", text: "Die Neukundengewinnung stockt – und niemand ruft gern kalt an." },
{ numeral: "02", text: "Bestandskunden werden betreut, aber nicht ausgebaut." }, { numeral: "02", text: "Bestandskunden werden betreut, aber nicht ausgebaut." },
{ numeral: "03", text: "Führung und Teamdynamik bremsen den Vertrieb, statt ihn zu tragen." }, { numeral: "03", text: "Führung und Teamdynamik bremsen den Vertrieb, statt ihn zu tragen." },
{ numeral: "04", text: "Im Team wird aneinander vorbeigeredet – Konflikte bleiben liegen, der Teamgeist fehlt." },
] satisfies readonly SituationRow[], ] satisfies readonly SituationRow[],
}, },
modules: { modules: {
id: "module", id: "module",
eyebrow: "Leistungsbausteine", eyebrow: "Leistungsbausteine",
h2: { before: "Drei Module.", em: "", after: "" } satisfies Headline, h2: { before: "Vier Module.", em: "", after: "" } satisfies Headline,
labels: moduleLabels,
items: [ items: [
{ {
/* U+2011 (non-breaking hyphen): the Bodoni title must never break after „B2B-“. */ /* U+2011 (non-breaking hyphen): the Bodoni title must never break after „B2B-“. */
@@ -78,10 +92,23 @@ export const trainings = {
{ {
title: "Leadership & Teamdynamik", title: "Leadership & Teamdynamik",
goal: "Führung, die Vertriebsteams stärkt.", goal: "Führung, die Vertriebsteams stärkt.",
contents: ["Verhaltensanalysen im Team", "Rollen", "Feedback", "Konflikte", "Zielvereinbarung"], /* „Konflikte“ ist am 02.10.2026 ins HR-Modul gewandert – dort ist es der Kern. */
contents: ["Verhaltensanalysen im Team", "Rollen", "Feedback", "Zielvereinbarung"],
audience: "Vertriebsleitung, Teamleitung, Geschäftsführung", audience: "Vertriebsleitung, Teamleitung, Geschäftsführung",
meta: "Workshop 1 Tag · vor Ort · DE/EN", meta: "Workshop 1 Tag · vor Ort · DE/EN",
}, },
{
/* Aus Anelias Nachricht (02.10.2026): das Team lernt sich besser kennen, Teamgeist
entsteht, weil man sich und sein Gegenüber versteht – und Konflikte löst.
Bewusst ein normaler Bindestrich (kein U+2011): „HR‑Teambuilding“ am Stück
lief zwischen 1024 und 1320 px in die Ziel-Spalte. Dauer und Freigabe:
CONTENT_TODO. */
title: "HR-Teambuilding & Konfliktlösung",
goal: "Ein Team, das sich kennt und versteht – und Konflikte selbst löst.",
contents: ["Sich und das Gegenüber verstehen", "Kommunikation untereinander", "Teamgeist entwickeln", "Konflikte lösen"],
audience: "Teams, Führungskräfte und HR",
meta: `Workshop ${ph("Dauer")} · inhouse · DE/EN`,
},
] satisfies readonly ModuleItem[], ] satisfies readonly ModuleItem[],
}, },
@@ -95,44 +122,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: { ablauf: {
id: "ablauf", id: "ablauf",
eyebrow: "Ablauf", eyebrow: "Ablauf",
steps: [ steps: [
{ numeral: "01", title: "Bedarfsgespräch", text: "" }, { 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: "03", title: "Training", text: "" },
{ numeral: "04", title: "Follow-up nach 30 Tagen", text: "" }, { numeral: "04", title: "Follow-up nach 30 Tagen", text: "" },
] satisfies readonly StepItem[], ] 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: { faq: {
@@ -143,10 +141,6 @@ export const trainings = {
q: "Inhouse oder extern?", q: "Inhouse oder extern?",
a: "Inhouse bei dir im Unternehmen oder online. Ein externer Veranstaltungsort ist auf Anfrage möglich.", 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?", 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.", a: "Ja. Das Modul Leadership & Teamdynamik richtet sich an Vertriebsleitung, Teamleitung und Geschäftsführung – eine eigene Vertriebsrolle ist keine Voraussetzung.",
@@ -158,10 +152,19 @@ export const trainings = {
] satisfies readonly FaqItem[], ] satisfies readonly FaqItem[],
}, },
band: { /**
h2: { before: "Sprechen wir über dein Team.", em: "", after: "" } satisfies Headline, * Terminanfrage (ink) – das Formular der Startseite mit zwei freiwilligen Feldern.
cta: { label: "Training anfragen", href: mailto.training } satisfies Cta, * `topic` wird zum Mail-Betreff; `mailLabel` beschriftet die Felder in der Mail.
micro: "Unverbindlich", */
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; } as const;
+38
View File
@@ -56,3 +56,41 @@ export function getLogoSvg(): string | null {
export function getSpeakerPdf(): string | null { export function getSpeakerPdf(): string | null {
return publicUrl("speaker-profil.pdf"); return publicUrl("speaker-profil.pdf");
} }
/** Schlüssel in `about.certificates` (Texte) – Reihenfolge siehe `CERTIFICATES`. */
export type CertificateKey = "persoenlichkeitscoach" | "persolog" | "speaker";
export interface CertificateAsset {
key: CertificateKey;
/** z. B. `/zertifikate/greator-speaker.pdf` – das Original zum Öffnen/Herunterladen. */
pdf: string;
/** Seite 1 als Bild für die Vorschau (kein eingebettetes PDF, kein iframe). */
image: string;
width: number;
height: number;
/** Siegel bzw. Emblem des Ausstellers, freigestellt – es steht im Lorbeerkranz. */
seal: string;
sealSize: number;
}
/** Oben steht das neueste Zertifikat. */
const CERTIFICATES = [
{ key: "persoenlichkeitscoach", file: "greator-persoenlichkeitscoach", seal: "greator-siegel-coach.webp", width: 989, height: 1398 },
{ key: "persolog", file: "persolog-persoenlichkeitsmodell", seal: "persolog-emblem.webp", width: 989, height: 1398 },
{ key: "speaker", file: "greator-speaker", seal: "greator-siegel.webp", width: 989, height: 1400 },
] as const satisfies readonly { key: CertificateKey; file: string; seal: string; width: number; height: number }[];
/**
* Zertifikate für die Sektion „Über Anelia“. Ein Zertifikat erscheint nur,
* wenn alle drei Dateien liegen – fehlen alle, rendert die Sektion wie vorher
* (Variante B, wie bei den Fotos).
*/
export function getCertificates(): CertificateAsset[] {
return CERTIFICATES.flatMap(({ key, file, seal: sealFile, width, height }) => {
const pdf = publicUrl(`zertifikate/${file}.pdf`);
const image = publicUrl(`zertifikate/${file}.jpg`);
const seal = publicUrl(`zertifikate/${sealFile}`);
if (!pdf || !image || !seal) return [];
return [{ key, pdf, image, width, height, seal, sealSize: 372 }];
});
}
+29
View File
@@ -62,6 +62,35 @@ export function otherLang(lang: Lang): Lang {
return lang === "de" ? "en" : "de"; return lang === "de" ? "en" : "de";
} }
/**
* Zu welcher Sprachfassung gehört ein Pfad? Alles unter `/en/` ist Englisch, alles
* andere Deutsch – dieselbe Grenze, die `app/en/` von der Gruppe `app/(de)/` trennt.
*/
export function langOfPath(pathname: string): Lang {
const p = pathname.split("#")[0].split("?")[0];
return p === "/en" || p.startsWith("/en/") ? "en" : "de";
}
/**
* Wechselt ein Klick die Sprachfassung? Dann ist das Ziel ein neues Dokument, und
* den Wechsel macht der Browser (`location.assign`), nicht der Client-Router.
*
* Zwei Gründe, der zweite ist der harte:
*
* 1. Deutsch und Englisch haben eigene Wurzel-Layouts (`<html lang>`), über diese
* Grenze navigiert Next ohnehin mit einem vollständigen Seitenaufbau.
* 2. Die Messeseite wird unter ZWEI Adressen ausgeliefert: anelia-wolf.com/her-career/
* und her-career.anelia-wolf.com, wo dieselbe Datei unter „/“ liegt und jeder
* andere Pfad per 301 auf die Hauptdomain geht (docs/SUBDOMAIN-HERCAREER.md).
* Von dort löst der Router ein Ziel unter /en/ nicht mehr auf – sein Datenabruf
* wird auf die Hauptdomain umgeleitet, und er landet auf der englischen
* STARTSEITE statt auf der englischen Messeseite. Genau das sah, wer am Stand
* den Code gescannt und dann auf „EN“ getippt hat.
*/
export function crossesLangRoot(from: string, to: string): boolean {
return langOfPath(from) !== langOfPath(to);
}
/** /**
* Gegenstück einer Seite in der anderen Sprache. Nimmt den aktuellen Pfad und * Gegenstück einer Seite in der anderen Sprache. Nimmt den aktuellen Pfad und
* liefert denselben Inhalt in der Zielsprache; unbekannte Pfade landen auf der * liefert denselben Inhalt in der Zielsprache; unbekannte Pfade landen auf der
+2 -2
View File
@@ -104,7 +104,7 @@ export const CONTENT_TODO: string[] = [
"Coaching, Verhaltensanalyse: Dauer des Auswertungsgesprächs und Tool/Zertifizierung eintragen (src/content/coaching.ts).", "Coaching, Verhaltensanalyse: Dauer des Auswertungsgesprächs und Tool/Zertifizierung eintragen (src/content/coaching.ts).",
"Coaching, Meta-Zeile der Analyse: Dauer eintragen (src/content/coaching.ts).", "Coaching, Meta-Zeile der Analyse: Dauer eintragen (src/content/coaching.ts).",
"Coaching, Rahmen-Tabelle: Dauer und Paket eintragen (src/content/coaching.ts).", "Coaching, Rahmen-Tabelle: Dauer und Paket eintragen (src/content/coaching.ts).",
"Trainings, Rahmen-Tabelle (Hero und Rahmen-Abschnitt): Gruppengröße eintragen (src/content/trainings.ts).", "Trainings, Fakten im Hero: Gruppengröße eintragen (src/content/trainings.ts; der Rahmen-Abschnitt ist seit 02.10.2026 entfallen).",
"Trainings, Modul „Neukundengewinnung“: Umfang der Trainingsreihe eintragen (src/content/trainings.ts).", "Trainings, Modul „Neukundengewinnung“: Umfang der Trainingsreihe eintragen (src/content/trainings.ts).",
"Messe-Landingpage /her-career/: Angebot, Frist und Bedingung des Messe-Specials eintragen – in beiden Sprachen (src/content/her-career.ts, src/content/en/her-career.ts). Der QR-Code auf der Rückwandgrafik führt genau auf diesen Block.", "Trainings, Modul „HR-Teambuilding & Konfliktlösung“ (seit 02.10.2026): Dauer eintragen und den aus Anelias Nachricht formulierten Modultext von ihr freigeben lassen (src/content/trainings.ts, src/content/en/trainings.ts).",
]; ];
-5
View File
@@ -84,8 +84,3 @@ export const routes = {
} as const; } as const;
export type Route = (typeof routes)[keyof typeof routes]; 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;