Kumasy-Marketing/app/components/ui.tsx

626 lines
20 KiB
TypeScript

"use client";
import {
useActionState,
useCallback,
useEffect,
useId,
useRef,
useState,
type ReactNode,
} from "react";
import Link from "next/link";
import { AnimatePresence, motion } from "motion/react";
import { Check, Lock, Menu, Plus, X } from "lucide-react";
import { Logo } from "./logo";
import { useReducedMotion } from "./motion";
import { joinWaitlist } from "../lib/waitlist";
import { initialWaitlistState } from "../lib/waitlist-state";
/* ------------------------------------------------------------------ *
* 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>
);
}
/* ------------------------------------------------------------------ *
* Login dialog — there is no app to log into yet
*
* The product is still in its pilot, so app.kumasy.de would either
* refuse the visitor or, worse, look broken. Intercepting the click and
* saying so keeps the one path that does exist — the waitlist — in
* front of them instead of sending them to a dead end.
* ------------------------------------------------------------------ */
export function LoginDialog({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const reduced = useReducedMotion();
const panelRef = useRef<HTMLDivElement>(null);
const titleId = useId();
const bodyId = `${titleId}-body`;
// Close on Escape, and keep Tab inside the panel while it is open —
// a dialog the keyboard can walk out of is not really modal.
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
return;
}
if (event.key !== "Tab") return;
const panel = panelRef.current;
if (!panel) return;
const focusable = panel.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input, [tabindex]:not([tabindex="-1"])',
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
// Move focus into the panel on open, and stop the page behind from
// scrolling under it.
useEffect(() => {
if (!open) return;
const previous = document.activeElement as HTMLElement | null;
const overflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const frame = requestAnimationFrame(() => {
panelRef.current
?.querySelector<HTMLElement>("input, button, a[href]")
?.focus();
});
return () => {
cancelAnimationFrame(frame);
document.body.style.overflow = overflow;
previous?.focus();
};
}, [open]);
return (
<AnimatePresence>
{open ? (
<div className="fixed inset-0 z-[70] flex items-center justify-center px-4">
{/* Darkened, not blurred — the same call the app makes for
dialogs, so the panel's own text stays crisp. */}
<motion.div
className="absolute inset-0 bg-ink/45"
onClick={onClose}
initial={reduced ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={reduced ? undefined : { opacity: 0 }}
transition={{ duration: 0.25 }}
/>
<motion.div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={bodyId}
className="glass-rim relative w-full max-w-[460px] rounded-cards border border-white/70 bg-paper p-7 shadow-elevated sm:p-8"
initial={reduced ? false : { opacity: 0, y: 16, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduced ? undefined : { opacity: 0, y: 8, scale: 0.98 }}
transition={{ duration: 0.34, ease: [0.22, 1, 0.36, 1] }}
>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="absolute right-5 top-5 rounded-full p-1 text-slate-helper transition-colors duration-200 hover:bg-mist hover:text-ink"
>
<X className="size-4" strokeWidth={2} />
</button>
<span className="grid size-10 place-items-center rounded-full bg-amber-soft">
<Lock className="size-4 text-[#8a6d00]" strokeWidth={2.2} />
</span>
<h2
id={titleId}
className="mt-5 font-display text-heading-sm font-semibold text-ink"
>
Der Login kommt bald.
</h2>
<p id={bodyId} className="mt-3 text-body text-slate-body">
Kumasy ist noch in der Pilotphase — Zugänge vergeben wir nach
und nach über die Warteliste. Trag dich ein, und du bekommst
eine E-Mail, sobald dein Zugang bereit ist.
</p>
<div className="mt-6">
<WaitlistForm compact source="Login-Dialog" />
</div>
</motion.div>
</div>
) : null}
</AnimatePresence>
);
}
/* ------------------------------------------------------------------ *
* 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 [loginOpen, setLoginOpen] = useState(false);
const reduced = useReducedMotion();
// Closes the mobile menu too, so the dialog never opens behind it.
const openLogin = useCallback(() => {
setOpen(false);
setLoginOpen(true);
}, []);
const closeLogin = useCallback(() => setLoginOpen(false), []);
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">
<button
type="button"
onClick={openLogin}
className="inline-flex items-center gap-1.5 text-[14px] font-medium text-ink transition-opacity duration-200 hover:opacity-60"
>
Login
</button>
<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">
<button
type="button"
onClick={openLogin}
className="inline-flex items-center gap-1.5 text-body font-medium text-ink transition-opacity duration-200 hover:opacity-60"
>
Login
</button>
<BrandButton href="#warteliste" className="!py-2 !text-[14px]">
Auf die Warteliste
</BrandButton>
</li>
</ul>
</motion.div>
) : null}
</AnimatePresence>
</motion.nav>
</header>
{/* Outside the header on purpose: that element is pointer-events-none
and a centred flex row, both of which a fixed dialog must not
inherit. */}
<LoginDialog open={loginOpen} onClose={closeLogin} />
</>
);
}
/* ------------------------------------------------------------------ *
* Waitlist form
* ------------------------------------------------------------------ */
export function WaitlistForm({
compact = false,
/** Names the form in the notification email, so you can tell them apart. */
source = "Startseite",
}: {
compact?: boolean;
source?: string;
}) {
const [state, formAction, pending] = useActionState(
joinWaitlist,
initialWaitlistState,
);
const fieldId = useId();
const statusId = `${fieldId}-status`;
const errorId = `${fieldId}-error`;
const consentId = `${fieldId}-consent`;
if (state.status === "success") {
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>
);
}
const failed = state.status === "error";
return (
<form action={formAction} className="w-full">
<label htmlFor={fieldId} className="sr-only">
E-Mail-Adresse
</label>
{/* Bait for bots: off-screen, skipped by keyboard and screen readers. */}
<div aria-hidden className="absolute left-[-9999px] top-auto h-px w-px overflow-hidden">
<label htmlFor={`${fieldId}-website`}>Website</label>
<input
id={`${fieldId}-website`}
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
/>
</div>
<input type="hidden" name="source" value={source} />
<div
className={`glass glass-rim flex w-full items-center gap-2 rounded-full p-2 transition-shadow duration-200 focus-within:ring-2 focus-within:ring-ink/70 focus-within:ring-offset-2 focus-within:ring-offset-canvas ${
compact ? "" : "sm:p-2.5"
} ${failed ? "ring-2 ring-peach-ink/50" : ""}`}
>
<input
id={fieldId}
type="email"
name="email"
required
autoComplete="email"
inputMode="email"
spellCheck={false}
maxLength={254}
placeholder="name@studio.de"
defaultValue={state.email ?? ""}
aria-describedby={failed ? `${errorId} ${statusId}` : statusId}
aria-invalid={failed || undefined}
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={pending}>
<span className="min-w-[6.5rem] text-center">
{pending ? "Moment…" : "Platz sichern"}
</span>
</BrandButton>
</div>
{/* text-left: these forms sit inside centred sections, and centred
multi-line consent copy is noticeably harder to read. */}
<div className="mt-3.5 flex items-start gap-2.5 text-left">
<input
id={consentId}
type="checkbox"
name="consent"
value="ja"
required
className="mt-0.5 size-4 shrink-0 accent-amber-deep"
/>
<label htmlFor={consentId} className="text-[14px] text-slate-helper">
Ja, ihr dürft mich per E-Mail über den Start von Kumasy
informieren. Ich kann das jederzeit formlos widerrufen — mehr dazu
in der{" "}
<Link
href="/datenschutz"
className="font-medium text-ink underline decoration-amber decoration-2 underline-offset-2"
>
Datenschutzerklärung
</Link>
.
</label>
</div>
{failed ? (
<p
id={errorId}
role="alert"
className="mt-3 text-left text-[14px] font-medium text-[#9a2c06]"
>
{state.message}
</p>
) : null}
<p id={statusId} className="mt-2.5 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>
);
}