Kumasy-Marketing/app/components/ui.tsx
2026-09-20 01:52:28 +02:00

390 lines
12 KiB
TypeScript

"use client";
import { useEffect, useId, useState, type ReactNode } from "react";
import { AnimatePresence, motion } from "motion/react";
import { Check, Menu, Plus, X } from "lucide-react";
import { Logo } from "./logo";
import { useReducedMotion } from "./motion";
/* ------------------------------------------------------------------ *
* Eyebrow label pill
* ------------------------------------------------------------------ */
const WASH = {
amber: "bg-amber-soft/70 text-[#8a6d00] border-amber/30",
sky: "bg-sky-wash/70 text-sky-ink border-sky-ink/15",
mint: "bg-mint-wash/70 text-mint-ink border-mint-ink/15",
peach: "bg-peach-wash/70 text-peach-ink border-peach-ink/15",
} as const;
export function Eyebrow({
children,
wash = "amber",
className = "",
}: {
children: ReactNode;
wash?: keyof typeof WASH;
className?: string;
}) {
return (
<span
className={`inline-flex items-center rounded-full border px-2.5 py-[4px] text-caption font-semibold uppercase backdrop-blur-sm ${WASH[wash]} ${className}`}
>
{children}
</span>
);
}
/* ------------------------------------------------------------------ *
* Primary CTA — solid amber, the brand's one loud surface
* ------------------------------------------------------------------ */
export function BrandButton({
children,
href,
type,
className = "",
disabled,
}: {
children: ReactNode;
href?: string;
type?: "button" | "submit";
className?: string;
disabled?: boolean;
}) {
const reduced = useReducedMotion();
const classes = `group relative inline-flex items-center justify-center gap-2 overflow-hidden rounded-buttons px-[22px] py-[11px] text-body font-semibold text-ink shadow-amber transition-shadow duration-300 disabled:pointer-events-none disabled:opacity-55 ${className}`;
const style = { background: "var(--gradient-amber)" };
const inner = (
<>
{/* Sheen sweep on hover — the glass highlight passing over. */}
<span
aria-hidden
className="pointer-events-none absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/55 to-transparent transition-transform duration-700 ease-out-soft group-hover:translate-x-full motion-reduce:hidden"
/>
<span className="relative flex items-center gap-2">{children}</span>
</>
);
if (href) {
return (
<motion.a
href={href}
className={classes}
style={style}
whileHover={reduced ? undefined : { y: -2, scale: 1.015 }}
whileTap={reduced ? undefined : { y: 0, scale: 0.985 }}
transition={{ type: "spring", stiffness: 420, damping: 24 }}
>
{inner}
</motion.a>
);
}
return (
<motion.button
type={type ?? "button"}
className={classes}
style={style}
disabled={disabled}
whileHover={reduced || disabled ? undefined : { y: -2, scale: 1.015 }}
whileTap={reduced || disabled ? undefined : { y: 0, scale: 0.985 }}
transition={{ type: "spring", stiffness: 420, damping: 24 }}
>
{inner}
</motion.button>
);
}
/** Secondary: glass surface, navy text. */
export function GlassButton({
children,
href,
className = "",
}: {
children: ReactNode;
href: string;
className?: string;
}) {
const reduced = useReducedMotion();
return (
<motion.a
href={href}
className={`glass glass-rim inline-flex items-center justify-center gap-2 rounded-buttons px-[20px] py-[11px] text-body font-medium text-ink ${className}`}
whileHover={reduced ? undefined : { y: -2 }}
transition={{ type: "spring", stiffness: 420, damping: 24 }}
>
{children}
</motion.a>
);
}
export function GhostLink({
children,
href,
className = "",
}: {
children: ReactNode;
href: string;
className?: string;
}) {
return (
<a
href={href}
className={`inline-flex items-center gap-1.5 text-body font-medium text-ink transition-opacity duration-200 hover:opacity-60 ${className}`}
>
{children}
</a>
);
}
/* ------------------------------------------------------------------ *
* Floating glass nav
* ------------------------------------------------------------------ */
const NAV_LINKS = [
{ href: "#funktionen", label: "Funktionen" },
{ href: "#einblick", label: "Einblick" },
{ href: "#preis", label: "Preis" },
{ href: "#faq", label: "FAQ" },
];
export function Nav() {
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
const reduced = useReducedMotion();
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 24);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
return (
<header className="pointer-events-none fixed inset-x-0 top-4 z-50 flex justify-center px-4">
<motion.nav
aria-label="Hauptnavigation"
initial={reduced ? false : { y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
className={`glass-rim pointer-events-auto w-full max-w-[1000px] rounded-nav transition-all duration-500 ease-out-soft ${
scrolled ? "glass shadow-elevated" : "glass-sm"
}`}
>
<div className="flex h-14 items-center justify-between px-5">
<a href="#hauptinhalt" aria-label="Kumasy — zum Seitenanfang">
<Logo />
</a>
<ul className="hidden items-center gap-1 md:flex">
{NAV_LINKS.map((link) => (
<li key={link.href}>
<a
href={link.href}
className="rounded-full px-3.5 py-2 text-body font-medium text-slate-body transition-colors duration-200 hover:bg-white/60 hover:text-ink"
>
{link.label}
</a>
</li>
))}
</ul>
<div className="hidden items-center gap-3 md:flex">
<GhostLink href="https://app.kumasy.de" className="text-[14px]">
Login
</GhostLink>
<BrandButton href="#warteliste" className="!py-2 !text-[14px]">
Auf die Warteliste
</BrandButton>
</div>
<button
type="button"
className="text-ink md:hidden"
aria-expanded={open}
aria-controls="mobile-nav"
aria-label={open ? "Menü schließen" : "Menü öffnen"}
onClick={() => setOpen((value) => !value)}
>
{open ? (
<X className="size-5" strokeWidth={1.8} />
) : (
<Menu className="size-5" strokeWidth={1.8} />
)}
</button>
</div>
<AnimatePresence initial={false}>
{open ? (
<motion.div
id="mobile-nav"
key="mobile-nav"
className="overflow-hidden border-t border-white/50 px-5 md:hidden"
initial={reduced ? false : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={reduced ? undefined : { height: 0, opacity: 0 }}
transition={{ duration: 0.32, ease: [0.22, 1, 0.36, 1] }}
>
<ul className="flex flex-col gap-1 py-3">
{NAV_LINKS.map((link) => (
<li key={link.href}>
<a
href={link.href}
onClick={() => setOpen(false)}
className="block rounded-2xl px-3 py-2.5 text-body font-medium transition-colors duration-200 hover:bg-white/60"
>
{link.label}
</a>
</li>
))}
<li className="mt-2 flex items-center justify-between gap-3 px-3 pb-2">
<GhostLink href="https://app.kumasy.de">Login</GhostLink>
<BrandButton href="#warteliste" className="!py-2 !text-[14px]">
Auf die Warteliste
</BrandButton>
</li>
</ul>
</motion.div>
) : null}
</AnimatePresence>
</motion.nav>
</header>
);
}
/* ------------------------------------------------------------------ *
* Waitlist form
* ------------------------------------------------------------------ */
export function WaitlistForm({ compact = false }: { compact?: boolean }) {
const [email, setEmail] = useState("");
const [state, setState] = useState<"idle" | "sending" | "done">("idle");
const fieldId = useId();
const statusId = `${fieldId}-status`;
// No backend is wired up yet — this validates and acknowledges locally.
const onSubmit = (event: React.FormEvent) => {
event.preventDefault();
if (state !== "idle") return;
setState("sending");
window.setTimeout(() => setState("done"), 700);
};
if (state === "done") {
return (
<motion.div
className="glass glass-rim flex items-center gap-3 rounded-inputs px-5 py-4"
role="status"
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
>
<span className="grid size-7 shrink-0 place-items-center rounded-full bg-mint-wash">
<Check className="size-4 text-mint-ink" strokeWidth={2.4} />
</span>
<p className="text-body text-ink">
Danke — du stehst auf der Liste. Wir melden uns per E-Mail.
</p>
</motion.div>
);
}
return (
<form onSubmit={onSubmit} className="w-full">
<label htmlFor={fieldId} className="sr-only">
E-Mail-Adresse
</label>
<div
className={`glass glass-rim flex w-full items-center gap-2 rounded-full p-2 ${
compact ? "" : "sm:p-2.5"
}`}
>
<input
id={fieldId}
type="email"
name="email"
required
autoComplete="email"
inputMode="email"
placeholder="E-Mail-Adresse"
aria-describedby={statusId}
value={email}
onChange={(event) => setEmail(event.target.value)}
className="min-w-0 flex-1 bg-transparent px-4 py-2.5 text-body text-ink outline-none placeholder:text-slate-quiet"
/>
<BrandButton type="submit" disabled={state === "sending"}>
{state === "sending" ? "Moment…" : "Platz sichern"}
</BrandButton>
</div>
<p id={statusId} className="mt-3 text-[14px] text-slate-helper">
Du bekommst eine E-Mail, sobald dein Zugang bereit ist. Keine
Verpflichtung, keine Zahlungsdaten.
</p>
</form>
);
}
/* ------------------------------------------------------------------ *
* FAQ accordion
* ------------------------------------------------------------------ */
export function Faq({
question,
children,
}: {
question: string;
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
const panelId = useId();
return (
<div className="border-b border-hairline">
<h3>
<button
type="button"
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpen((value) => !value)}
className="flex w-full items-center justify-between gap-6 py-5 text-left text-body-lg font-medium text-ink transition-colors duration-200 hover:text-slate-body"
>
{question}
<Plus
aria-hidden
className={`size-5 shrink-0 text-slate-helper transition-transform duration-400 ease-out-soft ${
open ? "rotate-45" : ""
}`}
strokeWidth={1.6}
/>
</button>
</h3>
<div
id={panelId}
// grid-template-rows animates to auto-height without measuring.
className="grid transition-[grid-template-rows,opacity] duration-400 ease-out-soft"
style={{
gridTemplateRows: open ? "1fr" : "0fr",
opacity: open ? 1 : 0,
}}
>
<div className="overflow-hidden">
<div className="pb-5 pr-10 text-body text-slate-body">{children}</div>
</div>
</div>
</div>
);
}