Compare commits

...
11 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
knsoandClaude Opus 5 9725e0229d Messeseite: Hinweiszeile unter dem Absenden-Knopf entfällt dort
Auf der Messeseite stehen unter dem Knopf kein Hinweis und kein
Datenschutz-Link mehr (Kundenwunsch). Beides folgt jetzt dem Inhalt: leere
Texte in src/content/her-career.ts, keine Zeile – die Startseite bleibt
unverändert.

Rückmeldung und Rückfall-Link hängen NICHT daran. Antwortet /api/contact
nicht, erscheint auch auf der Messeseite „Das hat gerade nicht geklappt“
samt Mail-Link; dessen Betreff ist „herCAREER Expo 2026“. Das <p> bleibt
immer im Dokument, weil ein aria-live-Bereich, den es beim Absenden noch
nicht gab, von Screenreadern nicht zuverlässig vorgelesen wird.

Offen und vor dem Livegang zu entscheiden (docs/OFFENE-DATEN.md 3.17): Seit
dem Formular-Umbau überträgt das Formular an den Server, die
Datenschutzerklärung beschreibt das in Ziffer 5. Die entfernte Zeile war
damit die Information nach Art. 13 DSGVO am Ort der Erhebung; erreichbar
bleibt die Erklärung nur noch über den Fuß.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:41:54 +02:00
knsoandClaude Opus 5 33ec0df7e3 Messe-Landingpage /her-career/ zur herCAREER Expo 2026
Ziel des QR-Codes auf der Rückwandgrafik. Der gedruckte Code zeigt auf
https://her-career.anelia-wolf.com – die Adresse ist damit unveränderlich.
Die Seite liegt als /her-career/ im normalen Export, die Subdomain liefert
dieselbe Datei aus demselben Docroot aus; DNS, Zertifikat, Vhosts für nginx
und Apache und die Prüfbefehle stehen in docs/SUBDOMAIN-HERCAREER.md.

Aufbau in beiden Sprachen: Hero (ink) → 01 Messe-Special → 02 Themen →
03 Auf der Messe → 04 Kontakt mit dem Formular. Die vier Themenblöcke der
Rückwand liegen auf den drei Leistungsseiten, wie in der Navigation; jede
Zeile verlinkt absolut auf die Hauptdomain.

Der Wortlaut des Messe-Specials fehlt noch und steht sichtbar als „[…]“ auf
der Seite. Der Build-Guard hält den Launch-Flip an, bis er da ist
(CONTENT_TODO in src/lib/legal.ts, docs/OFFENE-DATEN.md Punkte 2.7–2.9).
Deadline ist nicht der Livegang, sondern der 22.10.2026.

- Canonical auf anelia-wolf.com/her-career/, damit die Subdomain-Kopie nicht
  ein zweites Mal im Index steht
- flags.showHerCareer trägt Termine-Ledger, Sitemap-Eintrag und die
  Meta-Beschreibung mit Stand und Uhrzeit. Ab dem 24.10.2026 auf false
- ServiceHero: facts und cta sind optional – ohne beides und ohne Foto
  entfällt die rechte Spalte
- ProcessContact: optionaler Zähler, die Messeseite nummeriert ihre Abschnitte
- Kopf-CTA auf dieser Seite ausgeblendet. Das macht eine Regel in globals.css,
  weil der Kopf außerhalb von <main> liegt und auf der Subdomain der Pfad
  ohnehin „/“ ist

