From c7f8672cd054f234192ffd06313556e93e996d49 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Mon, 5 Oct 2026 10:01:23 +0200 Subject: [PATCH] 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 --- apps/web/app/[locale]/freelance/page.tsx | 53 ++++++++++ .../src/components/home/ContactComposer.tsx | 10 ++ apps/web/src/components/home/PathPicker.tsx | 76 ++++++++------ apps/web/src/components/home/TopicButton.tsx | 19 ++++ apps/web/src/components/home/contactTopic.ts | 19 ++++ apps/web/src/content/de.ts | 99 ++++++++++--------- apps/web/src/content/en.ts | 91 +++++++++-------- apps/web/src/content/types.ts | 31 ++++-- 8 files changed, 276 insertions(+), 122 deletions(-) create mode 100644 apps/web/src/components/home/TopicButton.tsx create mode 100644 apps/web/src/components/home/contactTopic.ts diff --git a/apps/web/app/[locale]/freelance/page.tsx b/apps/web/app/[locale]/freelance/page.tsx index 1cb38ba..85cb3eb 100644 --- a/apps/web/app/[locale]/freelance/page.tsx +++ b/apps/web/app/[locale]/freelance/page.tsx @@ -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 (
@@ -135,6 +137,57 @@ export default async function FreelancePage({ params }: Props) { + + {booking.title.lead}{" "} + {booking.title.rest} + + } + > + +
+
+

{booking.text}

+
    + {booking.terms.map((term) => ( +
  • + {term} +
  • + ))} +
+
+
+
+
+ {[booking.rate, booking.example].map((price) => ( +
+

+ {price.label} +

+

+ {price.amount} +

+

+ {price.note} +

+
+ ))} +
+
+ {booking.cta} +
+
+
+
+
+
+
); diff --git a/apps/web/src/components/home/ContactComposer.tsx b/apps/web/src/components/home/ContactComposer.tsx index 138953f..b8b5049 100644 --- a/apps/web/src/components/home/ContactComposer.tsx +++ b/apps/web/src/components/home/ContactComposer.tsx @@ -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 ?? ""); diff --git a/apps/web/src/components/home/PathPicker.tsx b/apps/web/src/components/home/PathPicker.tsx index 4898fb1..1bc54b5 100644 --- a/apps/web/src/components/home/PathPicker.tsx +++ b/apps/web/src/components/home/PathPicker.tsx @@ -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 = () => {
-

{services.whatItCosts}

+

{estimate.label}

-
+

- {path.pricingModel} + {estimate.title}

-

- {path.pricingText} +

    + {estimate.steps.map((item, index) => ( +
  1. + + {index + 1} + + + + {item.title} + + + {item.text} + + +
  2. + ))} +
+
+
+

{estimate.orientationLabel}

+

+ {path.orientation}

+

+ {path.orientationNote} {estimate.net} +

+
+ {estimate.cta} + {path.id === "developer" && ( + + {estimate.freelance} → + + )} +
-
- {path.prices.map((price) => ( -
-

- {price.label} -

-

- {price.amount} -

-

{price.note}

-
- ))} -
-
-
-

- {path.smallPrint} -

-
diff --git a/apps/web/src/components/home/TopicButton.tsx b/apps/web/src/components/home/TopicButton.tsx new file mode 100644 index 0000000..e478444 --- /dev/null +++ b/apps/web/src/components/home/TopicButton.tsx @@ -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 }) => ( + +); diff --git a/apps/web/src/components/home/contactTopic.ts b/apps/web/src/components/home/contactTopic.ts new file mode 100644 index 0000000..89cf44e --- /dev/null +++ b/apps/web/src/components/home/contactTopic.ts @@ -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(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); +}; diff --git a/apps/web/src/content/de.ts b/apps/web/src/content/de.ts index 0339e0a..3bb63d2 100644 --- a/apps/web/src/content/de.ts +++ b/apps/web/src/content/de.ts @@ -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.", diff --git a/apps/web/src/content/en.ts b/apps/web/src/content/en.ts index 4162867..824e95a 100644 --- a/apps/web/src/content/en.ts +++ b/apps/web/src/content/en.ts @@ -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.", diff --git a/apps/web/src/content/types.ts b/apps/web/src/content/types.ts index 52435b1..3dddb37 100644 --- a/apps/web/src/content/types.ts +++ b/apps/web/src/content/types.ts @@ -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 }; };