added site

This commit is contained in:
Rajpreet0 2026-09-20 01:52:28 +02:00
parent 6cd86a9495
commit eb5aca3a87
34 changed files with 3720 additions and 30 deletions

208
app/components/hero.tsx Normal file
View file

@ -0,0 +1,208 @@
"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. */}
<h1 className="mt-7 font-display text-[clamp(2.75rem,8.6vw,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 />
</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>
);
}

59
app/components/logo.tsx Normal file
View file

@ -0,0 +1,59 @@
"use client";
// Imported straight from motion/react, not from ./motion, to keep this leaf
// component free of a cycle (ui.tsx -> logo.tsx). Only used as a truthy check,
// so the hook's `null` initial value behaves the same as `false` here.
import { motion, useReducedMotion } from "motion/react";
/**
* The Kumasy mark, rebuilt as vector from public/icons/icon-512.png:
* a rounded stem plus a left-pointing triangle, forming a "k".
* Drawn on a 0 0 512 512 grid to match the source proportions.
*/
export function LogoMark({
className = "",
/** Rides on currentColor so it inverts on dark surfaces. */
title,
}: {
className?: string;
title?: string;
}) {
return (
<svg
viewBox="0 0 512 512"
className={className}
role={title ? "img" : undefined}
aria-label={title}
aria-hidden={title ? undefined : true}
fill="currentColor"
>
{/* Geometry traced from public/icons/icon-512.png.
Stem: x 153–224, y 118–395. */}
<rect x="153" y="118" width="71" height="277" rx="35.5" />
{/* Triangle: apex ~(236, 307), flat right edge at x≈356,
spanning y≈218–396, with 20px corner and 10px apex radii. */}
<path d="M234.6 299.6 317.4 220.5 A20 20 0 0 1 352 234.4 L352 379.6 A20 20 0 0 1 317.4 393.5 L234.6 314.4 A10 10 0 0 1 234.6 299.6 Z" />
</svg>
);
}
/** Wordmark: the mark in an amber tile plus the name. */
export function Logo({ className = "" }: { className?: string }) {
const reduced = useReducedMotion();
return (
<span className={`inline-flex items-center gap-2.5 ${className}`}>
<motion.span
className="grid size-7 shrink-0 place-items-center rounded-[9px] text-ink shadow-amber"
style={{ background: "var(--gradient-amber)" }}
whileHover={reduced ? undefined : { rotate: -8, scale: 1.06 }}
transition={{ type: "spring", stiffness: 400, damping: 18 }}
>
<LogoMark className="size-[18px]" />
</motion.span>
<span className="text-subheading font-semibold tracking-[-0.4px] text-ink">
kumasy
</span>
</span>
);
}

222
app/components/mockup.tsx Normal file
View file

@ -0,0 +1,222 @@
"use client";
import Image from "next/image";
import { useRef, type ReactNode } from "react";
import {
motion,
useMotionTemplate,
useReducedMotion,
useScroll,
useSpring,
useTransform,
} from "motion/react";
import { Lock, TrendingUp, Users } from "lucide-react";
/* ------------------------------------------------------------------ *
* Browser chrome — glass bar over the screenshot
* ------------------------------------------------------------------ */
export function BrowserFrame({
children,
url = "app.kumasy.de",
className = "",
}: {
children: ReactNode;
url?: string;
className?: string;
}) {
return (
<div
className={`glass-rim overflow-hidden rounded-[16px] border border-white/60 bg-white/40 shadow-elevated backdrop-blur-xl ${className}`}
>
<div className="flex items-center gap-3 border-b border-white/50 bg-white/55 px-4 py-2.5 backdrop-blur-xl">
<span className="flex gap-1.5" aria-hidden>
<span className="size-[9px] rounded-full bg-[#ff5f57]" />
<span className="size-[9px] rounded-full bg-[#febc2e]" />
<span className="size-[9px] rounded-full bg-[#28c840]" />
</span>
<span
className="mx-auto flex items-center gap-1.5 rounded-full border border-white/70 bg-white/60 px-3 py-1 text-[11px] text-slate-helper"
aria-hidden
>
<Lock className="size-[11px]" strokeWidth={2.2} />
{url}
</span>
<span className="w-[42px]" aria-hidden />
</div>
{children}
</div>
);
}
/* ------------------------------------------------------------------ *
* Floating glass stat chip
* ------------------------------------------------------------------ */
function GlassStat({
Icon,
label,
value,
tone,
}: {
Icon: typeof Users;
label: string;
value: string;
tone: "mint" | "amber";
}) {
return (
<div className="glass-xl glass-rim w-[196px] rounded-[20px] p-4">
<div className="flex items-center gap-2">
<span
className={`grid size-7 place-items-center rounded-full ${
tone === "mint"
? "bg-mint-wash text-mint-ink"
: "bg-amber-soft text-[#8a6d00]"
}`}
>
<Icon className="size-4" strokeWidth={2} />
</span>
<span className="text-caption font-semibold uppercase text-slate-helper">
{label}
</span>
</div>
<p className="mt-2.5 font-display text-subheading font-semibold leading-tight text-ink">
{value}
</p>
</div>
);
}
/* ------------------------------------------------------------------ *
* ParallaxLayer
* ------------------------------------------------------------------ */
export function ParallaxLayer({
children,
depth = 60,
className = "",
}: {
children: ReactNode;
depth?: number;
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const reduced = useReducedMotion();
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start end", "end start"],
});
const y = useSpring(useTransform(scrollYProgress, [0, 1], [depth, -depth]), {
stiffness: 120,
damping: 28,
mass: 0.4,
});
return (
<motion.div
ref={ref}
className={className}
style={reduced ? undefined : { y }}
>
{children}
</motion.div>
);
}
/* ------------------------------------------------------------------ *
* ShowcaseStage — one product shot, presented cleanly
* ------------------------------------------------------------------ */
export function ShowcaseStage() {
const ref = useRef<HTMLDivElement>(null);
const reduced = useReducedMotion();
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start 0.95", "end 0.2"],
});
// The window tips up from a shallow angle and settles flat, as if being
// set down on the pedestal. One gesture, not five competing ones.
const rotateX = useSpring(useTransform(scrollYProgress, [0, 0.6], [12, 0]), {
stiffness: 90,
damping: 26,
});
const scale = useSpring(useTransform(scrollYProgress, [0, 0.6], [0.94, 1]), {
stiffness: 90,
damping: 26,
});
const lift = useTransform(scrollYProgress, [0, 0.6], [50, 0]);
const glowY = useTransform(scrollYProgress, [0, 1], [40, -40]);
const chipY = useTransform(scrollYProgress, [0, 1], [-46, 46]);
const transform = useMotionTemplate`perspective(1600px) rotateX(${rotateX}deg) scale(${scale})`;
return (
<div ref={ref} className="relative mx-auto max-w-[1080px] px-4">
{/* Pedestal: a warm plinth the window stands on, so it reads as
placed rather than pasted onto the page. */}
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
<motion.div
className="absolute left-1/2 top-[38%] size-[720px] -translate-x-1/2 -translate-y-1/2 rounded-full opacity-70"
style={{
background:
"radial-gradient(circle, rgba(244,192,12,0.45) 0%, rgba(244,192,12,0.12) 45%, transparent 70%)",
filter: "blur(80px)",
...(reduced ? {} : { y: glowY }),
}}
/>
</div>
{/* The product shot. Full opacity, nothing covering it. */}
<motion.div
className="relative z-10"
style={
reduced
? undefined
: { transform, y: lift, transformOrigin: "50% 100%" }
}
>
<BrowserFrame>
<Image
src="/dashboard.png"
alt="Kumasy Dashboard mit Umsatzverlauf über zwölf Monate und Kurs-Kennzahlen"
width={1899}
height={905}
priority
sizes="(max-width: 1120px) 94vw, 1080px"
className="h-auto w-full"
/>
</BrowserFrame>
</motion.div>
{/* Two chips, clear of the window on either side. They annotate the
product; they never sit on top of the UI they point at. */}
<motion.div
className="absolute -left-4 top-[26%] z-20 hidden 2xl:block"
style={reduced ? undefined : { y: chipY }}
>
<GlassStat
Icon={Users}
tone="mint"
label="Warteliste"
value="Platz rückt nach"
/>
</motion.div>
<motion.div
className="absolute -right-4 top-[58%] z-20 hidden 2xl:block"
style={reduced ? undefined : { y: chipY }}
>
<GlassStat
Icon={TrendingUp}
tone="amber"
label="Bezahlt"
value="In Echtzeit"
/>
</motion.div>
</div>
);
}

488
app/components/motion.tsx Normal file
View file

