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>
372 lines
15 KiB
TypeScript
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>
|
|
);
|
|
};
|