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>
183 lines
6.7 KiB
TypeScript
183 lines
6.7 KiB
TypeScript
"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";
|
|
import { assembleEmail, mailtoHref, spellOutEmail } from "@/src/domain/email";
|
|
|
|
const TYPE_MS = 9;
|
|
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em]";
|
|
|
|
/** The text typed out so far. Jumps to the full text when motion is reduced. */
|
|
const useTyped = (text: string): string => {
|
|
const [length, setLength] = React.useState(text.length);
|
|
|
|
React.useEffect(() => {
|
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
setLength(text.length);
|
|
return;
|
|
}
|
|
setLength(0);
|
|
const timer = window.setInterval(() => {
|
|
setLength((current) => {
|
|
if (current >= text.length) {
|
|
window.clearInterval(timer);
|
|
return current;
|
|
}
|
|
return Math.min(text.length, current + 3);
|
|
});
|
|
}, TYPE_MS);
|
|
return () => window.clearInterval(timer);
|
|
}, [text]);
|
|
|
|
return text.slice(0, length);
|
|
};
|
|
|
|
/** Blanks the visitor fills in are set apart from the prepared wording. */
|
|
const withBlanks = (text: string): React.ReactNode =>
|
|
text.split(/(\[[^\]]*\]?)/).map((part, index) =>
|
|
part.startsWith("[") ? (
|
|
<span key={index} className="rounded-sm bg-amber-200/15 text-amber-200">
|
|
{part}
|
|
</span>
|
|
) : (
|
|
part
|
|
),
|
|
);
|
|
|
|
/**
|
|
* ContactComposer: pick what you need and the email writes itself. One press
|
|
* opens it in the visitor's own mail app, ready to finish and send. The address
|
|
* is assembled in the browser, so it never appears in the page source.
|
|
*/
|
|
export const ContactComposer: React.FC = () => {
|
|
const { contact } = useContent();
|
|
const topics = contact.topics;
|
|
const [topicId, setTopicId] = React.useState(topics[0]?.id ?? "");
|
|
const [address, setAddress] = React.useState<string | null>(null);
|
|
const [copied, setCopied] = React.useState(false);
|
|
|
|
React.useEffect(() => {
|
|
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 ?? "");
|
|
|
|
if (!topic) return null;
|
|
|
|
const done = typed.length === topic.draft.body.length;
|
|
|
|
const copy = async () => {
|
|
if (!address) return;
|
|
try {
|
|
await navigator.clipboard.writeText(address);
|
|
setCopied(true);
|
|
window.setTimeout(() => setCopied(false), 1800);
|
|
} catch {
|
|
// Clipboard access can be refused; the address stays visible to copy by hand.
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="overflow-hidden rounded-3xl border border-slate-700 bg-slate-950/60 backdrop-blur-sm">
|
|
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4 md:px-7">
|
|
<div className="flex items-center gap-3">
|
|
<span className="flex gap-1.5" aria-hidden="true">
|
|
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
|
|
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
|
|
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
|
|
</span>
|
|
<span className={`${MONO} text-slate-400`}>{contact.newMessage}</span>
|
|
</div>
|
|
<span className={`${MONO} flex items-center gap-2 text-slate-500`}>
|
|
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-400" />
|
|
{contact.draft}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="border-b border-slate-800 px-5 py-5 md:px-7">
|
|
<p className={`${MONO} m-0 text-slate-500`}>{contact.need}</p>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
{topics.map((item) => {
|
|
const selected = item.id === topic.id;
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
aria-pressed={selected}
|
|
onClick={() => setTopicId(item.id)}
|
|
className={`rounded-full border px-4 py-2 font-mono text-[11px] uppercase tracking-[0.15em] transition-colors duration-200 ${
|
|
selected
|
|
? "border-white bg-white text-slate-900"
|
|
: "border-slate-700 text-slate-300 hover:border-slate-400 hover:text-white"
|
|
}`}
|
|
>
|
|
{item.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<dl className="m-0 border-b border-slate-800 px-5 py-4 font-mono text-[12px] leading-relaxed md:px-7 md:text-[13px]">
|
|
<div className="flex gap-4">
|
|
<dt className="w-16 shrink-0 text-slate-500">{contact.to}</dt>
|
|
<dd className="m-0 select-all text-slate-200">
|
|
{address ?? spellOutEmail(CONTACT_EMAIL_PARTS)}
|
|
</dd>
|
|
</div>
|
|
<div className="mt-1 flex gap-4">
|
|
<dt className="w-16 shrink-0 text-slate-500">{contact.subject}</dt>
|
|
<dd className="m-0 text-white">{topic.draft.subject}</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
<div
|
|
className="min-h-[15.5rem] whitespace-pre-wrap px-5 py-5 font-mono text-[12px] leading-relaxed text-slate-300 md:min-h-[14rem] md:px-7 md:text-[13px]"
|
|
aria-live="off"
|
|
>
|
|
{withBlanks(typed)}
|
|
<span
|
|
aria-hidden="true"
|
|
className={`ml-0.5 inline-block h-[1.05em] w-[0.55em] translate-y-[0.18em] bg-amber-200 ${done ? "animate-pulse" : ""}`}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-3 border-t border-slate-800 p-5 sm:flex-row sm:items-center md:px-7">
|
|
<a
|
|
href={
|
|
address ? mailtoHref(CONTACT_EMAIL_PARTS, topic.draft) : "#contact"
|
|
}
|
|
className="bit-cta bit-cta-light group inline-flex flex-1 items-center justify-between gap-4 rounded-full bg-white px-7 py-4 font-sans text-[12px] font-bold uppercase tracking-[0.18em] text-slate-900 no-underline transition-transform duration-300 hover:-translate-y-0.5"
|
|
>
|
|
{contact.open}
|
|
<span
|
|
aria-hidden="true"
|
|
className="transition-transform duration-300 group-hover:translate-x-1"
|
|
>
|
|
→
|
|
</span>
|
|
</a>
|
|
<button
|
|
type="button"
|
|
onClick={copy}
|
|
className="rounded-full border border-slate-700 px-6 py-4 font-sans text-[12px] font-bold uppercase tracking-[0.18em] text-slate-300 transition-colors duration-200 hover:border-slate-400 hover:text-white"
|
|
>
|
|
{copied ? contact.copied : contact.copy}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|