Section headlines are written out behind a column of digits, mono labels resolve from binary, the portrait assembles itself from bits, section indexes rattle and keep one bit alive, cards get corner marks, the header shows the scroll position as an eight-bit number, pressed links and buttons shed a few digits, and the work stage leans towards the pointer. All effects respect reduced motion and leave the content readable without JavaScript. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
270 lines
10 KiB
TypeScript
270 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { motion } from "framer-motion";
|
|
import { Button } from "@/src/components/Button";
|
|
import { DecodeText } from "@/src/components/bits/DecodeText";
|
|
import { PathScene } from "@/src/components/illustrations/PathScenes";
|
|
import { SERVICE_PATHS, type ServicePath } from "@/src/content/paths";
|
|
|
|
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500";
|
|
const STEP_MS = 4800;
|
|
|
|
/**
|
|
* PathStory: the four steps of one path, each with a drawing that explains it.
|
|
* The steps advance on their own until the visitor picks one.
|
|
*/
|
|
const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
|
|
path,
|
|
active,
|
|
}) => {
|
|
const [step, setStep] = React.useState(0);
|
|
const [auto, setAuto] = React.useState(true);
|
|
const ref = React.useRef<HTMLDivElement>(null);
|
|
const [inView, setInView] = React.useState(false);
|
|
|
|
React.useEffect(() => {
|
|
const node = ref.current;
|
|
if (!node) return;
|
|
const observer = new IntersectionObserver(
|
|
([entry]) => setInView(entry?.isIntersecting ?? false),
|
|
{ threshold: 0.35 },
|
|
);
|
|
observer.observe(node);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (!active || !auto || !inView) return;
|
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
|
const timer = window.setInterval(
|
|
() => setStep((current) => (current + 1) % path.steps.length),
|
|
STEP_MS,
|
|
);
|
|
return () => window.clearInterval(timer);
|
|
}, [active, auto, inView, path.steps.length]);
|
|
|
|
const current = path.steps[step];
|
|
|
|
return (
|
|
<div ref={ref} className="grid grid-cols-1 gap-8 md:grid-cols-12 md:gap-12">
|
|
<div className="md:order-2 md:col-span-7">
|
|
<div className="relative overflow-hidden rounded-3xl bg-slate-900 md:sticky md:top-28">
|
|
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4 md:px-7">
|
|
<DecodeText
|
|
key={current?.title}
|
|
text={current?.title ?? ""}
|
|
className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-300"
|
|
/>
|
|
<span className="font-mono text-[11px] tracking-[0.2em] text-slate-500">
|
|
{step + 1} / {path.steps.length}
|
|
</span>
|
|
</div>
|
|
<div className="px-2 py-4 md:px-10 md:py-8">
|
|
<PathScene
|
|
pathId={path.id}
|
|
step={step}
|
|
title={current ? `${current.title}. ${current.text}` : path.need}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<ol className="m-0 list-none p-0 md:order-1 md:col-span-5">
|
|
{path.steps.map((item, index) => {
|
|
const selected = index === step;
|
|
return (
|
|
<li key={item.title}>
|
|
<button
|
|
type="button"
|
|
aria-current={selected ? "step" : undefined}
|
|
onClick={() => {
|
|
setStep(index);
|
|
setAuto(false);
|
|
}}
|
|
className={`relative flex w-full gap-5 border-t py-6 text-left transition-colors duration-300 md:gap-6 ${
|
|
selected ? "border-slate-900" : "border-slate-200"
|
|
}`}
|
|
>
|
|
{selected && auto && inView && (
|
|
<motion.span
|
|
key={`${path.id}-${index}`}
|
|
className="absolute left-0 top-[-1px] h-px bg-slate-900"
|
|
initial={{ width: "0%" }}
|
|
animate={{ width: "100%" }}
|
|
transition={{ duration: STEP_MS / 1000, ease: "linear" }}
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
<span
|
|
className={`w-8 shrink-0 font-sans text-3xl font-black leading-none tracking-tighter transition-colors duration-300 md:text-4xl ${
|
|
selected ? "text-slate-900" : "text-slate-200"
|
|
}`}
|
|
>
|
|
{index + 1}
|
|
</span>
|
|
<span>
|
|
<span
|
|
className={`block font-sans text-lg font-bold tracking-tight transition-colors duration-300 md:text-xl ${
|
|
selected ? "text-slate-900" : "text-slate-500"
|
|
}`}
|
|
>
|
|
{item.title}
|
|
</span>
|
|
<span
|
|
className={`mt-1.5 block font-sans text-base leading-relaxed transition-colors duration-300 ${
|
|
selected ? "text-slate-600" : "text-slate-400"
|
|
}`}
|
|
>
|
|
{item.text}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* PathPicker: the visitor says what they need and gets the answer for exactly that:
|
|
* what they get, how it runs, what it costs, what to do next.
|
|
* All three panels stay in the DOM so the content is readable without JavaScript.
|
|
*/
|
|
export const PathPicker: React.FC = () => {
|
|
const [activeId, setActiveId] = React.useState(SERVICE_PATHS[0]?.id ?? "");
|
|
|
|
return (
|
|
<div>
|
|
<div
|
|
role="tablist"
|
|
aria-label="What do you need?"
|
|
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4 lg:gap-4"
|
|
>
|
|
{SERVICE_PATHS.map((path, index) => {
|
|
const selected = path.id === activeId;
|
|
return (
|
|
<button
|
|
key={path.id}
|
|
type="button"
|
|
role="tab"
|
|
id={`path-tab-${path.id}`}
|
|
aria-selected={selected}
|
|
aria-controls={`path-panel-${path.id}`}
|
|
onClick={() => setActiveId(path.id)}
|
|
className={`bit-card group flex h-full flex-col justify-between rounded-3xl border p-5 text-left transition-all duration-300 md:p-7 ${
|
|
selected
|
|
? "border-slate-900 bg-slate-900 text-white"
|
|
: "border-slate-200 bg-white text-slate-900 hover:-translate-y-1 hover:border-slate-400"
|
|
}`}
|
|
>
|
|
<span className="font-mono text-[11px] tracking-[0.2em] text-slate-400">
|
|
0{index + 1}
|
|
</span>
|
|
<span className="mt-3 block font-sans text-xl font-bold leading-tight tracking-tight md:mt-10 md:text-2xl">
|
|
{path.need}
|
|
</span>
|
|
<span
|
|
className={`mt-2 block font-sans text-sm leading-relaxed md:mt-4 md:text-base ${selected ? "text-slate-300" : "text-slate-500"}`}
|
|
>
|
|
{path.forWhom}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{SERVICE_PATHS.map((path) => {
|
|
const selected = path.id === activeId;
|
|
return (
|
|
<div
|
|
key={path.id}
|
|
role="tabpanel"
|
|
id={`path-panel-${path.id}`}
|
|
aria-labelledby={`path-tab-${path.id}`}
|
|
hidden={!selected}
|
|
className="mt-12 md:mt-20"
|
|
>
|
|
<motion.div
|
|
key={selected ? "on" : "off"}
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.35 }}
|
|
>
|
|
<div className="grid grid-cols-1 gap-12 md:grid-cols-12 md:gap-12">
|
|
<div className="md:col-span-7">
|
|
<h3 className="m-0 font-sans text-3xl font-bold leading-[1.05] tracking-tighter text-slate-900 md:text-5xl">
|
|
{path.headline}
|
|
</h3>
|
|
<p className="m-0 mt-6 max-w-2xl font-sans text-lg leading-relaxed text-slate-500 md:text-xl">
|
|
{path.intro}
|
|
</p>
|
|
</div>
|
|
<div className="md:col-span-5">
|
|
<p className={`${MONO} m-0`}>What you get</p>
|
|
<ul className="m-0 mt-4 list-none p-0">
|
|
{path.youGet.map((item) => (
|
|
<li
|
|
key={item}
|
|
className="flex gap-4 border-t border-slate-200 py-4 font-sans text-base text-slate-900 md:text-lg"
|
|
>
|
|
<span
|
|
className="mt-2.5 h-1.5 w-1.5 shrink-0 rounded-full bg-slate-900"
|
|
aria-hidden="true"
|
|
/>
|
|
{item}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-14 md:mt-24">
|
|
<p className={`${MONO} m-0 mb-6`}>How it runs</p>
|
|
<PathStory path={path} active={selected} />
|
|
</div>
|
|
|
|
<div className="mt-12 rounded-3xl bg-slate-50 p-6 md:mt-16 md:p-10">
|
|
<p className={`${MONO} m-0`}>What it costs</p>
|
|
<div className="mt-6 grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-12">
|
|
<div className="md:col-span-6">
|
|
<p className="m-0 font-sans text-2xl font-bold leading-tight tracking-tight text-slate-900 md:text-4xl">
|
|
{path.pricingModel}
|
|
</p>
|
|
<p className="m-0 mt-4 font-sans text-base leading-relaxed text-slate-500 md:text-lg">
|
|
{path.pricingText}
|
|
</p>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 md:col-span-6">
|
|
{path.prices.map((price) => (
|
|
<div key={price.label}>
|
|
<p className="m-0 font-sans text-base text-slate-500 md:text-lg">
|
|
{price.label}
|
|
</p>
|
|
<p className="m-0 mt-2 font-sans text-3xl font-black tracking-tighter text-slate-900 md:text-5xl">
|
|
{price.amount}
|
|
</p>
|
|
<p className={`${MONO} m-0 mt-2`}>{price.note}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="mt-10 flex flex-col gap-6 border-t border-slate-200 pt-8 md:flex-row md:items-center md:justify-between">
|
|
<p className="m-0 max-w-2xl font-sans text-base leading-relaxed text-slate-500">
|
|
{path.smallPrint}
|
|
</p>
|
|
<Button href="#contact" size="large">
|
|
{path.cta}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|