Compare commits
11
Commits
b9e1d4d15c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3351c9a6d | ||
|
|
1e7b84b27c | ||
|
|
30e9151de4 | ||
|
|
6fdd7df1f8 | ||
|
|
f825e74222 | ||
|
|
a37e48e4ef | ||
|
|
ecf66c7340 | ||
|
|
389b3b9d71 | ||
|
|
c09f674653 | ||
|
|
9725e0229d | ||
|
|
33ec0df7e3 |
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
Binary file not shown.
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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} />
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,14 +4,12 @@ import { ServiceLayout } from "@/components/services/ServiceLayout";
|
||||
import { TrainingsSituations } from "@/components/services/trainings/TrainingsSituations";
|
||||
import { TrainingsModules } from "@/components/services/trainings/TrainingsModules";
|
||||
import { TrainingsMethod } from "@/components/services/trainings/TrainingsMethod";
|
||||
import { TrainingsGoals } from "@/components/services/trainings/TrainingsGoals";
|
||||
import { TrainingsRahmen } from "@/components/services/trainings/TrainingsRahmen";
|
||||
import { TrainingsAblauf } from "@/components/services/trainings/TrainingsAblauf";
|
||||
import { TrainingsFaq } from "@/components/services/trainings/TrainingsFaq";
|
||||
import { TrainingsBand } from "@/components/services/trainings/TrainingsBand";
|
||||
import { ProcessContact } from "@/components/home/ProcessContact";
|
||||
import type { TrainingsContent } from "@/content/trainings";
|
||||
import type { HomeContent } from "@/content/home";
|
||||
import { flags } from "@/lib/site";
|
||||
import { contact as contactDe, type HomeContent } from "@/content/home";
|
||||
import { flags, routes } from "@/lib/site";
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
@@ -19,6 +17,10 @@ export interface TrainingsBodyProps {
|
||||
content: TrainingsContent;
|
||||
/** „Über Anelia“-Randspalte im Abschnitt „Wie ich arbeite“ – in der Sprache der Seite. */
|
||||
marginalia: HomeContent["about"]["marginalia"];
|
||||
/** Formular-Texte (Feldnamen, Rückmeldungen, Kontaktzeilen) in der Sprache der Seite. */
|
||||
contact?: HomeContent["contact"];
|
||||
/** Ziel des Datenschutz-Links im Formular. */
|
||||
privacyHref?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,8 +28,16 @@ export interface TrainingsBodyProps {
|
||||
* Die „Angebots“-Seite, am wenigsten Bewegung: nur <Rule/>, <Lines/>, <Reveal/> – kein Pin,
|
||||
* kein Draw, kein Blur, kein Foto (das Bühnenfoto gehört zu den Keynotes, §7.1).
|
||||
* Kein <main>: template.tsx liefert `main#inhalt`.
|
||||
*
|
||||
* Reihenfolge: Hero → Ausgangslage → Module → Wie ich arbeite → Ablauf → FAQ (Flag) →
|
||||
* Terminanfrage (ink, Formular). Die Terminanfrage zählt als letzter Abschnitt mit.
|
||||
*/
|
||||
export function TrainingsBody({ content: trainings, marginalia }: TrainingsBodyProps) {
|
||||
export function TrainingsBody({
|
||||
content: trainings,
|
||||
marginalia,
|
||||
contact = contactDe,
|
||||
privacyHref = routes.datenschutz,
|
||||
}: TrainingsBodyProps) {
|
||||
/* Helle Abschnitte in Lesereihenfolge; FAQ nur mit `flags.showFaq` (Spec §7.1.6). */
|
||||
const sections = [
|
||||
{ id: trainings.situations.id, label: trainings.situations.eyebrow, body: <TrainingsSituations content={trainings} /> },
|
||||
@@ -37,14 +47,13 @@ export function TrainingsBody({ content: trainings, marginalia }: TrainingsBodyP
|
||||
label: trainings.how.eyebrow,
|
||||
body: <TrainingsMethod content={trainings} marginalia={marginalia} />,
|
||||
},
|
||||
{ id: trainings.goals.id, label: trainings.goals.eyebrow, body: <TrainingsGoals content={trainings} /> },
|
||||
{ id: trainings.rahmen.id, label: trainings.rahmen.eyebrow, body: <TrainingsRahmen content={trainings} /> },
|
||||
{ id: trainings.ablauf.id, label: trainings.ablauf.eyebrow, body: <TrainingsAblauf content={trainings} /> },
|
||||
...(flags.showFaq && trainings.faq.items.length > 0
|
||||
? [{ id: trainings.faq.id, label: trainings.faq.eyebrow, body: <TrainingsFaq content={trainings} /> }]
|
||||
: []),
|
||||
];
|
||||
const total = pad(sections.length);
|
||||
const total = pad(sections.length + 1);
|
||||
const { anfrage } = trainings;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -56,7 +65,23 @@ export function TrainingsBody({ content: trainings, marginalia }: TrainingsBodyP
|
||||
</Section>
|
||||
))}
|
||||
</ServiceLayout>
|
||||
<TrainingsBand content={trainings} />
|
||||
{/* Terminanfrage statt Kontakt-Band: derselbe Block wie auf der Startseite, ohne
|
||||
den Claim darunter (der steht direkt danach im Fuß). */}
|
||||
<ProcessContact
|
||||
content={{
|
||||
...contact,
|
||||
id: anfrage.id,
|
||||
numeral: total,
|
||||
eyebrow: anfrage.eyebrow,
|
||||
h2: anfrage.h2,
|
||||
signoff: "",
|
||||
form: { ...contact.form, subject: anfrage.topic },
|
||||
}}
|
||||
privacyHref={privacyHref}
|
||||
total={total}
|
||||
fields={anfrage.fields}
|
||||
topic={anfrage.topic}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,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 (
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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 }];
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
Reference in new issue
Block a user