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:
@@ -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 ?? "");
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user