Files
Wabenhain/src/components/ui.tsx
T
Nick SzaunigandClaude Opus 5.5 ee854bd77b feat(ui): Formular-Karten, Backup-Codes als Kacheln, Gutschein zum Kopieren
- Gemeinsame FormPanel-Karte fuer Passwort- und Zwei-Faktor-Seiten
- Zwei-Faktor-Code: grosses, zentriertes Ziffernfeld
- Backup-Codes als nummerierte Kacheln statt langer Liste
- Newsletter-Bestaetigung: Willkommensrabatt als Gutschein mit
  gestricheltem Rand und Kopier-Knopf (bestaetigt "Kopiert")

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 13:17:58 +02:00

185 lines
6.4 KiB
TypeScript

import Link from "next/link";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { HexMark, HoneycombPattern } from "@/components/honeycomb";
export function Container({
id,
className = "",
children,
}: {
id?: string;
className?: string;
children: ReactNode;
}) {
return (
<div id={id} className={`mx-auto w-full max-w-6xl px-6 md:px-10 ${className}`}>
{children}
</div>
);
}
type ButtonVariant = "primary" | "secondary" | "ghost" | "inverted" | "accent";
// Signatur-Hover: der Knopf fuellt sich von unten wie ein Glas — beim
// Primaerknopf mit Honig, beim Sekundaerknopf mit Waldgruen. Umgesetzt als
// Hintergrund-Verlauf, dessen Hoehe waechst (kein Layout-Eigenschafts-
// Tweening, kein zusaetzliches Element).
const fillBase =
"bg-no-repeat bg-bottom bg-[length:100%_0%] hover:bg-[length:100%_100%] focus-visible:bg-[length:100%_100%] motion-reduce:transition-colors";
const buttonStyles: Record<ButtonVariant, string> = {
primary: `${fillBase} bg-forest bg-[linear-gradient(var(--color-honey-dark),var(--color-honey-dark))] text-cream border border-forest hover:border-honey-dark`,
secondary: `${fillBase} bg-transparent bg-[linear-gradient(var(--color-forest),var(--color-forest))] text-forest border border-forest hover:text-cream focus-visible:text-cream`,
ghost: "bg-transparent text-ink border border-ink/20 hover:border-ink hover:bg-ink/[0.03]",
// Fuer dunkle Flaechen (Hero, Waldgruen-Abschnitte, Menue): Creme-Rahmen,
// fuellt sich mit Creme, Text wechselt auf Waldgruen.
inverted: `${fillBase} bg-transparent bg-[linear-gradient(var(--color-cream),var(--color-cream))] text-cream border border-cream hover:text-forest focus-visible:text-forest`,
// Hauptaktion auf dunklen Flaechen, wo Waldgruen im Grund verschwinden wuerde.
accent: `${fillBase} bg-honey-dark bg-[linear-gradient(var(--color-cream),var(--color-cream))] text-cream border border-honey-dark hover:text-forest hover:border-cream focus-visible:text-forest`,
};
const buttonBase =
"inline-flex items-center justify-center gap-2 px-7 py-3 text-sm tracking-wide-caps transition-[color,background-size,border-color,transform,box-shadow] duration-500 ease-[var(--ease-arrive)] hover:shadow-[0_10px_24px_-14px_rgba(36,53,43,0.55)] active:scale-[0.97] active:duration-100";
interface LinkButtonProps extends ComponentPropsWithoutRef<typeof Link> {
variant?: ButtonVariant;
}
export function LinkButton({
variant = "primary",
className = "",
children,
...props
}: LinkButtonProps) {
return (
<Link
className={`${buttonBase} ${buttonStyles[variant]} ${className}`}
{...props}
>
{children}
</Link>
);
}
interface ButtonProps extends ComponentPropsWithoutRef<"button"> {
variant?: ButtonVariant;
}
export function Button({
variant = "primary",
className = "",
children,
...props
}: ButtonProps) {
return (
<button
className={`${buttonBase} disabled:cursor-not-allowed disabled:opacity-50 disabled:active:scale-100 disabled:hover:bg-[length:100%_0%] disabled:hover:shadow-none ${buttonStyles[variant]} ${className}`}
{...props}
>
{children}
</button>
);
}
export function SectionHeading({
eyebrow,
title,
subtitle,
align = "left",
className = "",
level = "h2",
tone = "default",
}: {
eyebrow?: string;
title: string;
subtitle?: string;
align?: "left" | "center";
className?: string;
/** "h1" fuer Seiten, auf denen diese Komponente die einzige/primaere Ueberschrift ist (SEO-Heading-Hierarchie). Default h2, da die meisten Verwendungen einzelne Abschnitte innerhalb einer Seite mit eigenem h1 sind. */
level?: "h1" | "h2";
/** "inverted" fuer Abschnitte auf dunklem Grund (z.B. bg-forest) — steuert die Eyebrow-Farbe fuer ausreichenden Kontrast. */
tone?: "default" | "inverted";
}) {
const Heading = level;
return (
<div
className={`${align === "center" ? "text-center mx-auto" : "text-left"} max-w-2xl ${className}`}
>
{eyebrow ? (
<p
className={`flex items-center gap-2 tracking-wide-caps text-xs mb-4 ${
align === "center" ? "justify-center" : ""
} ${tone === "inverted" ? "text-honey-light" : "text-honey-dark"}`}
>
<HexMark className="h-2.5 w-2.5 flex-shrink-0" />
{eyebrow}
</p>
) : null}
<Heading
className={`text-4xl md:text-5xl leading-[1.05] tracking-[-0.01em] ${
tone === "inverted" ? "text-cream" : "text-forest"
}`}
>
{title}
</Heading>
{subtitle ? (
<p
className={`mt-5 max-w-[60ch] leading-relaxed ${align === "center" ? "mx-auto" : ""} ${
tone === "inverted" ? "text-cream/75" : "text-ink/70"
}`}
>
{subtitle}
</p>
) : null}
</div>
);
}
/**
* Bild mit versetztem Honigrahmen dahinter — gibt den Bildspalten der
* Startseite Tiefe, statt sie flach neben den Text zu legen. Der Rahmen
* weicht beim Hover etwas zurueck, das Bild bleibt ruhig.
*/
export function FramedMedia({
children,
side = "right",
className = "",
}: {
children: ReactNode;
/** Richtung, in die der Rahmen versetzt ist. */
side?: "left" | "right";
className?: string;
}) {
return (
<div className={`group/frame relative isolate ${className}`}>
<span
aria-hidden="true"
className={`absolute inset-0 -z-10 border border-honey/60 transition-transform duration-700 ease-[var(--ease-arrive)] ${
side === "right"
? "translate-x-4 translate-y-4 group-hover/frame:translate-x-6 group-hover/frame:translate-y-6"
: "-translate-x-4 translate-y-4 group-hover/frame:-translate-x-6 group-hover/frame:translate-y-6"
}`}
/>
{children}
</div>
);
}
/**
* Karte fuer die kurzen Formularseiten (Passwort, Zwei-Faktor, ...): hebt
* das Formular von der Flaeche ab, mit Wabenraster in der oberen Ecke als
* leiser Markengruss.
*/
export function FormPanel({ children, className = "" }: { children: ReactNode; className?: string }) {
return (
<div
className={`relative overflow-hidden border border-ink/10 bg-background px-7 py-9 shadow-[0_24px_50px_-34px_rgba(36,53,43,0.45)] sm:px-10 sm:py-11 ${className}`}
>
<div aria-hidden="true" className="pointer-events-none absolute -right-8 -top-8 h-40 w-40">
<HoneycombPattern className="text-honey/30" size={14} />
</div>
<div className="relative">{children}</div>
</div>
);
}