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