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

+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;