feat: message starter in the contact section
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Successful in 6m26s
Build & Deploy / 🚀 Deploy (push) Successful in 18s
Build & Deploy / 🩺 Smoke Test (push) Successful in 5s
Build & Deploy / 🔔 Notify (push) Successful in 2s

The visitor picks what they need and a prepared email types itself out; one
press opens it in their own mail app with subject and body filled in. The
mailto link is built by a tested pure function (src/domain/email.ts, spec
first) and only in the browser, so the address still never appears in the
page source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 09:01:03 +02:00
co-authored by Claude Opus 5.5
parent dc4a01cbe4
commit 390936856c
5 changed files with 318 additions and 10 deletions
@@ -0,0 +1,171 @@
"use client";
import * as React from "react";
import { CONTACT_TOPICS } from "@/src/content/contact";
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 [topicId, setTopicId] = React.useState(CONTACT_TOPICS[0]?.id ?? "");
const [address, setAddress] = React.useState<string | null>(null);
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
setAddress(assembleEmail(CONTACT_EMAIL_PARTS));
}, []);
const topic =
CONTACT_TOPICS.find((item) => item.id === topicId) ?? CONTACT_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`}>New message</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" />
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`}>I need</p>
<div className="mt-3 flex flex-wrap gap-2">
{CONTACT_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">to</dt>
<dd className="m-0 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">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="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"
>
Open in my mail app
<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 ? "Copied" : "Copy address"}
</button>
</div>
</div>
);
};