Enthält außerdem die bis dahin nicht committeten Änderungen an Header,
LangSwitch, OverlayMenu, Wordmark und globals.css.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:39:32 +02:00
71 changed files with 2774 additions and 564 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"`
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).
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`.
- Sichtbare Copy lebt ausschließlich in `src/content/*.ts`, nie im JSX.
+11 -5
View File
@@ -1,8 +1,9 @@
# Anelia Wolf – Website
Statische Website für Anelia Wolf (Keynote Speakerin, Trainerin, Coach).
Zwölf Routen in zwei Sprachen: Startseite, drei Leistungsseiten, Impressum und
Datenschutz – deutsch auf `/`, englisch unter `/en/` – plus die 404.
Vierzehn Routen in zwei Sprachen: Startseite, drei Leistungsseiten, die Messeseite
zur herCAREER Expo, Impressum und Datenschutz – deutsch auf `/`, englisch unter
`/en/` – plus die 404.
Next.js 16 (App Router) im **Static Export**, Tailwind v4, GSAP + Lenis, TypeScript.
Kein Backend, keine Datenbank, keine Formulare.
@@ -31,10 +32,13 @@ Diese Regeln sind nicht kosmetisch – die Datenschutzerklärung sagt sie zu:
`next/font` zur Build-Zeit selbst gehostet.
- **kein Storage**: kein localStorage, kein sessionStorage, keine Cookies –
deshalb auch kein Cookie-Banner.
- **Kontaktformular mit Serverversand**, kein Newsletter. Abschnitt 06 sendet an
`/api/contact` beim Hoster; die Anfrage landet im Postfach
- **Kontaktformular mit Serverversand**, kein Newsletter. Abschnitt 06 der
Startseite, die Messeseite und die Terminanfrage am Fuß von Trainings und
Keynotes senden an `/api/contact` beim Hoster; die Anfrage landet im Postfach
`info@anelia-wolf.com` im Ordner `web-anfragen` (kein Formulardienst eines
Dritten). Alle übrigen Kontaktwege laufen weiter über `mailto:` und Telefon.
Dritten). Die Terminanfrage schickt zusätzlich `topic` (Betreff) und
`details` (Wunschtermin usw.) mit. Alle übrigen Kontaktwege laufen weiter über
`mailto:` und Telefon.
Wer eine dieser Zusagen ändert, muss `src/content/legal.ts` (Ziffer 2, 3 und 6)
mit ändern.
@@ -56,6 +60,7 @@ mit ändern.
| `src/components/` | `layout/`, `motion/` (GSAP-Primitiven), `ui/`, `brand/`, `home/`, `services/` |
| `public/brand/`, `public/photos/` | Drop-in-Slots für Logo und Fotos (je eigene README) |
| `docs/superpowers/` | Spec und Build-Plan |
| `docs/SUBDOMAIN-HERCAREER.md` | `her-career.anelia-wolf.com` – Ziel des QR-Codes auf der Rückwandgrafik: DNS, Zertifikat, Vhost |
## Drop-in-Assets
@@ -80,6 +85,7 @@ Das gelieferte Logo-SVG wird inline eingebettet. Enthält es ein `<script>`,
| `showPlaceholders` | `true` bis zum Launch: offene Angaben stehen sichtbar als „[…]“ in Gold |
| `hostOutsideEu` | nur `true`, wenn Hoster/Office-Anbieter außerhalb EU/EWR sitzen (Drittland-Absatz) |
| `showFaq`, `showStimmen`, `showSpeakerPdf`, `showSecondTalk`, `show24hPromise`, `showIndustry`, `showTwins` | schalten einzelne Blöcke frei, sobald die Inhalte freigegeben sind |
| `showHerCareer` | `true`, solange die herCAREER Expo bevorsteht: nur dann trägt `/her-career/` die Termin-Angaben und steht in der Sitemap. Ab 24.10.2026 auf `false`. |
## Vor dem Livegang
+36 -9
View File
@@ -26,6 +26,7 @@ Adressen, Englisch liegt vollstaendig unter `/en/`:
| `/leistungen/keynotes/` | Keynotes & Speaking |
| `/impressum/` | Impressum |
| `/datenschutz/` | Datenschutzerklärung |
| `/her-career/` | Messe-Landingpage zur herCAREER Expo 2026 – Ziel des QR-Codes auf der Rückwandgrafik, ausgeliefert auch unter `her-career.anelia-wolf.com` (`docs/SUBDOMAIN-HERCAREER.md`) |
| 404 | „Diese Fährte endet hier.“ – deutsch, mit englischem Zweizeiler darunter |
| `/en/` | English start page |
| `/en/services/coaching/` | Coaching & Assessments |
@@ -33,6 +34,7 @@ Adressen, Englisch liegt vollstaendig unter `/en/`:
| `/en/services/keynotes/` | Keynotes & Speaking |
| `/en/imprint/` | Imprint (Übersetzung, verbindlich bleibt `/impressum/`) |
| `/en/privacy/` | Privacy (Übersetzung, verbindlich bleibt `/datenschutz/`) |
| `/en/her-career/` | herCAREER Expo 2026 (englische Fassung der Messeseite) |
Dazu erzeugt der Build `sitemap.xml`, `robots.txt`, `icon.svg` und `apple-icon.png`.
@@ -154,12 +156,22 @@ Beides steht an genau einer Stelle: `routesByLang` in `src/lib/i18n.ts`.
Wer eine Adresse aendert, aendert sie dort – Navigation, Fuss, Sitemap,
hreflang und der Sprachumschalter lesen alle aus dieser Tabelle.
**Umschalter.** Die beiden Flaggen im Kopf (`LangSwitch.tsx`) fuehren nicht
pauschal auf die Startseite, sondern auf das Gegenstueck derselben Seite
(`counterpart()`). Unter 1024 px ist im Kopf kein Platz; dort steht der
Umschalter im Overlay-Menue. Eine Flagge steht streng genommen fuer ein Land
und nicht fuer eine Sprache – deshalb steht das Kuerzel DE/EN daneben und der
zugaengliche Name nennt die Sprache ausgeschrieben.
**Umschalter.** Der Umschalter (`LangSwitch.tsx`) steht auf jeder Breite im Kopf
und fuehrt nicht pauschal auf die Startseite, sondern auf das Gegenstueck
derselben Seite (`counterpart()`). Zwei Formen:
- ab 1024 px beide Sprachen nebeneinander, die aktive still in Gold,
- darunter nur die Zielsprache als ein Knopf – ein Tipp, ein Wechsel. Neben
Wortmarke und „Menue“ hat auf einem Telefon kein Paar Platz.
Der Kopf liegt ueber dem Overlay-Menue, der Knopf bleibt also auch bei offenem
Menue erreichbar. Im Menue selbst steht er nicht mehr: Dort fiel er auf kurzen
Geraeten (iPhone SE, 667 px) unter die Kante, ohne dass man dem Menue ansah,
dass darunter noch etwas kommt.
Eine Flagge steht streng genommen fuer ein Land und nicht fuer eine Sprache –
deshalb steht das Kuerzel DE/EN daneben und der zugaengliche Name nennt die
Sprache ausgeschrieben.
**Zwei Wurzel-Layouts.** `app/(de)/layout.tsx` schreibt `<html lang="de">`,
`app/en/layout.tsx` schreibt `<html lang="en">`. Im App Router schreibt nur das
@@ -240,6 +252,12 @@ Ressourcen lädt, ist eine sehr strenge Content-Security-Policy möglich.
speist Canonical-URLs, OpenGraph und `sitemap.xml`. Bei anderer Domain hier
ändern und neu bauen.
**Subdomain `her-career.anelia-wolf.com`**: Der QR-Code auf der Rückwandgrafik
zeigt auf diese Adresse – gedruckt und damit unveränderlich. Sie braucht keinen
zweiten Build: derselbe Docroot, `/` liefert `her-career/index.html`, alles andere
geht per 301 auf die Hauptdomain. Vhost-Beispiele für nginx und Apache, DNS,
Zertifikat und Prüfbefehle stehen in **`docs/SUBDOMAIN-HERCAREER.md`**.
---
## 3. Bilder und Logo einspielen (Drop-in)
@@ -330,6 +348,7 @@ Freigaben vorliegen. Kein Flag verlangt eine Codeänderung an Komponenten.
| `show24hPromise` | `false` | Zusage „Antwort innerhalb von 24 Stunden“ |
| `showIndustry` | `false` | Branchennennung im Nebentätigkeits-Kontext |
| `showTwins` | `true` | Zwillinge-Satz in der Biografie |
| `showHerCareer` | `true` | Messeseite `/her-career/` trägt den Termine-Ledger („Auf der Messe“) und steht in der Sitemap; die Meta-Beschreibung nennt Stand und Uhrzeit. Ab dem 24.10.2026 auf `false` – siehe Abschnitt 6. |
| `showLangSwitch` | `true` | Sprachumschalter im Kopf und im Overlay-Menü. Auf `false` verschwinden beide Flaggen, die englischen Seiten bleiben über ihre Adresse erreichbar. |
Nicht bestätigte Aussagen sind bewusst ausgeschaltet: eine Zusage, die nicht
@@ -414,7 +433,14 @@ vergangenes Event). Zu tun, mit Termin im Kalender:
3. Auch anpassen: `src/content/keynotes.ts` (Meta-Zeile
„Keynote · herCAREER Expo 2026 · 23.10. · 10:30“) und
`src/content/home.ts` (Marginalie „Bühne“, Fact-Zeile).
4. `npm run build`, `out/` hochladen.
4. **Messeseite `/her-career/`**: `flags.showHerCareer` auf `false` setzen. Damit
fällt der Termine-Ledger weg, die Meta-Beschreibung wechselt auf die Fassung ohne
Stand und Uhrzeit, und die Seite verschwindet aus der Sitemap; Hero, Messe-Special,
Themen und Kontakt bleiben stehen. Die Seite selbst bleibt erreichbar – gedruckte
QR-Codes laufen weiter.
Ist das Messe-Special nach der Messe hinfällig, muss sein Text mit weg
(`src/content/her-career.ts`, `src/content/en/her-career.ts`).
5. `npm run build`, `out/` hochladen.
**Offen: Wer pflegt das, und wann?** Das ist Spec §12 Punkt 17 und muss mit
der Kundin geklärt werden – am besten als feste Zuständigkeit plus
@@ -481,7 +507,7 @@ Produktions-Build.
| `npx tsc --noEmit -p tsconfig.json` | fehlerfrei |
| `npm run lint` (ESLint) | fehlerfrei |
| `npm run build` | erfolgreich, 12 statische Seiten |
| Routen in `out/` | `/`, `/leistungen/coaching/`, `/leistungen/trainings/`, `/leistungen/keynotes/`, `/impressum/`, `/datenschutz/`, `404.html` (+ `_not-found/`) – alle vorhanden |
| Routen in `out/` | `/`, `/leistungen/coaching/`, `/leistungen/trainings/`, `/leistungen/keynotes/`, `/her-career/`, `/impressum/`, `/datenschutz/`, `404.html` (+ `_not-found/`) – alle vorhanden |
| `localStorage` / `sessionStorage` / `document.cookie` in `out/` | **kein Treffer** (HTML, JS, CSS geprüft) |
| Externe Origins in den HTML-Dateien | nur `anelia-wolf.com`, `linkedin.com`, `her-career.com`, `bfdi.bund.de`, `schema.org`, `w3.org` (SVG-Namespace) |
| Externe Ressourcen | keine: 0 iframes, alle `<script src>` unter `/_next/`, alle 18 `@font-face` zeigen auf lokale `.woff2` in `_next/static/media/`. Die Treffer `nextjs.org`, `react.dev`, `gsap.com`, `github.com` liegen ausschließlich als Text in Fehlermeldungen und Lizenzkommentaren in Vendor-Bundles – **keine Requests**. |
@@ -530,7 +556,8 @@ Produktions-Build aus `out/` ist er nicht vorhanden.
- [ ] Freigegebene Blöcke per Flag eingeschaltet (FAQ, Stimmen, Speaker-PDF …)
- [ ] `hostOutsideEu` korrekt gesetzt, AV-Verträge mit Hoster und E-Mail-Anbieter
- [ ] Domain in `src/lib/site.ts` stimmt, HTTPS aktiv, 404-Seite konfiguriert
- [ ] `out/` hochgeladen, alle zwölf Routen im Browser geprüft (sechs deutsch, sechs englisch)
- [ ] `out/` hochgeladen, alle vierzehn Routen im Browser geprüft (sieben deutsch, sieben englisch)
- [ ] Subdomain `her-career.anelia-wolf.com` eingerichtet und mit dem **gedruckten** QR-Code gescannt (`docs/SUBDOMAIN-HERCAREER.md`)
- [ ] Sprachumschalter auf jeder Seite geprüft: er führt auf dieselbe Seite in der anderen Sprache, nicht auf die Startseite
- [ ] Kalendereintrag für den 24.10.2026 (Termine-Block, Abschnitt 6)
+240
View File
@@ -0,0 +1,240 @@
# Offene Daten – Website Anelia Wolf
Stand: 07.09.2026 · Pflegen: bei jeder Lieferung die Statusspalte ändern, nicht die Zeile löschen.
Die Website ist fertig gebaut und läuft in beiden Sprachen. Was hier steht, ist alles,
was noch von außen kommen muss, bevor sie live gehen kann. Drei Quellen im Code halten
dieselbe Liste maschinenlesbar – wer hier etwas einträgt, trägt es dort mit ein:
| Quelle | Inhalt |
|---|---|
| `LEGAL_TODO` in `src/lib/legal.ts` | Pflichtangaben für Impressum und Datenschutz |
| `CONTENT_TODO` in `src/lib/legal.ts` | sichtbare Platzhalter auf den Leistungsseiten |
| `flags` in `src/lib/site.ts` | Blöcke, die auf eine Freigabe warten |
**Der Build passt auf.** Solange irgendwo ein „[…]“ steht, bricht `npm run build` ab,
sobald `flags.showPlaceholders` auf `false` gesetzt wird. Diesen Schalter umzulegen ist
der letzte Schritt vor dem Livegang – niemand kann ihn versehentlich überspringen.
**Status-Spalte:** `offen` · `liegt vor` · `entfällt` (mit Begründung).
---
## 1. Rechtlich – blockiert den Livegang
Ohne diese Angaben darf die Seite nicht online. Ein Impressum ohne ladungsfähige
Anschrift ist abmahnfähig, eine Datenschutzerklärung ohne Hoster-Angabe unvollständig.
| # | Angabe | Wofür | Wer liefert | Status |
|---|---|---|---|---|
| 1.1 | Ladungsfähige Anschrift: Straße, Hausnummer, PLZ, Ort – **kein Postfach** | Impressum (§ 5 DDG), Datenschutz Ziffer 1 | Anelia | offen |
| 1.2 | USt-IdNr. (§ 27a UStG) **oder** W-IdNr. (§ 139c AO) – oder: Abschnitt streichen | Impressum | Anelia / Steuerberatung | offen |
| 1.3 | Kleinunternehmerregelung nach § 19 UStG: ja / nein | Impressum | Anelia / Steuerberatung | offen |
| 1.4 | Berufshaftpflicht: Versicherer, Anschrift, räumlicher Geltungsbereich – oder: Abschnitt streichen | Impressum | Anelia | offen |
| 1.5 | Geschäftsbezeichnung – nur, wenn sie tatsächlich so geführt wird | Impressum (optional) | Anelia | offen |
| 1.6 | Bundesland des Firmensitzes → daraus die zuständige Datenschutz-Aufsichtsbehörde (Name, Anschrift, Website) | Datenschutz, Art. 77 DSGVO | Anelia | offen |
| 1.7 | Hosting-Anbieter: Firma, Anschrift, Land | Datenschutz Ziffer 3 | webklar | offen |
| 1.8 | Löschfrist der Server-Logfiles in Tagen (üblich 7–14) – beim Hoster erfragen | Datenschutz Ziffer 3 | webklar | offen |
| 1.9 | **AV-Vertrag mit dem Hoster** (Art. 28 DSGVO), unterschrieben | Datenschutz Ziffer 3 | Anelia + Hoster | offen |
| 1.10 | E-Mail-/Office-Anbieter: Firma, Anschrift, Land | Datenschutz Ziffer 4 | Anelia | offen |
| 1.11 | **AV-Vertrag mit dem E-Mail-Anbieter** (Art. 28 DSGVO) | Datenschutz Ziffer 4 | Anelia | offen |
| 1.12 | Sitzt Hoster **oder** E-Mail-Anbieter außerhalb EU/EWR? Wenn ja: Land + Rechtsgrundlage (Angemessenheitsbeschluss oder Standardvertragsklauseln) | schaltet `flags.hostOutsideEu` | webklar prüft | offen |
| 1.13 | Aufbewahrungsfrist für Kontaktanfragen (z. B. 12 Monate) | Datenschutz Ziffer 4 | Anelia | offen |
| 1.14 | Bildnachweis: Name der Fotografin, ggf. Website | Impressum + Bildunterschriften (§ 13 UrhG) | Anelia | offen |
| 1.15 | Bildnachweis: Logo-Designer:in bzw. Agentur | Impressum (optional) | Anelia | offen |
| 1.16 | „Stand“-Datum für beide Rechtstexte setzen | Impressum, Datenschutz | webklar beim Flip | offen |
> **Hinweis zur englischen Fassung:** `/en/imprint/` und `/en/privacy/` sind Übersetzungen,
> verbindlich bleibt die deutsche Fassung – das steht dort auch im ersten Absatz. Jede
> Angabe aus dieser Tabelle muss trotzdem in **beiden** Dateien landen; der Build-Guard
> prüft beide.
---
## 2. Inhaltlich – sichtbare Platzhalter auf den Leistungsseiten
Diese „[…]“ stehen aktuell in Gold auf der Seite und sind für jeden Besucher lesbar.
| # | Angabe | Wo auf der Seite | Status |
|---|---|---|---|
| 2.1 | Name des Analyse-Instruments (DISG / INSIGHTS / persolog / …) + Zertifizierung. Aktive Lizenz? Darf Name oder Badge erscheinen? | Coaching → Bausteine | offen |
| 2.2 | Dauer des Auswertungsgesprächs | Coaching → Bausteine (zweimal) | offen |
| 2.3 | Sitzungsdauer im Coaching | Coaching → Rahmen | offen |
| 2.4 | Paket bzw. übliche Anzahl Sitzungen | Coaching → Rahmen | offen |
| 2.5 | Gruppengröße bei Trainings | Trainings → Hero-Fakten **und** Rahmen | offen |
| 2.6 | Umfang der Trainingsreihe „Neukundengewinnung“ | Trainings → Module | offen |
| 2.7 | **Messe-Special: Was bekommt, wer den QR-Code auf der Rückwand scannt?** | Messeseite `/her-career/` → Das Special | offen |
| 2.8 | Frist des Specials (z. B. „Anfragen bis 30.11.2026“) | Messeseite → Das Special | offen |
| 2.9 | Bedingung zum Einlösen (Stichwort, Nachweis, nichts?) | Messeseite → Das Special | offen |
> **Die Zeilen 2.7–2.9 hängen an einem Druckerzeugnis.** Der QR-Code auf der
> Rückwandgrafik ist mit „Messe-Special · Code scannen“ beschriftet und führt auf
> `her-career.anelia-wolf.com`. Wer dort ankommt, erwartet genau dieses Angebot;
> bis zum Wortlaut steht an der Stelle sichtbar „[…]“. Deadline ist damit nicht der
> Livegang, sondern der **22.10.2026**.
---
## 3. Freigaben – schalten fertige Blöcke frei
Diese Blöcke sind gebaut und ausgeschaltet. Kein Codeaufwand, nur ein Schalter in
`src/lib/site.ts`. Ausgeschaltet bleiben sie bewusst: Eine Zusage, die nicht gehalten
wird, und ein Testimonial ohne Einwilligung sind riskanter als eine Leerstelle.
| # | Flag | Was fehlt | Status |
|---|---|---|---|
| 3.1 | `showStimmen` | Zitate mit Name, Rolle, Unternehmen – und **schriftlicher** Einwilligung. Kundenlogos nur mit Erlaubnis. | offen |
| 3.2 | `showSpeakerPdf` | Speaker-Profil als PDF (Kurzvita, Themen, Fotos, Technik) → `public/speaker-profil.pdf` | offen |
| 3.3 | `showSecondTalk` | Darf „Unlock your Sales-DNA“ gelistet werden? Mit welchem Inhalt? Weitere Vorträge? | offen |
| 3.4 | `show24hPromise` | Zusage „Antwort innerhalb von 24 Stunden“ – ja oder nein? | offen |
| 3.5 | `showIndustry` | Darf die Branche der Nebentätigkeit genannt werden (z. B. „Medizinprodukte“)? Der Arbeitgeber wird nie genannt. | offen |
| 3.6 | `showTwins` | Steht auf `true`. Freigabe für den Satz über die Zwillinge bitte bestätigen. | zu bestätigen |
### Einzelfreigaben Biografie
Jede Angabe einzeln bestätigen, sie steht namentlich auf der Startseite:
Vater als Händler · Ausbildung zur Zahntechnikerin · Abteilungsleitung ·
Verkauf und Store Management im Premium-Segment · B2B-Außendienst heute ·
Zwillinge. Dazu: Dürfen später Jahreszahlen ergänzt werden?
### Weitere offene Punkte
| # | Frage | Status |
|---|---|---|
| 3.7 | Preise: Spanne oder „auf Anfrage“ für Coaching, Trainingstag, Keynote | offen |
| 3.8 | Reiseradius und Bühnentechnik bei Keynotes | offen |
| 3.9 | herCAREER-Logo: Erlaubnis vorhanden? (Ohne bleibt es beim reinen Textlink – so ist es aktuell gebaut.) | offen |
| 3.10 | Markenrecherche „WOLF-Prinzip“ – Dritte verwenden die Bezeichnung bereits. Bis zu einer Eintragung sagt die Seite „Bezeichnung“, nie „Wortmarke“. | empfohlen |
| 3.11 | Wer pflegt den Termine-Block nach dem 23.10.2026? | offen |
| 3.12 | FAQ-Antworten auf den drei Leistungsseiten: von uns formuliert, von Anelia noch nicht gegengelesen. Sie stehen **sichtbar** auf der Seite (`showFaq` ist an). | zu bestätigen |
| 3.13 | Zusage im Hero-Knopf: „30 Min · kostenfrei“ – bestätigen oder ändern. | zu bestätigen |
| 3.14 | Zielgruppen-Reihenfolge: Unternehmen zuerst oder Einzelpersonen zuerst? Reiner Texttausch in „Leistungen“ und Hero. | offen |
| 3.15 | Messeseite `/her-career/`: Hero-Text, Themen-Zeilen und Kontakt-Überschrift sind von uns formuliert (Ton der Website, Inhalte der Rückwand) – von Anelia gegenlesen. | zu bestätigen |
| 3.17 | **Messeseite, Hinweis unter dem Absenden-Knopf.** Auf Wunsch sind dort Hinweiszeile und Datenschutz-Link entfernt. Seit dem Umbau überträgt das Formular an `/api/contact` (Datenschutz Ziffer 5) – die Zeile ist damit die Information nach Art. 13 DSGVO am Ort der Erhebung; erreichbar bleibt die Erklärung nur über den Fuß. Vor dem Livegang entscheiden: zurücknehmen oder bewusst so lassen. | zu entscheiden |
| 3.16 | Subdomain `her-career.anelia-wolf.com`: DNS, Zertifikat und Vhost einrichten. Muss **vor dem 22.10.2026** stehen, die Adresse ist gedruckt (`docs/SUBDOMAIN-HERCAREER.md`). Wer: webklar. | offen |
---
## 4. Bild- und Design-Dateien
**Das ist die Tabelle für den Designer bzw. die Fotografin.** Fehlt eine Datei, blendet
die Seite den Bildplatz sauber aus oder weicht auf eine typografische Lösung aus –
es bricht nichts, aber die Seite verliert ihr Gesicht.
| Datei | Ablage | Format | Mindestmaß | Verhältnis | Wo sie erscheint | Pflicht | Status |
|---|---|---|---|---|---|---|---|
| `anelia-hero.jpg` | `public/photos/` | JPG, sRGB | 1600 px breit | 4:5 hoch | Startseite, Hero (großflächig) | ja | offen |
| `anelia-portrait.jpg` | `public/photos/` | JPG, sRGB | 1400 px breit | 3:4 hoch | „Über Anelia“, engerer Schnitt | nein – fällt sonst auf das Hero-Foto zurück | offen |
| `anelia-stage.jpg` | `public/photos/` | JPG, sRGB | 1600 px breit | 3:2 quer | Keynotes-Seite, Bühnenfoto | nein | offen |
| `logo-mark.svg` | `public/brand/` | SVG, transparent, ohne `<script>` | – | – | Kopf, Hero, WOLF-Prinzip, Fuß, Seitenwechsel, 404 | ja | **liegt vor** |
| `logo-full.png` | `public/brand/` | PNG auf Tinte | 2400 × 1260 px | 1,91:1 | Social-Vorschau beim Teilen (LinkedIn) | nein | **liegt vor** |
| `speaker-profil.pdf` | `public/` | PDF | – | – | Keynotes → „Für Veranstalter“ (nur mit `showSpeakerPdf`) | nein | offen |
Dazu zwei Punkte, die keine Datei sind, aber am Logo hängen:
| # | Angabe | Wer liefert | Status |
|---|---|---|---|
| 4.1 | **Schriftliche Nutzungsrechte am Logo** vom Designer – die Website veröffentlicht es, das Impressum nennt ihn | Anelia | offen |
| 4.2 | Bestätigung, dass das Monogramm **ohne Wortmarke** und der Wolfskopf **einzeln** gezeigt werden dürfen (Favicon, Seitenwechsel, 404) | Anelia / Designer | offen |
### Regeln für die Bilddateien
- **Ruhiger Hintergrund** beim Hero-Foto: Darüber liegt das Monogramm, und die Headline
läuft bis in die linke Bildhälfte.
- **Kein Text im Bild.** Alles Geschriebene steht auf der Seite als echter Text – das
bleibt scharf, ist vorlesbar und lässt sich übersetzen.
- **sRGB**, nicht Adobe RGB. Sonst wirken die Bilder im Browser flau.
- **Originale ≥ 2000 px** mitliefern, nicht nur die zugeschnittene Fassung. Der Zuschnitt
kann sich noch ändern.
- **Name der Fotografin** gehört dazu (Punkt 1.14). „Foto: [Name]“ ist eine gesetzliche
Pflicht, kein Höflichkeitsvermerk.
### Regeln für ein neues Logo
Falls das Monogramm ersetzt wird: SVG, transparent, **ohne `<script>`** (ein Skript
bricht den Build absichtlich ab – die Datei wird inline in jede Seite eingebettet, und
die Seite sagt „keine externen Skripte“ zu). Die schwarzen Flächen auf
`fill="currentColor"`, damit das W auf dunklem Grund elfenbein und auf hellem tinte
erscheint. IDs `v-left`, `v-right`, `wolf` vergeben, sonst läuft die Zeichen-Animation in
DOM-Reihenfolge. Details in `public/brand/README.md`.
---
## 5. Prompt – Liefertabelle für den Designer erzeugen
Der Block unten ist zum Kopieren gedacht. Er steht für sich allein: Wer ihn in einen
Chat einfügt, bekommt die Tabelle, ohne dieses Dokument mitschicken zu müssen. Die
Platzhalter in eckigen Klammern vorher ersetzen.
```text
Du bist Projektleiter einer Webagentur. Erstelle eine Liefertabelle für die
Fotografin und den Grafiker der Website von Anelia Wolf (Keynote Speakerin,
Trainerin und Coach). Ton: knapp, sachlich, keine Werbesprache.
Die Website ist fertig gebaut: statischer Export, Next.js, zwei Sprachen
(Deutsch auf /, Englisch auf /en/). Gestaltung: warmes Schwarz (#0F0E0C),
Elfenbein (#F6F1E8), Gold (#C9A24D), keine Schatten, keine runden Ecken,
Playfair Display für Überschriften. Fehlt eine Bilddatei, blendet die Seite den
Platz sauber aus – nichts bricht, aber die Seite verliert ihr Gesicht.
Diese Dateien fehlen noch:
1. anelia-hero.jpg – Startseite, großflächig hinter Headline und Monogramm.
JPG, sRGB, hochkant 4:5, mindestens 1600 px breit, ruhiger Hintergrund.
2. anelia-portrait.jpg – Abschnitt „Über Anelia“, engerer Schnitt.
JPG, sRGB, hochkant 3:4, mindestens 1400 px breit. Optional.
3. anelia-stage.jpg – Keynotes-Seite, Anelia auf der Bühne.
JPG, sRGB, quer 3:2, mindestens 1600 px breit. Optional.
4. speaker-profil.pdf – Kurzvita, Themen, Fotos, Technik. Optional.
Feste Regeln: kein Text im Bild (alle Schrift liegt als echter Text auf der
Seite), sRGB statt Adobe RGB, Originale ab 2000 px mitliefern, Name der
Fotografin für den Bildnachweis nach § 13 UrhG.
Gib eine Markdown-Tabelle aus mit den Spalten:
Datei | Motiv/Bildidee | Format | Mindestmaß | Seitenverhältnis | Wo es erscheint |
Pflicht | Wer liefert | Termin | Status
Fülle „Motiv/Bildidee“ mit einem konkreten Vorschlag je Zeile, der zur
Gestaltung passt. „Termin“ mit [DATUM] vorbelegen, „Status“ mit „offen“.
Setze unter die Tabelle drei Sätze, was passiert, wenn eine Datei fehlt.
Antworte auf Deutsch.
```
### Variante für die Rechtsangaben
Dieselbe Mechanik für Abschnitt 1, falls Anelia eine Liste zum Ausfüllen möchte:
```text
Erstelle eine Ausfüll-Liste für eine Selbstständige, die einer Webagentur die
Pflichtangaben für Impressum und Datenschutzerklärung ihrer neuen Website
liefern muss. Deutsches Recht, DDG und DSGVO.
Benötigt werden: ladungsfähige Anschrift (kein Postfach), USt-IdNr. oder
Wirtschafts-Identifikationsnummer oder die Entscheidung, den Abschnitt zu
streichen, Kleinunternehmerregelung nach § 19 UStG ja/nein,
Berufshaftpflichtversicherung (Versicherer, Anschrift, Geltungsbereich) oder
die Entscheidung, den Abschnitt zu streichen, Bundesland des Firmensitzes
(daraus folgt die zuständige Aufsichtsbehörde), E-Mail- und Office-Anbieter
mit Anschrift und Land, Aufbewahrungsfrist für Kontaktanfragen in Monaten,
Name der Fotografin und der Logo-Designerin für den Bildnachweis.
Gib eine Markdown-Tabelle aus: Angabe | Warum sie gebraucht wird (ein
Halbsatz) | Beispiel | Ihre Antwort. Die Spalte „Ihre Antwort“ bleibt leer.
Schreibe die Kundin mit „Sie“ an, keine Paragraphenketten im Fließtext.
Antworte auf Deutsch.
```
---
## 6. Wenn alles da ist – der Livegang
1. Angaben in `src/content/legal.ts` **und** `src/content/en/legal.ts` eintragen,
Platzhalter in `src/content/coaching.ts` und `trainings.ts` ersetzen (deutsch und
englisch).
2. Bilder nach `public/photos/` legen, Namen exakt wie in der Tabelle oben.
3. Freigegebene Blöcke per Flag einschalten.
4. `flags.hostOutsideEu` korrekt setzen.
5. `flags.showPlaceholders` auf `false` setzen und `npm run build` laufen lassen. Bricht
der Build ab, nennt er die Fundstelle – dann ist noch etwas offen.
6. Restliche Checkliste in `docs/HANDOVER.md`, Abschnitt 9.
+128
View File
@@ -0,0 +1,128 @@
# Subdomain `her-career.anelia-wolf.com`
Stand: 07.09.2026 · Zuständig: webklar (Hoster-Konfiguration)
Auf der Rückwandgrafik am Messestand steht unten links ein QR-Code, beschriftet
„Messe-Special · Code scannen“. Er zeigt auf:
```
https://her-career.anelia-wolf.com
```
**Diese Adresse ist gedruckt.** Sie lässt sich nicht mehr ändern, und sie muss am
22.10.2026 funktionieren – inklusive gültigem Zertifikat. Ein Scan, der auf einer
Zertifikatswarnung landet, ist schlimmer als kein Code.
Die Datei dahinter ist `out/her-career/index.html` aus dem normalen Build
(`npm run build`). Es gibt **keinen zweiten Build und kein zweites Deployment** –
die Subdomain zeigt in denselben Docroot wie die Hauptdomain.
## Was einzurichten ist
1. **DNS:** `her-career` als A- (und AAAA-)Record auf dieselbe IP wie
`anelia-wolf.com`, oder als CNAME auf den Hauptdomain-Namen.
2. **Zertifikat:** Die Subdomain muss im Zertifikat stehen – als weiterer
SAN-Eintrag (`certbot ... -d anelia-wolf.com -d www.anelia-wolf.com -d
her-career.anelia-wolf.com`) oder über ein Wildcard-Zertifikat.
3. **Vhost:** Derselbe Docroot, drei Regeln (unten). Kurz gesagt: `/` liefert die
Messeseite, ihre Dateien kommen aus demselben Verzeichnis, jede andere Adresse
wandert per 301 auf die Hauptdomain.
Der dritte Punkt ist der wichtige. Ohne ihn läge die **komplette Website** unter
zwei Hostnamen – jede Seite doppelt, mit doppelten Adressen in der Suche. Die
Messeseite trägt zwar einen Canonical auf `https://anelia-wolf.com/her-career/`
(gesetzt in `src/app/(de)/her-career/page.tsx`), der die Kopie zusammenfasst; die
übrigen Seiten tun das für ihre Subdomain-Zwillinge nicht.
## nginx
```nginx
server {
listen 443 ssl;
http2 on;
server_name her-career.anelia-wolf.com;
ssl_certificate /etc/letsencrypt/live/anelia-wolf.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/anelia-wolf.com/privkey.pem;
# Derselbe Docroot wie die Hauptdomain: der hochgeladene Inhalt von out/.
root /var/www/anelia-wolf.com;
# „/“ ist die Messeseite.
location = / {
try_files /her-career/index.html =404;
}
# Was die Seite selbst lädt, kommt aus demselben Verzeichnis.
# (Leere Blöcke erben root und liefern die Datei aus – kein Redirect.)
location /_next/ { }
location /brand/ { }
location /photos/ { }
location = /favicon.ico { }
location = /icon.svg { }
location = /apple-icon.png { }
# Alles andere gehört auf die Hauptdomain.
location / {
return 301 https://anelia-wolf.com$request_uri;
}
}
# Port 80 nur zum Hochreichen auf HTTPS.
server {
listen 80;
server_name her-career.anelia-wolf.com;
return 301 https://her-career.anelia-wolf.com$request_uri;
}
```
## Apache
```apache
<VirtualHost *:443>
ServerName her-career.anelia-wolf.com
DocumentRoot /var/www/anelia-wolf.com
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/anelia-wolf.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/anelia-wolf.com/privkey.pem
RewriteEngine On
# „/“ ist die Messeseite (intern, ohne Redirect – die Adresse bleibt stehen).
RewriteRule ^/?$ /her-career/index.html [L]
# Dateien der Seite bleiben hier.
RewriteRule ^/(_next|brand|photos)/ - [L]
RewriteRule ^/(favicon\.ico|icon\.svg|apple-icon\.png)$ - [L]
# Alles andere auf die Hauptdomain.
RewriteRule ^/(.*)$ https://anelia-wolf.com/$1 [R=301,L]
</VirtualHost>
```
## Wenn der Hoster keine Rewrites kann
Dann die ganze Subdomain per 301 auf `https://anelia-wolf.com/her-career/`
umleiten. Der QR-Code funktioniert weiterhin – der Browser folgt der Umleitung,
ohne dass jemand etwas merkt; in der Adresszeile steht danach die Hauptdomain.
Das ist die schlechtere, aber jederzeit funktionierende Variante.
## Prüfen (nach dem Ausrollen, vor der Messe)
```bash
curl -sI https://her-career.anelia-wolf.com/ | head -1
curl -s https://her-career.anelia-wolf.com/ | grep -o '<link rel="canonical"[^>]*>'
curl -sI https://her-career.anelia-wolf.com/impressum/ | head -2
```
Erwartet: `200`, ein Canonical auf `https://anelia-wolf.com/her-career/`, und für
`/impressum/` ein `301` auf `https://anelia-wolf.com/impressum/`.
Dazu einmal mit dem Handy den echten, gedruckten Code scannen – nicht den aus der
PDF-Vorschau, sondern den auf dem gelieferten Druck.
## Nach der Messe
Ab dem 24.10.2026 steht in `src/lib/site.ts` `flags.showHerCareer` auf `false`
(siehe `docs/HANDOVER.md`, Abschnitt 6). Die Seite bleibt erreichbar – gedruckte
Codes laufen weiter, und Anelias Rückwand wird nicht am Tag danach eingestampft –,
verliert aber die Termin-Angaben und den Eintrag in der Sitemap. Der Vhost bleibt,
wie er ist.
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

+33
View File
@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { HerCareerBody } from "@/components/pages/HerCareerBody";
import { herCareer } from "@/content/her-career";
import { alternatesFor, OG_LOCALE } from "@/lib/i18n";
import { flags, site } from "@/lib/site";
/* Solange die Messe bevorsteht, nennt die Beschreibung Stand und Uhrzeit; danach
(`flags.showHerCareer: false`) die Fassung ohne Termine – eine Suchergebnis-Zeile,
die einen vergangenen Termin ankündigt, ist schlechter als gar keine Zeile. */
const description = flags.showHerCareer ? herCareer.meta.descriptionMesse : herCareer.meta.description;
/**
* Ziel des QR-Codes auf der Rückwandgrafik. Der Canonical zeigt bewusst auf
* anelia-wolf.com/her-career/: Dieselbe Datei wird auch unter
* her-career.anelia-wolf.com ausgeliefert, und ohne diese Angabe stünde die Seite
* unter zwei Adressen im Index (docs/SUBDOMAIN-HERCAREER.md).
*/
export const metadata: Metadata = {
title: herCareer.meta.title,
description,
alternates: alternatesFor("de", "herCareer"),
openGraph: {
title: `${herCareer.meta.title} – ${site.name}`,
description,
url: alternatesFor("de", "herCareer").canonical,
locale: OG_LOCALE.de,
},
};
/** /her-career/ – Aufbau in <HerCareerBody/>, Texte in src/content/her-career.ts. */
export default function HerCareerPage() {
return <HerCareerBody content={herCareer} />;
}
+27
View File
@@ -0,0 +1,27 @@
import type { Metadata } from "next";
import { HerCareerBody } from "@/components/pages/HerCareerBody";
import { herCareerEn } from "@/content/en/her-career";
import { alternatesFor, OG_LOCALE } from "@/lib/i18n";
import { flags, site } from "@/lib/site";
/* Solange die Messe bevorsteht, nennt die Beschreibung Stand und Uhrzeit; danach
(`flags.showHerCareer: false`) die Fassung ohne Termine – eine Suchergebnis-Zeile,
die einen vergangenen Termin ankündigt, ist schlechter als gar keine Zeile. */
const description = flags.showHerCareer ? herCareerEn.meta.descriptionMesse : herCareerEn.meta.description;
export const metadata: Metadata = {
title: herCareerEn.meta.title,
description,
alternates: alternatesFor("en", "herCareer"),
openGraph: {
title: `${herCareerEn.meta.title} – ${site.name}`,
description,
url: alternatesFor("en", "herCareer").canonical,
locale: OG_LOCALE.en,
},
};
/** /en/her-career/ – layout in <HerCareerBody/>, copy in src/content/en/her-career.ts. */
export default function EnHerCareerPage() {
return <HerCareerBody content={herCareerEn} />;
}
+10 -2
View File
@@ -1,7 +1,8 @@
import type { Metadata } from "next";
import { KeynotesBody } from "@/components/pages/KeynotesBody";
import { homeEn } from "@/content/en/home";
import { keynotesEn, talkLabelsEn } from "@/content/en/keynotes";
import { alternatesFor, OG_LOCALE } from "@/lib/i18n";
import { alternatesFor, OG_LOCALE, routesByLang } from "@/lib/i18n";
import { site } from "@/lib/site";
export const metadata: Metadata = {
@@ -18,5 +19,12 @@ export const metadata: Metadata = {
/** /en/services/keynotes/ – layout in <KeynotesBody/>, copy in src/content/en/keynotes.ts. */
export default function EnKeynotesPage() {
return <KeynotesBody content={keynotesEn} talkLabels={talkLabelsEn} />;
return (
<KeynotesBody
content={keynotesEn}
talkLabels={talkLabelsEn}
contact={homeEn.contact}
privacyHref={routesByLang.en.datenschutz}
/>
);
}
+9 -2
View File
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { TrainingsBody } from "@/components/pages/TrainingsBody";
import { homeEn } from "@/content/en/home";
import { trainingsEn } from "@/content/en/trainings";
import { alternatesFor, OG_LOCALE } from "@/lib/i18n";
import { alternatesFor, OG_LOCALE, routesByLang } from "@/lib/i18n";
import { site } from "@/lib/site";
export const metadata: Metadata = {
@@ -19,5 +19,12 @@ export const metadata: Metadata = {
/** /en/services/trainings/ – layout in <TrainingsBody/>, copy in src/content/en/trainings.ts. */
export default function EnTrainingsPage() {
return <TrainingsBody content={trainingsEn} marginalia={homeEn.about.marginalia} />;
return (
<TrainingsBody
content={trainingsEn}
marginalia={homeEn.about.marginalia}
contact={homeEn.contact}
privacyHref={routesByLang.en.datenschutz}
/>
);
}
+74
View File
@@ -30,6 +30,13 @@
--gold-700: #a37a2c;
--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) */
--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%);
@@ -82,6 +89,12 @@
--breakpoint-wide: 90rem;
}
/* „Kurze“ Ansicht: Telefon im Hochformat mit sichtbarer Adressleiste (iPhone SE 667 px,
viele Androids 640 px) und jedes Telefon im Querformat. Das Overlay-Menü rückt dort
enger zusammen, sonst fällt sein Fuß (Telefonnummer, E-Mail, Rechtslinks) unter die
Kante. Höhe statt Breite, weil das Problem die Höhe ist. */
@custom-variant short (@media (max-height: 47.5rem));
@theme inline {
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
@@ -93,6 +106,7 @@
--color-gold-200: var(--gold-200);
--color-gold-700: var(--gold-700);
--color-gold-900: var(--gold-900);
--color-sale: var(--sale);
--color-grey-warm: var(--grey-warm);
--color-grey-deep: var(--grey-deep);
--color-line-dark: var(--line-dark);
@@ -182,6 +196,55 @@ body:has(main > [data-theme="light"]:first-child) header:not([data-scrolled]) {
--line: var(--line-light);
}
/* Messeseite (/her-career/): Dort trägt der Kopf keinen CTA – die eine Aufforderung
der Seite ist das Formular im Kontaktblock, und der Knopf im Kopf zeigte als
einziges Element von der Messeseite weg (auf den Kontaktblock der Startseite).
Der Kopf liegt außerhalb von <main> und kennt die Route nicht; auf der Subdomain
her-career.anelia-wolf.com ist der Pfad ohnehin „/“. Deshalb erkennt die Regel die
Seite an ihrem eigenen Abschnitt „Messe-Special“ – dessen id ist zugleich das Ziel
des Folio-Links im Hero (src/content/her-career.ts). */
body:has(main #messe-special) header [data-header-cta] {
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
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,
@@ -229,6 +292,17 @@ svg {
-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 */
.rule-gold { height: 1px; background: var(--gold-hairline); }
[data-theme="light"] .rule-gold { background: var(--gold-hairline-light); }
+12 -2
View File
@@ -1,17 +1,27 @@
import type { MetadataRoute } from "next";
import { LANGS, routesByLang, type Lang, type PageKey } from "@/lib/i18n";
import { site } from "@/lib/site";
import { flags, site } from "@/lib/site";
/** Static export: metadata routes are prerendered; mark explicitly (Next static-exports guide). */
export const dynamic = "force-static";
type ChangeFrequency = NonNullable<MetadataRoute.Sitemap[number]["changeFrequency"]>;
const pages: ReadonlyArray<{ page: PageKey; changeFrequency: ChangeFrequency; priority: number }> = [
type PageEntry = { page: PageKey; changeFrequency: ChangeFrequency; priority: number };
/* Die Messe-Landingpage steht nur in der Sitemap, solange die Messe bevorsteht.
Gefunden wird sie ohnehin über den gedruckten QR-Code, nicht über die Suche –
nach dem Event wäre sie ein Eintrag, der auf ein vergangenes Datum zeigt. */
const herCareerPages: ReadonlyArray<PageEntry> = flags.showHerCareer
? [{ page: "herCareer", changeFrequency: "monthly", priority: 0.5 }]
: [];
const pages: ReadonlyArray<PageEntry> = [
{ page: "home", changeFrequency: "monthly", priority: 1 },
{ page: "coaching", changeFrequency: "monthly", priority: 0.8 },
{ page: "trainings", changeFrequency: "monthly", priority: 0.8 },
{ page: "keynotes", changeFrequency: "monthly", priority: 0.8 },
...herCareerPages,
{ page: "impressum", changeFrequency: "yearly", priority: 0.2 },
{ page: "datenschutz", changeFrequency: "yearly", priority: 0.2 },
];
+41 -4
View File
@@ -25,6 +25,18 @@ export interface BrandMarkProps {
handoff?: boolean;
/** Accessible name. */
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> = {
@@ -40,7 +52,9 @@ function escapeAttr(s: string): string {
function prepareSvg(
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">,
): string {
/* 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}>`;
});
/* 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) => {
let a = attrs.replace(/\s(width|height)="[^"]*"/g, "");
if (/\bclass="/.test(a)) {
@@ -97,6 +115,11 @@ function prepareSvg(
} else if (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 (!/\baria-label=/.test(a)) a += ` aria-label="${escapeAttr(title)}"`;
return `<svg${a}>`;
@@ -111,16 +134,30 @@ export function BrandMark({
variant = "full",
handoff = false,
title = "Anelia Wolf – Monogramm",
decorative = false,
}: BrandMarkProps) {
const raw = getLogoSvg();
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 }} />;
}
/* 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") {
return (
<span className="contents [&_.wolfmark-wolf]:hidden">
<WolfMark className={className} idPrefix={idPrefix} title={title} />
<span className="contents [&_.wolfmark-wolf]:hidden" {...hide}>
<WolfMark className={className} idPrefix={idPrefix} title={fallbackTitle} />
</span>
);
}
if (decorative) {
return (
<span className="contents" {...hide}>
<WolfMark className={className} idPrefix={idPrefix} title={fallbackTitle} />
</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;
+14 -3
View File
@@ -27,7 +27,11 @@ export function Wordmark({ size = "nav", href = "/", className, mark, chrome = c
<TransitionLink
href={href}
aria-label={chrome.wordmark.ariaLabel}
className={cn("wordmark group inline-flex max-w-full items-center", footer ? "gap-4 sm:gap-6" : "gap-4", className)}
className={cn(
"wordmark group inline-flex max-w-full items-center",
footer ? "gap-4 sm:gap-6" : "gap-3 sm:gap-4",
className,
)}
>
{mark && (
<span
@@ -40,12 +44,19 @@ export function Wordmark({ size = "nav", href = "/", className, mark, chrome = c
<span
className={cn(
"wordmark__text flex flex-col",
footer ? "min-w-0 text-[clamp(1.25rem,0.75rem+1.8vw,2.75rem)]" : "text-[1.0625rem]",
/* Unter 640 px eine Nummer kleiner: „ANELIA WOLF“ ist mit 17 px und 0.32em
Laufweite 158 px breit und lief damit schon immer über seine sechs Spalten
hinaus – sichtbar wurde das erst, als rechts daneben der Sprachknopf dazukam
(vorher lief es in leeren Raum). Der Monogramm-Kopf bleibt 28 px. */
footer ? "min-w-0 text-[clamp(1.25rem,0.75rem+1.8vw,2.75rem)]" : "text-[0.9375rem] sm:text-[1.0625rem]",
)}
>
<span
className={cn(
"wordmark__name whitespace-nowrap pl-[0.32em] font-sans text-[1em] font-light uppercase leading-none tracking-[0.32em]",
"wordmark__name whitespace-nowrap pl-[0.32em] font-sans text-[1em] font-light uppercase leading-none",
/* Die Laufweite trägt den größten Teil der Breite (10 Lücken × 0.32em). Im Kopf
wird sie unter 640 px enger; im Fuß bleibt sie, dort ist die volle Breite da. */
footer ? "tracking-[0.32em]" : "tracking-[0.22em] sm:tracking-[0.32em]",
"text-fg",
)}
>
+50 -5
View File
@@ -1,5 +1,6 @@
import { cn } from "@/lib/utils";
import { flags } from "@/lib/site";
import type { CertificateAsset } from "@/lib/assets";
import { about as aboutDe, type HomeContent } from "@/content/home";
import { Section } from "@/components/layout/Section";
import { Lines } from "@/components/motion/Lines";
@@ -9,12 +10,15 @@ import { Eyebrow } from "@/components/ui/Eyebrow";
import { Ledger } from "@/components/ui/Ledger";
import { Marginalia } from "@/components/ui/Marginalia";
import { Portrait } from "@/components/ui/Portrait";
import { Certificate } from "@/components/ui/Certificate";
export interface AboutProps {
/** `getPhotos().portrait` – `null` renders the documented variant B (no photo). */
photo: string | null;
/** Inhalt der Sektion. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */
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). */
@@ -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
* verifiable facts. No counters, no logos.
*
* With photo: Portrait cols 3–6 (sticky) · text + ledger cols 7–12 · marginalia cols 10–12.
* Without: text cols 3–8 · ledger cols 3–9 · marginalia under the ledger
* (no monogram vignette – see below).
* With photo: Portrait cols 3–6 (sticky) · text + ledger cols 7–12 · marginalia and
* certificates stacked under the ledger (cols 7–12) – the text already
* 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) =>
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}
label={about.eyebrow}
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 && (
<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
className={cn(
"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} />
</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>
</div>
);
+68 -31
View File
@@ -1,6 +1,6 @@
"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 { flags } from "@/lib/site";
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
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;
/** 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
* Rasterzeile bleibt leer, damit die übrigen Zeilenzuweisungen unverändert gelten.
* 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
* folio hairline, which draws outward from that x. Variant B (no photo):
* monogram 56vh cols 7–12, right-aligned, same handoff. Mobile: masthead,
* monogram 14svh, H1, CTAs, portrait 3:4 (≤ 48svh) – no folio row (it costs
* 69 px the phone cover does not have; Positionierung carries „01 / 06“).
* monogram 45vh cols 7–12, right-aligned, same handoff. Mobile: masthead,
* monogram 12svh, H1, CTAs, portrait 3:4 (≤ 48svh) – no folio row (it costs 69 px
* 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) {
const root = useRef<HTMLDivElement>(null);
@@ -181,7 +189,7 @@ export function Hero({ mark, photo, content: hero = heroDe }: HeroProps) {
<Section
id={hero.id}
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:
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>
)}
{/* 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
ref={markWrap}
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",
/* WolfMark's 7-unit stroke is ~18 px at hero size – far from the
spec's hairline mark. Overridden here, not in WolfMark.tsx. */
"[&_.wolfmark-v]:[stroke-width:3] [&_.wolfmark-wolf]:[stroke-width:2.6]",
hasPhoto
? "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",
hasPhoto ? "lg:col-span-3 lg:col-start-6 lg:items-start" : "lg:col-span-6 lg:col-start-7 lg:items-end",
)}
>
<DrawMark
play="load"
sheen
handoff
handoffLength={handoffLength}
delay={T.draw}
className="block h-full [&_svg]:h-full [&_svg]:w-auto"
>
{mark}
</DrawMark>
{/* Ab lg wieder die Größe von vor dem Claim (45vh / 28vh). Am Handy bleiben es
12svh: Der Platz geht an den Abstand über der Slogan-Plakette. */}
<div className={cn("h-[12svh]", hasPhoto ? "lg:h-[28vh]" : "lg:h-[min(45vh,24vw)]")}>
<DrawMark
play="load"
sheen
handoff
handoffLength={handoffLength}
delay={T.draw}
className="block h-full [&_svg]:h-full [&_svg]:w-auto"
>
{mark}
</DrawMark>
</div>
</div>
{/* H1 */}
@@ -257,25 +268,51 @@ export function Hero({ mark, photo, content: hero = heroDe }: HeroProps) {
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"
>
{/* The H1 is capped by viewport HEIGHT as well as width: `26vh - 108px`
is neutral at 1440×900 (the cover's reference frame) and pulls the
three Bodoni lines back on 800/768 px laptops, where the vw term
alone grows faster than the fold shrinks. */}
{/* `max-w-[10.9em]` wurde für die Vorgänger-Headline gemessen und passt auch
für Anelias Satz (02.10.2026, gemessen): Desktop DE und EN je drei Zeilen
(„Mit starker Persönlichkeit | und souveräner | Wirkung zum Erfolg!“),
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
as="h1"
play="load"
delay={T.lines}
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}
<em className="italic">{hero.h1.em}</em>
{hero.h1.after}
{hero.h1.parts ? (
/* Mehr als eine Betonung: kursiv trägt jedes Teil, den Gold-Verlauf
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>
</div>
{/* 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
1440×900 – the cover's reference frame – the original 24/32 px rhythm is
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 { positioning as positioningDe, type HomeContent } from "@/content/home";
import { Section } from "@/components/layout/Section";
import { Lines } from "@/components/motion/Lines";
import { Reveal, RevealGroup, RevealItem } from "@/components/motion/Reveal";
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. */
export const H2 =
@@ -12,6 +13,48 @@ export const H2 =
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 +
* H2 + editorial paragraph + the three question rows cols 3–9
@@ -24,7 +67,7 @@ export interface PositioningProps {
}
export function Positioning({ content: positioning = positioningDe }: PositioningProps = {}) {
const { numeral, eyebrow, h2, paragraph, rows } = positioning;
const { numeral, eyebrow, h2, paragraph, rows, slogan } = positioning;
return (
<Section
@@ -33,6 +76,7 @@ export function Positioning({ content: positioning = positioningDe }: Positionin
numeral={numeral}
label={eyebrow}
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">
<Reveal>
+36 -8
View File
@@ -5,7 +5,7 @@ import { Reveal } from "@/components/motion/Reveal";
import { Rule } from "@/components/motion/Rule";
import { ContactLinks } from "@/components/ui/ContactLinks";
import { Eyebrow, EYEBROW_TEXT } from "@/components/ui/Eyebrow";
import { ContactForm } from "@/components/ui/ContactForm";
import { ContactForm, type ContactCoupon, type ContactField } from "@/components/ui/ContactForm";
/* H2 on ink: Bodoni, `clamp(2.75rem, 1.6rem + 4.4vw, 6rem)`, ivory (spec §6.7). */
const H2 =
@@ -25,16 +25,40 @@ export interface ProcessContactProps {
content?: HomeContent["contact"];
/** Ziel des Datenschutz-Links im Formular. */
privacyHref?: string;
/** Nenner des Abschnittszählers („04 / 04“). Die Startseite zählt ohne Nenner. */
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 }: ProcessContactProps = {}) {
export function ProcessContact({
content: contact = contactDe,
privacyHref,
total,
coupon,
fields,
topic,
}: ProcessContactProps = {}) {
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" />
<Section
id={contact.id}
theme="dark"
numeral={contact.numeral}
total={total}
label={contact.eyebrow}
className="pt-[clamp(5rem,11vh,9rem)] pb-[clamp(3.5rem,7vh,6rem)] md:pt-[clamp(8rem,18vh,15rem)] md:pb-[clamp(5rem,10vh,8rem)]"
>
@@ -52,7 +76,7 @@ export function ProcessContact({ content: contact = contactDe, privacyHref }: Pr
Es verschickt nichts selbst, sondern übergibt eine fertige E-Mail an das
Mailprogramm – siehe den Kopf von ContactForm.tsx. */}
<Reveal className="col-span-12 mt-12 lg:col-span-10 lg:col-start-3 lg:mt-14 xl:col-span-6">
<ContactForm content={contact.form} privacyHref={privacyHref} />
<ContactForm content={contact.form} privacyHref={privacyHref} coupon={coupon} fields={fields} topic={topic} />
</Reveal>
{/* Contact block: under the Ablauf up to 1280 (the CTA button is 354 px
@@ -63,10 +87,14 @@ export function ProcessContact({ content: contact = contactDe, privacyHref }: Pr
</div>
</div>
{/* Sign-off above the colophon (the Footer follows globally). */}
<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>
{/* Sign-off above the colophon (the Footer follows globally). Leer = entfällt:
Auf den Leistungsseiten stünde der Claim sonst direkt über dem Fuß, der ihn
ebenfalls trägt. */}
{contact.signoff && (
<Reveal as="p" className={`col-span-12 mt-20 lg:col-span-10 lg:col-start-3 lg:mt-32 ${EYEBROW_TEXT} text-gold`}>
{contact.signoff}
</Reveal>
)}
</Section>
</div>
);
+28 -4
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 { chrome as chromeDe, type Chrome } from "@/content/chrome";
import { Wordmark } from "@/components/brand/Wordmark";
import { Collab } from "@/components/brand/Collab";
import { TransitionLink } from "@/components/motion/TransitionLink";
import { subscribe } from "@/components/motion/transition-store";
import { Container } from "./Container";
@@ -108,9 +109,25 @@ export function Header({ mark, chrome = chromeDe, nav = navDe, legal = legalDe,
>
<Container>
<div className="grid h-[60px] grid-cols-12 items-center gap-x-[clamp(16px,2vw,32px)] lg:h-[72px]">
<div data-menu-inert="" className="col-span-6 flex min-w-0 items-center xl:col-span-3">
{/* Unter 640 px eine Flex-Zeile statt des 12-Spalten-Rasters. Grund ist Arithmetik:
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} />
<Collab label={chrome.collab.ariaLabel} />
</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">
@@ -129,10 +146,17 @@ export function Header({ mark, chrome = chromeDe, nav = navDe, legal = legalDe,
</nav>
<div className="col-span-6 flex items-center justify-end gap-6 xl:col-span-4 2xl:gap-8">
{/* Steht an der Stelle, an der bis zum 06.09.2026 „Kontakt“ stand. */}
{/* Steht an der Stelle, an der bis zum 06.09.2026 „Kontakt“ stand.
Zwei Formen, weil neben Wortmarke und „Menü“ auf einem Telefon kein Paar
Platz hat: unter 1024 px nur die Zielsprache als ein Knopf, darüber beide.
Im Overlay-Menü steht er nicht mehr – dort fiel er auf kurzen Geräten
unter die Kante, ohne dass man ihm ansah, dass da noch etwas kommt. */}
{flags.showLangSwitch && <LangSwitch lang={lang} variant="compact" className="lg:hidden" />}
{flags.showLangSwitch && <LangSwitch lang={lang} className="hidden lg:flex" />}
<a
data-menu-inert=""
/* Haken für globals.css: Auf der Messeseite entfällt dieser Knopf. */
data-header-cta=""
href={chrome.header.cta.href}
className="hidden whitespace-nowrap border border-gold bg-[image:var(--gold-sheen)] bg-[length:250%_100%] bg-[position:0%_50%] px-[1.4em] py-[1.05em] font-sans text-[0.75rem] font-medium uppercase leading-none tracking-[0.16em] text-ink transition-[background-position] duration-[600ms] ease-[var(--ease-out-quart)] hover:bg-[position:100%_50%] focus-visible:bg-[position:100%_50%] xl:inline-block 2xl:px-[1.6em] 2xl:tracking-[0.2em]"
>
@@ -161,7 +185,7 @@ export function Header({ mark, chrome = chromeDe, nav = navDe, legal = legalDe,
</div>
</Container>
</header>
<OverlayMenu open={open} onClose={() => setOpen(false)} chrome={chrome} legal={legal} lang={lang} />
<OverlayMenu open={open} onClose={() => setOpen(false)} chrome={chrome} legal={legal} />
</>
);
}
+80 -8
View File
@@ -1,31 +1,103 @@
"use client";
import { usePathname } from "next/navigation";
import { useSyncExternalStore } from "react";
import { cn } from "@/lib/utils";
import { counterpart, langSwitch, otherLang, type Lang } from "@/lib/i18n";
import { TransitionLink } from "@/components/motion/TransitionLink";
export interface LangSwitchProps {
lang: Lang;
className?: string;
/**
* `pair` zeigt beide Sprachen nebeneinander (Platz ab 1024 px).
* `compact` zeigt nur die andere Sprache als einen Knopf – ein Tipp, ein Wechsel.
* Auf dem Telefon ist das die einzige Form, die neben Wortmarke und „Menü“ passt.
*/
variant?: "pair" | "compact";
}
const BASE =
"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.
*
* Zeigt beide Sprachen: die aktive als stiller Zustand, die andere als Link auf das
* Gegenstück derselben Seite (`counterpart()`), nicht pauschal auf die Startseite.
* Zielt auf das Gegenstück derselben Seite, nicht pauschal auf die Startseite: erst
* `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
* neben ihr immer das Sprachkürzel, und der zugängliche Name nennt die Sprache
* ausgeschrieben. Wer die Flaggen weglassen will, entfernt hier zwei Zeilen.
*/
export function LangSwitch({ lang, className }: { lang: Lang; className?: string }) {
export function LangSwitch({ lang, className, variant = "pair" }: LangSwitchProps) {
const pathname = usePathname() ?? "/";
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),
);
const base =
"inline-flex items-center gap-1.5 font-mono text-[11px] uppercase tracking-[0.14em] transition-colors duration-300";
/* 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
Grundlinie oder den Abstand zum Nachbarn zu verschieben (WCAG 2.5.8: 24 × 24 px
Minimum, 44 × 44 px ist die Empfehlung – die Breite von 38 px lag darunter). */
if (variant === "compact") {
return (
<TransitionLink
href={href}
label={langSwitch[other].label}
hrefLang={other}
aria-label={langSwitch[other].title}
className={cn(BASE, "-mx-2 -my-3 shrink-0 px-2 py-3 text-fg", className)}
>
<Flag lang={other} />
{langSwitch[other].short}
</TransitionLink>
);
}
return (
<div className={cn("flex items-center gap-2", className)}>
<span className={cn(base, "text-accent")} aria-current="true" title={langSwitch[lang].title}>
<span className={cn(BASE, "text-accent")} aria-current="true" title={langSwitch[lang].title}>
<Flag lang={lang} />
{langSwitch[lang].short}
</span>
@@ -35,7 +107,7 @@ export function LangSwitch({ lang, className }: { lang: Lang; className?: string
label={langSwitch[other].label}
hrefLang={other}
aria-label={langSwitch[other].title}
className={cn(base, "-my-3 py-3 text-fg-muted hover:text-accent focus-visible:text-accent")}
className={cn(BASE, "-my-3 py-3 text-fg-muted hover:text-accent focus-visible:text-accent")}
>
<Flag lang={other} />
{langSwitch[other].short}
+7 -13
View File
@@ -2,9 +2,7 @@
import { usePathname } from "next/navigation";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { flags, legal as legalDe, site } from "@/lib/site";
import type { Lang } from "@/lib/i18n";
import { LangSwitch } from "./LangSwitch";
import { legal as legalDe, site } from "@/lib/site";
import { chrome as chromeDe, type Chrome } from "@/content/chrome";
import { gsap, EASE, MM, getLenis, refreshSoon } from "@/lib/gsap";
import { TransitionLink } from "@/components/motion/TransitionLink";
@@ -17,8 +15,6 @@ export interface OverlayMenuProps {
chrome?: Chrome;
/** Impressum/Datenschutz der jeweiligen Sprache. */
legal?: readonly { href: string; label: string }[];
/** Aktuelle Sprache – für den Umschalter im Fuß des Menüs. */
lang?: Lang;
}
/** The header's „Menü“ button – part of the dialog's operable set (see the trap below). */
@@ -34,7 +30,7 @@ export const MENU_TOGGLE_ID = "menue-toggle";
* as well as by mouse. Every other header control is `inert` while the sheet is
* open, so the mouse-operable and the keyboard-operable set are the same one.
*/
export function OverlayMenu({ open, onClose, chrome = chromeDe, legal = legalDe, lang = "de" }: OverlayMenuProps) {
export function OverlayMenu({ open, onClose, chrome = chromeDe, legal = legalDe }: OverlayMenuProps) {
const panel = useRef<HTMLDivElement>(null);
const pathname = usePathname();
const onCloseRef = useRef(onClose);
@@ -168,7 +164,9 @@ export function OverlayMenu({ open, onClose, chrome = chromeDe, legal = legalDe,
data-lenis-prevent=""
className="fixed inset-0 z-40 flex flex-col overflow-y-auto overscroll-contain bg-ivory text-ink"
>
<Container className="flex min-h-full flex-1 flex-col pb-10 pt-[calc(var(--header-h,60px)+24px)]">
{/* short: = kurze Ansicht (≤ 760 px hoch). Ohne das Engersetzen fällt der Fuß
(Telefonnummer, E-Mail, Rechtslinks) auf einem iPhone SE unter die Kante. */}
<Container className="flex min-h-full flex-1 flex-col pb-10 pt-[calc(var(--header-h,60px)+24px)] short:pb-6 short:pt-[calc(var(--header-h,60px)+12px)]">
<ol className="border-t border-line-light">
{chrome.menu.items.map((item) => (
<li key={item.href} className="overflow-hidden border-b border-line-light">
@@ -177,7 +175,7 @@ export function OverlayMenu({ open, onClose, chrome = chromeDe, legal = legalDe,
href={item.href}
label={item.label}
onClick={() => onCloseRef.current()}
className="group flex items-baseline gap-5 py-5 lg:gap-8 lg:py-7"
className="group flex items-baseline gap-5 py-5 short:py-3 lg:gap-8 lg:py-7"
>
<span className="font-mono text-xs tracking-[0.06em] tabular-nums text-gold-deep">{item.numeral}</span>
<span className="font-display text-[clamp(2rem,1.4rem+3vw,4rem)] leading-none text-ink transition-[font-style] group-hover:italic">
@@ -197,7 +195,7 @@ export function OverlayMenu({ open, onClose, chrome = chromeDe, legal = legalDe,
<div
data-menu-foot=""
className="mt-auto flex flex-col gap-4 pt-12 lg:flex-row lg:items-end lg:justify-between"
className="mt-auto flex flex-col gap-4 pt-12 short:gap-3 short:pt-6 lg:flex-row lg:items-end lg:justify-between"
>
<div className="flex flex-col gap-2">
<a href={site.phoneHref} className="font-display text-[1.75rem] leading-none text-ink">
@@ -208,10 +206,6 @@ export function OverlayMenu({ open, onClose, chrome = chromeDe, legal = legalDe,
</a>
</div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 font-mono text-xs tracking-[0.06em] text-grey-deep">
{/* Unter 1024 px versteckt der Kopf den Umschalter (dort ist kein Platz neben
der Wortmarke). Ohne diese Zeile gäbe es auf dem Telefon gar keinen Weg in
die andere Sprache – er steht deshalb genau dort, wo er im Kopf fehlt. */}
{flags.showLangSwitch && <LangSwitch lang={lang} className="lg:hidden" />}
<a href={site.linkedin} target="_blank" rel="noopener noreferrer" className="hover:text-gold-deep">
{chrome.menu.linkedin} <span aria-hidden="true">↗</span>
</a>
+12 -1
View File
@@ -23,6 +23,14 @@ export interface SectionProps {
space?: "chapter" | "block";
className?: string;
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;
}
@@ -51,6 +59,8 @@ export function Section({
space = "chapter",
className,
as = "section",
lead,
gridClassName,
children,
}: SectionProps) {
const Tag = as;
@@ -65,8 +75,9 @@ export function Section({
className,
)}
>
{lead}
<Container className="relative">
<div className={GRID}>
<div className={cn(GRID, gridClassName)}>
{numeral && (
<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 { WolfMark } from "@/components/brand/WolfMark";
import { chrome as chromeDe, type Chrome } from "@/content/chrome";
import { crossesLangRoot } from "@/lib/i18n";
import { endTransition, setTransitionPhase, subscribe } from "./transition-store";
export interface CurtainProps {
@@ -49,6 +50,13 @@ export function Curtain({ children, chrome = chromeDe }: CurtainProps) {
onComplete: () => {
phase.current = "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 });
window.clearTimeout(timer.current);
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.
useEffect(() => {
+100 -12
View File
@@ -1,7 +1,7 @@
"use client";
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
@@ -9,34 +9,122 @@ import { gsap, EASE, MM } from "@/lib/gsap";
*
* Scrubbed by ScrollTrigger, not by a second animation runtime: this was the
* 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() {
const bar = useRef<HTMLDivElement>(null);
const tag = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const el = bar.current;
if (!el) return;
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 –
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.to(el, {
scaleX: 1,
ease: EASE.none,
scrollTrigger: { trigger: document.documentElement, start: "top top", end: "bottom bottom", scrub: true },
gsap.to(el, { scaleX: 1, ease: EASE.none, scrollTrigger: scrub });
/* Das Fähnchen steht immer im Dokument und wird hier beschriftet, statt seinen
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 (
<div
ref={bar}
aria-hidden="true"
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)" }}
/>
<>
<div
ref={bar}
aria-hidden="true"
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 type { ComponentProps, MouseEvent } from "react";
import { getLenis, hasJs, prefersReducedMotion } from "@/lib/gsap";
import { crossesLangRoot } from "@/lib/i18n";
import { isTransitioning, startTransition } from "./transition-store";
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
* 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();
/* 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>) => {
onClick?.(e);
@@ -79,13 +85,27 @@ export function TransitionLink({ href, label, onClick, scroll, ...rest }: Transi
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
e.preventDefault();
if (isTransitioning()) return; // ignore double clicks
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;
+99
View File
@@ -0,0 +1,99 @@
import { BrandMark } from "@/components/brand/BrandMark";
import { Section } from "@/components/layout/Section";
import { ServiceHero } from "@/components/services/ServiceHero";
import { ServiceLayout } from "@/components/services/ServiceLayout";
import { SectionHeading } from "@/components/services/SectionHeading";
import { ProcessContact } from "@/components/home/ProcessContact";
import { Coupon } from "@/components/ui/Coupon";
import { Ledger } from "@/components/ui/Ledger";
import type { HerCareerContent } from "@/content/her-career";
/** 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";
/** Heading column: cols 3–8. */
const HEAD = "col-span-12 lg:col-span-6 lg:col-start-3";
export interface HerCareerBodyProps {
content: HerCareerContent;
}
/**
* Der Rumpf von /her-career/ bzw. /en/her-career/ – die Messe-Landingpage hinter dem
* QR-Code der Rückwandgrafik (Adresse: her-career.anelia-wolf.com, Auslieferung:
* docs/SUBDOMAIN-HERCAREER.md).
*
* Kurz gehalten, weil hier jemand mit dem Handy am Stand steht: Hero mit dem
* Gutschein (ink) → 01 Messe-Special → 02 Themen → 03 Kontakt-Band (ink). Alle
* weiterführenden Links liegen als absolute Adressen in der Inhaltsdatei und führen
* auf die Hauptdomain. Kein <main> – das rendert template.tsx.
*/
export function HerCareerBody({ content }: HerCareerBodyProps) {
const { special, themen, contact } = content;
const { coupon, formNotice } = special;
/* Der Hinweis im Formular wird aus dem Gutschein oben abgeleitet, nicht daneben
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 (
<>
{/* Weder Fakten-Stapel noch Subline: <ServiceHero/> stellt die Begrüßung ohne `subline`
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>
{/* 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}>
<SectionHeading eyebrow={special.bonus.eyebrow} h2={special.bonus.h2} className={HEAD} />
<div className={`${BODY} mt-12 lg:mt-16`}>
<Ledger variant="werdegang" rows={special.bonus.rows} />
</div>
</Section>
{/* 02 Themen – die vier Blöcke der Rückwand, verlinkt auf die Hauptdomain. */}
<Section id={themen.id} theme="light" numeral="02" total={total} label={themen.eyebrow}>
<SectionHeading eyebrow={themen.eyebrow} h2={themen.h2} className={HEAD} />
<div className={`${BODY} mt-12 lg:mt-16`}>
<Ledger variant="toc" rows={themen.rows} />
</div>
</Section>
</ServiceLayout>
{/* Kontakt: derselbe Block wie Sektion 06 der Startseite, mit dem Formular. */}
<ProcessContact content={contact} privacyHref={content.privacyHref} total={total} coupon={contactCoupon} />
</>
);
}
export default HerCareerBody;
+2 -2
View File
@@ -7,7 +7,7 @@ import { About } from "@/components/home/About";
import { Audience } from "@/components/home/Audience";
import { ProcessContact } from "@/components/home/ProcessContact";
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 { rootCopy } from "@/lib/metadata";
import { site } from "@/lib/site";
@@ -37,7 +37,7 @@ export function HomeBody({ lang, content }: { lang: Lang; content: HomeContent }
<Positioning content={content.positioning} />
<ServicesIndex content={content.services} />
<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} />
<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 { SignatureTalk, type TalkLabels } from "@/components/services/keynotes/SignatureTalk";
import { SpeakerProfile } from "@/components/services/keynotes/SpeakerProfile";
import { CtaBand } from "@/components/ui/CtaBand";
import { FactsStrip } from "@/components/ui/FactsStrip";
import { ProcessContact } from "@/components/home/ProcessContact";
import { Faq } from "@/components/ui/Faq";
import { Interruption } from "@/components/ui/Interruption";
import { Ledger } from "@/components/ui/Ledger";
import { Reveal } from "@/components/motion/Reveal";
import { talkLabels as talkLabelsDe, type KeynotesContent } from "@/content/keynotes";
import { contact as contactDe, type HomeContent } from "@/content/home";
import { getPhotos, getSpeakerPdf } from "@/lib/assets";
import { flags } from "@/lib/site";
import { flags, routes } from "@/lib/site";
/** Content column of every light section: cols 3–12 on desktop. */
const BODY = "col-span-12 lg:col-span-10 lg:col-start-3";
@@ -26,6 +25,10 @@ export interface KeynotesBodyProps {
content: KeynotesContent;
/** Zeilenbeschriftungen im Vortragsblock – in der Sprache der Seite. */
talkLabels?: TalkLabels;
/** Formular-Texte (Feldnamen, Rückmeldungen, Kontaktzeilen) in der Sprache der Seite. */
contact?: HomeContent["contact"];
/** Ziel des Datenschutz-Links im Formular. */
privacyHref?: string;
}
/**
@@ -33,18 +36,22 @@ export interface KeynotesBodyProps {
* „die Bühnenseite“). Server Component: Foto und PDF werden zur Bauzeit über lib/assets
* geprüft. Kein <main> – template.tsx rendert `main#inhalt`.
*
* Reihenfolge: Hero (ink, optionales Bühnenfoto) → 01 Der Vortrag → Interruption (ink, auf
* oberster Ebene, damit sie ihren eigenen Grund malt) → 02 Anlässe → 03 Publikum →
* 04 Für Veranstalter (Speaker-Facts, Termine, PDF-Zeile nur bei gelieferter Datei) →
* 05 FAQ (Flag) → Kontakt-Band (ink).
* Reihenfolge: Hero (ink, optionales Bühnenfoto, Fakten inkl. Technik) → 01 Der Vortrag
* (mit Terminen und – nur bei gelieferter Datei – der PDF-Zeile) → 02 Anlässe →
* 03 Publikum → 04 FAQ (Flag) → Terminanfrage (ink, Formular, zählt mit).
*/
export function KeynotesBody({ content: keynotes, talkLabels = talkLabelsDe }: KeynotesBodyProps) {
export function KeynotesBody({
content: keynotes,
talkLabels = talkLabelsDe,
contact = contactDe,
privacyHref = routes.datenschutz,
}: KeynotesBodyProps) {
const { stage } = getPhotos();
const speakerPdf = flags.showSpeakerPdf ? getSpeakerPdf() : null;
const showFaq = flags.showFaq && keynotes.faq.items.length > 0;
const total = showFaq ? "05" : "04";
const { talk, interruption, occasions, takeaways, speakerFacts, termine, veranstalter, faq, band } = keynotes;
const { talk, occasions, takeaways, termine, faq, anfrage } = keynotes;
return (
<>
@@ -58,18 +65,23 @@ export function KeynotesBody({ content: keynotes, talkLabels = talkLabelsDe }: K
<div className={`${BODY} mt-16`}>
<SignatureTalk rows={talk.rows} labels={talkLabels} />
</div>
{/* Wo der Vortrag als Nächstes läuft – direkt unter dem Vortrag statt in einem
eigenen Abschnitt, der sonst nur noch aus dieser Tafel bestünde. */}
<div id={termine.id} className={`${BODY} mt-16`}>
<Ledger variant="termine" rows={termine.rows} heading={termine.heading} />
</div>
{/* Spec §7.4: die Zeile erscheint nur, wenn die PDF-Datei geliefert wurde. */}
{speakerPdf && (
<div className={`${BODY} mt-16`}>
<SpeakerProfile
label={keynotes.speakerPdf.label}
text={keynotes.speakerPdf.text}
href={speakerPdf}
/>
</div>
)}
</Section>
</ServiceLayout>
{/* 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}>
<SectionHeading eyebrow={occasions.eyebrow} h2={occasions.h2} className={HEAD} />
<div className={`${BODY} mt-16`}>
@@ -84,30 +96,8 @@ export function KeynotesBody({ content: keynotes, talkLabels = talkLabelsDe }: K
</div>
</Section>
<Section id={veranstalter.id} theme="light" numeral="04" total={total} label={veranstalter.eyebrow}>
<SectionHeading eyebrow={veranstalter.eyebrow} h2={veranstalter.h2} className={HEAD} />
<Reveal className={`${BODY} mt-16`}>
<div id={speakerFacts.id}>
<FactsStrip items={speakerFacts.items} />
</div>
</Reveal>
<div id={termine.id} className={`${BODY} mt-16`}>
<Ledger variant="termine" rows={termine.rows} heading={termine.heading} />
</div>
{/* Spec §7.4: die Zeile erscheint nur, wenn die PDF-Datei geliefert wurde. */}
{speakerPdf && (
<div className={`${BODY} mt-16`}>
<SpeakerProfile
label={veranstalter.speakerPdf.label}
text={veranstalter.speakerPdf.text}
href={speakerPdf}
/>
</div>
)}
</Section>
{showFaq && (
<Section id={faq.id} theme="light" numeral="05" total={total} label={faq.eyebrow}>
<Section id={faq.id} theme="light" numeral="04" total={total} label={faq.eyebrow}>
<SectionHeading eyebrow={faq.eyebrow} className={HEAD} />
<Reveal className={`${FAQ_COL} mt-12`}>
<Faq items={faq.items} />
@@ -116,7 +106,23 @@ export function KeynotesBody({ content: keynotes, talkLabels = talkLabelsDe }: K
)}
</ServiceLayout>
<CtaBand h2={band.h2} cta={band.cta} micro={band.micro} />
{/* Terminanfrage statt Kontakt-Band: derselbe Block wie auf der Startseite, ohne
den Claim darunter (der steht direkt danach im Fuß). */}
<ProcessContact
content={{
...contact,
id: anfrage.id,
numeral: total,
eyebrow: anfrage.eyebrow,
h2: anfrage.h2,
signoff: "",
form: { ...contact.form, subject: anfrage.topic },
}}
privacyHref={privacyHref}
total={total}
fields={anfrage.fields}
topic={anfrage.topic}
/>
</>
);
}
+35 -10
View File
@@ -4,14 +4,12 @@ import { ServiceLayout } from "@/components/services/ServiceLayout";
import { TrainingsSituations } from "@/components/services/trainings/TrainingsSituations";
import { TrainingsModules } from "@/components/services/trainings/TrainingsModules";
import { TrainingsMethod } from "@/components/services/trainings/TrainingsMethod";
import { TrainingsGoals } from "@/components/services/trainings/TrainingsGoals";
import { TrainingsRahmen } from "@/components/services/trainings/TrainingsRahmen";
import { TrainingsAblauf } from "@/components/services/trainings/TrainingsAblauf";
import { TrainingsFaq } from "@/components/services/trainings/TrainingsFaq";
import { TrainingsBand } from "@/components/services/trainings/TrainingsBand";
import { ProcessContact } from "@/components/home/ProcessContact";
import type { TrainingsContent } from "@/content/trainings";
import type { HomeContent } from "@/content/home";
import { flags } from "@/lib/site";
import { contact as contactDe, type HomeContent } from "@/content/home";
import { flags, routes } from "@/lib/site";
const pad = (n: number) => String(n).padStart(2, "0");
@@ -19,6 +17,10 @@ export interface TrainingsBodyProps {
content: TrainingsContent;
/** „Über Anelia“-Randspalte im Abschnitt „Wie ich arbeite“ – in der Sprache der Seite. */
marginalia: HomeContent["about"]["marginalia"];
/** Formular-Texte (Feldnamen, Rückmeldungen, Kontaktzeilen) in der Sprache der Seite. */
contact?: HomeContent["contact"];
/** Ziel des Datenschutz-Links im Formular. */
privacyHref?: string;
}
/**
@@ -26,8 +28,16 @@ export interface TrainingsBodyProps {
* Die „Angebots“-Seite, am wenigsten Bewegung: nur <Rule/>, <Lines/>, <Reveal/> – kein Pin,
* kein Draw, kein Blur, kein Foto (das Bühnenfoto gehört zu den Keynotes, §7.1).
* Kein <main>: template.tsx liefert `main#inhalt`.
*
* Reihenfolge: Hero → Ausgangslage → Module → Wie ich arbeite → Ablauf → FAQ (Flag) →
* Terminanfrage (ink, Formular). Die Terminanfrage zählt als letzter Abschnitt mit.
*/
export function TrainingsBody({ content: trainings, marginalia }: TrainingsBodyProps) {
export function TrainingsBody({
content: trainings,
marginalia,
contact = contactDe,
privacyHref = routes.datenschutz,
}: TrainingsBodyProps) {
/* Helle Abschnitte in Lesereihenfolge; FAQ nur mit `flags.showFaq` (Spec §7.1.6). */
const sections = [
{ id: trainings.situations.id, label: trainings.situations.eyebrow, body: <TrainingsSituations content={trainings} /> },
@@ -37,14 +47,13 @@ export function TrainingsBody({ content: trainings, marginalia }: TrainingsBodyP
label: trainings.how.eyebrow,
body: <TrainingsMethod content={trainings} marginalia={marginalia} />,
},
{ id: trainings.goals.id, label: trainings.goals.eyebrow, body: <TrainingsGoals content={trainings} /> },
{ id: trainings.rahmen.id, label: trainings.rahmen.eyebrow, body: <TrainingsRahmen content={trainings} /> },
{ id: trainings.ablauf.id, label: trainings.ablauf.eyebrow, body: <TrainingsAblauf content={trainings} /> },
...(flags.showFaq && trainings.faq.items.length > 0
? [{ id: trainings.faq.id, label: trainings.faq.eyebrow, body: <TrainingsFaq content={trainings} /> }]
: []),
];
const total = pad(sections.length);
const total = pad(sections.length + 1);
const { anfrage } = trainings;
return (
<>
@@ -56,7 +65,23 @@ export function TrainingsBody({ content: trainings, marginalia }: TrainingsBodyP
</Section>
))}
</ServiceLayout>
<TrainingsBand content={trainings} />
{/* Terminanfrage statt Kontakt-Band: derselbe Block wie auf der Startseite, ohne
den Claim darunter (der steht direkt danach im Fuß). */}
<ProcessContact
content={{
...contact,
id: anfrage.id,
numeral: total,
eyebrow: anfrage.eyebrow,
h2: anfrage.h2,
signoff: "",
form: { ...contact.form, subject: anfrage.topic },
}}
privacyHref={privacyHref}
total={total}
fields={anfrage.fields}
topic={anfrage.topic}
/>
</>
);
}
+86 -15
View File
@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { Container } from "@/components/layout/Container";
import { Folio } from "@/components/layout/Folio";
@@ -18,9 +19,12 @@ export interface ServiceHeroContent {
h1: Headline;
/** The italic word may also carry `.gold-text` (keynotes – the page's one gradient word). */
h1GoldEm?: boolean;
subline: string;
cta: Cta;
facts: readonly FactItem[];
/** 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. */
cta?: Cta;
/** Fakten-Stapel oben rechts. Ohne Angabe (und ohne Foto) entfällt die Spalte. */
facts?: readonly FactItem[];
/** No fraction here – the page's own section counter is the only counter (see <Folio/>). */
folio: { next?: { label: string; href: string } };
/** Photo alt / caption (keynote stage photo). */
@@ -32,6 +36,18 @@ export interface ServiceHeroProps {
content: ServiceHeroContent;
/** Stage photo (keynotes) – `null`/undefined = no photo. */
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;
}
@@ -39,13 +55,25 @@ export interface ServiceHeroProps {
* 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
* 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
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. */
const { numeral, masthead, h1, h1GoldEm, subline, cta, facts, folio, alt, caption } = content;
const hasPhoto = Boolean(photo);
/* 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. */
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 (
<section
@@ -66,34 +94,76 @@ export function ServiceHero({ content, photo, className }: ServiceHeroProps) {
{numeral}
</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
as="h1"
play="load"
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.em && <em className={cn("italic", h1GoldEm && "gold-text")}>{h1.em}</em>}
{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>
<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}
</Reveal>
<Reveal play="load" delay={1.2} className="mt-8">
<Button {...cta} />
</Reveal>
{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">
{subline}
</Reveal>
)}
{cta && (
<Reveal play="load" delay={1.2} className="mt-8">
<Button {...cta} />
</Reveal>
)}
{children && (
<Reveal play="load" delay={1.0} className="mt-12 lg:mt-14">
{children}
</Reveal>
)}
</div>
{hasAside && (
<div
className={cn(
"col-span-12 flex flex-col gap-10",
hasPhoto ? "lg:col-span-5 lg:col-start-8" : "lg:col-span-3 lg:col-start-10",
)}
>
<Reveal play="load" delay={0.6} className={cn(hasPhoto && "lg:self-end")}>
<Facts items={facts} />
</Reveal>
{facts && facts.length > 0 && (
<Reveal play="load" delay={0.6} className={cn(hasPhoto && "lg:self-end")}>
<Facts items={facts} />
</Reveal>
)}
{photo && (
<Portrait
src={photo}
@@ -106,6 +176,7 @@ export function ServiceHero({ content, photo, className }: ServiceHeroProps) {
/>
)}
</div>
)}
</div>
<Folio next={folio.next} play="load" delay={1.3} />
@@ -1,27 +1,20 @@
import { Reveal } from "@/components/motion/Reveal";
import { Marginalia } from "@/components/ui/Marginalia";
import { Steps } from "@/components/ui/Steps";
import { trainings as trainingsDe, type TrainingsContent } from "@/content/trainings";
import { SectionHeading } from "@/components/services/SectionHeading";
/**
* Ablauf – four numbered rows (Bedarfsgespräch → Follow-up nach 30 Tagen),
* cols 3–8, plus the „Was du bekommst“ marginalia in cols 10–12 (sticky on
* desktop) so the spread reads like coaching’s Ablauf.
* Ablauf – four numbered rows (Bedarfsgespräch → Follow-up nach 30 Tagen), cols 3–8.
* Die Randspalte „Was du bekommst“ ist am 02.10.2026 entfallen: Sie wiederholte die
* Schritte (Konzept, 30 Tage, Follow-up) Wort für Wort.
*/
export function TrainingsAblauf({ content = trainingsDe }: { content?: TrainingsContent }) {
const { eyebrow, steps, marginalia } = content.ablauf;
const { eyebrow, steps } = content.ablauf;
return (
<>
<SectionHeading eyebrow={eyebrow} className="col-span-12 lg:col-span-6 lg:col-start-3" />
<div className="col-span-12 mt-10 lg:col-span-6 lg:col-start-3">
<Steps items={steps} />
</div>
<div className="col-span-12 mt-12 lg:col-span-3 lg:col-start-10 lg:mt-10">
<Reveal delay={0.2}>
<Marginalia title={marginalia.title} items={marginalia.items} sticky />
</Reveal>
</div>
</>
);
}
@@ -1,15 +0,0 @@
import { CtaBand } from "@/components/ui/CtaBand";
import { site } from "@/lib/site";
import { trainings as trainingsDe, type TrainingsContent } from "@/content/trainings";
/**
* Kontakt band „Sprechen wir über dein Team.“ – same primary CTA as the hero.
* Phone prominent: the number sits in the CTA micro line, in addition to the
* large „Ruf an“ tel link that <ContactLinks/> renders on the right.
*/
export function TrainingsBand({ content = trainingsDe }: { content?: TrainingsContent }) {
const { h2, cta, micro } = content.band;
return <CtaBand h2={h2} cta={cta} micro={`${micro} · ${site.phoneDisplay}`} />;
}
export default TrainingsBand;
@@ -1,18 +0,0 @@
import { Ledger } from "@/components/ui/Ledger";
import { trainings as trainingsDe, type TrainingsContent } from "@/content/trainings";
import { SectionHeading } from "@/components/services/SectionHeading";
/** „Typische Ziele“ – goals (not promises) as three hairline ledger lines, cols 3–8. */
export function TrainingsGoals({ content = trainingsDe }: { content?: TrainingsContent }) {
const { eyebrow, rows } = content.goals;
return (
<>
<SectionHeading eyebrow={eyebrow} className="col-span-12 lg:col-span-6 lg:col-start-3" />
<div className="col-span-12 mt-10 lg:col-span-6 lg:col-start-3">
<Ledger variant="goals" rows={rows} />
</div>
</>
);
}
export default TrainingsGoals;
@@ -26,17 +26,17 @@ const MODULE_TABLE_FIX = [
].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 (+ → ×),
* opening to the hairline-separated „Inhalte“ list.
*/
export function TrainingsModules({ content = trainingsDe }: { content?: TrainingsContent }) {
const { eyebrow, h2, items } = content.modules;
const { eyebrow, h2, items, labels } = content.modules;
return (
<>
<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">
<ModuleTable modules={items} className={MODULE_TABLE_FIX} />
<ModuleTable modules={items} labels={labels} className={MODULE_TABLE_FIX} />
</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 { 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 }) {
const { eyebrow, h2, rows } = content.situations;
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;
+126 -5
View File
@@ -5,6 +5,7 @@ import { cn } from "@/lib/utils";
import { site, routes } from "@/lib/site";
import { contact as contactDe, type HomeContent } from "@/content/home";
import { BUTTON_BASE, BUTTON_PRIMARY } from "@/components/ui/Button";
import { COUPON_SURFACE, CouponPerforation } from "@/components/ui/Coupon";
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. */
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";
export function ContactForm({
className,
content: form = contactDe.form,
privacyHref = routes.datenschutz,
coupon,
fields = [],
topic,
}: {
className?: string;
/** Ziel des Datenschutz-Links – in der englischen Fassung /en/privacy/. */
privacyHref?: string;
/** Inhalt. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */
content?: HomeContent["contact"]["form"];
/** Vorteil, der schon an der Anfrage hängt. Ohne Angabe entfällt das Feld. */
coupon?: ContactCoupon;
/** Zusätzliche freiwillige Felder (Terminanfrage der Leistungsseiten). */
fields?: readonly ContactField[];
/**
* Anliegen der Seite, z. B. „Terminanfrage Training“. `/api/contact` macht daraus
* den Betreff, damit die Anfrage im Postfach ohne Öffnen einzuordnen ist.
*/
topic?: string;
}) {
const uid = useId();
const [status, setStatus] = useState<Status>("idle");
@@ -53,12 +111,23 @@ export function ContactForm({
const data = new FormData(formEl);
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 = {
name: value("name"),
email: value("email"),
company: value("company"),
message: value("message"),
message: coupon ? `${written}\n\n${coupon.line}` : written,
page: typeof window === "undefined" ? "" : window.location.pathname,
...(topic ? { topic } : {}),
...(details.length ? { details } : {}),
// Honeypot: Menschen sehen das Feld nicht, Bots füllen es aus.
website: value("website"),
};
@@ -79,13 +148,14 @@ export function ContactForm({
`${form.fields.name}: ${payload.name}`,
`${form.fields.email}: ${payload.email}`,
payload.company ? `${form.fields.company}: ${payload.company}` : null,
...details.map((d) => `${d.label}: ${d.value}`),
"",
payload.message,
]
.filter((line) => line !== null)
.join("\r\n");
setMailtoHref(
`mailto:${site.email}?subject=${encodeURIComponent(form.subject)}&body=${encodeURIComponent(body)}`,
`mailto:${site.email}?subject=${encodeURIComponent(topic ?? form.subject)}&body=${encodeURIComponent(body)}`,
);
setStatus("error");
}
@@ -102,8 +172,39 @@ export function ContactForm({
error: form.error,
}[status];
/* Hinweis und Datenschutz-Link folgen dem Inhalt: Wo beide Texte leer sind, bleibt
die Zeile im Ruhezustand leer (Messeseite, src/content/her-career.ts). Rückmeldung
und Rückfall-Link hängen NICHT daran – ohne sie verschwände eine Nachricht
kommentarlos, sobald der Dienst nicht antwortet. Das <p> steht deshalb immer im
Dokument: Ein aria-live-Bereich, den es beim Absenden noch gar nicht gab, wird
von Screenreadern nicht zuverlässig vorgelesen. */
const fallbackLink = status === "error" && Boolean(mailtoHref);
const showPrivacy = Boolean(form.privacy && privacyHref);
return (
<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>
<label htmlFor={field("name")} className={label}>
@@ -126,6 +227,25 @@ export function ContactForm({
<input id={field("company")} name="company" type="text" autoComplete="organization" className={input} />
</div>
{fields.length > 0 && (
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
{fields.map((f) => (
<div key={f.name}>
<label htmlFor={field(f.name)} className={label}>
{f.label}
</label>
<input
id={field(f.name)}
name={f.name}
type="text"
autoComplete={f.autoComplete ?? "off"}
className={input}
/>
</div>
))}
</div>
)}
<div>
<label htmlFor={field("message")} className={label}>
{form.fields.message}
@@ -155,11 +275,12 @@ export function ContactForm({
aria-live="polite"
className="max-w-[52ch] font-mono text-[11px] leading-relaxed tracking-[0.04em] text-fg-muted"
>
{note}{" "}
{status === "error" && mailtoHref ? (
{note}
{note && (fallbackLink || showPrivacy) ? " " : null}
{fallbackLink ? (
<TextLink href={mailtoHref} label={form.errorLink} className="!text-[11px] !tracking-[0.1em]" />
) : (
<TextLink href={privacyHref} label={form.privacy} className="!text-[11px] !tracking-[0.1em]" />
showPrivacy && <TextLink href={privacyHref} label={form.privacy} className="!text-[11px] !tracking-[0.1em]" />
)}
</p>
</div>
+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;
+5 -3
View File
@@ -4,7 +4,7 @@ import { GRID } from "@/components/layout/Section";
import { Lines } from "@/components/motion/Lines";
import { Reveal } from "@/components/motion/Reveal";
import { Rule } from "@/components/motion/Rule";
import type { Headline } from "@/content/home";
import type { Headline, HomeContent } from "@/content/home";
import { Button, type ButtonProps } from "./Button";
import { ContactLinks } from "./ContactLinks";
@@ -16,6 +16,8 @@ export interface CtaBandProps {
cta: ButtonProps;
/** Mono micro line under the button (falls back to `cta.micro`). */
micro?: string;
/** Kontaktzeilen in der Sprache der Seite. Ohne Angabe die deutsche Fassung. */
contact?: HomeContent["contact"]["links"];
id?: string;
className?: string;
}
@@ -37,7 +39,7 @@ export function HeadlineText({ before, em, after }: Headline) {
* primary CTA. Paints its own ink ground; the seam to the ivory above is the
* gold hairline alone (spec §2.3) – no fade, no gradient.
*/
export function CtaBand({ h2, cta, micro, id = "kontakt", className }: CtaBandProps) {
export function CtaBand({ h2, cta, micro, contact, id = "kontakt", className }: CtaBandProps) {
return (
<section id={id} data-theme="dark" className={cn("relative text-fg", className)}>
<Rule variant="gold" className="relative z-[1]" />
@@ -55,7 +57,7 @@ export function CtaBand({ h2, cta, micro, id = "kontakt", className }: CtaBandPr
</Reveal>
</div>
<div className="col-span-12 lg:col-start-9 lg:col-span-4">
<ContactLinks />
<ContactLinks content={contact} />
</div>
</div>
</Container>
-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 {
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;
}
@@ -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
* 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";
return (
<div className={cn("w-full", className)}>
<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}>{moduleLabels.goal}</span>
<span className={HEAD}>{moduleLabels.audience}</span>
<span className={HEAD}>{labels.module}</span>
<span className={HEAD}>{labels.goal}</span>
<span className={HEAD}>{labels.audience}</span>
<span />
<span />
</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
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={cn(HEAD, "mr-3 lg:sr-only")}>{moduleLabels.goal}</span>
<span className={cn(HEAD, "mr-3 lg:sr-only")}>{labels.goal}</span>
{m.goal}
</span>
<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}
</span>
<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. */}
<span className="sr-only">{moduleLabels.meta}</span>
<span className="sr-only">{labels.meta}</span>
<MetaLines text={m.meta} />
</span>
</summary>
<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">
{m.contents.map((line) => (
<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",
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
Sprache der Seite sprechen, sonst liest ihn ein Screenreader deutsch auf einer
englischen Seite vor. */
+3
View File
@@ -26,6 +26,9 @@ export const chromeEn: Chrome = {
sub: "Sales Mentoring & Potential Development",
ariaLabel: "Anelia Wolf – home",
},
collab: {
ariaLabel: "In collaboration with herCAREER Expo",
},
skipLink: "Skip to content",
header: {
navAriaLabel: "Main navigation",
+118
View File
@@ -0,0 +1,118 @@
/**
* English copy of /en/her-career/. Same shape as src/content/her-career.ts.
*
* The trade fair is German, the audience is not: herCAREER draws an international
* crowd, and Anelia speaks at the booth in both languages. The QR code on the
* stand wall points at the German page; the language switch in the header leads
* here. Wording of the brand terms follows Anelia's own English herCAREER
* profile: „WOLF Principle“, „Courage to Succeed!“.
*
* Placeholders keep their German wording on purpose – they address Anelia, not
* the visitor, exactly as in the other English content files.
*/
import { site } from "@/lib/site";
import { routesByLang } from "@/lib/i18n";
import { assertNoPlaceholders, collectStrings } from "@/lib/legal";
import type { HerCareerContent } from "@/content/her-career";
import { homeEn } from "./home";
const r = routesByLang.en;
/** Absolute address on the main domain – see the German file for the why. */
const main = (path: string): string => `${site.url}${path}`;
export const herCareerEn: HerCareerContent = {
meta: {
title: "herCAREER Expo 2026",
description:
"Anelia Wolf at herCAREER Expo 2026: the fair special, the topics from the booth and the direct line to the keynote speaker, trainer and coach.",
descriptionMesse:
"Anelia Wolf at herCAREER Expo 2026 in Munich: stand P.10.2 in hall 3, keynote on 23 October at 10:30 in Auditorium 2. Fair special, topics and the direct line.",
},
numeral: "2026",
masthead: {
role: "Keynote Speaker · Trainer · Coach",
center: "herCAREER Expo 2026",
lang: "German & English",
},
eyebrow: "herCAREER Expo 2026 · MOC Munich",
h1: { before: "Thanks for ", em: "scanning", after: "." },
/* No hero subline – see the German file: whoever scanned the code knows they
scanned it. The greeting carries the hero on its own, one size up. */
folio: { next: { label: "Fair special", href: "#messe-special" } },
special: {
id: "messe-special",
eyebrow: "Fair special",
coupon: {
kicker: "Your fair special",
rate: "−20",
unit: "%",
on: "on personality & behaviour analyses",
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: {
id: "themen",
eyebrow: "Topics",
h2: { before: "What we talk about.", em: "", after: "" },
rows: [
{
numeral: "01",
title: "Coaching & Assessments",
oneLiner:
"Who am I, where do I stand, where am I going? A personality and behaviour assessment first, then the work in a pair.",
keywords: ["Potential assessment", "One-to-one coaching", "Career change", "Leadership"],
link: { label: "See coaching", href: main(r.coaching) },
},
{
numeral: "02",
title: "Training & Workshops",
oneLiner:
"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", "HR team building & conflict resolution"],
link: { label: "See training", href: main(r.trainings) },
},
{
numeral: "03",
title: "Keynotes & Speaking",
oneLiner: "Talks for company events and conferences – an impulse that stays with the room.",
keywords: ["WOLF Principle", "Courage to Succeed", "German & English"],
link: { label: "See keynotes", href: main(r.keynotes) },
},
],
},
privacyHref: main(r.datenschutz),
contact: {
...homeEn.contact,
numeral: "03",
h2: { before: "Let us keep ", em: "talking", after: "." },
form: { ...homeEn.contact.form, subject: "herCAREER Expo 2026", note: "", privacy: "" },
},
};
assertNoPlaceholders("src/content/en/her-career.ts", collectStrings(herCareerEn));
+62 -8
View File
@@ -21,7 +21,21 @@ export const homeEn: HomeContent = {
center: "Sales Mentoring & Potential Development",
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:
"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: {
@@ -37,6 +51,7 @@ export const homeEn: HomeContent = {
/* ---------- 01 Positioning (ivory) ---------- */
positioning: {
id: "positionierung",
slogan: { words: ["Personality", "Impact", "Success"], lang: "en" },
numeral: "01",
eyebrow: "Positioning",
h2: { before: "Three questions. One principle.", em: "", after: "" },
@@ -67,8 +82,8 @@ export const homeEn: HomeContent = {
{
numeral: "02",
title: "Training & Workshops",
oneLiner: "Sales with personality – for teams and their leaders.",
keywords: ["B2B sales training", "New and existing customers", "Leadership & team dynamics"],
oneLiner: "Leadership & HR with personality – for leaders and their teams.",
keywords: ["B2B sales training", "HR team building & conflict resolution", "Communication & team spirit"],
link: { label: "See training", href: r.trainings },
},
{
@@ -90,7 +105,8 @@ export const homeEn: HomeContent = {
opLabels: { "+": "plus ", "=": "equals " },
lines: [
{ 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 },
],
link: { label: "The WOLF Principle on stage", href: r.keynotes },
@@ -104,14 +120,14 @@ export const homeEn: HomeContent = {
eyebrow: "About Anelia",
h2: { before: "I am out selling myself. Every week.", em: "", after: "" },
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.",
],
twins: "And at home two sixteen-year-old twins put my negotiating to the test daily.",
werdegang: [
{ keyword: "Roots", text: "Sales conversations in my father's business" },
{ 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: "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: "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" },
},
@@ -134,11 +188,11 @@ export const homeEn: HomeContent = {
columns: [
{
label: "Companies",
title: "Sales teams & their leaders",
title: "Sales teams, leaders & HR",
lines: [
"New business that becomes predictable",
"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 },
},
+16 -36
View File
@@ -3,7 +3,7 @@
* The talk title follows Anelia's own English herCAREER listing:
* „The WOLF Principle – Courage to Succeed!“.
*/
import { mailto } from "@/lib/site";
import { routesByLang } from "@/lib/i18n";
import { assertNoPlaceholders, collectStrings, ph } from "@/lib/legal";
import type { KeynotesContent } from "@/content/keynotes";
import { homeEn } from "./home";
@@ -26,14 +26,15 @@ export const keynotesEn: KeynotesContent = {
h1: { before: "Courage to ", em: "succeed", after: "." },
h1GoldEm: true,
subline:
"Keynotes on the WOLF Principle for company events, kick-offs and conferences. An impulse that stays with the room – in German or English, live or online.",
cta: { label: "Request a keynote", href: mailto.keynote },
"Keynotes on the WOLF Principle for company events, kick-offs and conferences. An impulse that stays with the room.",
cta: { label: "Request a keynote", href: `${routesByLang.en.keynotes}#terminanfrage` },
secondary: { label: "Speaker profile (PDF)", href: "/speaker-profil.pdf", external: true },
facts: [
{ label: "Length", value: "20–60 minutes" },
{ label: "Format", value: "Live, online, hybrid" },
{ label: "Language", value: "German / English" },
{ label: "Travel", value: "Germany-wide, Europe on request" },
{ label: "Tech", value: "Headset, slides optional" },
],
alt: "Anelia Wolf on stage",
caption: `Anelia Wolf · Photo: ${ph("Name der Fotografin")}`,
@@ -48,19 +49,13 @@ export const keynotesEn: KeynotesContent = {
numeral: "01",
title: "The WOLF Principle – Courage to Succeed!",
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",
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: {
id: "anlaesse",
eyebrow: "Occasions",
@@ -92,26 +87,9 @@ export const keynotesEn: KeynotesContent = {
],
},
speakerFacts: {
id: "speaker-facts",
items: [
{ label: "Languages", value: "DE / EN" },
{ label: "Length", value: "20–60 min" },
{ label: "Format", value: "live, online, hybrid" },
{ label: "Tech", value: "Headset, slides optional" },
{ label: "Travel", value: "Germany-wide, Europe on request" },
{ label: "Enquiry", value: "no obligation" },
],
},
termine: homeEn.termine,
veranstalter: {
id: "veranstalter",
eyebrow: "For event hosts",
h2: { before: "Everything you need up front.", em: "", after: "" },
speakerPdf: { label: "Speaker profile (PDF)", text: "Short bio, topics, photos, tech", href: "/speaker-profil.pdf" },
},
speakerPdf: { label: "Speaker profile (PDF)", text: "Short bio, topics, photos, tech", href: "/speaker-profil.pdf" },
faq: {
id: "faq",
@@ -125,10 +103,6 @@ export const keynotesEn: KeynotesContent = {
q: "Can it be combined with a workshop?",
a: "Yes. The keynote works alongside a workshop or training – on the same day or as a follow-up.",
},
{
q: "In English?",
a: "Yes. I speak in German or English – live, online or hybrid.",
},
{
q: "Is there video or are there references?",
a: "On request. Get in touch and I will send you material on the talk.",
@@ -136,10 +110,16 @@ export const keynotesEn: KeynotesContent = {
],
},
band: {
h2: { before: "Bring the WOLF Principle to your stage.", em: "", after: "" },
cta: { label: "Request a keynote", href: mailto.keynote },
micro: "No obligation · reply in German or English",
anfrage: {
id: "terminanfrage",
eyebrow: "Request a date",
h2: { before: "Let's find a ", em: "date", after: "." },
/* Betreff und Feldnamen der Mail bleiben deutsch – sie gehen an Anelia. */
topic: "Terminanfrage Keynote (englische Seite)",
fields: [
{ name: "datum", label: "Date of the event", mailLabel: "Datum" },
{ name: "veranstaltung", label: "Event & venue", mailLabel: "Veranstaltung & Ort" },
],
},
};
+3 -3
View File
@@ -160,10 +160,10 @@ export const datenschutzEn: LegalPage = {
"– sets no cookies and stores no information on your device (no local storage, no session storage),",
"– uses no analytics, tracking or marketing services (no Google Analytics, no pixels, no fingerprinting),",
"– embeds no third-party content (no external fonts, no videos, no maps, no social media plugins),",
"– sends the contact form via the hosting provider's server directly to my email inbox (section 5); no third-party form service is involved,",
"– sends the contact forms via the hosting provider's server directly to my email inbox (section 5); no third-party form service is involved,",
"– contains no newsletter.",
"All fonts, scripts and images are delivered from the server of the hosting provider named below. A cookie banner is therefore not required.",
"Personal data is processed in three cases only: when the pages are technically retrieved (server log files, section 3), when you contact me by email or telephone (section 4) and when you submit the contact form (section 5).",
"Personal data is processed in three cases only: when the pages are technically retrieved (server log files, section 3), when you contact me by email or telephone (section 4) and when you submit a contact form (section 5).",
],
},
{
@@ -210,7 +210,7 @@ export const datenschutzEn: LegalPage = {
{
h2: "5. Contact form",
paragraphs: [
"When you submit the contact form on the home page, the data you enter (name, email address, optionally your company and the content of your message) is transmitted in encrypted form to the hosting provider's server (section 3) and delivered from there as an email to my inbox. The email also records the time of sending and the page from which the form was submitted. No third-party form service is involved, and no cookies are set.",
"When you submit a contact form on this website (home page, service pages, trade fair page), the data you enter (name, email address, optionally your company, for a date request on the service pages optionally also your preferred date, team size or event date, event and venue, and the content of your message) is transmitted in encrypted form to the hosting provider's server (section 3) and delivered from there as an email to my inbox. The email also records the time of sending and the page from which the form was submitted. No third-party form service is involved, and no cookies are set.",
"Purpose: receiving and answering your enquiry.",
"Legal basis: Art. 6 (1) (b) GDPR where your enquiry is aimed at entering into or performing a contract; otherwise Art. 6 (1) (f) GDPR – legitimate interest in answering enquiries.",
"Storage period: the hosting provider's server does not store the form data permanently; it delivers it immediately as an email. The periods set out in section 4 then apply to that email.",
+28 -46
View File
@@ -2,7 +2,7 @@
* English copy of /en/services/trainings/. Same shape as src/content/trainings.ts.
* The B2B nouns stay concrete – this page has to read like an offer, not a brochure.
*/
import { mailto } from "@/lib/site";
import { routesByLang } from "@/lib/i18n";
import { assertNoPlaceholders, collectStrings, ph } from "@/lib/legal";
import type { TrainingsContent } from "@/content/trainings";
@@ -10,7 +10,7 @@ export const trainingsEn: TrainingsContent = {
meta: {
title: "Training & Workshops",
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",
masthead: {
@@ -21,31 +21,33 @@ export const trainingsEn: TrainingsContent = {
eyebrow: "For companies & teams",
h1: { before: "Sales with ", em: "personality", after: "." },
subline:
"B2B sales training and leadership workshops that hold up in the working week, not just in the seminar room. In house, online, in German or English.",
cta: { label: "Request training", href: mailto.training, micro: "No obligation" },
"B2B sales training, leadership workshops and HR team building that hold up in the working week, not just in the seminar room.",
cta: { label: "Request training", href: `${routesByLang.en.trainings}#terminanfrage`, micro: "No obligation" },
facts: [
{ label: "Format", value: "In house or online" },
{ label: "Length", value: "1–2 days or a series" },
{ label: "Language", value: "German / English" },
{ label: "Group size", value: ph("Platzhalter"), placeholder: true },
],
folio: { next: { label: "Three brakes", href: "#ausgangslage" } },
folio: { next: { label: "Four brakes", href: "#ausgangslage" } },
situations: {
id: "ausgangslage",
eyebrow: "Starting point",
h2: { before: "Three brakes.", em: "", after: "" },
h2: { before: "Four brakes.", em: "", after: "" },
rows: [
{ numeral: "01", text: "New business has stalled – and nobody enjoys calling cold." },
{ 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: "04", text: "People in the team talk past each other – conflicts linger and team spirit is missing." },
],
},
modules: {
id: "module",
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: [
{
title: "B2B sales training",
@@ -64,10 +66,17 @@ export const trainingsEn: TrainingsContent = {
{
title: "Leadership & team dynamics",
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",
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: {
id: "ablauf",
eyebrow: "How it runs",
steps: [
{ numeral: "01", title: "Scoping call", text: "" },
{ numeral: "02", title: "Concept", text: "" },
{ numeral: "02", title: "A concept on one page", text: "" },
{ numeral: "03", title: "Training", text: "" },
{ numeral: "04", title: "Follow-up after 30 days", text: "" },
],
marginalia: {
title: "What you take away",
items: [
{ value: "A concept on one page" },
{ value: "Commitments for 30 days" },
{ value: "A follow-up conversation" },
],
},
},
faq: {
@@ -129,10 +109,6 @@ export const trainingsEn: TrainingsContent = {
q: "In house or external?",
a: "In house at your company, or online. An external venue is possible on request.",
},
{
q: "How many participants?",
a: "We agree the group size in the scoping call – small enough that everyone gets to speak.",
},
{
q: "Also for leaders without a sales role?",
a: "Yes. The Leadership & team dynamics module is aimed at sales leadership, team leads and management – a sales role of your own is not a requirement.",
@@ -144,10 +120,16 @@ export const trainingsEn: TrainingsContent = {
],
},
band: {
h2: { before: "Let's talk about your team.", em: "", after: "" },
cta: { label: "Request training", href: mailto.training },
micro: "No obligation",
anfrage: {
id: "terminanfrage",
eyebrow: "Request a date",
h2: { before: "Let's find a ", em: "date", after: "." },
/* Betreff und Feldnamen der Mail bleiben deutsch – sie gehen an Anelia. */
topic: "Terminanfrage Training (englische Seite)",
fields: [
{ name: "wunschtermin", label: "Preferred date or period", mailLabel: "Wunschtermin" },
{ name: "teamgroesse", label: "Team size", mailLabel: "Teamgröße" },
],
},
};
+186
View File
@@ -0,0 +1,186 @@
/**
* /her-career/ – Messe-Landingpage zur herCAREER Expo 2026.
*
* Diese Seite ist das Ziel des QR-Codes auf der Rückwandgrafik am Stand
* (`AneliaWolf_Rueckwandgrafik_1980x1240`). Der Code zeigt auf
* `https://her-career.anelia-wolf.com` – die Adresse ist gedruckt und damit
* unveränderlich; wie die Subdomain diese Datei ausliefert, steht in
* docs/SUBDOMAIN-HERCAREER.md.
*
* Wer hier landet, steht mit dem Handy am Stand. Deshalb ist die Seite kurz und
* beantwortet in dieser Reihenfolge: Wo bin ich, was war das Special, worüber
* reden wir, wie erreiche ich dich. Alle weiterführenden Links gehen ABSOLUT auf
* die Hauptdomain – ein Klick, und der Besucher ist auf anelia-wolf.com.
*
* Die vier Themenblöcke der Rückwand (Analysen, Trainings, 1:1 Coaching,
* Keynotes) liegen hier auf den drei Leistungsseiten der Website, wie in der
* Navigation: „Coaching & Analysen“ trägt die Analysen mit.
*/
import { routes, site } from "@/lib/site";
import type { Widen } from "./widen";
import { assertNoPlaceholders, collectStrings } from "@/lib/legal";
import type { Headline, TocRow, WerdegangRow } from "./home";
import { contact as contactHome } from "./home";
/**
* Absolute Adresse auf der Hauptdomain. Die Seite wird unter zwei Hosts
* ausgeliefert (anelia-wolf.com/her-career/ und her-career.anelia-wolf.com);
* ein relativer Link würde auf der Subdomain in deren eigenem Pfadraum landen.
*/
const main = (path: string): string => `${site.url}${path}`;
export const herCareer = {
meta: {
title: "herCAREER Expo 2026",
/** Ohne Terminangaben – stimmt auch nach der Messe (`flags.showHerCareer: false`). */
description:
"Anelia Wolf zur herCAREER Expo 2026: das Messe-Special, die Themen vom Stand und der direkte Draht zur Keynote Speakerin, Trainerin und Coach.",
/** Mit Stand und Keynote-Termin. Nur solange die Messe bevorsteht – die Route wählt. */
descriptionMesse:
"Anelia Wolf auf der herCAREER Expo 2026 im MOC München: Stand P.10.2 in Halle 3, Keynote am 23.10. um 10:30 im Auditorium 2. Messe-Special, Themen und der direkte Draht.",
},
numeral: "2026",
/* Wie auf den Leistungsseiten: steht in der Inhaltsdatei, wird im Hero nicht gerendert. */
masthead: {
role: "Keynote Speakerin · Trainerin · Coach",
center: "herCAREER Expo 2026",
lang: "Deutsch & Englisch",
},
eyebrow: "herCAREER Expo 2026 · MOC München",
h1: { before: "Schön, dass Sie ", em: "gescannt", after: " haben." } satisfies Headline,
/* Keine Subline im Hero (Kundenwunsch, 10.09.2026): Wer den Code gescannt hat, weiß,
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
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
Themen standen dort ein zweites Mal. */
folio: { next: { label: "Messe-Special", href: "#messe-special" } },
/* ---------- 01 Messe-Special (ivory) ---------- */
special: {
id: "messe-special",
eyebrow: "Messe-Special",
/* Keine eigene H2: Seit der Gutschein im Hero hängt, steht in dieser Sektion nur
noch, was obendrauf kommt – „Ihr Vorteil.“ hätte einen Abschnitt überschrieben,
in dem der Vorteil nicht mehr vorkommt. Die Überschrift der Sektion ist darum die
des Bonus-Blocks; `eyebrow` bleibt der Laufkopf in der Randspalte und das Ziel
des Folio-Links im Hero. */
/**
* Der Gutschein – Wortlaut von Anelia (10.09.2026); er löst die drei Platzhalter
* 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) ---------- */
themen: {
id: "themen",
eyebrow: "Themen",
h2: { before: "Worüber wir reden.", em: "", after: "" } satisfies Headline,
rows: [
{
numeral: "01",
title: "Coaching & Analysen",
oneLiner:
"Wer bin ich, wo stehe ich, wo will ich hin? Persönlichkeits- und Verhaltensanalyse als Grundlage, dann die Arbeit zu zweit.",
keywords: ["Potenzialanalyse", "1:1 Coaching", "Neuorientierung", "Leadership"],
link: { label: "Zum Coaching", href: main(routes.coaching) },
},
{
numeral: "02",
title: "Trainings & Workshops",
oneLiner:
"B2B-Vertriebstraining und HR-Teambuilding: Umsatzwachstum durch Neu- und Bestandskunden, Teamgeist und Konfliktlösung im Team.",
keywords: ["Neukundengewinnung", "Bestandskunden", "HR-Teambuilding & Konfliktlösung"],
link: { label: "Zu den Trainings", href: main(routes.trainings) },
},
{
numeral: "03",
title: "Keynotes & Speaking",
oneLiner:
"Inspirierende Vorträge für Firmen-Events und Veranstaltungen – Impulse, die im Gedächtnis bleiben.",
keywords: ["WOLF-Prinzip", "Mut zum Erfolg", "Deutsch & Englisch"],
link: { label: "Zu den Keynotes", href: main(routes.keynotes) },
},
] satisfies readonly TocRow[],
},
/* ---------- 03 Kontakt (ink) ---------- */
/** Ziel des Datenschutz-Links im Formular – absolut, wie jeder Link von hier weg. */
privacyHref: main(routes.datenschutz),
/**
* Derselbe Block wie Sektion 06 der Startseite: Formular links, Mail, Telefon und
* LinkedIn rechts. Feldbeschriftungen, Links und Abbinder kommen unverändert von
* dort – eine Quelle. Eigen sind nur die Nummer, die Überschrift und der Betreff:
* Mit „herCAREER Expo 2026“ ist im Postfach ohne Nachfrage zu sehen, welche
* Anfrage von der Messe kommt.
*/
contact: {
...contactHome,
numeral: "03",
h2: { before: "Lass uns ", em: "weiterreden", after: "." } satisfies Headline,
/* Hinweiszeile und Datenschutz-Link entfallen hier (Kundenwunsch, 07.09.2026).
Rückmeldung und Rückfall-Link beim Absenden bleiben. ACHTUNG: Seit dem Umbau
des Formulars („Kontaktformular verschickt wirklich“) gehen die Eingaben an
/api/contact – die Datenschutzerklärung beschreibt das in Ziffer 5. Damit ist
der Hinweis neben dem Formular eine Information nach Art. 13 DSGVO zum
Zeitpunkt der Erhebung; erreichbar bleibt sie nur über den Fuß. Vor dem
Livegang mit Anelia klären (docs/OFFENE-DATEN.md, Punkt 3.17). */
form: { ...contactHome.form, subject: "herCAREER Expo 2026", note: "", privacy: "" },
},
} as const;
/* Build-Guard: der Launch-Flip (`flags.showPlaceholders: false`) darf keine sichtbaren
„[…]“ übrig lassen. Offene Punkte stehen in CONTENT_TODO (src/lib/legal.ts). */
assertNoPlaceholders("src/content/her-career.ts", collectStrings(herCareer));
/** Struktur, nicht Wortlaut – die englische Fassung erfüllt denselben Typ. */
export type HerCareerContent = Widen<typeof herCareer>;
+107 -7
View File
@@ -8,10 +8,37 @@ import { assertNoPlaceholders, collectStrings, ph } from "@/lib/legal";
/* ---------- 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 {
before: string;
em: 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 {
@@ -93,6 +120,24 @@ export interface FaqItem {
/* ---------- 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 = {
id: "hero",
masthead: {
@@ -100,7 +145,7 @@ export const hero = {
center: "Sales Mentoring & Potential Development",
lang: "Deutsch & Englisch",
},
h1: { before: "Wer du bist, entscheidet, wie du ", em: "wirkst", after: "." } satisfies Headline,
h1: heroH1,
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.",
cta: {
@@ -117,6 +162,13 @@ export const hero = {
export const positioning = {
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",
eyebrow: "Positionierung",
h2: { before: "Drei Fragen. Ein Prinzip.", em: "", after: "" } satisfies Headline,
@@ -148,8 +200,8 @@ export const services = {
{
numeral: "02",
title: "Trainings & Workshops",
oneLiner: "Vertrieb mit Persönlichkeit – für Teams und Führungskräfte.",
keywords: ["B2B-Vertriebstraining", "Neukunden & Bestandskunden", "Leadership & Teamdynamik"],
oneLiner: "Leadership & HR mit Persönlichkeit – für Führungskräfte und Teams.",
keywords: ["B2B-Vertriebstraining", "HR-Teambuilding & Konfliktlösung", "Kommunikation & Teamgeist"],
link: { label: "Zu den Trainings", href: routes.trainings },
},
{
@@ -189,7 +241,7 @@ export const about = {
eyebrow: "Über Anelia",
h2: { before: "Ich stehe selbst im Vertrieb. Jede Woche.", em: "", after: "" } satisfies Headline,
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.",
],
/** Nur mit `flags.showTwins` an Absatz 2 anhängen (mit Leerzeichen). */
@@ -197,7 +249,9 @@ export const about = {
werdegang: [
{ keyword: "Prägung", text: "Verkaufsgespräche im Betrieb meines Vaters" },
{ 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: "Bühne", text: "Keynote Speakerin, Trainerin & Coach · herCAREER Expo 2026" },
] satisfies readonly WerdegangRow[],
@@ -208,6 +262,50 @@ export const about = {
{ value: "LinkedIn", href: site.linkedin, external: true },
{ value: "Deutsch · Englisch" },
] 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" },
} as const;
@@ -242,11 +340,11 @@ export const audience = {
columns: [
{
label: "Unternehmen",
title: "Vertriebsteams & Führungskräfte",
title: "Vertriebsteams, Führung & HR",
lines: [
"Neukundengewinnung, die planbar wird",
"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 },
},
@@ -319,3 +417,5 @@ export const home = { hero, positioning, services, principle, about, audience, t
assertNoPlaceholders("src/content/home.ts", collectStrings(home));
/** Struktur, nicht Wortlaut – die englische Fassung erfüllt denselben Typ. */
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.
* Entschlackt am 02.10.2026: Die typografische Unterbrechung („Mut zum Erfolg.“ – stand
* schon als H1 und im Vortragstitel), die Speaker-Facts (wiederholten die Hero-Fakten;
* nur „Technik“ war neu und steht jetzt dort) und die FAQ „Auf Englisch?“ sind
* entfallen. Die Termine hängen unter dem Vortrag. Statt des Kontakt-Bands mit
* mailto-Knopf schließt die Seite mit der Terminanfrage (Formular).
* Zweiter Vortrag („Unlock your Sales-DNA“) bleibt weg (flags.showSecondTalk: false).
* Speaker-Profil-Zeile nur mit flags.showSpeakerPdf bzw. vorhandener Datei (assets.getSpeakerPdf()).
* FAQ-Antworten: kurz, aus den Spec-Fakten abgeleitet (Spec A.8).
*/
import { mailto } from "@/lib/site";
import { routes } from "@/lib/site";
import type { Widen } from "./widen";
import { assertNoPlaceholders, collectStrings, ph } from "@/lib/legal";
import type { ColumnItem, Cta, FactItem, FaqItem, GoalRow, Headline } from "./home";
@@ -39,8 +44,9 @@ export const keynotes = {
h1: { before: "Mut zum ", em: "Erfolg", after: "." } satisfies Headline,
h1GoldEm: true,
subline:
"Keynotes nach dem WOLF-Prinzip für Firmen-Events, Kick-offs und Kongresse. Ein Impuls, der nachwirkt – auf Deutsch oder Englisch, live oder online.",
cta: { label: "Keynote anfragen", href: mailto.keynote } satisfies Cta,
"Keynotes nach dem WOLF-Prinzip für Firmen-Events, Kick-offs und Kongresse. Ein Impuls, der nachwirkt.",
/* Springt zur Terminanfrage am Fuß der Seite (vorher: mailto). */
cta: { label: "Keynote anfragen", href: `${routes.keynotes}#terminanfrage` } satisfies Cta,
/** Nur rendern, wenn die PDF-Datei existiert (assets.getSpeakerPdf()). */
secondary: { label: "Speaker-Profil (PDF)", href: "/speaker-profil.pdf", external: true },
facts: [
@@ -48,6 +54,7 @@ export const keynotes = {
{ label: "Format", value: "Live, online, hybrid" },
{ label: "Sprache", value: "Deutsch / Englisch" },
{ label: "Ort", value: "Deutschlandweit, Europa auf Anfrage" },
{ label: "Technik", value: "Headset, Präsentation optional" },
] satisfies readonly FactItem[],
alt: "Anelia Wolf auf der Bühne",
caption: `Anelia Wolf · Foto: ${ph("Name der Fotografin")}`,
@@ -70,17 +77,6 @@ export const keynotes = {
] satisfies readonly TalkRow[],
},
/* Spec §7.4 wörtlich. Die vorherige Fassung („Ein Impuls, der nachwirkt.“) stand nicht
in Abschnitt A und brach zugleich Spec §0 Kritik #4: der Satz darf EINMAL pro
Keynote-Seite vorkommen und stand dann in Subline, Meta-Description und in der
Unterbrechung – die eine große typografische Pause wiederholte, was 400 px darüber
schon zu lesen war. */
interruption: {
id: "mut",
text: "Mut zum Erfolg.",
mono: "Keynote · herCAREER Expo 2026 · 23.10. · 10:30 · Auditorium 2",
},
occasions: {
id: "anlaesse",
eyebrow: "Anlässe",
@@ -113,26 +109,10 @@ export const keynotes = {
] satisfies readonly GoalRow[],
},
speakerFacts: {
id: "speaker-facts",
items: [
{ label: "Sprachen", value: "DE / EN" },
{ label: "Dauer", value: "20–60 Min." },
{ label: "Format", value: "live, online, hybrid" },
{ label: "Technik", value: "Headset, Präsentation optional" },
{ label: "Reise", value: "deutschlandweit, Europa auf Anfrage" },
{ label: "Anfrage", value: "unverbindlich" },
] satisfies readonly FactItem[],
},
termine,
veranstalter: {
id: "veranstalter",
eyebrow: "Für Veranstalter",
h2: { before: "Alles Wichtige vorab.", em: "", after: "" } satisfies Headline,
speakerPdf: { label: "Speaker-Profil (PDF)", text: "Kurzvita, Themen, Fotos, Technik", href: "/speaker-profil.pdf" },
},
/** Zeile unter dem Vortrag – nur, wenn die PDF-Datei geliefert wurde (assets.getSpeakerPdf()). */
speakerPdf: { label: "Speaker-Profil (PDF)", text: "Kurzvita, Themen, Fotos, Technik", href: "/speaker-profil.pdf" },
faq: {
id: "faq",
@@ -146,10 +126,6 @@ export const keynotes = {
q: "Auch als Workshop nach der Keynote?",
a: "Ja. Die Keynote lässt sich mit einem Workshop oder Training kombinieren – am selben Tag oder als Folgetermin.",
},
{
q: "Auf Englisch?",
a: "Ja. Keynotes halte ich auf Deutsch oder Englisch – live, online oder hybrid.",
},
{
q: "Gibt es Video oder Referenzen?",
a: "Auf Anfrage. Sprich mich an, dann schicke ich dir Unterlagen zum Vortrag.",
@@ -157,10 +133,19 @@ export const keynotes = {
] satisfies readonly FaqItem[],
},
band: {
h2: { before: "Hol das WOLF-Prinzip auf deine Bühne.", em: "", after: "" } satisfies Headline,
cta: { label: "Keynote anfragen", href: mailto.keynote } satisfies Cta,
micro: "Unverbindlich · Antwort auf Deutsch oder Englisch",
/**
* Terminanfrage (ink) – das Formular der Startseite mit zwei freiwilligen Feldern.
* `topic` wird zum Mail-Betreff; `mailLabel` beschriftet die Felder in der Mail.
*/
anfrage: {
id: "terminanfrage",
eyebrow: "Terminanfrage",
h2: { before: "Lass uns einen ", em: "Termin", after: " finden." } satisfies Headline,
topic: "Terminanfrage Keynote",
fields: [
{ name: "datum", label: "Datum der Veranstaltung", mailLabel: "Datum" },
{ name: "veranstaltung", label: "Veranstaltung & Ort", mailLabel: "Veranstaltung & Ort" },
],
},
} as const;
+3 -3
View File
@@ -165,10 +165,10 @@ export const datenschutz: LegalPage = {
"– setzt keine Cookies und speichert keine Informationen in Ihrem Endgerät (kein Local Storage, kein Session Storage),",
"– verwendet keine Analyse-, Tracking- oder Marketing-Dienste (kein Google Analytics, keine Pixel, kein Fingerprinting),",
"– bindet keine Inhalte von Drittanbietern ein (keine externen Schriftarten, keine Videos, keine Karten, keine Social-Media-Plugins),",
"– verschickt das Kontaktformular über den Server des Hosters unmittelbar an mein E-Mail-Postfach (Ziffer 5); ein Formularanbieter eines Dritten ist nicht eingebunden,",
"– verschickt die Kontaktformulare über den Server des Hosters unmittelbar an mein E-Mail-Postfach (Ziffer 5); ein Formularanbieter eines Dritten ist nicht eingebunden,",
"– enthält keinen Newsletter.",
"Alle Schriftarten, Skripte und Bilder werden vom Server des unten genannten Hosting-Anbieters ausgeliefert. Ein Cookie-Banner ist deshalb nicht erforderlich.",
"Personenbezogene Daten werden nur in drei Fällen verarbeitet: beim technischen Abruf der Seiten (Server-Logfiles, Ziffer 3), wenn Sie mich per E-Mail oder Telefon kontaktieren (Ziffer 4) und wenn Sie das Kontaktformular absenden (Ziffer 5).",
"Personenbezogene Daten werden nur in drei Fällen verarbeitet: beim technischen Abruf der Seiten (Server-Logfiles, Ziffer 3), wenn Sie mich per E-Mail oder Telefon kontaktieren (Ziffer 4) und wenn Sie ein Kontaktformular absenden (Ziffer 5).",
],
},
{
@@ -218,7 +218,7 @@ export const datenschutz: LegalPage = {
{
h2: "5. Kontaktformular",
paragraphs: [
"Wenn Sie das Kontaktformular auf der Startseite absenden, werden die von Ihnen eingegebenen Daten (Name, E-Mail-Adresse, freiwillig Ihre Firma sowie der Inhalt Ihrer Nachricht) verschlüsselt an den Server des Hosters (Ziffer 3) übertragen und von dort als E-Mail an mein Postfach zugestellt. In der E-Mail werden zusätzlich der Zeitpunkt des Absendens und die Unterseite vermerkt, von der aus Sie das Formular abgeschickt haben. Ein Formularanbieter eines Dritten ist nicht eingebunden; Cookies werden dabei nicht gesetzt.",
"Wenn Sie ein Kontaktformular dieser Website absenden (Startseite, Leistungsseiten, Messeseite), werden die von Ihnen eingegebenen Daten (Name, E-Mail-Adresse, freiwillig Ihre Firma, bei einer Terminanfrage auf den Leistungsseiten freiwillig auch Wunschtermin, Teamgröße bzw. Datum, Veranstaltung und Ort, sowie der Inhalt Ihrer Nachricht) verschlüsselt an den Server des Hosters (Ziffer 3) übertragen und von dort als E-Mail an mein Postfach zugestellt. In der E-Mail werden zusätzlich der Zeitpunkt des Absendens und die Unterseite vermerkt, von der aus Sie das Formular abgeschickt haben. Ein Formularanbieter eines Dritten ist nicht eingebunden; Cookies werden dabei nicht gesetzt.",
"Zweck: Entgegennahme und Beantwortung Ihrer Anfrage.",
"Rechtsgrundlage: Art. 6 Abs. 1 lit. b DSGVO, soweit Ihre Anfrage auf den Abschluss oder die Durchführung eines Vertrags gerichtet ist; im Übrigen Art. 6 Abs. 1 lit. f DSGVO – berechtigtes Interesse an der Beantwortung von Anfragen.",
"Speicherdauer: Der Server des Hosters legt die Formulardaten nicht dauerhaft ab, sondern stellt sie unmittelbar als E-Mail zu. Für diese E-Mail gelten anschließend dieselben Fristen wie in Ziffer 4.",
+51 -48
View File
@@ -1,14 +1,25 @@
/**
* /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).
* FAQ-Antworten: kurz, aus den Spec-Fakten abgeleitet; Preise „auf Anfrage“ (Spec A.8).
*/
import { mailto } from "@/lib/site";
import { routes } from "@/lib/site";
import type { Widen } from "./widen";
import { assertNoPlaceholders, collectStrings, ph } from "@/lib/legal";
import type { Cta, FactItem, FaqItem, GoalRow, Headline, MarginaliaItem, SituationRow, StepItem } from "./home";
import type { Cta, FactItem, FaqItem, Headline, SituationRow, StepItem } from "./home";
/** Kopfzeile der Modul-Tabelle – von <ModuleTable/> importiert. */
/**
* 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 interface ModuleItem {
@@ -23,7 +34,7 @@ export const trainings = {
meta: {
title: "Trainings & Workshops",
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",
masthead: {
@@ -34,31 +45,34 @@ export const trainings = {
eyebrow: "Für Unternehmen & Teams",
h1: { before: "Vertrieb mit ", em: "Persönlichkeit", after: "." } satisfies Headline,
subline:
"B2B-Vertriebstrainings und Leadership-Workshops, die im Alltag funktionieren – nicht nur im Seminarraum. Inhouse, online, auf Deutsch oder Englisch.",
cta: { label: "Training anfragen", href: mailto.training, micro: "Unverbindlich" } satisfies Cta,
"B2B-Vertriebstrainings, Leadership-Workshops und HR-Teambuilding, die im Alltag funktionieren – nicht nur im Seminarraum.",
/* Springt zur Terminanfrage am Fuß der Seite (vorher: mailto). */
cta: { label: "Training anfragen", href: `${routes.trainings}#terminanfrage`, micro: "Unverbindlich" } satisfies Cta,
facts: [
{ label: "Format", value: "Inhouse oder online" },
{ label: "Dauer", value: "1–2 Tage oder Reihe" },
{ label: "Sprache", value: "Deutsch / Englisch" },
{ label: "Gruppengröße", value: ph("Platzhalter"), placeholder: true },
] satisfies readonly FactItem[],
folio: { next: { label: "Drei Bremsen", href: "#ausgangslage" } },
folio: { next: { label: "Vier Bremsen", href: "#ausgangslage" } },
situations: {
id: "ausgangslage",
eyebrow: "Ausgangslage",
h2: { before: "Drei Bremsen.", em: "", after: "" } satisfies Headline,
h2: { before: "Vier Bremsen.", em: "", after: "" } satisfies Headline,
rows: [
{ numeral: "01", text: "Die Neukundengewinnung stockt – und niemand ruft gern kalt an." },
{ numeral: "02", text: "Bestandskunden werden betreut, aber nicht ausgebaut." },
{ 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[],
},
modules: {
id: "module",
eyebrow: "Leistungsbausteine",
h2: { before: "Drei Module.", em: "", after: "" } satisfies Headline,
h2: { before: "Vier Module.", em: "", after: "" } satisfies Headline,
labels: moduleLabels,
items: [
{
/* U+2011 (non-breaking hyphen): the Bodoni title must never break after „B2B-“. */
@@ -78,10 +92,23 @@ export const trainings = {
{
title: "Leadership & Teamdynamik",
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",
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[],
},
@@ -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: {
id: "ablauf",
eyebrow: "Ablauf",
steps: [
{ numeral: "01", title: "Bedarfsgespräch", text: "" },
{ numeral: "02", title: "Konzept", text: "" },
{ numeral: "02", title: "Konzept auf einer Seite", text: "" },
{ numeral: "03", title: "Training", text: "" },
{ numeral: "04", title: "Follow-up nach 30 Tagen", text: "" },
] satisfies readonly StepItem[],
marginalia: {
title: "Was du bekommst",
items: [
{ value: "Konzept auf einer Seite" },
{ value: "Vereinbarungen für 30 Tage" },
{ value: "Follow-up-Gespräch" },
] satisfies readonly MarginaliaItem[],
},
},
faq: {
@@ -143,10 +141,6 @@ export const trainings = {
q: "Inhouse oder extern?",
a: "Inhouse bei dir im Unternehmen oder online. Ein externer Veranstaltungsort ist auf Anfrage möglich.",
},
{
q: "Wie viele Teilnehmende?",
a: "Die Gruppengröße stimmen wir im Bedarfsgespräch ab – so, dass jede und jeder im Training zu Wort kommt.",
},
{
q: "Auch für Führungskräfte ohne Vertriebsrolle?",
a: "Ja. Das Modul Leadership & Teamdynamik richtet sich an Vertriebsleitung, Teamleitung und Geschäftsführung – eine eigene Vertriebsrolle ist keine Voraussetzung.",
@@ -158,10 +152,19 @@ export const trainings = {
] satisfies readonly FaqItem[],
},
band: {
h2: { before: "Sprechen wir über dein Team.", em: "", after: "" } satisfies Headline,
cta: { label: "Training anfragen", href: mailto.training } satisfies Cta,
micro: "Unverbindlich",
/**
* Terminanfrage (ink) – das Formular der Startseite mit zwei freiwilligen Feldern.
* `topic` wird zum Mail-Betreff; `mailLabel` beschriftet die Felder in der Mail.
*/
anfrage: {
id: "terminanfrage",
eyebrow: "Terminanfrage",
h2: { before: "Lass uns einen ", em: "Termin", after: " finden." } satisfies Headline,
topic: "Terminanfrage Training",
fields: [
{ name: "wunschtermin", label: "Wunschtermin oder Zeitraum", mailLabel: "Wunschtermin" },
{ name: "teamgroesse", label: "Teamgröße", mailLabel: "Teamgröße" },
],
},
} as const;
+38
View File
@@ -56,3 +56,41 @@ export function getLogoSvg(): string | null {
export function getSpeakerPdf(): string | null {
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 }];
});
}
+34
View File
@@ -22,6 +22,8 @@ export interface LangRoutes {
keynotes: string;
impressum: string;
datenschutz: string;
/** Messe-Landingpage, Ziel des QR-Codes auf der Rückwandgrafik. */
herCareer: string;
/** Abschnitt 06 – Ziel des Haupt-CTA. */
kontakt: string;
}
@@ -34,6 +36,7 @@ export const routesByLang: Record<Lang, LangRoutes> = {
keynotes: "/leistungen/keynotes/",
impressum: "/impressum/",
datenschutz: "/datenschutz/",
herCareer: "/her-career/",
kontakt: "/#kontakt",
},
en: {
@@ -43,6 +46,7 @@ export const routesByLang: Record<Lang, LangRoutes> = {
keynotes: "/en/services/keynotes/",
impressum: "/en/imprint/",
datenschutz: "/en/privacy/",
herCareer: "/en/her-career/",
kontakt: "/en/#kontakt",
},
};
@@ -58,6 +62,35 @@ export function otherLang(lang: Lang): Lang {
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
* liefert denselben Inhalt in der Zielsprache; unbekannte Pfade landen auf der
@@ -73,6 +106,7 @@ export function counterpart(pathname: string, target: Lang): string {
[from.keynotes, to.keynotes],
[from.impressum, to.impressum],
[from.datenschutz, to.datenschutz],
[from.herCareer, to.herCareer],
[from.home, to.home],
];
const hit = pairs.find(([a]) => clean === a);
+2 -1
View File
@@ -104,6 +104,7 @@ export const CONTENT_TODO: string[] = [
"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, 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 „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).",
];
+15 -5
View File
@@ -15,6 +15,12 @@ export const site = {
linkedin: "https://www.linkedin.com/in/anelia-wolf-a26071280",
/** herCAREER Expo (Termine-Ledger, Footer-Textlink). Kein Logo, nur Textlink. */
hercareer: "https://www.her-career.com/",
/**
* Messe-Landingpage. Diese Adresse steht als QR-Code auf der Rückwandgrafik am
* Stand – sie ist gedruckt und damit unveränderlich. Der Vhost der Subdomain
* liefert dafür `out/her-career/index.html` aus (docs/SUBDOMAIN-HERCAREER.md).
*/
herCareerLanding: "https://her-career.anelia-wolf.com",
description:
"Anelia Wolf – Keynote Speakerin, Trainerin und Coach für Persönlichkeit, Wirkung und Vertrieb. Sales Mentoring & Potential Development nach dem WOLF-Prinzip.",
} as const;
@@ -50,6 +56,13 @@ export const flags = {
show24hPromise: false,
showIndustry: false,
showTwins: true,
/**
* Messe-Landingpage /her-career/ (QR-Code der Rückwandgrafik). `true`, solange die
* herCAREER Expo bevorsteht: nur dann trägt die Seite die Termin-Zeilen und steht
* in der Sitemap. Ab dem 24.10.2026 auf `false` – dann fallen die Datumsangaben
* weg, und der Rest der Seite bleibt richtig (docs/HANDOVER.md, Abschnitt 6).
*/
showHerCareer: true,
/** Sprachumschalter im Kopf. Seit dem 06.09.2026 steht die englische Fassung unter
* /en/ (eigenes Wurzel-Layout, eigene Slugs) – die Flagge zeigt also irgendwohin. */
showLangSwitch: true,
@@ -64,13 +77,10 @@ export const routes = {
keynotes: "/leistungen/keynotes/",
impressum: "/impressum/",
datenschutz: "/datenschutz/",
/** Messe-Landingpage, Ziel des QR-Codes auf der Rückwandgrafik. */
herCareer: "/her-career/",
/** Abschnitt 06 der Startseite – Ziel des Haupt-CTA „Kennenlerngespräch anfragen“. */
kontakt: "/#kontakt",
} as const;
export type Route = (typeof routes)[keyof typeof routes];
export const mailto = {
training: "mailto:info@anelia-wolf.com?subject=Training%20anfragen",
keynote: "mailto:info@anelia-wolf.com?subject=Keynote%20anfragen",
} as const;