From 390936856cb9d2a4fe795858ec20953eaa65e358 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Sun, 4 Oct 2026 09:01:03 +0200 Subject: [PATCH] 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 --- apps/web/src/components/home/ClosingCta.tsx | 30 ++- .../src/components/home/ContactComposer.tsx | 171 ++++++++++++++++++ apps/web/src/content/contact.ts | 57 ++++++ apps/web/src/domain/email.spec.ts | 51 +++++- apps/web/src/domain/email.ts | 19 ++ 5 files changed, 318 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/components/home/ContactComposer.tsx create mode 100644 apps/web/src/content/contact.ts diff --git a/apps/web/src/components/home/ClosingCta.tsx b/apps/web/src/components/home/ClosingCta.tsx index c76b461..06dc831 100644 --- a/apps/web/src/components/home/ClosingCta.tsx +++ b/apps/web/src/components/home/ClosingCta.tsx @@ -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 = () => (
-
- +
); diff --git a/apps/web/src/components/home/ContactComposer.tsx b/apps/web/src/components/home/ContactComposer.tsx new file mode 100644 index 0000000..d289310 --- /dev/null +++ b/apps/web/src/components/home/ContactComposer.tsx @@ -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("[") ? ( + + {part} + + ) : ( + 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(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 ( +
+
+
+
+ + + draft + +
+ +
+

I need

+
+ {CONTACT_TOPICS.map((item) => { + const selected = item.id === topic.id; + return ( + + ); + })} +
+
+ +
+
+
to
+
+ {address ?? spellOutEmail(CONTACT_EMAIL_PARTS)} +
+
+
+
subject
+
{topic.draft.subject}
+
+
+ +
+ {withBlanks(typed)} +
+ +
+ + Open in my mail app + + + +
+
+ ); +}; diff --git a/apps/web/src/content/contact.ts b/apps/web/src/content/contact.ts new file mode 100644 index 0000000..cc4771e --- /dev/null +++ b/apps/web/src/content/contact.ts @@ -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}`, + }, + }, +]; diff --git a/apps/web/src/domain/email.spec.ts b/apps/web/src/domain/email.spec.ts index 74febbe..4ac1e61 100644 --- a/apps/web/src/domain/email.spec.ts +++ b/apps/web/src/domain/email.spec.ts @@ -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", + ); + }); +}); diff --git a/apps/web/src/domain/email.ts b/apps/web/src/domain/email.ts index 0399b1b..82ae8bf 100644 --- a/apps/web/src/domain/email.ts +++ b/apps/web/src/domain/email.ts @@ -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("&")}`; +}