feat: estimates on the homepage, monthly booking on the freelance page

The homepage showed a price list per case, including a day rate. Buyers of
a result need a way to a price, not rates: each case now shows three steps
to an estimate, one line for orientation and a button that opens the
contact message with that topic chosen.

The developer case is booked by the month, not by the day. The day rate is
gone; the freelance page has the booking terms: 100 EUR per hour, fixed
days of the week agreed for one month at a time, an example month, monthly
invoice, two weeks notice to the end of a month.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 10:01:23 +02:00
co-authored by Claude Opus 5.5
parent 83616433e4
commit c7f8672cd0
8 changed files with 276 additions and 122 deletions
+53
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { DecodeText } from "@/src/components/bits/DecodeText";
import { Block } from "@/src/components/home/Block";
import { ClosingCta } from "@/src/components/home/ClosingCta";
import { TopicButton } from "@/src/components/home/TopicButton";
import { PipelineRun } from "@/src/components/illustrations/PipelineRun";
import { Reveal } from "@/src/components/Reveal";
import { SwipeRow } from "@/src/components/SwipeRow";
@@ -36,6 +37,7 @@ export default async function FreelancePage({ params }: Props) {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const content = contentFor(locale);
const { freelance, technology } = content;
const { booking } = freelance;
return (
<div className="relative flex flex-col bg-white">
@@ -135,6 +137,57 @@ export default async function FreelancePage({ params }: Props) {
</Reveal>
</Block>
<Block
index="03"
label={booking.label}
title={
<>
{booking.title.lead}{" "}
<span className={MUTED}>{booking.title.rest}</span>
</>
}
>
<Reveal>
<div className="grid grid-cols-1 gap-12 md:grid-cols-12 md:gap-12">
<div className="md:col-span-6">
<p className={BODY}>{booking.text}</p>
<ul className="m-0 mt-8 list-none p-0">
{booking.terms.map((term) => (
<li
key={term}
className="border-t border-slate-200 py-4 font-sans text-lg text-slate-900 md:text-xl"
>
{term}
</li>
))}
</ul>
</div>
<div className="md:col-span-6">
<div className="rounded-3xl bg-slate-50 p-6 md:p-10">
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
{[booking.rate, booking.example].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="m-0 mt-2 font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500">
{price.note}
</p>
</div>
))}
</div>
<div className="mt-10 border-t border-slate-200 pt-8">
<TopicButton topic="developer">{booking.cta}</TopicButton>
</div>
</div>
</div>
</div>
</Reveal>
</Block>
<ClosingCta contact={content.contact} />
</div>
);
@@ -1,5 +1,6 @@
"use client";
import { onContactTopic } from "./contactTopic";
import * as React from "react";
import { useContent } from "@/src/components/ContentProvider";
import { CONTACT_EMAIL_PARTS } from "@/src/content/site";
@@ -61,6 +62,15 @@ export const ContactComposer: React.FC = () => {
setAddress(assembleEmail(CONTACT_EMAIL_PARTS));
}, []);
// A button elsewhere on the page can open the message with its topic chosen.
React.useEffect(
() =>
onContactTopic((id) => {
if (topics.some((item) => item.id === id)) setTopicId(id);
}),
[topics],
);
const topic = topics.find((item) => item.id === topicId) ?? topics[0];
const typed = useTyped(topic?.draft.body ?? "");
+48 -28
View File
@@ -2,11 +2,13 @@
import * as React from "react";
import { motion } from "framer-motion";
import { Button } from "@/src/components/Button";
import Link from "next/link";
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 { useContent, useLocale } from "@/src/components/ContentProvider";
import { pathFor } from "@/src/domain/routes";
import { TopicButton } from "./TopicButton";
import type { ServicePath } from "@/src/content/types";
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500";
@@ -148,6 +150,8 @@ const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
*/
export const PathPicker: React.FC = () => {
const { services } = useContent();
const { estimate } = services;
const locale = useLocale();
const paths = services.paths;
const [activeId, setActiveId] = React.useState(paths[0]?.id ?? "");
@@ -244,37 +248,53 @@ export const PathPicker: React.FC = () => {
</div>
<div className="mt-12 rounded-3xl bg-slate-50 p-6 md:mt-16 md:p-10">
<p className={`${MONO} m-0`}>{services.whatItCosts}</p>
<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-6">
<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">
{path.pricingModel}
{estimate.title}
</p>
<p className="m-0 mt-4 font-sans text-base leading-relaxed text-slate-500 md:text-lg">
{path.pricingText}
<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>
{path.id === "developer" && (
<Link
href={pathFor("freelance", locale)}
className="border-b border-slate-900 pb-0.5 font-sans text-base font-bold text-slate-900 no-underline transition-opacity hover:opacity-60"
>
{estimate.freelance} →
</Link>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-6 md:col-span-6 md:gap-8">
{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>
@@ -0,0 +1,19 @@
"use client";
import * as React from "react";
import { Button } from "@/src/components/Button";
import { chooseContactTopic } from "./contactTopic";
/** A call to action that jumps to the contact section with a topic already chosen. */
export const TopicButton: React.FC<{
topic: string;
children: React.ReactNode;
}> = ({ topic, children }) => (
<Button
href="#contact"
size="large"
onClick={() => chooseContactTopic(topic)}
>
{children}
</Button>
);
@@ -0,0 +1,19 @@
/** The contact section listens for this to open with a given topic chosen. */
const EVENT = "mintel:contact-topic";
/** Ask the contact section to choose a topic (see ContactComposer). */
export const chooseContactTopic = (topicId: string): void => {
window.dispatchEvent(new CustomEvent<string>(EVENT, { detail: topicId }));
};
export const onContactTopic = (
listener: (topicId: string) => void,
): (() => void) => {
const handle = (event: Event) => {
if (event instanceof CustomEvent && typeof event.detail === "string") {
listener(event.detail);
}
};
window.addEventListener(EVENT, handle);
return () => window.removeEventListener(EVENT, handle);
};
+51 -48
View File
@@ -78,7 +78,28 @@ export const de: Content = {
tablistLabel: "Was brauchen Sie?",
youGet: "Das bekommen Sie",
howItRuns: "So läuft es ab",
whatItCosts: "Das kostet es",
estimate: {
label: "Was es kostet",
title: "So kommen Sie zu einem Preis",
steps: [
{
title: "Ein Gespräch, etwa 20 Minuten",
text: "Sie erzählen mir, was Sie brauchen.",
},
{
title: "Eine schriftliche Schätzung",
text: "Sie sehen die geschätzten Stunden und was darin enthalten ist.",
},
{
title: "Sie geben ein Budget frei",
text: "Ich rechne die tatsächlichen Stunden ab und sage früh Bescheid, wenn es nicht reicht.",
},
],
orientationLabel: "Zur Orientierung",
net: "Alle Preise netto.",
cta: "Schätzung anfragen",
freelance: "Buchung und Preise für Teams",
},
paths: [
{
id: "developer",
@@ -112,16 +133,9 @@ export const de: Content = {
text: "Die Vereinbarung kann mit zwei Wochen Frist zum Monatsende beendet werden.",
},
],
pricingModel: "Nach Aufwand, solange Ihr Projekt läuft",
pricingText:
"Ich arbeite an vereinbarten Wochentagen an Ihrem Projekt und rechne die Stunden ab, die ich tatsächlich gearbeitet habe. Es gibt kein Paket zu kaufen und keine Mindestlaufzeit.",
prices: [
{ label: "Pro Stunde", amount: "100 EUR", note: "netto" },
{ label: "Pro Tag", amount: "800 EUR", note: "8 Stunden, netto" },
],
smallPrint:
"Eine Rechnung pro Monat, mit den erfassten Stunden. Beide Seiten können mit zwei Wochen Frist zum Monatsende kündigen.",
cta: GET_IN_TOUCH,
orientation: "100 EUR pro Stunde, monatsweise gebucht",
orientationNote:
"Sie buchen feste Wochentage für jeweils einen Monat. Abgerechnet werden die Stunden, die ich tatsächlich gearbeitet habe.",
},
{
id: "rescue",
@@ -157,19 +171,9 @@ export const de: Content = {
text: "Mit mir, mit Ihrem eigenen Team oder gar nicht.",
},
],
pricingModel: "Eine feste Woche zum festen Preis",
pricingText:
"Der Umfang ist eine Woche meiner Zeit. Sie kennen die Kosten, bevor ich anfange.",
prices: [
{
label: "Eine Woche, 35 Stunden",
amount: "3.500 EUR",
note: "netto",
},
],
smallPrint:
"Eine Woche behebt nicht alles. Wenn ich im ersten Gespräch sehe, dass sie nicht reicht, um Ihnen zu nützen, sage ich Ihnen das.",
cta: GET_IN_TOUCH,
orientation: "3.500 EUR für eine feste Woche",
orientationNote:
"Der Umfang ist eine Woche meiner Zeit, 35 Stunden. Eine Woche behebt nicht alles: Wenn ich im ersten Gespräch sehe, dass sie nicht reicht, um Ihnen zu nützen, sage ich Ihnen das.",
},
{
id: "automation",
@@ -205,13 +209,9 @@ export const de: Content = {
text: "Sie bekommen eine Notiz dazu, was es tut und was zu prüfen ist, wenn sich eines der Programme ändert.",
},
],
pricingModel: "Erst eine Schätzung, dann ein Budget",
pricingText:
"Sobald ich den Ablauf gesehen habe, schätze ich die Stunden. Diese Schätzung wird zum Budget. Ich rechne die Stunden ab, die ich tatsächlich gebraucht habe, und sage früh Bescheid, wenn es nicht reicht.",
prices: [{ label: "Pro Stunde", amount: "100 EUR", note: "netto" }],
smallPrint:
orientation: "100 EUR pro Stunde, nach Schätzung",
orientationNote:
"Wie lange es dauert, hängt vom Ablauf ab. Deshalb gibt es keinen Preis, bevor ich ihn gesehen habe. Gebühren für die Programme selbst zahlen Sie.",
cta: GET_IN_TOUCH,
},
{
id: "website",
@@ -246,24 +246,9 @@ export const de: Content = {
text: "Ein neues Foto oder ein geänderter Preis: Sie schreiben mir eine E-Mail.",
},
],
pricingModel: "Erst eine Schätzung, dann ein Budget",
pricingText:
"Nach dem ersten Gespräch bekommen Sie eine Schätzung. Sie wird Ihr Budget: Ich rechne die Stunden ab, die tatsächlich angefallen sind. Die Schätzung hält in der Regel, und wenn nicht, sage ich es Ihnen, bevor das Budget aufgebraucht ist.",
prices: [
{
label: "Eine typische Unternehmenswebsite",
amount: "ab 3.800 EUR",
note: "einmalig, netto",
},
{
label: "Alles Weitere, pro Stunde",
amount: "100 EUR",
note: "netto",
},
],
smallPrint:
"Hosting und kleine Änderungen danach kosten einen monatlichen Betrag, den wir vor dem Start vereinbaren. E-Mail-Postfächer verwalte ich nicht; sie bleiben bei Ihrem Anbieter.",
cta: GET_IN_TOUCH,
orientation: "Ab 3.800 EUR, einmalig",
orientationNote:
"Das gilt für eine typische Unternehmenswebsite. Hosting und kleine Änderungen danach kosten einen monatlichen Betrag, den wir vor dem Start vereinbaren.",
},
],
scenes: {
@@ -518,6 +503,24 @@ export const de: Content = {
intro:
"Diese Seite ist für alle, die selbst entwickeln oder ein Team leiten: für welche Projekte ich passe, womit ich arbeite und wie ich Änderungen absichere, bevor sie live gehen.",
pipelineLabel: "Absicherung",
booking: {
label: "Buchung",
title: { lead: "Gebucht wird monatsweise.", rest: "Nicht tageweise." },
text: "Ich arbeite an festen Wochentagen an Ihrem Projekt, vereinbart für jeweils einen Monat. Es gibt kein Paket zu kaufen: Sie zahlen die Stunden, die ich tatsächlich gearbeitet habe.",
rate: { label: "Pro Stunde", amount: "100 EUR", note: "netto" },
example: {
label: "Beispiel: 2 Tage pro Woche",
amount: "ca. 6.400 EUR",
note: "64 Stunden in vier Wochen, netto",
},
terms: [
"Feste Wochentage, vereinbart für jeweils einen Monat",
"Eine Rechnung pro Monat, mit den erfassten Stunden",
"Beide Seiten können mit zwei Wochen Frist zum Monatsende kündigen",
"Einzelne Tage buche ich nicht",
],
cta: "Verfügbarkeit anfragen",
},
teaser: {
title: "Sie entwickeln selbst oder leiten ein Team?",
text: "Für welche Projekte ich passe, womit ich arbeite und wie ich Änderungen absichere, steht auf einer eigenen Seite.",
+51 -40
View File
@@ -77,7 +77,28 @@ export const en: Content = {
tablistLabel: "What do you need?",
youGet: "What you get",
howItRuns: "How it runs",
whatItCosts: "What it costs",
estimate: {
label: "What it costs",
title: "How you get to a price",
steps: [
{
title: "A call, about 20 minutes",
text: "You tell me what you need.",
},
{
title: "A written estimate",
text: "You see the estimated hours and what they include.",
},
{
title: "You approve a budget",
text: "I bill the hours actually used and tell you early if it will not be enough.",
},
],
orientationLabel: "For orientation",
net: "All prices net.",
cta: "Ask for an estimate",
freelance: "Booking and rates for teams",
},
paths: [
{
id: "developer",
@@ -111,16 +132,9 @@ export const en: Content = {
text: "The agreement can be ended with two weeks notice to the end of a month.",
},
],
pricingModel: "By effort, for as long as your project runs",
pricingText:
"I work on your project on agreed days of the week and bill the hours I actually worked. There is no package to buy and no minimum term.",
prices: [
{ label: "Per hour", amount: "100 EUR", note: "net" },
{ label: "Per day", amount: "800 EUR", note: "8 hours, net" },
],
smallPrint:
"One invoice per month, with the tracked hours. Either side can end it with two weeks notice to the end of a month.",
cta: GET_IN_TOUCH,
orientation: "100 EUR per hour, booked by the month",
orientationNote:
"You book fixed days of the week for one month at a time. I bill the hours I actually worked.",
},
{
id: "rescue",
@@ -154,15 +168,9 @@ export const en: Content = {
text: "With me, with your own team, or not at all.",
},
],
pricingModel: "A fixed week at a fixed price",
pricingText:
"The scope is one week of my time. You know the cost before I start.",
prices: [
{ label: "One week, 35 hours", amount: "3,500 EUR", note: "net" },
],
smallPrint:
"A week does not fix everything. If I see in the first call that it will not be enough to be useful, I tell you.",
cta: GET_IN_TOUCH,
orientation: "3,500 EUR for one fixed week",
orientationNote:
"The scope is one week of my time, 35 hours. A week does not fix everything: if I see in the first call that it will not be enough to be useful, I tell you.",
},
{
id: "automation",
@@ -198,13 +206,9 @@ export const en: Content = {
text: "You get a note on what it does and what to check when one of the tools changes.",
},
],
pricingModel: "An estimate first, then a budget",
pricingText:
"Once I have seen the process I estimate the hours. That estimate becomes the budget. I bill the hours I actually needed and tell you early if it will not be enough.",
prices: [{ label: "Per hour", amount: "100 EUR", note: "net" }],
smallPrint:
orientation: "100 EUR per hour, after an estimate",
orientationNote:
"How long it takes depends on the process, so there is no price before I have seen it. Fees for the tools themselves are paid by you.",
cta: GET_IN_TOUCH,
},
{
id: "website",
@@ -238,20 +242,9 @@ export const en: Content = {
text: "A new photo or a changed price: you send me an email.",
},
],
pricingModel: "An estimate first, then a budget",
pricingText:
"After the first call you get an estimate. It becomes your budget: I bill the hours actually used. The estimate usually holds, and if it will not, I tell you before the budget is used up.",
prices: [
{
label: "A typical company website",
amount: "from 3,800 EUR",
note: "one-off, net",
},
{ label: "Anything extra, per hour", amount: "100 EUR", note: "net" },
],
smallPrint:
"Hosting and small changes afterwards cost a monthly fee that we agree on before we start. I do not manage email inboxes; they stay with your provider.",
cta: GET_IN_TOUCH,
orientation: "From 3,800 EUR, one-off",
orientationNote:
"This is for a typical company website. Hosting and small changes afterwards cost a monthly fee that we agree on before we start.",
},
],
scenes: {
@@ -498,6 +491,24 @@ export const en: Content = {
intro:
"This page is for people who develop themselves or lead a team: which projects I am right for, what I work with, and how I secure changes before they go live.",
pipelineLabel: "Safeguards",
booking: {
label: "Booking",
title: { lead: "Booked by the month.", rest: "Not by the day." },
text: "I work on your project on fixed days of the week, agreed for one month at a time. There is no package to buy: you pay the hours I actually worked.",
rate: { label: "Per hour", amount: "100 EUR", note: "net" },
example: {
label: "Example: 2 days a week",
amount: "about 6,400 EUR",
note: "64 hours in four weeks, net",
},
terms: [
"Fixed days of the week, agreed for one month at a time",
"One invoice per month, with the tracked hours",
"Either side can end it with two weeks notice to the end of a month",
"I do not book single days",
],
cta: "Ask about availability",
},
teaser: {
title: "You develop yourself or lead a team?",
text: "Which projects I am right for, what I work with and how I secure changes is on a page of its own.",
+25 -6
View File
@@ -55,11 +55,10 @@ export type ServicePath = {
readonly youGet: readonly string[];
readonly steps: readonly TitledText[];
/** How this kind of work is priced, in one line, and why. */
readonly pricingModel: string;
readonly pricingText: string;
readonly prices: readonly PathPrice[];
readonly smallPrint: string;
readonly cta: string;
/** The order of magnitude in one line, such as "from 3,800 EUR, one-off". */
readonly orientation: string;
/** What that figure depends on or leaves out. */
readonly orientationNote: string;
};
/** The short labels inside the drawings that explain the steps of a path. */
@@ -180,7 +179,17 @@ export type Content = {
readonly tablistLabel: string;
readonly youGet: string;
readonly howItRuns: string;
readonly whatItCosts: string;
/** How a visitor gets to a price: the homepage shows this instead of a price list. */
readonly estimate: {
readonly label: string;
readonly title: string;
readonly steps: readonly TitledText[];
readonly orientationLabel: string;
readonly net: string;
readonly cta: string;
/** The pointer to the booking terms on the freelance page. */
readonly freelance: string;
};
readonly paths: readonly ServicePath[];
readonly scenes: SceneLabels;
};
@@ -241,6 +250,16 @@ export type Content = {
readonly title: SplitTitle;
readonly intro: string;
readonly pipelineLabel: string;
/** How the owner is booked as a developer: by the month, not by the day. */
readonly booking: {
readonly label: string;
readonly title: SplitTitle;
readonly text: string;
readonly rate: PathPrice;
readonly example: PathPrice;
readonly terms: readonly string[];
readonly cta: string;
};
/** The pointer to this page on the homepage. */
readonly teaser: TitledText & { readonly cta: string };
};