Kumasy-Marketing/app/components/hero.tsx

212 lines
7.2 KiB
TypeScript

"use client";
import { AnimatePresence, motion, useScroll, useTransform } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { BadgeCheck, CreditCard, ShieldCheck, Server, MapPin } from "lucide-react";
import { Eyebrow, WaitlistForm } from "./ui";
import { KineticLine, Reveal, useReducedMotion } from "./motion";
import { ShowcaseStage } from "./mockup";
const TRUST = [
{ label: "Zahlungen über Stripe", Icon: CreditCard },
{ label: "DSGVO-konform", Icon: ShieldCheck },
{ label: "Hosting in Frankfurt", Icon: MapPin },
{ label: "GoBD-konforme Rechnungen", Icon: BadgeCheck },
{ label: "Entwickelt in Deutschland", Icon: Server },
];
/**
* Ambient backdrop: a slow aurora wash plus amber and navy blobs.
* This is what the glass surfaces in front actually blur — without it
* the backdrop-filter has nothing to work with.
*/
function Ambience() {
const reduced = useReducedMotion();
const { scrollY } = useScroll();
const glowY = useTransform(scrollY, [0, 800], [0, 160]);
return (
<div
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 overflow-hidden"
>
<div className="aurora absolute inset-0" />
<motion.div
className="absolute left-1/2 top-[-20%] size-[880px] -translate-x-1/2 rounded-full opacity-60"
style={{
background:
"radial-gradient(circle, rgba(244,192,12,0.55) 0%, rgba(255,216,77,0.28) 38%, transparent 70%)",
filter: "blur(90px)",
...(reduced ? {} : { y: glowY }),
}}
/>
<div
className="blob absolute -left-40 top-1/3 size-[460px] rounded-full opacity-45"
style={{
background:
"radial-gradient(circle, rgba(30,41,59,0.34) 0%, transparent 68%)",
filter: "blur(80px)",
}}
/>
<div
className="blob absolute -right-32 top-24 size-[420px] rounded-full opacity-50"
style={{
background:
"radial-gradient(circle, rgba(244,192,12,0.45) 0%, transparent 68%)",
filter: "blur(80px)",
animationDelay: "-8s",
}}
/>
</div>
);
}
const REPLACED = [
"die Excel-Tabelle",
"die DM-Flut",
"den Zettel am Empfang",
"das Nachfassen per WhatsApp",
"die Bar-Kasse",
];
/** Cycles through what Kumasy replaces, one word swap at a time. */
function ReplacedWords() {
const reduced = useReducedMotion();
const [index, setIndex] = useState(0);
useEffect(() => {
if (reduced) return;
const id = window.setInterval(
() => setIndex((i) => (i + 1) % REPLACED.length),
2600,
);
return () => window.clearInterval(id);
}, [reduced]);
if (reduced) {
// Static fallback: name them all rather than animating through them.
return <span className="font-medium text-ink">{REPLACED.join(", ")}.</span>;
}
return (
// Fixed height + overflow clip so the swap never reflows the line.
<span className="relative inline-flex h-[1.6em] items-center overflow-hidden align-bottom">
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={index}
className="whitespace-nowrap font-medium text-ink"
initial={{ y: "100%", opacity: 0 }}
animate={{ y: "0%", opacity: 1 }}
exit={{ y: "-100%", opacity: 0 }}
transition={{ duration: 0.42, ease: [0.22, 1, 0.36, 1] }}
>
{REPLACED[index]}
</motion.span>
</AnimatePresence>
</span>
);
}
export function Hero() {
const ref = useRef<HTMLElement>(null);
const reduced = useReducedMotion();
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start start", "end start"],
});
const copyY = useTransform(scrollYProgress, [0, 1], [0, -90]);
const copyOpacity = useTransform(scrollYProgress, [0, 0.65], [1, 0]);
return (
<section
ref={ref}
className="relative isolate overflow-hidden px-4 pb-24 pt-32 sm:pt-40"
>
<Ambience />
<div className="mx-auto max-w-[1200px]">
<motion.div
className="mx-auto max-w-[860px] text-center"
style={reduced ? undefined : { y: copyY, opacity: copyOpacity }}
>
<Reveal>
<Eyebrow>Pilotphase · Zugang über Warteliste</Eyebrow>
</Reveal>
{/* Per-character entrance. The <h1> carries the readable text for
assistive tech; the animated spans are aria-hidden. */}
{/* The floor is 2.1rem, not 2.75rem: the longest line ("und
bezahlen sich") has to fit 321px of usable width on a 353px
Samsung viewport. 10.2vw carries it from there up to the
5.25rem ceiling. */}
<h1 className="mt-7 text-balance font-display text-[clamp(2.1rem,10.2vw,5.25rem)] font-semibold leading-[0.96] tracking-[-0.055em] text-ink">
<span className="sr-only">
Deine Kurse füllen und bezahlen sich von selbst.
</span>
<span className="block">
<KineticLine text="Deine Kurse füllen" />
</span>
<span className="block">
<KineticLine text="und bezahlen sich" delay={0.16} />
</span>
<span className="block">
<KineticLine text="von " delay={0.32} />
{/* Rises as one block so the gradient spans the word — see
KineticLine for why this can't be per-character. The delay
continues the stagger of "von " so the line reads as one. */}
<KineticLine
text="selbst."
delay={0.42}
gradient
className="italic"
/>
</span>
</h1>
<Reveal delay={0.55}>
<p className="mx-auto mt-8 max-w-[540px] text-body-lg text-slate-body">
Kumasy bündelt Kurse, Räume, Trainer, Buchungen und Zahlungen in
einer Oberfläche. Deine Teilnehmer:innen buchen und bezahlen in
unter zwei Minuten selbst — ohne DM, ohne Rückfrage, ohne Excel.
</p>
</Reveal>
<Reveal delay={0.65}>
<div className="mx-auto mt-9 max-w-[560px]" id="warteliste">
<WaitlistForm source="Hero" />
</div>
</Reveal>
<Reveal delay={0.75}>
<ul className="mt-9 flex flex-wrap items-center justify-center gap-x-5 gap-y-2.5">
{TRUST.map(({ label, Icon }) => (
<li
key={label}
className="flex items-center gap-1.5 text-[13px] text-slate-helper"
>
<Icon className="size-3.5 shrink-0" strokeWidth={1.8} />
{label}
</li>
))}
</ul>
</Reveal>
{/* One live line naming what Kumasy replaces — it keeps the hero
moving after the entrance without adding another surface. */}
<Reveal delay={0.85}>
<p className="mt-10 flex flex-wrap items-center justify-center gap-x-2 text-body text-slate-helper">
<span>Ersetzt</span>
<ReplacedWords />
</p>
</Reveal>
</motion.div>
</div>
<div className="mt-20 px-2">
<ShowcaseStage />
</div>
</section>
);
}