mirror of
https://github.com/Since-3/Kumasy-Marketing.git
synced 2026-10-12 20:51:18 +00:00
added site
This commit is contained in:
parent
6cd86a9495
commit
eb5aca3a87
34 changed files with 3720 additions and 30 deletions
208
app/components/hero.tsx
Normal file
208
app/components/hero.tsx
Normal 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
59
app/components/logo.tsx
Normal 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
222
app/components/mockup.tsx
Normal 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
488
app/components/motion.tsx
Normal 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)" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
245
app/components/phone-showcase.tsx
Normal file
245
app/components/phone-showcase.tsx
Normal 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
814
app/components/sections.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
app/components/smooth-scroll.tsx
Normal file
65
app/components/smooth-scroll.tsx
Normal 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
390
app/components/ui.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
311
app/globals.css
311
app/globals.css
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
123
app/opengraph-image.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
57
app/page.tsx
57
app/page.tsx
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue