Kumasy-Marketing/app/components/mockup.tsx
2026-09-20 01:52:28 +02:00

222 lines
6.5 KiB
TypeScript

"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>
);
}