feat: message starter in the contact section

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 ab7f0ed9c6
5 changed files with 318 additions and 10 deletions
+21 -9
View File
@@ -2,34 +2,46 @@ import * as React from "react";
import { Reveal } from "@/src/components/Reveal";
import { EmailLink } from "@/src/components/EmailLink";
import { BitHeadline, type BitLine } from "./BitHeadline";
import { ContactComposer } from "./ContactComposer";
const CLOSING: readonly BitLine[] = [
{ text: "LET'S", tone: "bits" },
{ text: "TALK.", tone: "solid" },
];
/** ClosingCta: the contact section. One address, no forms, no calendar. */
/** ClosingCta: the contact section. One address and a message starter, no form to submit. */
export const ClosingCta: React.FC = () => (
<section
id="contact"
className="relative border-t border-slate-200 bg-slate-900 text-white"
>
<div className="mx-auto w-full max-w-[1400px] px-5 py-24 md:px-12 md:py-44">
<Reveal>
<div
className="pointer-events-none absolute inset-0 opacity-70"
style={{
backgroundImage: "radial-gradient(#1e293b 1px, transparent 1px)",
backgroundSize: "20px 20px",
}}
aria-hidden="true"
/>
<div className="relative mx-auto grid w-full max-w-[1400px] grid-cols-1 gap-14 px-5 py-24 md:px-12 md:py-40 lg:grid-cols-12 lg:items-center lg:gap-12">
<Reveal className="lg:col-span-6">
<BitHeadline
as="h2"
invert
lines={CLOSING}
className="font-sans text-[19vw] font-black leading-[0.86] tracking-tighter md:text-[13vw] xl:text-[12rem]"
className="font-sans text-[19vw] font-black leading-[0.86] tracking-tighter md:text-[13vw] lg:text-[8.5vw] xl:text-[9.5rem]"
/>
<p className="m-0 mt-10 max-w-2xl font-sans text-xl leading-snug text-slate-300 md:text-3xl">
Write me a few lines about what you need. We then arrange a short
call.
<p className="m-0 mt-10 max-w-xl font-sans text-xl leading-snug text-slate-300 md:text-3xl">
Pick what you need and the email writes itself. Or write to me
directly:
</p>
<div className="mt-12">
<EmailLink className="inline-block border-b-2 border-white pb-2 font-sans text-3xl font-bold tracking-tight text-white no-underline transition-opacity hover:opacity-70 md:text-6xl" />
<div className="mt-8">
<EmailLink className="inline-block border-b-2 border-white pb-2 font-sans text-2xl font-bold tracking-tight text-white no-underline transition-opacity hover:opacity-70 md:text-4xl" />
</div>
</Reveal>
<Reveal delay={0.15} className="lg:col-span-6">
<ContactComposer />
</Reveal>
</div>
</section>
);
@@ -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>
);
};
+57
View File
@@ -0,0 +1,57 @@
import type { MailDraft } from "@/src/domain/email";
/**
* Message starters for the contact section. The visitor picks what they need and
* gets a prepared email to finish in their own mail app. The square brackets are
* blanks for the visitor to fill in.
*/
export type ContactTopic = {
readonly id: string;
readonly label: string;
readonly draft: MailDraft;
};
const SIGN_OFF = "Best regards";
export const CONTACT_TOPICS: readonly ContactTopic[] = [
{
id: "developer",
label: "A developer",
draft: {
subject: "A developer for our product",
body: `Hi Marc,\n\nwe are working on [what you are building] and need help with [what is next on your list].\n\nA first call would suit us around [date or week].\n\n${SIGN_OFF}`,
},
},
{
id: "rescue",
label: "A reliable app",
draft: {
subject: "Our app needs to become reliable",
body: `Hi Marc,\n\nour app [name or short description] works, but [what keeps going wrong].\n\nCould you take a look?\n\n${SIGN_OFF}`,
},
},
{
id: "automation",
label: "Automation",
draft: {
subject: "We want to automate a process",
body: `Hi Marc,\n\nwe would like to automate [the process]. Today it is done by hand in [the tools involved].\n\nCan we talk about it?\n\n${SIGN_OFF}`,
},
},
{
id: "website",
label: "A website",
draft: {
subject: "A website for my business",
body: `Hi Marc,\n\nI need a website for [your business]. It should [what it is for].\n\nWhen could we talk?\n\n${SIGN_OFF}`,
},
},
{
id: "other",
label: "Something else",
draft: {
subject: "A question",
body: `Hi Marc,\n\n[what you have in mind]\n\n${SIGN_OFF}`,
},
},
];
+50 -1
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { assembleEmail, parseEmailParts, spellOutEmail } from "./email";
import {
assembleEmail,
mailtoHref,
parseEmailParts,
spellOutEmail,
} from "./email";
const parts = { user: "hello", domain: "mintel", tld: "me" } as const;
@@ -120,3 +125,47 @@ describe("spellOutEmail", () => {
).toBe("hello [at] mail [dot] site [dot] org");
});
});
describe("mailtoHref", () => {
it("opens a message to the address with subject and body", () => {
expect(mailtoHref(parts, { subject: "Hello", body: "Hi" })).toBe(
"mailto:hello@mintel.me?subject=Hello&body=Hi",
);
});
it("encodes spaces as %20, not as plus signs", () => {
expect(mailtoHref(parts, { subject: "A website", body: "Hi Marc" })).toBe(
"mailto:hello@mintel.me?subject=A%20website&body=Hi%20Marc",
);
});
it("encodes line breaks so the mail app keeps the paragraphs", () => {
expect(mailtoHref(parts, { subject: "S", body: "one\n\ntwo" })).toBe(
"mailto:hello@mintel.me?subject=S&body=one%0D%0A%0D%0Atwo",
);
});
it("encodes characters that would break the link", () => {
expect(mailtoHref(parts, { subject: "Q&A: 50%?", body: "a=b #1" })).toBe(
"mailto:hello@mintel.me?subject=Q%26A%3A%2050%25%3F&body=a%3Db%20%231",
);
});
it("keeps umlauts readable for the mail app", () => {
expect(mailtoHref(parts, { subject: "Grüße", body: "ä" })).toBe(
"mailto:hello@mintel.me?subject=Gr%C3%BC%C3%9Fe&body=%C3%A4",
);
});
it("leaves out an empty subject or body instead of sending empty fields", () => {
expect(mailtoHref(parts, { subject: "", body: "" })).toBe(
"mailto:hello@mintel.me",
);
expect(mailtoHref(parts, { subject: "Only subject", body: "" })).toBe(
"mailto:hello@mintel.me?subject=Only%20subject",
);
expect(mailtoHref(parts, { subject: "", body: "Only body" })).toBe(
"mailto:hello@mintel.me?body=Only%20body",
);
});
});
+19
View File
@@ -43,3 +43,22 @@ export function spellOutEmail(parts: EmailParts): string {
const domain = parts.domain.split(".").join(" [dot] ");
return `${parts.user} [at] ${domain} [dot] ${parts.tld}`;
}
export type MailDraft = {
readonly subject: string;
readonly body: string;
};
/** Mail apps expect CRLF line breaks in a mailto body. */
const encodeField = (value: string): string =>
encodeURIComponent(value.replace(/\r?\n/g, "\r\n"));
/** A mailto link that opens a prepared message. For use in the browser only. */
export function mailtoHref(parts: EmailParts, draft: MailDraft): string {
const query = [
draft.subject === "" ? "" : `subject=${encodeField(draft.subject)}`,
draft.body === "" ? "" : `body=${encodeField(draft.body)}`,
].filter((field) => field !== "");
const address = `mailto:${assembleEmail(parts)}`;
return query.length === 0 ? address : `${address}?${query.join("&")}`;
}