Handy ansicht und website ansicht für den Qr gemacht
This commit is contained in:
knso committed 2026-09-10 20:06:42 +02:00
1 parent 9725e0229d
commit c09f674653
15 files changed
+639 -103

No files matched your search

+39
View File
@@ -30,6 +30,13 @@
--gold-700: #a37a2c;
--gold-900: #6e501a;
/* Rabatt-Rot. Die einzige Farbe der Seite außerhalb von Tinte, Elfenbein, Bein und
Gold – und zwar, weil Rot „reduziert“ bedeutet und Gold das nicht sagen kann.
Warm und tief gewählt, damit sie neben dem Gold nicht kreischt. Sie hat genau
eine Aufgabe: das Reduziert-Fähnchen am Scroll-Fortschritt (<ScrollProgress/>).
Elfenbein darauf: 5,5:1. */
--sale: #b3321f;
/* Gradients (fixed light direction: top-left) */
--gold-gradient: linear-gradient(135deg, #f4e5b8 0%, #e6c97a 22%, #c9a24d 48%, #a37a2c 74%, #6e501a 100%);
--gold-gradient-text: linear-gradient(100deg, #f4e5b8 0%, #e6c97a 32%, #c9a24d 62%, #a37a2c 100%);
@@ -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,37 @@ 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;
}
/* Unter 640 px steht der Zusatz unter der Wortmarke, und dort trägt der Kopf kein
Monogramm mehr: Mit ihm 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. Betrifft nur den Kopf der Messeseite auf dem Telefon: Der
Fuß behält sein Monogramm, alle anderen Seiten auch. */
@media (max-width: 639px) {
body:has(main #messe-special) header .wordmark__mark {
display: none;
}
/* Und 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,
+45
View File
@@ -0,0 +1,45 @@
import { cn } from "@/lib/utils";
import { HerCareerMark } from "./HerCareerMark";
export interface CollabProps {
/** Was ein Screenreader hört – der Zusatz ist eine Angabe, kein Bild ohne Inhalt. */
label: string;
className?: string;
}
/**
* Kollaborationszusatz neben der Wortmarke: `× herCAREER`.
*
* Das Malzeichen ist die übliche Kurzschrift für „zusammen mit“ (Marke × Marke)
* und trägt hier die ganze Aussage – deshalb ist es dekorativ und der Sinn steht
* im `aria-label` der Gruppe, nicht in einem Zeichen, das ein Screenreader als
* „mal“ vorlesen würde.
*
* Sichtbar ist der Zusatz nur auf der Messeseite; eingeschaltet wird er über
* `[data-collab]` in globals.css, weil der Kopf global in <Shell/> hängt und von
* dort die Route nicht kennt. Anelia arbeitet FÜR die herCAREER – sie ist nicht
* dauerhaft mit ihr verbunden, und auf dem Impressum hätte die fremde Marke
* nichts zu suchen.
*/
export function Collab({ label, className }: CollabProps) {
return (
<span
data-collab=""
role="img"
aria-label={label}
className={cn("items-center gap-[6px] text-fg sm:gap-2.5", className)}
>
{/* Etwas kleiner als die Marke daneben und gedämpft: Das Malzeichen verbindet,
es soll nicht mit einer der beiden Marken um Aufmerksamkeit streiten. */}
<span aria-hidden="true" className="font-sans text-[0.75rem] font-light leading-none text-fg-muted">
×
</span>
{/* Zweizeilige Marke: Die Gesamthöhe muss über der Versalhöhe von „ANELIA WOLF“
daneben liegen, sonst ist jede der beiden Zeilen nur halb so groß wie die
Wortmarke und der Zusatz wird unleserlich. */}
<HerCareerMark className="h-[18px] w-auto sm:h-[22px]" />
</span>
);
}
export default Collab;
+43
View File
@@ -0,0 +1,43 @@
/**
* Wortmarke der herCAREER Expo – Kollaborationszusatz im Kopf der Messeseite.
*
* Aus `herCAREER_Logo-1.svg` (Illustrator-Export) übernommen, mit drei
* Eingriffen, ohne die sie hier nicht funktioniert:
*
* 1. Die Ebene „Hilfslinien“ ist raus. Sie enthielt ein Rechteck über die
* ganze Artboard-Fläche mit `stroke: #fff` – auf dem tintenfarbenen Kopf
* hätte die Marke in einem weißen Kasten gestanden.
* 2. `fill="currentColor"` statt der Illustrator-Vorgabe Schwarz: Auf dunklem
* Grund war die Marke sonst unsichtbar, und so nimmt sie die Schriftfarbe
* ihrer Umgebung an – hell im Kopf, tinte im geöffneten Menü.
* 3. Die viewBox ist auf die Zeichnung beschnitten (vorher 0 0 1424.7 702.5
* mit rund 12 % Luft oben und unten). Nur so lässt sich die Höhe optisch
* auf die Wortmarke daneben abstimmen; der Abstand ringsum kommt aus dem
* Layout, nicht aus der Datei.
*
* Die Pfade selbst sind unverändert – es ist eine fremde Marke.
*/
export function HerCareerMark({ className }: { className?: string }) {
return (
<svg
viewBox="68.5 82.9 1273.3 536.8"
fill="currentColor"
aria-hidden="true"
focusable="false"
className={className}
>
<path d="M175.7,182.1c-33.2,0-60.8,27.6-60.8,65.6v107.2h-32V82.9h32v105.2c9.6-23.6,35.2-38,65.6-38,45.2,0,77.6,34.8,77.6,83.2v121.6h-32v-119.2c0-31.2-20.4-53.6-50.4-53.6h0Z" />
<path d="M279.7,255.7c0-61.2,40-105.6,96-105.6s94.4,46.4,94.4,110.4,0,3.6-.4,6.8h-154.8c4.8,35.6,29.6,60.4,62.4,60.4s50.4-16.8,55.2-40l36,11.2c-8.8,35.2-46.4,60.8-91.2,60.8s-97.6-43.6-97.6-104h0ZM435.7,236.1c-6.4-35.2-32.8-54.8-61.6-54.8s-52.4,22-58.4,54.8h120Z" />
<path d="M494.9,154.9h27.2l4,33.2c9.6-23.2,28.8-38,52-38s15.6,2,22.4,4.8l-1.6,37.6c-8-4.8-17.6-6.4-24-6.4-28,0-48,22.4-48,53.6v115.2h-32v-200h0Z" />
<path d="M166.9,619.7c-54.8,0-98.4-42-98.4-104.8s42.4-104.8,96.8-104.8,82.4,29.6,90.4,70.4l-35.2,11.2c-3.2-28.8-26-50.4-55.2-50.4s-62.4,30.4-62.4,73.6,27.2,72.8,62.4,72.8,54.4-20.8,56.8-52.8l36.8,11.2c-7.2,43.2-45.6,73.6-92,73.6Z" />
<path d="M323.7,506.1l61.6-11.2c7.6-1.2,12.8-5.6,12.8-11.2v-1.6c0-25.2-18.4-43.2-44-43.2s-44,20-44.8,48l-35.2-5.6c4-41.2,36.4-71.2,79.2-71.2s76.8,28.4,76.8,72v92.8l.8,40h-26.4l-5.2-32.8c-11.6,23.2-35.6,37.6-67.6,37.6s-64-24-64-57.6,19.2-49.2,56-56h0ZM384.5,522.1l-52.8,9.6c-19.2,3.6-28.8,15.6-28.8,30.4s12.8,26.4,35.2,26.4c33.2,0,60-24.8,60-56.8v-12.8c-2.8,1.2-8,2-13.6,3.2h0Z" />
<path d="M462.9,414.9h27.2l4,33.2c9.6-23.2,28.8-38,52-38s15.6,2,22.4,4.8l-1.6,37.6c-8-4.8-17.6-6.4-24-6.4-28,0-48,22.4-48,53.6v115.2h-32v-200h0Z" />
<path d="M570.9,515.7c0-61.2,40-105.6,96-105.6s94.4,46.4,94.4,110.4,0,3.6-.4,6.8h-154.8c4.8,35.6,29.6,60.4,62.4,60.4s50.4-16.8,55.2-40l36,11.2c-8.8,35.2-46.4,60.8-91.2,60.8s-97.6-43.6-97.6-104h0ZM726.9,496.1c-6.4-35.2-32.8-54.8-61.6-54.8s-52.4,22-58.4,54.8h120Z" />
<path d="M774.9,515.7c0-61.2,40-105.6,96-105.6s94.4,46.4,94.4,110.4,0,3.6-.4,6.8h-154.8c4.8,35.6,29.6,60.4,62.4,60.4s50.4-16.8,55.2-40l36,11.2c-8.8,35.2-46.4,60.8-91.2,60.8s-97.6-43.6-97.6-104h0ZM930.9,496.1c-6.4-35.2-32.8-54.8-61.6-54.8s-52.4,22-58.4,54.8h120Z" />
<path d="M990.4,415h27.2l4,33.2c9.6-23.2,28.8-38,52-38s15.6,2,22.4,4.8l-1.6,37.6c-8-4.8-17.6-6.4-24-6.4-28,0-48,22.4-48,53.6v115.2h-32v-200.1h0Z" />
<circle cx="1237" cy="514.9" r="104.8" />
</svg>
);
}
export default HerCareerMark;
+14 -4
View File
@@ -5,7 +5,7 @@ import { Reveal } from "@/components/motion/Reveal";
import { Rule } from "@/components/motion/Rule";
import { ContactLinks } from "@/components/ui/ContactLinks";
import { Eyebrow, EYEBROW_TEXT } from "@/components/ui/Eyebrow";
import { ContactForm } from "@/components/ui/ContactForm";
import { ContactForm, type ContactCoupon } 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,21 @@ 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;
}
export function ProcessContact({ content: contact = contactDe, privacyHref, total }: ProcessContactProps = {}) {
export function ProcessContact({ content: contact = contactDe, privacyHref, total, coupon }: 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 +65,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} />
</Reveal>
{/* Contact block: under the Ablauf up to 1280 (the CTA button is 354 px
+19 -2
View File
@@ -7,6 +7,7 @@ import { flags, legal as legalDe, nav as navDe } from "@/lib/site";
import { routesByLang, type Lang } from "@/lib/i18n";
import { 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">
+100 -12
View File
@@ -1,7 +1,7 @@
"use client";
import { useLayoutEffect, useRef } from "react";
import { gsap, EASE, MM } from "@/lib/gsap";
import { gsap, ScrollTrigger, EASE, MM } from "@/lib/gsap";
/**
* 1 px `--accent` line directly under the header (the header writes
@@ -9,34 +9,122 @@ import { gsap, EASE, MM } from "@/lib/gsap";
*
* Scrubbed by ScrollTrigger, not by a second animation runtime: this was the
* only reason `useScroll` (framer-motion) shipped on every route.
*
* ---------------------------------------------------------------------------
* Reduziert-Fähnchen
*
* Trägt eine Seite einen Gutschein, hängt an der Spitze des Balkens ein rotes
* Fähnchen mit dem Nachlass – dort, wo der Leser seine eigene Position abliest.
* Es blendet sich erst ein, wenn der Abschnitt mit dem Formular ins Bild kommt:
* Bis dahin sagt der Balken „so weit bist du“, ab dort zusätzlich „und hier wird
* er eingelöst“.
*
* Diese Komponente hängt in <Shell/> und läuft auf JEDER Seite – auf dem
* Impressum darf kein „−20 %“ kleben. Statt einen Zustand durch das Layout zu
* fädeln (was der App-Router nicht hergibt), liest sie den Text aus dem DOM:
* <ProcessContact/> schreibt ihn als `data-sale` an den Kontaktabschnitt, wenn
* ein Gutschein übergeben wurde. Denselben Weg geht schon die Regel in
* globals.css, die den Kopf-CTA auf der Messeseite ausblendet.
*/
export function ScrollProgress() {
const bar = useRef<HTMLDivElement>(null);
const tag = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const el = bar.current;
if (!el) return;
const mm = gsap.matchMedia();
mm.add({ reduce: MM.reduce, ok: MM.ok }, () => {
mm.add({ reduce: MM.reduce, ok: MM.ok }, (ctx) => {
/* Reduced motion too: the bar reports position, it does not animate for effect –
the scrub follows the reader's own scrolling either way. */
/* Die Strecke wird gemessen, nicht aus einem Element abgeleitet.
Vorher stand hier `trigger: document.documentElement` mit „top top“ bis
„bottom bottom“. <html> trägt aber `h-full`, ist also genau einen Bildschirm
hoch – Anfang und Ende fielen zusammen, die Strecke war null und der Balken
blieb auf 0. Auf dem Desktop fiel das nicht auf, weil Lenis dort seine eigene
Position meldet; auf Mobil ist Lenis aus (SmoothScroll.tsx überlässt Touch dem
nativen Scrollen), und der Balken zeichnete überhaupt nicht.
`maxScroll` fragt den tatsächlichen Scrollweg ab, `invalidateOnRefresh` rechnet
ihn neu, wenn die Seite ihre Höhe ändert. */
const scrub = {
start: 0,
end: () => ScrollTrigger.maxScroll(window),
scrub: true,
invalidateOnRefresh: true,
} as const;
gsap.set(el, { scaleX: 0, transformOrigin: "0 50%" });
gsap.to(el, {
scaleX: 1,
ease: EASE.none,
scrollTrigger: { trigger: document.documentElement, start: "top top", end: "bottom bottom", scrub: true },
gsap.to(el, { scaleX: 1, ease: EASE.none, scrollTrigger: scrub });
/* Das Fähnchen steht immer im Dokument und wird hier beschriftet, statt seinen
Text durch React-State zu schicken: Ein `setState` im Effekt wäre ein zweiter
Durchlauf für eine Angabe, die sich nach dem ersten Rendern nie mehr ändert
(und react-hooks verbietet es zu Recht). Ohne `data-sale` bleibt es leer und
durchsichtig – auf dem Impressum klebt kein „−20 %“. */
const flag = tag.current;
const anchor = document.querySelector<HTMLElement>("main [data-sale]");
if (!flag || !anchor?.dataset.sale) return;
const [value, stub] = flag.querySelectorAll<HTMLElement>("[data-cell]");
value.textContent = anchor.dataset.sale;
stub.textContent = anchor.dataset.saleStub ?? "";
/* Der Balken selbst wird über `scaleX` gezogen – ein Kind darin würde
mitgestaucht. Das Fähnchen läuft deshalb eigenständig über dieselbe
Strecke; `xPercent: -100` legt seine rechte Kante auf die Spitze, damit
es am Ende nicht über den Fensterrand hinausragt. Der Endwert ist eine
Funktion, `invalidateOnRefresh` rechnet ihn beim Größenwechsel neu. */
gsap.set(flag, { opacity: 0, xPercent: -100 });
gsap.fromTo(
flag,
{ x: 0 },
{ x: () => window.innerWidth, ease: EASE.none, scrollTrigger: { ...scrub } },
);
/* Nur `opacity` – nichts, was Fokus oder Tab-Reihenfolge anfasst. Bei
reduzierter Bewegung ohne Überblendung, aber mit demselben Ergebnis. */
const fade = ctx.conditions?.reduce ? 0 : 0.4;
ScrollTrigger.create({
trigger: anchor,
start: "top 90%",
onEnter: () => gsap.to(flag, { opacity: 1, duration: fade, ease: EASE.out }),
onLeaveBack: () => gsap.to(flag, { opacity: 0, duration: fade, ease: EASE.out }),
});
});
return () => mm.revert();
}, []);
return (
<div
ref={bar}
aria-hidden="true"
className="pointer-events-none fixed inset-x-0 z-50 h-px origin-left scale-x-0 bg-accent transition-opacity duration-300 [[data-menu-open]_&]:opacity-0"
style={{ top: "var(--header-h, 60px)" }}
/>
<>
<div
ref={bar}
aria-hidden="true"
className="pointer-events-none fixed inset-x-0 z-50 h-px origin-left scale-x-0 bg-accent transition-opacity duration-300 [[data-menu-open]_&]:opacity-0"
style={{ top: "var(--header-h, 60px)" }}
/>
{/* Ein Gutschein im Kleinstformat: runde Ecken wie der große, und quer durch die
Mitte dieselbe Reißkante – hier hochkant, weil das Fähnchen breiter als hoch
ist. Rechts hinter der Perforation sitzt der Abriss.
Anders als beim großen Gutschein sind es keine gestanzten Kerben, sondern nur
die gestrichelte Linie: Eine Kerbe muss den Grund dahinter zeigen, und dieses
Fähnchen schwebt über der Seite – mal über Tinte, mal über Elfenbein. Eine
Kerbe in fester Farbe wäre auf einem von beiden Gründen ein Fleck.
Rot, weil Rot reduziert heißt und Gold das nicht sagen kann. `aria-hidden` wie
der Balken: Dass der Vorteil an der Anfrage hängt, steht als Text im Formular
darunter, nicht nur in dieser Farbe. Leer und durchsichtig, solange die Seite
keinen Gutschein trägt. */}
<div
ref={tag}
aria-hidden="true"
className="pointer-events-none fixed left-0 z-50 flex -translate-y-1/2 items-stretch overflow-hidden rounded-[5px] bg-sale font-mono text-[10px] uppercase leading-none tracking-[0.12em] text-ivory opacity-0 [[data-menu-open]_&]:!opacity-0"
style={{ top: "var(--header-h, 60px)" }}
>
<span data-cell="" className="px-2 py-[5px]" />
<span className="border-l border-dashed border-ivory/55" />
<span data-cell="" className="px-[7px] py-[5px]" />
</div>
</>
);
}
+42 -30
View File
@@ -3,11 +3,9 @@ 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 +21,55 @@ 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}>
<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 +79,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} />
</>
);
}
+40 -7
View File
@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { Container } from "@/components/layout/Container";
import { Folio } from "@/components/layout/Folio";
@@ -18,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,13 @@ 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;
className?: string;
}
@@ -41,8 +50,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, 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 +64,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 +89,40 @@ 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}
</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 && (
+64 -1
View File
@@ -5,6 +5,7 @@ import { cn } from "@/lib/utils";
import { site, routes } from "@/lib/site";
import { contact as contactDe, type HomeContent } from "@/content/home";
import { BUTTON_BASE, BUTTON_PRIMARY } from "@/components/ui/Button";
import { COUPON_SURFACE, CouponPerforation } from "@/components/ui/Coupon";
import { TextLink } from "@/components/ui/TextLink";
/**
@@ -26,18 +27,54 @@ 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;
}
type Status = "idle" | "sending" | "sent" | "error";
export function ContactForm({
className,
content: form = contactDe.form,
privacyHref = routes.datenschutz,
coupon,
}: {
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;
}) {
const uid = useId();
const [status, setStatus] = useState<Status>("idle");
@@ -53,11 +90,15 @@ 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");
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,
// Honeypot: Menschen sehen das Feld nicht, Bots füllen es aus.
website: value("website"),
@@ -113,6 +154,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}>
+129
View File
@@ -0,0 +1,129 @@
import { cn } from "@/lib/utils";
export interface CouponContent {
/** Mono-Caps über dem Satz, z. B. „Ihr Messe-Special“. */
kicker: string;
/** Der Nachlass in Display-Ziffern, z. B. „−20“. */
rate: string;
/** Einheit neben der Zahl, z. B. „%“. */
unit: string;
/** Worauf er gilt – der eine Satz, der den Gutschein trägt. */
on: string;
/** Was drinsteckt. Eine Zeile je Leistung, mit goldenem Strich davor. */
includes: readonly string[];
/** Frist und Stichwort im Abriss unter der Perforation. */
valid: { label: string; value: string };
code: { label: string; value: string };
}
export interface CouponProps {
content: CouponContent;
className?: string;
}
const MONO = "font-mono text-xs tracking-[0.06em] tabular-nums";
/**
* Der Grund eines Gutscheins: blasses Gold, goldene Haarlinie, runde Ecken.
*
* Exportiert, weil zwei Bauteile denselben Gegenstand darstellen – der große
* Gutschein im Hero und der kleine Hinweis über dem Kontaktformular, dass der
* Vorteil schon anhängt. Sie müssen gleich aussehen, also gibt es die Werte
* einmal. Die runden Ecken sind die ausdrückliche Ausnahme von „Radius 0“
* (Kundenwunsch 10.09.2026) und gelten nur für diese beiden Stellen.
*/
export const COUPON_SURFACE = "rounded-[14px] border border-gold-700 bg-gold-100 text-ink";
/**
* Die Reißkante: gestrichelte Haarlinie über die volle Breite, an beiden Enden
* eine Kerbe. Die Kerben sind auf die Ecke gestellte Quadrate in der Grundfarbe
* des umgebenden Abschnitts (`bg-bg`) – sie sehen aus wie gestanzt und kommen
* ohne einen einzigen Eckenradius aus. Auf hellem Grund elfenbein, auf dunklem
* tintenschwarz, ganz von selbst.
*/
export function CouponPerforation() {
return (
<div aria-hidden="true" className="relative border-t border-dashed border-gold-700">
<span className="absolute left-0 top-0 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rotate-45 bg-bg" />
<span className="absolute right-0 top-0 h-3.5 w-3.5 translate-x-1/2 -translate-y-1/2 rotate-45 bg-bg" />
</div>
);
}
/**
* Der Messe-Gutschein (/her-career/, Sektion 01).
*
* Er soll als Gutschein erkennbar sein, ohne aus dem Haus-Stil zu fallen –
* also kein abgerundetes Ticket mit Schatten und Fremdschrift, sondern die
* drei Merkmale, die ein Gutschein wirklich braucht, in der Sprache der Seite:
*
* 0. Runde Ecken. Die einzige Stelle der Website mit einem Eckenradius
* (Projektregel ist sonst Radius 0, ausdrückliche Ausnahme vom 10.09.2026):
* Der Gutschein stellt einen Gegenstand dar, kein Interface-Element, und
* ein Gutschein mit scharfen Ecken sieht aus wie eine Tabelle. Der Radius
* bleibt auf dieses eine Bauteil beschränkt – Knöpfe, Rahmen und Tafeln
* ringsum sind weiter eckig.
* 1. Ein eigener Grund. `--gold-100` ist der blasse Goldton der Palette –
* bis hierher nirgends als Fläche benutzt und damit frei für genau das
* eine Objekt, das sich vom Elfenbein abheben soll. Die Schrift darauf ist
* Tinte, nicht Gold: Gold auf Gold liest sich nicht, und goldene Schrift
* bleibt ohnehin dem dunklen Grund vorbehalten.
* 2. Eine Reißkante. Gestrichelte Haarlinie über die volle Breite, an beiden
* Enden eine Kerbe. Die Kerben sind auf die Ecke gestellte Quadrate in der
* Grundfarbe der Sektion – gestanzt aussehende Löcher ohne einen einzigen
* Eckenradius.
* 3. Ein Abriss. Unter der Kante stehen Frist und Stichwort, das Stichwort im
* eigenen Kästchen wie ein Stempelfeld.
*
* Der Block steht immer auf `data-theme="light"`; die Kerben nehmen mit `bg-bg`
* den Grund der Sektion an.
*/
export function Coupon({ content, className }: CouponProps) {
const { kicker, rate, unit, on, includes, valid, code } = content;
return (
<div className={cn("relative", COUPON_SURFACE, className)}>
<div className="p-7 sm:p-12">
<p className={cn(MONO, "uppercase tracking-[0.16em] text-gold-900")}>{kicker}</p>
{/* Zahl und Einheit auf einer Grundlinie; die Einheit misst sich an der Zahl. */}
<p className="mt-5 flex items-baseline gap-[0.1em] font-display font-normal leading-[0.82] tracking-[-0.02em] [font-variant-numeric:lining-nums]">
<span className="text-[clamp(4.5rem,2.2rem+9vw,8.5rem)]">{rate}</span>
<span className="text-[clamp(1.75rem,1rem+3vw,3rem)]">{unit}</span>
</p>
<p className="mt-6 max-w-[22ch] font-display text-[clamp(1.75rem,1.35rem+1.5vw,2.75rem)] font-normal leading-[1.08] tracking-[-0.01em] [text-wrap:balance]">
{on}
</p>
{/* Dieselben Schienen wie <Facts variant="stack"/>: Strich | Text. */}
<ul className={cn(MONO, "mt-9 grid gap-y-3")}>
{includes.map((item) => (
<li key={item} className="grid grid-cols-[1.5rem_1fr] gap-x-3">
<span aria-hidden="true" className="mt-[0.55em] h-px w-6 self-start bg-gold-900" />
<span>{item}</span>
</li>
))}
</ul>
</div>
<CouponPerforation />
{/* Der Abriss: Frist links, Stichwort rechts im Stempelfeld. */}
<dl className={cn(MONO, "flex flex-wrap items-center justify-between gap-x-10 gap-y-4 p-7 sm:px-12 sm:py-8")}>
<div className="flex items-baseline gap-2">
<dt className="text-gold-900">{valid.label}</dt>
<dd>{valid.value}</dd>
</div>
<div className="flex items-center gap-3">
<dt className="text-gold-900">{code.label}</dt>
{/* Kein `uppercase`: „herCAREER“ ist gemischt geschrieben, Versalien
machten daraus ein „HERCAREER“, das die Messe so nicht führt. */}
<dd className="rounded-[6px] border border-gold-700 px-3 py-1.5 tracking-[0.14em]">{code.value}</dd>
</div>
</dl>
</div>
);
}
export default Coupon;
+5
View File
@@ -18,6 +18,11 @@ export const chrome = {
sub: "Sales Mentoring & Potential Development",
ariaLabel: "Anelia Wolf – Startseite",
},
/* Kollaborationszusatz neben der Wortmarke. Sichtbar ist nur „× herCAREER“;
was das Malzeichen meint, hört ein Screenreader hier ausgeschrieben. */
collab: {
ariaLabel: "In Zusammenarbeit mit der herCAREER Expo",
},
/* Der Sprunglink ist das erste fokussierbare Element im Dokument – er muss die
Sprache der Seite sprechen, sonst liest ihn ein Screenreader deutsch auf einer
englischen Seite vor. */
+3
View File
@@ -26,6 +26,9 @@ export const chromeEn: Chrome = {
sub: "Sales Mentoring & Potential Development",
ariaLabel: "Anelia Wolf – home",
},
collab: {
ariaLabel: "In collaboration with herCAREER Expo",
},
skipLink: "Skip to content",
header: {
navAriaLabel: "Main navigation",
+33 -18
View File
@@ -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: {
@@ -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: "" },
},
+63 -28
View File
@@ -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) ---------- */
@@ -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
-1
View File
@@ -106,5 +106,4 @@ export const CONTENT_TODO: string[] = [
"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, 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.",
];