Compare commits
9
Commits
9725e0229d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3351c9a6d | ||
|
|
1e7b84b27c | ||
|
|
30e9151de4 | ||
|
|
6fdd7df1f8 | ||
|
|
f825e74222 | ||
|
|
a37e48e4ef | ||
|
|
ecf66c7340 | ||
|
|
389b3b9d71 | ||
|
|
c09f674653 |
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.
|
||||
|
||||
|
||||
@@ -32,10 +32,13 @@ Diese Regeln sind nicht kosmetisch – die Datenschutzerklärung sagt sie zu:
|
||||
`next/font` zur Build-Zeit selbst gehostet.
|
||||
- **kein Storage**: kein localStorage, kein sessionStorage, keine Cookies –
|
||||
deshalb auch kein Cookie-Banner.
|
||||
- **Kontaktformular mit Serverversand**, kein Newsletter. Abschnitt 06 sendet an
|
||||
`/api/contact` beim Hoster; die Anfrage landet im Postfach
|
||||
- **Kontaktformular mit Serverversand**, kein Newsletter. Abschnitt 06 der
|
||||
Startseite, die Messeseite und die Terminanfrage am Fuß von Trainings und
|
||||
Keynotes senden an `/api/contact` beim Hoster; die Anfrage landet im Postfach
|
||||
`info@anelia-wolf.com` im Ordner `web-anfragen` (kein Formulardienst eines
|
||||
Dritten). Alle übrigen Kontaktwege laufen weiter über `mailto:` und Telefon.
|
||||
Dritten). Die Terminanfrage schickt zusätzlich `topic` (Betreff) und
|
||||
`details` (Wunschtermin usw.) mit. Alle übrigen Kontaktwege laufen weiter über
|
||||
`mailto:` und Telefon.
|
||||
|
||||
Wer eine dieser Zusagen ändert, muss `src/content/legal.ts` (Ziffer 2, 3 und 6)
|
||||
mit ändern.
|
||||
|
||||
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.
@@ -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%);
|
||||
@@ -99,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);
|
||||
@@ -199,6 +207,44 @@ 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,
|
||||
@@ -246,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); }
|
||||
|
||||
@@ -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;
|
||||
@@ -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 =
|
||||
@@ -27,11 +27,32 @@ export interface ProcessContactProps {
|
||||
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, total }: 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}
|
||||
@@ -55,7 +76,7 @@ export function ProcessContact({ content: contact = contactDe, privacyHref, tota
|
||||
Es verschickt nichts selbst, sondern übergibt eine fertige E-Mail an das
|
||||
Mailprogramm – siehe den Kopf von ContactForm.tsx. */}
|
||||
<Reveal className="col-span-12 mt-12 lg:col-span-10 lg:col-start-3 lg:mt-14 xl:col-span-6">
|
||||
<ContactForm content={contact.form} privacyHref={privacyHref} />
|
||||
<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
|
||||
@@ -66,10 +87,14 @@ export function ProcessContact({ content: contact = contactDe, privacyHref, tota
|
||||
</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">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"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";
|
||||
@@ -19,11 +20,44 @@ export interface LangSwitchProps {
|
||||
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.
|
||||
*
|
||||
* Zielt 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
|
||||
@@ -32,7 +66,15 @@ const BASE =
|
||||
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),
|
||||
);
|
||||
|
||||
/* 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
|
||||
|
||||
@@ -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;
|
||||
@@ -1,13 +1,12 @@
|
||||
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 { Facts } from "@/components/ui/Facts";
|
||||
import { Coupon } from "@/components/ui/Coupon";
|
||||
import { Ledger } from "@/components/ui/Ledger";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
import type { HerCareerContent } from "@/content/her-career";
|
||||
import { flags } from "@/lib/site";
|
||||
|
||||
/** Content column of every light section: cols 3–12 on desktop – as on the service pages. */
|
||||
const BODY = "col-span-12 lg:col-span-10 lg:col-start-3";
|
||||
@@ -23,31 +22,63 @@ export interface HerCareerBodyProps {
|
||||
* QR-Code der Rückwandgrafik (Adresse: her-career.anelia-wolf.com, Auslieferung:
|
||||
* docs/SUBDOMAIN-HERCAREER.md).
|
||||
*
|
||||
* Kurz gehalten, weil hier jemand mit dem Handy am Stand steht: Hero (ink) →
|
||||
* 01 Messe-Special → 02 Themen → 03 Auf der Messe (nur `flags.showHerCareer`) →
|
||||
* 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.
|
||||
* 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, messe, contact } = content;
|
||||
const showFair = flags.showHerCareer;
|
||||
const { special, themen, contact } = content;
|
||||
const { coupon, formNotice } = special;
|
||||
|
||||
/* Der Kontaktblock zählt mit – er ist der letzte nummerierte Abschnitt der Seite. */
|
||||
const total = showFair ? "04" : "03";
|
||||
/* 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 (
|
||||
<>
|
||||
{/* Ohne Fakten-Stapel: der Hero trägt Headline, Subline und den einen Knopf. */}
|
||||
<ServiceHero content={content} />
|
||||
{/* 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 – der Grund, warum jemand den Code gescannt hat. */}
|
||||
{/* 01 Messe-Special – was Firmen zum Gutschein oben im Hero obendrauf bekommen.
|
||||
Wie eingelöst wird, steht im Abriss des Gutscheins („Stichwort herCAREER“);
|
||||
der Weg dorthin ist das Formular in Sektion 04. */}
|
||||
<Section id={special.id} theme="light" numeral="01" total={total} label={special.eyebrow}>
|
||||
<SectionHeading eyebrow={special.eyebrow} h2={special.h2} className={HEAD} />
|
||||
<Reveal className={`${BODY} mt-12 lg:mt-16`}>
|
||||
<Facts variant="table" title={special.title} items={special.rows} />
|
||||
</Reveal>
|
||||
<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. */}
|
||||
@@ -57,20 +88,10 @@ export function HerCareerBody({ content }: HerCareerBodyProps) {
|
||||
<Ledger variant="toc" rows={themen.rows} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 03 Auf der Messe – derselbe Termine-Ledger wie auf der Startseite, eine Quelle. */}
|
||||
{showFair && (
|
||||
<Section id={messe.id} theme="light" numeral="03" total={total} label={messe.eyebrow}>
|
||||
<SectionHeading eyebrow={messe.eyebrow} className={HEAD} />
|
||||
<div className={`${BODY} mt-12 lg:mt-16`}>
|
||||
<Ledger variant="termine" rows={messe.termine.rows} heading={messe.termine.heading} />
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
</ServiceLayout>
|
||||
|
||||
{/* Kontakt: derselbe Block wie Sektion 06 der Startseite, mit dem Formular. */}
|
||||
<ProcessContact content={contact} privacyHref={content.privacyHref} total={total} />
|
||||
<ProcessContact content={contact} privacyHref={content.privacyHref} total={total} coupon={contactCoupon} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,7 +19,8 @@ export interface ServiceHeroContent {
|
||||
h1: Headline;
|
||||
/** The italic word may also carry `.gold-text` (keynotes – the page's one gradient word). */
|
||||
h1GoldEm?: boolean;
|
||||
subline: string;
|
||||
/** Fließtext unter der Headline. Ohne Angabe trägt die Headline den Hero allein. */
|
||||
subline?: string;
|
||||
/** Haupt-Knopf. Ohne Angabe trägt der Hero nur Headline und Subline. */
|
||||
cta?: Cta;
|
||||
/** Fakten-Stapel oben rechts. Ohne Angabe (und ohne Foto) entfällt die Spalte. */
|
||||
@@ -34,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;
|
||||
}
|
||||
|
||||
@@ -41,8 +55,12 @@ 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. */
|
||||
@@ -51,6 +69,11 @@ export function ServiceHero({ content, photo, className }: ServiceHeroProps) {
|
||||
/* Die Messeseite trägt weder Fakten noch Foto – dann gibt es die rechte Spalte
|
||||
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
|
||||
@@ -71,25 +94,62 @@ 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>
|
||||
{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 && (
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -113,6 +183,28 @@ export function ContactForm({
|
||||
|
||||
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}>
|
||||
@@ -135,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}
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
import { site } from "@/lib/site";
|
||||
import { routesByLang } from "@/lib/i18n";
|
||||
import { assertNoPlaceholders, collectStrings, ph } from "@/lib/legal";
|
||||
import { assertNoPlaceholders, collectStrings } from "@/lib/legal";
|
||||
import type { HerCareerContent } from "@/content/her-career";
|
||||
import { homeEn } from "./home";
|
||||
|
||||
@@ -36,21 +36,42 @@ export const herCareerEn: HerCareerContent = {
|
||||
lang: "German & English",
|
||||
},
|
||||
eyebrow: "herCAREER Expo 2026 · MOC Munich",
|
||||
h1: { before: "Good to have you ", em: "here", after: "." },
|
||||
subline:
|
||||
"You scanned the code on the stand wall. Here is what we talk about at the booth – the fair special, my three topics and the shortest way to a conversation.",
|
||||
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",
|
||||
h2: { before: "Your advantage.", em: "", after: "" },
|
||||
title: "The special",
|
||||
rows: [
|
||||
{ label: "The offer", value: ph("Messe-Special: Was bekommt, wer den Code scannt?") },
|
||||
{ label: "Valid until", value: ph("Frist, z. B. Anfragen bis 30.11.2026") },
|
||||
{ label: "How to claim", value: ph("Bedingung, z. B. Stichwort in der Anfrage") },
|
||||
],
|
||||
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: {
|
||||
@@ -70,8 +91,8 @@ export const herCareerEn: HerCareerContent = {
|
||||
numeral: "02",
|
||||
title: "Training & Workshops",
|
||||
oneLiner:
|
||||
"B2B sales training: revenue growth through new business and existing customers, communication that fits the type across the team.",
|
||||
keywords: ["New business", "Existing customers", "Team communication"],
|
||||
"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) },
|
||||
},
|
||||
{
|
||||
@@ -84,17 +105,11 @@ export const herCareerEn: HerCareerContent = {
|
||||
],
|
||||
},
|
||||
|
||||
messe: {
|
||||
id: "auf-der-messe",
|
||||
eyebrow: "At the fair",
|
||||
termine: homeEn.termine,
|
||||
},
|
||||
|
||||
privacyHref: main(r.datenschutz),
|
||||
|
||||
contact: {
|
||||
...homeEn.contact,
|
||||
numeral: "04",
|
||||
numeral: "03",
|
||||
h2: { before: "Let us keep ", em: "talking", after: "." },
|
||||
form: { ...homeEn.contact.form, subject: "herCAREER Expo 2026", note: "", privacy: "" },
|
||||
},
|
||||
|
||||
+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" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+65
-30
@@ -18,9 +18,9 @@
|
||||
*/
|
||||
import { routes, site } from "@/lib/site";
|
||||
import type { Widen } from "./widen";
|
||||
import { assertNoPlaceholders, collectStrings, ph } from "@/lib/legal";
|
||||
import type { FactItem, Headline, TocRow } from "./home";
|
||||
import { contact as contactHome, termine } from "./home";
|
||||
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
|
||||
@@ -47,14 +47,15 @@ export const herCareer = {
|
||||
lang: "Deutsch & Englisch",
|
||||
},
|
||||
eyebrow: "herCAREER Expo 2026 · MOC München",
|
||||
h1: { before: "Schön, dass du ", em: "hier", after: " bist." } satisfies Headline,
|
||||
subline:
|
||||
"Du hast den Code an der Rückwand gescannt. Hier steht, worüber wir am Stand sprechen – das Messe-Special, meine drei Themen und der kürzeste Weg zu einem Gespräch.",
|
||||
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 – Standnummer und Termin trägt der
|
||||
Termine-Ledger weiter unten, die Themen der Abschnitt „Worüber wir reden“. */
|
||||
Themen standen dort ein zweites Mal. */
|
||||
folio: { next: { label: "Messe-Special", href: "#messe-special" } },
|
||||
|
||||
/* ---------- 01 Messe-Special (ivory) ---------- */
|
||||
@@ -62,15 +63,58 @@ export const herCareer = {
|
||||
special: {
|
||||
id: "messe-special",
|
||||
eyebrow: "Messe-Special",
|
||||
h2: { before: "Dein Vorteil.", em: "", after: "" } satisfies Headline,
|
||||
title: "Das Special",
|
||||
/* Wortlaut kommt von Anelia (docs/OFFENE-DATEN.md, Punkt 2.7). Bis dahin stehen die
|
||||
Zeilen sichtbar als „[…]“ auf der Seite, und der Build-Guard hält den Launch-Flip an. */
|
||||
rows: [
|
||||
{ label: "Das Angebot", value: ph("Messe-Special: Was bekommt, wer den Code scannt?") },
|
||||
{ label: "Gültig bis", value: ph("Frist, z. B. Anfragen bis 30.11.2026") },
|
||||
{ label: "So einlösen", value: ph("Bedingung, z. B. Stichwort in der Anfrage") },
|
||||
] satisfies readonly FactItem[],
|
||||
/* 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) ---------- */
|
||||
@@ -92,8 +136,8 @@ export const herCareer = {
|
||||
numeral: "02",
|
||||
title: "Trainings & Workshops",
|
||||
oneLiner:
|
||||
"B2B-Vertriebstraining: Umsatzwachstum durch Neukundengewinnung und Bestandskundenaufbau, typgerechte Kommunikation im Team.",
|
||||
keywords: ["Neukundengewinnung", "Bestandskunden", "Teamkommunikation"],
|
||||
"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) },
|
||||
},
|
||||
{
|
||||
@@ -107,16 +151,7 @@ export const herCareer = {
|
||||
] satisfies readonly TocRow[],
|
||||
},
|
||||
|
||||
/* ---------- 03 Auf der Messe (ivory) – nur mit flags.showHerCareer ---------- */
|
||||
|
||||
messe: {
|
||||
id: "auf-der-messe",
|
||||
eyebrow: "Auf der Messe",
|
||||
/* Eine Quelle für beide Seiten: derselbe Ledger steht auf der Startseite. */
|
||||
termine,
|
||||
},
|
||||
|
||||
/* ---------- 04 Kontakt (ink) ---------- */
|
||||
/* ---------- 03 Kontakt (ink) ---------- */
|
||||
|
||||
/** Ziel des Datenschutz-Links im Formular – absolut, wie jeder Link von hier weg. */
|
||||
privacyHref: main(routes.datenschutz),
|
||||
@@ -130,7 +165,7 @@ export const herCareer = {
|
||||
*/
|
||||
contact: {
|
||||
...contactHome,
|
||||
numeral: "04",
|
||||
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
|
||||
|
||||
+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 }];
|
||||
});
|
||||
}
|
||||
@@ -62,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
|
||||
|
||||
+2
-2
@@ -104,7 +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).",
|
||||
"Messe-Landingpage /her-career/: Angebot, Frist und Bedingung des Messe-Specials eintragen – in beiden Sprachen (src/content/her-career.ts, src/content/en/her-career.ts). Der QR-Code auf der Rückwandgrafik führt genau auf diesen Block.",
|
||||
"Trainings, Modul „HR-Teambuilding & Konfliktlösung“ (seit 02.10.2026): Dauer eintragen und den aus Anelias Nachricht formulierten Modultext von ihr freigeben lassen (src/content/trainings.ts, src/content/en/trainings.ts).",
|
||||
];
|
||||
@@ -84,8 +84,3 @@ export const routes = {
|
||||
} as const;
|
||||
|
||||
export type Route = (typeof routes)[keyof typeof routes];
|
||||
|
||||
export const mailto = {
|
||||
training: "mailto:info@anelia-wolf.com?subject=Training%20anfragen",
|
||||
keynote: "mailto:info@anelia-wolf.com?subject=Keynote%20anfragen",
|
||||
} as const;
|
||||
Reference in new issue
Block a user