@ -0,0 +1,488 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import {
motion,
useInView,
useReducedMotion as useReducedMotionRaw,
useScroll,
useSpring,
useTransform,
type Variants,
} from "motion/react";
/**
* Motion's hook returns `boolean | null` — null until the media query
* resolves. Narrow it to a plain boolean so callers can pass it straight
* into props; an unresolved preference means "motion allowed".
*/
export function useReducedMotion(): boolean {
return useReducedMotionRaw() === true;
}
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
const EASE_MASK = [0.16, 1, 0.3, 1] as const;
/* ------------------------------------------------------------------ *
* Reveal
* ------------------------------------------------------------------ */
export function Reveal({
children,
delay = 0,
className,
y = 24,
}: {
children: ReactNode;
delay?: number;
className?: string;
y?: number;
}) {
const reduced = useReducedMotion();
return (
<motion.div
className={className}
initial={reduced ? false : { opacity: 0, y }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2, margin: "0px 0px -8% 0px" }}
transition={{ duration: 0.7, delay, ease: EASE_OUT }}
>
{children}
</motion.div>
);
}
/** List-item variant, so Reveal never breaks <ul> semantics. */
export function RevealItem({
children,
delay = 0,
className,
}: {
children: ReactNode;
delay?: number;
className?: string;
}) {
const reduced = useReducedMotion();
return (
<motion.li
className={className}
initial={reduced ? false : { opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2, margin: "0px 0px -8% 0px" }}
transition={{ duration: 0.7, delay, ease: EASE_OUT }}
>
{children}
</motion.li>
);
}
/* ------------------------------------------------------------------ *
* MaskedWords — words rise from behind a clipping mask
* ------------------------------------------------------------------ */
const lineVariants: Variants = {
hidden: {},
shown: { transition: { staggerChildren: 0.055, delayChildren: 0.05 } },
};
const wordVariants: Variants = {
hidden: { y: "108%", rotate: 3, skewY: 2.5 },
shown: {
y: "0%",
rotate: 0,
skewY: 0,
transition: { duration: 0.9, ease: EASE_MASK },
},
};
export function MaskedWords({
words,
className = "",
delay = 0,
}: {
words: ReactNode[];
className?: string;
delay?: number;
}) {
const reduced = useReducedMotion();
if (reduced) {
return (
<span className={className}>
{words.map((word, index) => (
<span key={index}>
{word}
{index < words.length - 1 ? " " : ""}
</span>
))}
</span>
);
}
return (
<motion.span
className={className}
variants={lineVariants}
initial="hidden"
whileInView="shown"
viewport={{ once: true, amount: 0.4 }}
transition={{ delayChildren: delay }}
>
{words.map((word, index) => (
<span
key={index}
className="inline-flex overflow-hidden pb-[0.12em] align-bottom"
>
<motion.span variants={wordVariants} className="inline-block">
{word}
</motion.span>
{index < words.length - 1 ? <span className="w-[0.26em]" /> : null}
</span>
))}
</motion.span>
);
}
/* ------------------------------------------------------------------ *
* KineticHeadline — per-character entrance for the hero only
* ------------------------------------------------------------------ */
const charVariants: Variants = {
hidden: { opacity: 0, y: "70%", rotateX: -70 },
shown: {
opacity: 1,
y: "0%",
rotateX: 0,
transition: { duration: 0.75, ease: EASE_MASK },
},
};
/**
* Splits a string into characters and tips each one up into place.
* The visible text is aria-hidden and the whole line is relabelled,
* so screen readers get the sentence, not a stream of letters.
*
* `gradient` keeps `.brand-text` on a single element so the amber fill
* spans the whole word, and animates that element as one unit — per
* character the gradient would restart on every letter, and the clipped
* fill drops out under a 3D rotateX.
*/
export function KineticLine({
text,
className = "",
delay = 0,
stagger = 0.022,
gradient = false,
}: {
text: string;
className?: string;
delay?: number;
stagger?: number;
/** Fill the word with the amber gradient and animate it as one block. */
gradient?: boolean;
}) {
const reduced = useReducedMotion();
if (reduced) {
return (
<span className={`${gradient ? "brand-text" : ""} ${className}`}>
{text}
</span>
);
}
if (gradient) {
return (
<motion.span
aria-hidden
// 2D only: rotateX would break background-clip:text.
className={`brand-text inline-block ${className}`}
initial={{ opacity: 0, y: "55%" }}
whileInView={{ opacity: 1, y: "0%" }}
viewport={{ once: true, amount: 0.3 }}
transition={{ duration: 0.75, delay, ease: EASE_MASK }}
>
{text}
</motion.span>
);
}
return (
<motion.span
aria-hidden
className={`inline-block ${className}`}
style={{ perspective: 600 }}
variants={{
hidden: {},
shown: { transition: { staggerChildren: stagger, delayChildren: delay } },
}}
initial="hidden"
whileInView="shown"
viewport={{ once: true, amount: 0.3 }}
>
{Array.from(text).map((char, index) => (
<motion.span
key={index}
variants={charVariants}
className="inline-block origin-bottom"
style={{ transformStyle: "preserve-3d" }}
>
{char === " " ? " " : char}
</motion.span>
))}
</motion.span>
);
}
/* ------------------------------------------------------------------ *
* ScrollTracking — letter-spacing tightens as the line scrolls through
* ------------------------------------------------------------------ */
export function ScrollTracking({
children,
className = "",
from = 0.06,
to = -0.04,
}: {
children: ReactNode;
className?: string;
/** Letter-spacing in em at entry and at exit. */
from?: number;
to?: number;
}) {
const ref = useRef<HTMLDivElement>(null);
const reduced = useReducedMotion();
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start end", "center center"],
});
const raw = useTransform(scrollYProgress, [0, 1], [from, to]);
const spring = useSpring(raw, { stiffness: 90, damping: 24 });
const letterSpacing = useTransform(spring, (v) => `${v}em`);
return (
<motion.div
ref={ref}
className={className}
style={reduced ? undefined : { letterSpacing }}
>
{children}
</motion.div>
);
}
/* ------------------------------------------------------------------ *
* ScrollStage — the section itself reacts to being scrolled past
* ------------------------------------------------------------------ */
/**
* Wraps a section so it rises and settles on entry, then recedes slightly
* as it leaves — the scroll becomes the animation rather than a trigger
* for one. Kept subtle on purpose: this runs on every section, so anything
* stronger would read as the page wobbling.
*/
export function ScrollStage({
children,
className = "",
}: {
children: ReactNode;
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const reduced = useReducedMotion();
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start end", "end start"],
});
const scale = useSpring(
useTransform(scrollYProgress, [0, 0.2, 0.8, 1], [0.97, 1, 1, 0.97]),
{ stiffness: 100, damping: 30, mass: 0.5 },
);
const opacity = useTransform(
scrollYProgress,
[0, 0.14, 0.86, 1],
[0.55, 1, 1, 0.55],
);
return (
<motion.div
ref={ref}
className={className}
style={reduced ? undefined : { scale, opacity }}
>
{children}
</motion.div>
);
}
/* ------------------------------------------------------------------ *
* Parallax
* ------------------------------------------------------------------ */
export function Parallax({
children,
distance = 60,
className,
}: {
children: ReactNode;
distance?: number;
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const reduced = useReducedMotion();
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start end", "end start"],
});
const y = useSpring(
useTransform(scrollYProgress, [0, 1], [distance, -distance]),
{ stiffness: 120, damping: 28, mass: 0.4 },
);
return (
<motion.div ref={ref} className={className} style={reduced ? undefined : { y }}>
{children}
</motion.div>
);
}
/* ------------------------------------------------------------------ *
* Tilt
* ------------------------------------------------------------------ */
export function Tilt({
children,
className,
max = 5,
}: {
children: ReactNode;
className?: string;
max?: number;
}) {
const ref = useRef<HTMLDivElement>(null);
const reduced = useReducedMotion();
useEffect(() => {
const node = ref.current;
if (!node || reduced) return;
if (!window.matchMedia("(hover: hover) and (pointer: fine)").matches) return;
let frame = 0;
const onMove = (event: PointerEvent) => {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
const rect = node.getBoundingClientRect();
const x = (event.clientX - rect.left) / rect.width - 0.5;
const y = (event.clientY - rect.top) / rect.height - 0.5;
node.style.transform = `perspective(1000px) rotateX(${(-y * max).toFixed(2)}deg) rotateY(${(x * max).toFixed(2)}deg)`;
});
};
const onLeave = () => {
if (frame) cancelAnimationFrame(frame);
frame = 0;
node.style.transform = "";
};
node.addEventListener("pointermove", onMove);
node.addEventListener("pointerleave", onLeave);
return () => {
node.removeEventListener("pointermove", onMove);
node.removeEventListener("pointerleave", onLeave);
if (frame) cancelAnimationFrame(frame);
};
}, [max, reduced]);
return (
<div
ref={ref}
className={className}
style={{ transition: "transform 0.4s cubic-bezier(0.22,1,0.36,1)" }}
>
{children}
</div>
);
}
/* ------------------------------------------------------------------ *
* Counter
* ------------------------------------------------------------------ */
export function Counter({
to,
suffix = "",
prefix = "",
decimals = 0,
duration = 1400,
className,
}: {
to: number;
suffix?: string;
prefix?: string;
decimals?: number;
duration?: number;
className?: string;
}) {
const ref = useRef<HTMLSpanElement>(null);
const reduced = useReducedMotion();
const inView = useInView(ref, { once: true, amount: 0.4 });
const [progressValue, setProgressValue] = useState(0);
const value = reduced ? to : progressValue;
useEffect(() => {
if (!inView || reduced) return;
let frame = 0;
const start = performance.now();
const tick = (now: number) => {
const progress = Math.min((now - start) / duration, 1);
const eased = progress === 1 ? 1 : 1 - Math.pow(2, -10 * progress);
setProgressValue(to * eased);
if (progress < 1) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [inView, reduced, to, duration]);
return (
<span ref={ref} className={className}>
{prefix}
{value.toLocaleString("de-DE", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
})}
{suffix}
</span>
);
}
/* ------------------------------------------------------------------ *
* ScrollProgress
* ------------------------------------------------------------------ */
export function ScrollProgress() {
const { scrollYProgress } = useScroll();
const scaleX = useSpring(scrollYProgress, {
stiffness: 180,
damping: 32,
restDelta: 0.001,
});
return (
<motion.div
aria-hidden
className="fixed inset-x-0 top-0 z-[60] h-[3px] origin-left"
style={{ scaleX, background: "var(--gradient-amber)" }}
/>
);
}

View file

@ -0,0 +1,245 @@
"use client";
import Image from "next/image";
import { useRef, useSyncExternalStore } from "react";
import {
motion,
useMotionTemplate,
useScroll,
useSpring,
useTransform,
type MotionValue,
} from "motion/react";
import { Eyebrow } from "./ui";
import { Reveal, RevealItem, useReducedMotion } from "./motion";
/** Each phone's resting place, and how it gets there. */
const PHONES = [
{
src: "/phones/phone-nav.png",
alt: "Kumasy Navigation auf dem iPhone: Dashboard, Kurse, Mitarbeiter, Kunden, Zahlungen und Räume",
label: "Navigation",
/** Final position, as a percentage of the stage width. */
x: -140,
y: 7,
rotate: -7,
scale: 0.86,
/** Parallax travel in px across the scroll pass — higher is nearer. */
depth: 30,
z: 10,
},
{
src: "/phones/phone-dashboard.png",
alt: "Kumasy Dashboard auf dem iPhone mit Umsatz, Teilnehmerzahl und Kurs-Kennzahlen",
label: "Dashboard",
x: -47,
y: -3,
rotate: -2.5,
scale: 1,
depth: 55,
z: 30,
},
{
src: "/phones/phone-booking.png",
alt: "Öffentliche Buchungsseite auf dem iPhone mit Kursfiltern und zwei buchbaren Kursen",
label: "Buchungsseite",
x: 47,
y: -3,
rotate: 2.5,
scale: 1,
depth: 55,
z: 30,
},
{
src: "/phones/phone-discounts.png",
alt: "Rabattcodes auf dem iPhone mit Status, Rabatthöhe und Gültigkeitszeitraum",
label: "Rabattcodes",
x: 140,
y: 7,
rotate: 7,
scale: 0.86,
depth: 30,
z: 10,
},
];
/**
* Tracks a media query, for behaviour that CSS cannot express (here: whether
* to run the scroll-linked fan animation at all).
*
* Which layout is *visible* is decided by CSS classes, not by this hook —
* server-rendering one branch and swapping it on hydration showed the wrong
* layout on first paint.
*/
function useMediaQuery(query: string) {
return useSyncExternalStore(
(onChange: () => void) => {
const mql = window.matchMedia(query);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
},
() => window.matchMedia(query).matches,
() => false,
);
}
function Phone({
phone,
progress,
index,
reduced,
animate,
}: {
phone: (typeof PHONES)[number];
progress: MotionValue<number>;
index: number;
reduced: boolean;
/** False below the fan breakpoint, where this markup is display:none. */
animate: boolean;
}) {
// The fan opens: phones start stacked at centre and spread to their spot.
const spread = useSpring(useTransform(progress, [0, 0.5], [0, 1]), {
stiffness: 70,
damping: 24,
});
// This component only renders on wide screens, so the full spread and
// tilt always apply — narrow screens get a plain grid instead.
const x = useTransform(spread, (v) => `${phone.x * v}%`);
const rotate = useTransform(spread, (v) => phone.rotate * v);
const scale = useTransform(spread, (v) => 0.88 + (phone.scale - 0.88) * v);
// Continuous drift for depth, independent of the opening.
const drift = useSpring(
useTransform(progress, [0, 1], [phone.depth, -phone.depth]),
{ stiffness: 110, damping: 26, mass: 0.4 },
);
const y = useMotionTemplate`calc(${phone.y}% + ${drift}px)`;
return (
<motion.div
className="absolute left-1/2 top-1/2 w-[27%] max-w-[300px] -translate-x-1/2 -translate-y-1/2"
style={{
zIndex: phone.z,
...(reduced || !animate ? {} : { x, y, rotate, scale }),
}}
initial={reduced ? false : { opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.6, delay: index * 0.08, ease: [0.22, 1, 0.36, 1] }}
>
<Image
src={phone.src}
alt={phone.alt}
width={342}
height={692}
sizes="300px"
className="h-auto w-full drop-shadow-[0_28px_40px_rgba(30,41,59,0.22)]"
/>
</motion.div>
);
}
export function PhoneShowcase() {
const ref = useRef<HTMLDivElement>(null);
const reduced = useReducedMotion();
// The fan needs real width to read as a fan. Below this the phones
// overlap so heavily that the tilt just looks like a mistake, so we
// drop the whole arrangement and show a plain grid instead.
const fanned = useMediaQuery("(min-width: 1024px)");
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start end", "end start"],
});
return (
<section id="mobil" className="relative overflow-hidden px-4 py-20 sm:py-[80px]">
{/* Amber wash behind the devices, so the transparent PNGs sit on colour
rather than floating on flat canvas. */}
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
<div className="aurora absolute inset-0 opacity-70" />
<div
className="blob absolute left-1/2 top-1/2 size-[720px] -translate-x-1/2 -translate-y-1/2 rounded-full opacity-50"
style={{
background:
"radial-gradient(circle, rgba(244,192,12,0.42) 0%, transparent 68%)",
filter: "blur(90px)",
}}
/>
</div>
<div className="mx-auto max-w-[1200px]">
<div className="mx-auto max-w-[680px] text-center">
<Reveal>
<Eyebrow>Auch unterwegs</Eyebrow>
</Reveal>
<Reveal delay={0.08}>
<h2 className="mt-5 font-display text-[clamp(2rem,4.8vw,2.9rem)] font-semibold leading-[1.06] tracking-[-0.032em] text-ink">
Dein Studio passt in die{" "}
<em className="brand-text italic">Hosentasche</em>.
</h2>
</Reveal>
<Reveal delay={0.16}>
<p className="mx-auto mt-5 max-w-[540px] text-body-lg text-slate-body">
Zwischen zwei Kursen kurz nachsehen, wer gebucht hat, einen
Rabattcode anlegen oder den Umsatz prüfen — dieselbe Oberfläche,
nur kleiner.
</p>
</Reveal>
</div>
<div ref={ref}>
{/* Both layouts render; CSS picks one. Deciding in JS meant the
wrong layout was painted until hydration caught up. */}
<div className="relative mx-auto mt-16 hidden h-[660px] w-full max-w-[1120px] lg:block">
{PHONES.map((phone, index) => (
<Phone
key={phone.src}
phone={phone}
progress={scrollYProgress}
index={index}
reduced={reduced}
animate={fanned}
/>
))}
</div>
{/* Narrow screens: upright, evenly spaced, no overlap, no tilt. */}
<ul className="mx-auto mt-14 grid max-w-[560px] grid-cols-2 gap-6 sm:gap-8 lg:hidden">
{PHONES.map((phone, index) => (
<RevealItem key={phone.src} delay={index * 0.08}>
<figure className="flex flex-col items-center gap-3">
<Image
src={phone.src}
alt={phone.alt}
width={342}
height={692}
sizes="(max-width: 1024px) 42vw, 240px"
className="h-auto w-full drop-shadow-[0_20px_30px_rgba(30,41,59,0.18)]"
/>
<figcaption className="glass glass-rim rounded-full px-3.5 py-1.5 text-[12px] font-medium text-slate-body">
{phone.label}
</figcaption>
</figure>
</RevealItem>
))}
</ul>
</div>
<Reveal delay={0.2}>
<ul className="mt-6 hidden flex-wrap items-center justify-center gap-2.5 lg:flex">
{PHONES.map((phone) => (
<li
key={phone.label}
className="glass glass-rim rounded-full px-4 py-2 text-[13px] font-medium text-slate-body"
>
{phone.label}
</li>
))}
</ul>
</Reveal>
</div>
</section>
);
}

