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:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}`,
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("&")}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user