Files
mintel.me/apps/web/src/components/home/PathPicker.tsx
T
mmintelandClaude Opus 5.5 af66f61c26 feat: care offer for websites, with its price and its limits
What happens after a website is live was one vague sentence about a monthly
fee to be agreed. It is now one clear offer in the website case and in the
questions: 89 EUR per month, cancel monthly, the site runs on servers in
Germany, small changes by email are included, no editing system and no login.
The limits are said as plainly as what is included: the first three months
are part of the project price, anything bigger gets a price first, replies
within two working days, no on-call, and the client can leave with everything.

The decision and the parts that are not on the website (the larger variant,
CMS as an exception, how the sites are served) are in the concept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:21:46 +02:00

372 lines
15 KiB
TypeScript

"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { BitAura } from "@/src/components/bits/BitAura";
import { DecodeText } from "@/src/components/bits/DecodeText";
import { PathScene } from "@/src/components/illustrations/PathScenes";
import { useContent } from "@/src/components/ContentProvider";
import { TopicButton } from "./TopicButton";
import type { ServicePath, ServicePathId } from "@/src/content/types";
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">
<BitAura source="top" tone="light" className="md:sticky md:top-28">
<div
data-glow
className="relative overflow-hidden rounded-3xl bg-slate-900"
>
<span className="bit-glow bit-glow-dots" aria-hidden="true" />
<div className="relative 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="relative 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>
</BitAura>
</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-4 text-left transition-colors duration-300 md:gap-6 md:py-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 font-sans text-base leading-relaxed transition-colors duration-300 ${
// On phones only the current step is spelled out.
selected
? "block text-slate-600"
: "hidden text-slate-400 md:block"
}`}
>
{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<{
/** The cases to show, in this order. */
ids: readonly ServicePathId[];
/** `offer`: ask for an offer, no figures. `estimate`: the way to a price, with figures. */
pricing: "offer" | "estimate";
}> = ({ ids, pricing }) => {
const { services } = useContent();
const { estimate, care } = services;
const paths = ids.flatMap((id) =>
services.paths.filter((path) => path.id === id),
);
const [activeId, setActiveId] = React.useState(paths[0]?.id ?? "");
return (
<div>
<div
role="tablist"
aria-label={services.tablistLabel}
className="grid grid-cols-1 gap-2 md:grid-cols-2 md:gap-4"
>
{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)}
data-glow
className={`group flex h-full flex-col justify-between rounded-3xl border p-4 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="bit-glow bit-glow-edge" aria-hidden="true" />
<span className="font-mono text-[11px] tracking-[0.2em] text-slate-400">
0{index + 1}
</span>
<span className="mt-3 block font-sans text-base font-bold leading-tight tracking-tight md:mt-10 md:text-2xl">
{path.need}
</span>
<span
className={`mt-2 hidden font-sans text-sm leading-relaxed md:mt-4 md:block md:text-base ${selected ? "text-slate-300" : "text-slate-500"}`}
>
{path.forWhom}
</span>
</button>
);
})}
</div>
{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-8 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`}>{services.youGet}</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`}>{services.howItRuns}</p>
<PathStory path={path} active={selected} />
</div>
{pricing === "offer" && path.id === "website" && (
<div className="mt-12 rounded-3xl border border-slate-200 p-6 md:mt-16 md:p-10">
<p className={`${MONO} m-0`}>{care.label}</p>
<div className="mt-6 grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-12">
<div className="md:col-span-7">
<p className="m-0 font-sans text-2xl font-bold leading-tight tracking-tight text-slate-900 md:text-4xl">
{care.title}
</p>
<p className="m-0 mt-4 max-w-2xl font-sans text-base leading-relaxed text-slate-500 md:text-lg">
{care.text}
</p>
</div>
<div className="md:col-span-5">
<p className="m-0 font-sans text-base text-slate-500 md:text-lg">
{care.price.label}
</p>
<p className="m-0 mt-2 font-sans text-3xl font-black tracking-tighter text-slate-900 md:text-5xl">
{care.price.amount}
</p>
<p className={`${MONO} m-0 mt-2`}>{care.price.note}</p>
</div>
</div>
<div className="mt-10 grid grid-cols-1 gap-10 border-t border-slate-200 pt-8 md:grid-cols-2 md:gap-12">
{[
{ title: care.includedLabel, items: care.included },
{ title: care.termsLabel, items: care.terms },
].map((group) => (
<div key={group.title}>
<p className="m-0 font-sans text-lg font-bold tracking-tight text-slate-900 md:text-xl">
{group.title}
</p>
<ul className="m-0 mt-3 list-none p-0">
{group.items.map((item) => (
<li
key={item}
className="border-b border-slate-100 py-3 font-sans text-base leading-relaxed text-slate-600 md:text-lg"
>
{item}
</li>
))}
</ul>
</div>
))}
</div>
</div>
)}
{pricing === "offer" ? (
<div className="mt-12 flex flex-col gap-8 rounded-3xl bg-slate-50 p-6 md:mt-16 md:flex-row md:items-center md:justify-between md:gap-12 md:p-10">
<div className="max-w-2xl">
<p className="m-0 font-sans text-2xl font-bold leading-tight tracking-tight text-slate-900 md:text-4xl">
{services.offer.title}
</p>
<p className="m-0 mt-4 font-sans text-base leading-relaxed text-slate-500 md:text-lg">
{services.offer.text}
</p>
</div>
<div className="shrink-0">
<TopicButton topic={path.id}>
{services.offer.cta}
</TopicButton>
</div>
</div>
) : (
<div className="mt-12 rounded-3xl bg-slate-50 p-6 md:mt-16 md:p-10">
<p className={`${MONO} m-0`}>{estimate.label}</p>
<div className="mt-6 grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-12">
<div className="md:col-span-7">
<p className="m-0 font-sans text-2xl font-bold leading-tight tracking-tight text-slate-900 md:text-4xl">
{estimate.title}
</p>
<ol className="m-0 mt-6 list-none p-0">
{estimate.steps.map((item, index) => (
<li
key={item.title}
className="flex gap-5 border-t border-slate-200 py-4"
>
<span className="w-6 shrink-0 font-sans text-2xl font-black leading-none tracking-tighter text-slate-300">
{index + 1}
</span>
<span>
<span className="block font-sans text-lg font-bold tracking-tight text-slate-900">
{item.title}
</span>
<span className="mt-1 block font-sans text-base leading-relaxed text-slate-500">
{item.text}
</span>
</span>
</li>
))}
</ol>
</div>
<div className="flex flex-col md:col-span-5">
<p className={`${MONO} m-0`}>
{estimate.orientationLabel}
</p>
<p className="m-0 mt-3 font-sans text-2xl font-black leading-tight tracking-tighter text-slate-900 md:text-4xl">
{path.orientation}
</p>
<p className="m-0 mt-4 font-sans text-base leading-relaxed text-slate-500">
{path.orientationNote} {estimate.net}
</p>
<div className="mt-8 flex flex-col items-start gap-5 md:mt-auto md:pt-8">
<TopicButton topic={path.id}>
{estimate.cta}
</TopicButton>
</div>
</div>
</div>
</div>
)}
</motion.div>
</div>
);
})}
</div>
);
};