814
app/components/sections.tsx Normal file
View file

@ -0,0 +1,814 @@
"use client";
import Image from "next/image";
import type { ComponentType, ReactNode } from "react";
import {
ArrowRight,
Building2,
CalendarDays,
CalendarX2,
Check,
CreditCard,
DoorOpen,
Globe,
Link2,
MessageCircle,
Settings2,
Ticket,
UploadCloud,
UserPlus,
Wallet,
} from "lucide-react";
import {
Counter,
MaskedWords,
Reveal,
RevealItem,
ScrollTracking,
Tilt,
} from "./motion";
import { BrowserFrame, ParallaxLayer } from "./mockup";
import { BrandButton, Eyebrow, Faq, GhostLink, WaitlistForm } from "./ui";
import { Logo } from "./logo";
/* ------------------------------------------------------------------ *
* Shared section scaffolding
* ------------------------------------------------------------------ */
function Section({
id,
children,
className = "",
}: {
id?: string;
children: ReactNode;
className?: string;
}) {
return (
<section id={id} className={`px-4 py-20 sm:py-[80px] ${className}`}>
<div className="mx-auto max-w-[1200px]">{children}</div>
</section>
);
}
function SectionHead({
eyebrow,
titleLines,
lead,
wash = "amber",
align = "left",
}: {
eyebrow: string;
/** Each entry is one visual line, split into words for the masked reveal. */
titleLines: ReactNode[][];
lead?: string;
wash?: "amber" | "sky" | "mint" | "peach";
align?: "left" | "center";
}) {
const centered = align === "center";
return (
<div className={centered ? "mx-auto max-w-[760px] text-center" : "max-w-[760px]"}>
<Reveal>
<Eyebrow wash={wash}>{eyebrow}</Eyebrow>
</Reveal>
{/* Tracking tightens as the heading scrolls toward centre — the type
settles into its final, compressed form. */}
<ScrollTracking from={0.02} to={-0.032}>
<h2 className="mt-5 font-display text-[clamp(2rem,4.8vw,2.9rem)] font-semibold leading-[1.06] text-ink">
{titleLines.map((line, index) => (
<MaskedWords
key={index}
className="block"
words={line}
delay={index * 0.08}
/>
))}
</h2>
</ScrollTracking>
{lead ? (
<Reveal delay={0.18}>
<p
className={`mt-5 text-body-lg text-slate-body ${centered ? "mx-auto max-w-[560px]" : "max-w-[560px]"}`}
>
{lead}
</p>
</Reveal>
) : null}
</div>
);
}
type Item = {
title: string;
body: string;
Icon: ComponentType<{ className?: string; strokeWidth?: number }>;
};
/* ------------------------------------------------------------------ *
* Problem
* ------------------------------------------------------------------ */
const PROBLEMS: Item[] = [
{
title: "Buchungen kommen per DM",
body: "Instagram, WhatsApp, Telefon, zwischen zwei Kursen. Die Teilnehmerliste führst du im Kopf oder in einer Tabelle.",
Icon: MessageCircle,
},
{
title: "Wer hat eigentlich bezahlt?",
body: "Bar, Überweisung, „bring ich nächstes Mal mit“. Nachfassen kostet dich jede Woche Stunden — und manchmal den Betrag.",
Icon: Wallet,
},
{
title: "Raum doppelt belegt",
body: "Zwei Kurse, ein Raum, eine Trainerin. Auffallen tut es oft erst am Kurstag — vor wartenden Kunden.",
Icon: Building2,
},
{
title: "Abgesagt heißt leer",
body: "Kurzfristige Stornos bleiben unbesetzt, obwohl drei Leute gewartet hätten. Bezahlt hat am Ende niemand.",
Icon: CalendarX2,
},
];
export function Problem() {
return (
<Section id="problem">
<SectionHead
eyebrow="Das Problem"
wash="peach"
titleLines={[
["Der", "Kurs", "ist", "ausgebucht."],
[
"Dein",
"Kopf",
<em key="leider" className="brand-text italic">
leider
</em>,
"auch.",
],
]}
lead="Du bist Trainerin, nicht Sachbearbeiterin. Trotzdem geht jede Woche ein halber Arbeitstag für Koordination drauf."
/>
<ul className="mt-14 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{PROBLEMS.map(({ title, body, Icon }, index) => (
<RevealItem key={title} delay={index * 0.08}>
<div className="h-full glass glass-rim rounded-cards p-5 transition-transform duration-500 ease-[var(--ease-out-soft)] hover:-translate-y-1">
<span className="flex size-9 items-center justify-center rounded-full bg-peach-wash text-peach-ink">
<Icon className="size-[18px]" strokeWidth={1.7} />
</span>
<h3 className="mt-4 text-body-lg font-semibold leading-snug text-ink">
{title}
</h3>
<p className="mt-2 text-body text-slate-body">{body}</p>
</div>
</RevealItem>
))}
</ul>
</Section>
);
}
/* ------------------------------------------------------------------ *
* Solution
* ------------------------------------------------------------------ */
const FEATURES: Item[] = [
{
title: "Kurse & Serientermine",
body: "Einzeltermin oder Dauerauftrag in wenigen Klicks. Level, Preis, Teilnehmerzahl, Raum und Trainer gehören direkt dazu.",
Icon: CalendarDays,
},
{
title: "Öffentliche Buchungsseite",
body: "Deine eigene Seite mit Kalender, Kursfiltern und deinem Logo. Gastbuchung ohne Account — kein Abbruch an der Registrierung.",
Icon: Globe,
},
{
title: "Zahlung läuft über Stripe",
body: "Bezahlt wird beim Buchen, nicht beim Nachfragen. Du siehst jeden Eingang in Echtzeit, Rechnungen sind GoBD-konform und als ZIP exportierbar.",
Icon: CreditCard,
},
{
title: "Warteliste, die nachrückt",
body: "Sagt jemand ab, rückt der nächste automatisch auf den Platz. Der Kurs bleibt voll, ohne dass du eine Nachricht schreibst.",
Icon: UserPlus,
},
{
title: "Räume & Trainer",
body: "Wer unterrichtet wo, mit welchen Rechten. Doppelbelegungen fallen beim Anlegen auf — nicht am Kurstag.",
Icon: DoorOpen,
},
{
title: "Rabattcodes & Stornoregeln",
body: "Prozentual oder als Fixbetrag, mit Nutzungslimit und Gültigkeitszeitraum. Stornofristen legst du einmal fest, danach gelten sie automatisch.",
Icon: Ticket,
},
];
export function Solution() {
return (
<Section id="funktionen">
<SectionHead
eyebrow="Die Lösung"
wash="mint"
titleLines={[
["Ein", "System", "statt", "drei", "Tools"],
[
"und",
<em key="einer" className="brand-text italic">
einer
</em>,
"Tabelle.",
],
]}
lead="Alles, was ein Kursbetrieb wirklich braucht — und nichts, wofür du eine Schulung brauchst."
/>
<ul className="mt-14 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{FEATURES.map(({ title, body, Icon }, index) => (
<RevealItem key={title} delay={(index % 3) * 0.08}>
<div className="h-full glass glass-rim rounded-cards p-6 transition-transform duration-500 ease-[var(--ease-out-soft)] hover:-translate-y-1">
<span className="flex size-10 items-center justify-center rounded-full bg-amber-soft text-[#8a6d00]">
<Icon className="size-5" strokeWidth={1.6} />
</span>
<h3 className="mt-5 text-subheading font-semibold tracking-[-0.26px] text-ink">
{title}
</h3>
<p className="mt-2.5 text-body text-slate-body">{body}</p>
</div>
</RevealItem>
))}
</ul>
</Section>
);
}
/* ------------------------------------------------------------------ *
* Showcase — alternating sticky-ish parallax rows
* ------------------------------------------------------------------ */
const SHOTS = [
{
src: "/booking-page.png",
url: "since3.kumasy.de",
eyebrow: "Für deine Kundinnen",
title: "Die Seite, die dein Kunde sieht",
body: "Kalender, Kursfilter, Preis, freie Plätze — und ein Button. Buchen ohne Account möglich, damit niemand an der Registrierung abspringt.",
alt: "Öffentliche Buchungsseite mit Monatskalender, Kursfiltern und zwei buchbaren Kursen",
depth: 70,
},
{
src: "/courses.png",
url: "app.kumasy.de/kurse",
eyebrow: "Dein Kursplan",
title: "Kurse im Griff",
body: "Nach Tag sortiert, mit Belegung, Level, Raum und Status. Entwürfe veröffentlichst du gesammelt statt einzeln.",
alt: "Kursübersicht mit nach Datum gruppierten Kurskarten, Belegung und Veröffentlichungsstatus",
depth: 50,
},
{
src: "/payments.png",
url: "app.kumasy.de/zahlungen",
eyebrow: "Deine Zahlen",
title: "Geld, nachvollziehbar",
body: "Umsatz, offene Posten, Stornos und Teilerstattungen auf einer Seite. Alle Rechnungen auf einmal als ZIP.",
alt: "Zahlungsübersicht mit Monatsumsatz, offenen Posten und einer Tabelle aller Transaktionen",
depth: 70,
},
];
export function Showcase() {
return (
<Section id="einblick">
<div className="flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
<SectionHead
eyebrow="Einblick"
titleLines={[
["So", "sieht", "dein", "Studio"],
[
"von",
<em key="innen" className="brand-text italic">
innen
</em>,
"aus.",
],
]}
lead="Keine Render-Bilder — das ist die Oberfläche, mit der unser Pilotstudio heute arbeitet."
/>
<Reveal delay={0.2}>
<BrandButton href="#warteliste">
Zugang sichern
<ArrowRight className="size-4" strokeWidth={2} />
</BrandButton>
</Reveal>
</div>
<div className="mt-20 flex flex-col gap-20 lg:gap-28">
{SHOTS.map((shot, index) => (
<div key={shot.src}>
{/* Copy sits above the shot so the screenshot gets the full
column width — at half-width the UI text was unreadable. */}
<Reveal className="mx-auto max-w-[620px] text-center">
<Eyebrow wash={index % 2 === 1 ? "peach" : "sky"}>
{shot.eyebrow}
</Eyebrow>
<h3 className="mt-5 font-display text-heading-sm font-semibold tracking-[-0.4px] text-ink">
{shot.title}
</h3>
<p className="mx-auto mt-4 max-w-[540px] text-body-lg text-slate-body">
{shot.body}
</p>
</Reveal>
{/* Tinted stage: the screenshot rests on colour rather than
floating on the flat page. */}
<div className="relative mt-10 overflow-hidden rounded-[32px] px-4 pb-0 pt-10 sm:px-10 sm:pt-14">
<div aria-hidden className="absolute inset-0 -z-10">
<div className="aurora absolute inset-0 opacity-80" />
<div
className="absolute inset-x-0 bottom-0 h-1/3"
style={{
background:
"linear-gradient(to top, rgba(249,250,251,0.9), transparent)",
}}
/>
</div>
<ParallaxLayer depth={shot.depth}>
<Tilt max={3}>
<BrowserFrame url={shot.url} className="mx-auto max-w-[980px]">
<Image
src={shot.src}
alt={shot.alt}
width={1918}
height={911}
sizes="(max-width: 1024px) 94vw, 980px"
className="h-auto w-full"
/>
</BrowserFrame>
</Tilt>
</ParallaxLayer>
</div>
</div>
))}
</div>
</Section>
);
}
/* ------------------------------------------------------------------ *
* Steps
* ------------------------------------------------------------------ */
const STEPS: Item[] = [
{
title: "Daten übernehmen",
body: "Unser Migrationstool zieht Kurse, Kundinnen und Termine aus deinem bisherigen System in Kumasy. Du tippst nichts ab.",
Icon: UploadCloud,
},
{
title: "Einmal einrichten",
body: "Räume, Trainer, Preise, Stornoregeln und dein Branding. Danach legst du nur noch Termine an.",
Icon: Settings2,
},
{
title: "Link teilen",
body: "Deine Buchungsseite kommt in Instagram-Bio, Website und Newsletter. Ab da buchen und bezahlen deine Kundinnen selbst.",
Icon: Link2,
},
];
export function Steps() {
return (
<Section>
<SectionHead
eyebrow="In drei Schritten"
wash="mint"
align="center"
titleLines={[
["Vom", "ersten", "Login"],
[
"bis",
"zur",
<em key="ersten" className="brand-text italic">
ersten
</em>,
"Buchung.",
],
]}
/>
<ol className="mt-14 grid gap-4 md:grid-cols-3">
{STEPS.map(({ title, body, Icon }, index) => (
<RevealItem key={title} delay={index * 0.11}>
<div className="h-full glass glass-rim rounded-cards p-6">
<div className="flex items-center justify-between">
<span className="flex size-10 items-center justify-center rounded-full bg-mint-wash text-mint-ink">
<Icon className="size-5" strokeWidth={1.6} />
</span>
<span
aria-hidden
className="font-display text-heading-sm font-semibold leading-none"
style={{
background: "var(--gradient-amber)",
WebkitBackgroundClip: "text",
backgroundClip: "text",
color: "transparent",
}}
>
{index + 1}
</span>
</div>
<h3 className="mt-5 text-subheading font-semibold tracking-[-0.26px] text-ink">
{title}
</h3>
<p className="mt-2.5 text-body text-slate-body">{body}</p>
</div>
</RevealItem>
))}
</ol>
</Section>
);
}
/* ------------------------------------------------------------------ *
* Pricing
* ------------------------------------------------------------------ */
const INCLUDED = [
"Öffentliche Buchungsseite inklusive Gastbuchung, Warteliste und Rabattcodes",
"Zahlungen über Stripe, GoBD-konforme Rechnungen, Sammelexport",
"Migration deiner bestehenden Daten mit unserem Migrationstool",
"Support von echten Menschen, erreichbar und ansprechbar — kein KI-Chatbot",
"Keine Einrichtungsgebühr, keine Umsatzbeteiligung, keine Preisstufen",
];
export function Pricing() {
return (
<Section id="preis">
<SectionHead
eyebrow="Preis"
align="center"
titleLines={[
["Ein", "Preis.", "Keine", "Staffelung."],
[
"Keine",
<em key="provision" className="brand-text italic">
Provision.
</em>,
],
]}
lead="Du sollst am Monatsende wissen, was Kumasy kostet — ohne Taschenrechner."
/>
<div className="mt-14 grid gap-4 lg:grid-cols-[1.15fr_1fr]">
<Reveal>
<div className="flex h-full flex-col glass-xl glass-rim rounded-cards p-8">
<p className="flex items-baseline gap-2">
<Counter
to={150}
suffix=" €"
className="font-display text-heading font-semibold tracking-[-1.15px] text-ink"
/>
<span className="text-body text-slate-helper">pro Monat</span>
</p>
<p className="mt-2 text-body-lg text-slate-body">
Alle Funktionen. Unbegrenzt Kurse, Trainer und Kundinnen.
</p>
<ul className="mt-7 flex flex-col gap-3.5">
{INCLUDED.map((item) => (
<li key={item} className="flex gap-3 text-body text-slate-body">
<Check
className="mt-1 size-4 shrink-0 text-mint-ink"
strokeWidth={2.4}
/>
{item}
</li>
))}
</ul>
<div className="mt-8 flex flex-col items-start gap-3">
<BrandButton href="#warteliste">
Platz auf der Warteliste sichern
</BrandButton>
<p className="text-[13px] text-slate-helper">
[Mindestlaufzeit und Kündigungsfrist hier ergänzen]
</p>
</div>
</div>
</Reveal>
<div className="flex flex-col gap-4">
<Reveal delay={0.09}>
<div className="rounded-cards glass glass-rim bg-sky-wash/50 p-7">
<p className="flex items-baseline gap-2">
<span className="font-display text-heading-sm font-semibold tracking-[-0.4px] text-ink">
+ 0,50 €
</span>
<span className="text-body text-sky-ink">pro Buchung</span>
</p>
<p className="mt-3 text-body text-slate-body">
Trägt deine Kundin beim Checkout, nicht du. Für dich bleibt die
monatliche Rechnung planbar bei 150 €.
</p>
</div>
</Reveal>
<Reveal delay={0.16}>
<div className="rounded-cards glass glass-rim bg-peach-wash/50 p-7">
<Eyebrow wash="peach" className="!bg-white/70">
Transparenz-Hinweis
</Eyebrow>
<p className="mt-3 text-body text-slate-body">
Zusätzlich fallen die Transaktionsgebühren von Stripe bzw.
PayPal nach deren jeweils gültiger Preisliste an. Diese rechnen
die Zahlungsdienstleister direkt ab — sie laufen nicht über uns,
und wir verdienen nichts daran.
</p>
</div>
</Reveal>
</div>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ *
* Objections
* ------------------------------------------------------------------ */
const OBJECTIONS = [
{
eyebrow: null,
title: "„Ich bin zu klein dafür.“",
body: "Es gibt keine Mindestgröße und keine Staffelpreise. Wenn du regelmäßig Kurse gibst, rechnet sich das System ab den ersten Stunden, die du nicht mehr mit Buchungs- und Zahlungs-Nachverfolgung verbringst.",
},
{
eyebrow: null,
title: "„Ich bin schon woanders.“",
body: "Unser Migrationstool übernimmt deine bestehenden Daten automatisch — der Umzug ist kein Wochenendprojekt. Kumasy ist günstiger und aufgeräumter, komplett in Deutschland entwickelt, und im Support sitzt ein Mensch mit Namen statt eines KI-Chatbots.",
},
{
eyebrow: "Auf der Roadmap",
title: "„Und was kommt danach?“",
body: "Wir arbeiten daran, wiederkehrende Abläufe im Studioalltag weitgehend per KI zu automatisieren. Wer jetzt auf der Warteliste steht, bekommt das zuerst zu sehen.",
},
];
export function Objections() {
return (
<Section>
<SectionHead
eyebrow="Ehrlich gesagt"
wash="peach"
align="center"
titleLines={[["Was", "du", "jetzt"], ["wahrscheinlich", "denkst."]]}
/>
<ul className="mt-14 grid gap-4 md:grid-cols-3">
{OBJECTIONS.map((item, index) => (
<RevealItem key={item.title} delay={index * 0.1}>
<div className="h-full glass glass-rim rounded-cards p-6">
{item.eyebrow ? (
<Eyebrow wash="mint" className="mb-3">
{item.eyebrow}
</Eyebrow>
) : null}
<h3 className="text-subheading font-semibold tracking-[-0.26px] text-ink">
{item.title}
</h3>
<p className="mt-3 text-body text-slate-body">{item.body}</p>
</div>
</RevealItem>
))}
</ul>
</Section>
);
}
/* ------------------------------------------------------------------ *
* Pilot studio
* ------------------------------------------------------------------ */
const TECH = [
"Zahlungsabwicklung über Stripe",
"Hosting über Vercel, Region Frankfurt",
"DSGVO-konform, Daten bleiben in der EU",
"GoBD-konforme Rechnungsstellung",
];
export function Pilot() {
return (
<Section>
<div className="grid gap-4 lg:grid-cols-[1.35fr_1fr]">
<Reveal>
<div className="flex h-full flex-col justify-between glass glass-rim rounded-cards p-8">
<div>
<Eyebrow wash="mint">Pilotstudio</Eyebrow>
<h2 className="mt-5 font-display text-[clamp(1.75rem,3.4vw,2.25rem)] font-semibold leading-[1.08] tracking-[-0.026em] text-ink">
Kumasy läuft im Alltag bei 120gradStudio.
</h2>
<p className="mt-4 max-w-[520px] text-body-lg text-slate-body">
Wir testen jede Funktion zuerst dort, bevor sie in die
Warteliste-Freigabe geht — an echten Kursen, echten Buchungen
und echten Zahlungen.
</p>
</div>
<blockquote className="mt-8 border-l-2 border-hairline pl-5">
<p className="text-body-lg italic text-ink">
„[Hier das Zitat von 120gradStudio einsetzen — ein bis zwei
Sätze, am besten mit konkreter Zahl.]“
</p>
<footer className="mt-3 text-body text-slate-helper">
[Name], 120gradStudio
</footer>
</blockquote>
</div>
</Reveal>
<Reveal delay={0.1}>
<div className="glass-ink glass-rim h-full rounded-cards p-8">
<Eyebrow wash="amber" className="!border-white/25 !bg-white/15 !text-amber">
Technik & Recht
</Eyebrow>
<ul className="mt-6 flex flex-col gap-4">
{TECH.map((item) => (
<li key={item} className="flex gap-3 text-body text-white/85">
<Check
className="mt-1 size-4 shrink-0 text-amber"
strokeWidth={2.2}
/>
{item}
</li>
))}
</ul>
</div>
</Reveal>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ *
* FAQ
* ------------------------------------------------------------------ */
export function FaqSection() {
return (
<Section id="faq">
<div className="grid gap-10 lg:grid-cols-[0.8fr_1.2fr]">
<div>
<Reveal>
<Eyebrow>FAQ</Eyebrow>
</Reveal>
<h2 className="mt-5 font-display text-[clamp(2rem,4.6vw,2.75rem)] font-semibold leading-[1.08] tracking-[-0.032em] text-ink">
<MaskedWords words={["Häufige", "Fragen"]} />
</h2>
<Reveal delay={0.15}>
<p className="mt-4 text-body text-slate-body">
Deine Frage ist nicht dabei? Schreib uns — es antwortet ein
Mensch.
</p>
</Reveal>
<Reveal delay={0.2}>
<GhostLink href="mailto:info@kumasy.de" className="mt-3">
info@kumasy.de
<ArrowRight className="size-4" strokeWidth={2} />
</GhostLink>
</Reveal>
</div>
<Reveal delay={0.1}>
<div className="border-t border-hairline">
<Faq question="Brauchen meine Kundinnen einen Account?">
Nein. Gastbuchung ohne Registrierung ist Standard — genau da
springen sonst die meisten ab. Wer will, legt sich trotzdem ein
Konto an und sieht seine Buchungshistorie.
</Faq>
<Faq question="Was passiert mit meinen bestehenden Daten?">
Unser Migrationstool pflegt Kurse, Kundinnen und Termine aus
deinem alten System automatisch in Kumasy ein. Du musst nichts
abtippen und kannst vorher prüfen, was übernommen wird.
</Faq>
<Faq question="Wer zahlt die 0,50 € pro Buchung?">
Deine Kundin, beim Checkout. Dein monatlicher Betrag bleibt bei
150 € — unabhängig davon, ob du 20 oder 2.000 Buchungen im Monat
hast. Dazu kommen nur die Transaktionsgebühren von Stripe bzw.
PayPal.
</Faq>
<Faq question="Ist Kumasy DSGVO-konform?">
Ja. Gehostet wird über Vercel in der Region Frankfurt, Zahlungen
laufen über Stripe, Rechnungen werden GoBD-konform erstellt.
[Auftragsverarbeitungsvertrag und Datenschutzerklärung hier
verlinken.]
</Faq>
<Faq question="Wann kann ich starten?">
Wir sind in der Pilotphase und geben nach und nach Zugänge frei.
Du bekommst eine E-Mail, sobald du dran bist.
</Faq>
</div>
</Reveal>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ *
* Closing CTA
* ------------------------------------------------------------------ */
export function ClosingCta() {
return (
<Section className="pb-28">
<Reveal>
<div className="relative isolate overflow-hidden glass-xl glass-rim rounded-cards px-6 py-16 text-center sm:px-12">
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-[3px]"
style={{ background: "var(--gradient-amber)" }}
/>
<div
aria-hidden
className="blob pointer-events-none absolute left-1/2 top-full size-[520px] -translate-x-1/2 -translate-y-1/2 rounded-full opacity-25"
style={{
background:
"radial-gradient(circle, rgba(38,192,255,0.55) 0%, transparent 68%)",
filter: "blur(70px)",
}}
/>
<Eyebrow>Pilotphase · Warteliste</Eyebrow>
<h2 className="mx-auto mt-6 max-w-[620px] font-display text-[clamp(2rem,5vw,3.0625rem)] font-semibold leading-[1.04] tracking-[-0.04em] text-ink">
<MaskedWords
words={[
"Sei",
"dabei,",
"bevor",
"wir",
<em key="oeffnen" className="brand-text italic">
öffnen
</em>,
]}
/>
</h2>
<Reveal delay={0.25}>
<p className="mx-auto mt-5 max-w-[460px] text-body-lg text-slate-body">
Trag dich ein und wir melden uns, sobald dein Zugang bereit ist.
Keine Zahlungsdaten, keine Verpflichtung.
</p>
</Reveal>
<Reveal delay={0.32}>
<div className="mx-auto mt-9 max-w-[520px] text-left">
<WaitlistForm compact />
</div>
</Reveal>
</div>
</Reveal>
</Section>
);
}
/* ------------------------------------------------------------------ *
* Footer
* ------------------------------------------------------------------ */
export function Footer() {
return (
<footer className="border-t border-hairline px-4 py-10">
<div className="mx-auto flex max-w-[1200px] flex-col gap-6 sm:flex-row sm:items-center sm:justify-between">
<Logo />
<nav aria-label="Rechtliches">
<ul className="flex flex-wrap items-center gap-x-6 gap-y-2 text-body text-slate-helper">
<li>
<a href="mailto:info@kumasy.de" className="hover:text-ink">
info@kumasy.de
</a>
</li>
<li>
<a href="/impressum" className="hover:text-ink">
Impressum
</a>
</li>
<li>
<a href="/datenschutz" className="hover:text-ink">
Datenschutz
</a>
</li>
<li>
<a href="/agb" className="hover:text-ink">
AGB
</a>
</li>
</ul>
</nav>
</div>
</footer>
);
}

View file

@ -0,0 +1,65 @@
"use client";
import { useEffect } from "react";
import Lenis from "lenis";
import { useReducedMotion } from "./motion";
/**
* Momentum scrolling. The wheel no longer maps 1:1 to scroll position —
* it feeds a spring that eases toward the target, so every scroll-linked
* animation on the page inherits the same softened motion.
*
* Disabled entirely under prefers-reduced-motion: hijacking the scroll is
* exactly what that preference asks us not to do.
*/
export function SmoothScroll() {
const reduced = useReducedMotion();
useEffect(() => {
if (reduced) return;
const lenis = new Lenis({
// Slightly longer than default so the easing is felt, not just seen.
duration: 1.1,
easing: (t: number) => 1 - Math.pow(1 - t, 3),
// Touch devices keep their native scrolling; overriding it there
// fights the platform and breaks momentum handoff.
smoothWheel: true,
touchMultiplier: 1.6,
});
let frame = 0;
const raf = (time: number) => {
lenis.raf(time);
frame = requestAnimationFrame(raf);
};
frame = requestAnimationFrame(raf);
// In-page anchors must go through Lenis, or they jump past the easing.
const onClick = (event: MouseEvent) => {
const anchor = (event.target as HTMLElement | null)?.closest?.(
'a[href^="#"]',
) as HTMLAnchorElement | null;
if (!anchor) return;
const id = anchor.getAttribute("href");
if (!id || id === "#") return;
const target = document.querySelector(id);
if (!target) return;
event.preventDefault();
lenis.scrollTo(target as HTMLElement, { offset: -104 });
};
document.addEventListener("click", onClick);
return () => {
document.removeEventListener("click", onClick);
cancelAnimationFrame(frame);
lenis.destroy();
};
}, [reduced]);
return null;
}

390
app/components/ui.tsx Normal file
View file

@ -0,0 +1,390 @@
"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>
);
}

View file

@ -1,26 +1,307 @@
@import "tailwindcss";
@theme {
/* ── Brand ──────────────────────────────────────────────
Sampled from public/icons/icon-512.png: the amber tile
and the navy mark. These two carry the whole palette. */
--color-amber: #f4c00c;
--color-amber-deep: #d9a800;
--color-amber-soft: #ffe9a3;
--color-ink: #1e293b;
--color-ink-soft: #334155;
--color-slate-body: #475569;
--color-slate-helper: #64748b;
--color-slate-quiet: #94a3b8;
/* Surfaces & lines */
--color-canvas: #f9fafb;
--color-paper: #ffffff;
--color-hairline: #e2e8f0;
--color-mist: #eef2f6;
/* Status washes, borrowed from the app's badge colours */
--color-mint-wash: #dcfce7;
--color-mint-ink: #15803d;
--color-sky-wash: #e0f2fe;
--color-sky-ink: #0369a1;
--color-peach-wash: #ffedd5;
--color-peach-ink: #c2410c;
/* ── Typography ─────────────────────────────────────── */
--font-switzer: var(--font-switzer-local), ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-display: var(--font-display-local), ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--text-caption: 10px;
--text-caption--line-height: 1.5;
--text-caption--letter-spacing: 1.4px;
--text-body: 16px;
--text-body--line-height: 1.5;
--text-body-lg: 18px;
--text-body-lg--line-height: 1.45;
--text-subheading: 20px;
--text-subheading--line-height: 1.43;
--text-subheading--letter-spacing: -0.26px;
--text-heading-sm: 31px;
--text-heading-sm--line-height: 1.1;
--text-heading-sm--letter-spacing: -0.4px;
--text-heading: 44px;
--text-heading--line-height: 1.08;
--text-heading--letter-spacing: -1.15px;
--text-heading-lg: 49px;
--text-heading-lg--line-height: 1.04;
--text-heading-lg--letter-spacing: -1.96px;
--text-display: 76px;
--text-display--line-height: 1;
--text-display--letter-spacing: -4.25px;
/* ── Radii ──────────────────────────────────────────── */
--radius-cards: 24px;
--radius-images: 24px;
--radius-inputs: 16px;
--radius-buttons: 28px;
--radius-nav: 28px;
/* ── Shadows ────────────────────────────────────────── */
--shadow-float: 0 16px 16px -8px rgba(30, 41, 59, 0.04),
0 10px 10px -5px rgba(30, 41, 59, 0.04), 0 5px 5px -2.5px rgba(30, 41, 59, 0.03),
0 3px 3px -1.5px rgba(30, 41, 59, 0.03), 0 1px 1px -0.5px rgba(30, 41, 59, 0.03);
--shadow-card: 0 0 0 1px rgba(30, 41, 59, 0.06), 0 16px 16px -8px rgba(30, 41, 59, 0.04),
0 10px 10px -5px rgba(30, 41, 59, 0.03), 0 5px 5px -2.5px rgba(30, 41, 59, 0.03),
0 2px 2px -1px rgba(30, 41, 59, 0.03);
--shadow-elevated: 0 0 0 1px rgba(30, 41, 59, 0.05),
0 28px 32px -12px rgba(30, 41, 59, 0.1), 0 12px 14px -6px rgba(30, 41, 59, 0.05),
0 5px 5px -2.5px rgba(30, 41, 59, 0.03), 0 2px 2px -1px rgba(30, 41, 59, 0.03);
--shadow-amber: 0 18px 40px -14px rgba(217, 168, 0, 0.55);
/* ── Motion ─────────────────────────────────────────── */
--ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1);
--ease-spring: cubic-bezier(0.34, 1.4, 0.64, 1);
}
:root {
--background: #ffffff;
--foreground: #171717;
/* The signature gradient: amber into navy, used on accents and
as the light source behind glass. */
--gradient-brand: linear-gradient(115deg, #f4c00c 0%, #f0a500 42%, #1e293b 100%);
--gradient-amber: linear-gradient(120deg, #ffd84d 0%, #f4c00c 45%, #e09b00 100%);
--gradient-ink: linear-gradient(150deg, #334155 0%, #1e293b 55%, #0f172a 100%);
--page-max: 1200px;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
html {
/* Native smooth scrolling would fight Lenis. Under reduced motion Lenis
never starts, and jumping instantly is the correct behaviour anyway. */
scroll-behavior: auto;
scroll-padding-top: 104px;
-webkit-text-size-adjust: 100%;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
/* Lenis drives scrolling via transform on <html>; these are its required
host styles. Without them the page height collapses on some browsers. */
html.lenis,
html.lenis body {
height: auto;
}
.lenis.lenis-smooth {
scroll-behavior: auto !important;
}
.lenis.lenis-stopped {
overflow: hidden;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
background: var(--color-canvas);
color: var(--color-ink);
font-family: var(--font-switzer);
overflow-x: clip;
}
::selection {
background: var(--color-amber);
color: var(--color-ink);
}
:focus-visible {
outline: 2px solid var(--color-ink);
outline-offset: 3px;
border-radius: 6px;
}
/* ── Glass system ─────────────────────────────────────────
The four ingredients from DESIGN-SYSTEM.md, bundled once:
translucent fill, backdrop blur, light edge, soft shadow.
Intensity scales with the surface's visual priority. */
.glass {
background: rgba(255, 255, 255, 0.58);
backdrop-filter: blur(24px) saturate(160%);
-webkit-backdrop-filter: blur(24px) saturate(160%);
border: 1px solid rgba(255, 255, 255, 0.65);
box-shadow: var(--shadow-card);
}
.glass-sm {
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(10px) saturate(140%);
-webkit-backdrop-filter: blur(10px) saturate(140%);
border: 1px solid rgba(255, 255, 255, 0.7);
box-shadow: var(--shadow-float);
}
.glass-xl {
background: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(40px) saturate(180%);
-webkit-backdrop-filter: blur(40px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.7);
box-shadow: var(--shadow-elevated);
}
/* Dark frosted panel — the auth-panel treatment from the app. */
.glass-ink {
background: rgba(30, 41, 59, 0.72);
backdrop-filter: blur(40px) saturate(150%);
-webkit-backdrop-filter: blur(40px) saturate(150%);
border: 1px solid rgba(255, 255, 255, 0.14);
box-shadow: var(--shadow-elevated);
}
/* A top light-edge, as if a highlight catches the glass rim. */
.glass-rim {
position: relative;
}
.glass-rim::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding-top: 1px;
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.9),
transparent 40%
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
/* Backdrop-filter is expensive; on small screens trade blur for opacity. */
@media (max-width: 640px) {
.glass,
.glass-xl {
backdrop-filter: blur(14px) saturate(150%);
-webkit-backdrop-filter: blur(14px) saturate(150%);
background: rgba(255, 255, 255, 0.78);
}
}
@supports not (backdrop-filter: blur(1px)) {
.glass,
.glass-sm,
.glass-xl {
background: rgba(255, 255, 255, 0.94);
}
.glass-ink {
background: rgba(30, 41, 59, 0.94);
}
}
/* ── Brand gradient primitives ────────────────────────── */
.brand-text {
background: var(--gradient-amber);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
padding-inline-end: 0.06em;
}
/* Amber gradient border via double-background. */
.brand-border {
background:
linear-gradient(var(--color-paper), var(--color-paper)) padding-box,
var(--gradient-amber) border-box;
border: 1.5px solid transparent;
}
/* ── Ambient motion ───────────────────────────────────── */
@keyframes blob-float {
0%,
100% {
transform: translate3d(0, 0, 0) scale(1);
}
50% {
transform: translate3d(3%, -4%, 0) scale(1.08);
}
}
.blob {
animation: blob-float 18s var(--ease-out-soft) infinite;
will-change: transform;
}
@keyframes aurora-shift {
0%,
100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
/* Slow-drifting wash so the glass always has something to blur. */
.aurora {
background: linear-gradient(
120deg,
rgba(244, 192, 12, 0.22) 0%,
rgba(255, 216, 77, 0.16) 22%,
rgba(224, 242, 254, 0.3) 48%,
rgba(30, 41, 59, 0.1) 74%,
rgba(244, 192, 12, 0.2) 100%
);
background-size: 300% 300%;
animation: aurora-shift 22s ease-in-out infinite;
}
@keyframes marquee {
from {
transform: translate3d(0, 0, 0);
}
to {
transform: translate3d(-50%, 0, 0);
}
}
.marquee-track {
animation: marquee 42s linear infinite;
will-change: transform;
}
/* ── Accessibility ────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
.blob,
.marquee-track,
.aurora {
animation: none !important;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View file

@ -1,29 +1,88 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import type { Metadata, Viewport } from "next";
import { Manrope, Plus_Jakarta_Sans } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
// DESIGN.md names Switzer / Basier Circle; neither ships on Google Fonts.
// These are the documented substitutes — Manrope for UI, Plus Jakarta Sans for
// display, which holds the tight negative tracking the brand depends on.
const switzer = Manrope({
variable: "--font-switzer-local",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
display: "swap",
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
const display = Plus_Jakarta_Sans({
variable: "--font-display-local",
subsets: ["latin"],
weight: ["500", "600"],
style: ["normal", "italic"],
display: "swap",
});
const SITE_URL = "https://kumasy.de";
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
metadataBase: new URL(SITE_URL),
title: {
default: "Kumasy — Deine Kurse füllen und bezahlen sich von selbst.",
template: "%s · Kumasy",
},
description:
"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.",
keywords: [
"Kursverwaltung",
"Buchungssystem",
"Fitnessstudio Software",
"Yoga Studio Verwaltung",
"Kursbuchung",
"Stripe Zahlungen",
"GoBD",
"DSGVO",
],
alternates: { canonical: "/" },
openGraph: {
type: "website",
locale: "de_DE",
url: SITE_URL,
siteName: "Kumasy",
title: "Deine Kurse füllen und bezahlen sich von selbst.",
description:
"Kurse, Räume, Trainer, Buchungen und Zahlungen in einer Oberfläche. Jetzt auf die Warteliste.",
},
twitter: {
card: "summary_large_image",
title: "Kumasy — Deine Kurse füllen und bezahlen sich von selbst.",
description:
"Kurse, Räume, Trainer, Buchungen und Zahlungen in einer Oberfläche.",
},
icons: {
icon: "/favicon.ico",
apple: "/icons/apple-touch-icon.png",
},
robots: { index: true, follow: true },
};
export const viewport: Viewport = {
themeColor: "#ffffff",
colorScheme: "light",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
lang="de"
className={`${switzer.variable} ${display.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full flex flex-col bg-canvas text-ink">
<a
href="#hauptinhalt"
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[100] focus:rounded-2xl focus:bg-canvas focus:px-5 focus:py-3 focus:text-body focus:font-medium focus:shadow-elevated"
>
Zum Hauptinhalt springen
</a>
{children}
</body>
</html>
);
}

123
app/opengraph-image.tsx Normal file
View file

@ -0,0 +1,123 @@
import { ImageResponse } from "next/og";
export const alt =
"Kumasy — Deine Kurse füllen und bezahlen sich von selbst.";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
// Static export: rendered once at build time.
export default function OpengraphImage() {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
background: "#f9fafb",
padding: 72,
fontFamily: "sans-serif",
}}
>
{/* Amber hairline, mirroring the site's scroll indicator. */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 10,
background:
"linear-gradient(90deg, #ffd84d 0%, #f4c00c 45%, #e09b00 100%)",
}}
/>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 56,
height: 56,
borderRadius: 14,
background: "linear-gradient(120deg, #ffd84d, #f4c00c 45%, #e09b00)",
}}
>
<svg width="34" height="34" viewBox="0 0 512 512" fill="#1e293b">
<rect x="153" y="118" width="71" height="277" rx="35.5" />
<path d="M234.6 299.6 317.4 220.5 A20 20 0 0 1 352 234.4 L352 379.6 A20 20 0 0 1 317.4 393.5 L234.6 314.4 A10 10 0 0 1 234.6 299.6 Z" />
</svg>
</div>
<div style={{ fontSize: 40, fontWeight: 600, color: "#1e293b" }}>
kumasy
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
color: "#1e293b",
}}
>
<div
style={{
fontSize: 78,
fontWeight: 700,
lineHeight: 1.04,
letterSpacing: -3,
}}
>
Deine Kurse füllen und
</div>
<div
style={{
fontSize: 78,
fontWeight: 700,
lineHeight: 1.04,
letterSpacing: -3,
}}
>
bezahlen sich von selbst.
</div>
<div
style={{
marginTop: 28,
fontSize: 30,
color: "#475569",
lineHeight: 1.4,
}}
>
Kurse, Räume, Trainer, Buchungen und Zahlungen — in einer
Oberfläche.
</div>
</div>
<div style={{ display: "flex", gap: 14 }}>
{["Stripe", "DSGVO-konform", "Hosting Frankfurt", "GoBD"].map(
(chip) => (
<div
key={chip}
style={{
display: "flex",
padding: "10px 20px",
borderRadius: 999,
background: "#ffffff",
color: "#475569",
border: "1px solid #e2e8f0",
fontSize: 22,
}}
>
{chip}
</div>
),
)}
</div>
</div>
),
size,
);
}

