mirror of
https://github.com/Since-3/Kumasy-Marketing.git
synced 2026-10-12 19:51:17 +00:00
626 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|