feat: rebuild mintel.me as a single-page freelance site
The site now presents one freelance developer on one page: hero, introduction, four typical cases (developer for a product, make an app reliable, automate a process, a website) with what you get, how it runs and what it costs, a process automation section, technology and project fit, how I work, two live references, questions and contact. - Hero and closing headline are drawn from running binary digits. - Each case explains its four steps with an animated drawing; the automation section shows example workflows with work travelling between systems; a pipeline run shows what a test catches before a release. - Contact is one email address, kept in parts and assembled in the browser so it never appears in the page source (src/domain/email.ts, spec first). - Imprint and privacy pages added. - The old site (blog, case studies, contact form, fixed-price pages, video rendering and related scripts) is removed. Its URLs redirect to the homepage. - The deploy smoke test checks the legal pages and the redirects instead of the removed case study. - Dev scripts use docker compose v2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Button } from "@/src/components/Button";
|
||||
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">
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-300">
|
||||
{current?.title}
|
||||
</span>
|
||||
<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={`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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user