- 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]>
185 lines
6.4 KiB
TypeScript
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>
|
|
);
|
|
}
|