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

245 lines
7.9 KiB
TypeScript

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