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