mirror of
https://github.com/Since-3/Kumasy-Marketing.git
synced 2026-10-12 19:51:17 +00:00
245 lines
7.9 KiB
TypeScript
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>
|
|
);
|
|
}
|