View file

@ -1,8 +1,59 @@
import { Hero } from "./components/hero";
import { ScrollProgress, ScrollStage } from "./components/motion";
import { PhoneShowcase } from "./components/phone-showcase";
import { SmoothScroll } from "./components/smooth-scroll";
import {
ClosingCta,
FaqSection,
Footer,
Objections,
Pilot,
Pricing,
Problem,
Showcase,
Solution,
Steps,
} from "./components/sections";
import { Nav } from "./components/ui";
export default function Home() {
export default function Page() {
return (
<div>
<>
<SmoothScroll />
<ScrollProgress />
<Nav />
</div>
<main id="hauptinhalt" className="flex-1">
{/* The hero is not staged — it is already at rest on load, and
fading it in as the page opens would hide the headline. */}
<Hero />
<ScrollStage>
<Problem />
</ScrollStage>
<ScrollStage>
<Solution />
</ScrollStage>
<Showcase />
<PhoneShowcase />
<ScrollStage>
<Steps />
</ScrollStage>
<ScrollStage>
<Pricing />
</ScrollStage>
<ScrollStage>
<Objections />
</ScrollStage>
<ScrollStage>
<Pilot />
</ScrollStage>
<ScrollStage>
<FaqSection />
</ScrollStage>
<ClosingCta />
</main>
<Footer />
</>
);
}