QR Web
Handy ansicht und website ansicht für den Qr gemacht
This commit is contained in:
1 parent
9725e0229d
commit
c09f674653
15 files changed
+639
-103
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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,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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
@@ -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: {
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
];
|
||||
Reference in new issue
Block a user