feat(web): count the two moments on the contact form that matter

There is not a single custom event in this codebase, which is why every argument
about the site's copy has been an argument about taste. Two events, a visitor
beginning the contact form and a visitor sending it, turn the hero rewrite that
follows this into a before-and-after instead of an opinion.

`contact-started` fires once per mounted form, on the first real edit rather than
on focus: someone tabbing through on the way elsewhere is not a lead.
`contact-submitted` fires on success. Both carry only which form it was, inquiry
or support.

`track()` does no consent checking of its own on purpose. UmamiAnalytics already
declines to load the script under Global Privacy Control and the script honours
Do Not Track, so `window.umami` is simply absent for a visitor who opted out and
every call is a no-op. A second gate here could disagree with the first. It also
never throws: an analytics call is not worth losing a form submission over.

/privacy now says what we count, in the same commit that starts counting it.
This commit is contained in:
jwinterm
2026-09-05 23:43:57 -04:00
parent abc968fe6f
commit 7208095b06
3 changed files with 139 additions and 42 deletions
+7
View File
@@ -84,6 +84,13 @@ export default function PrivacyPage() {
collected from your visit. We do not use Google Analytics, advertising pixels, or similar collected from your visit. We do not use Google Analytics, advertising pixels, or similar
third-party tracking tools on the Site. third-party tracking tools on the Site.
</p> </p>
<p className="mt-3">
Alongside page views, we count two things you do on purpose: that someone began filling in
the contact form, and that someone sent it. These are counters, not contents. They record
which of the two forms it was and nothing you typed, and like everything else here they are
not collected at all if Do Not Track or Global Privacy Control is on. We use them to tell
whether a change to the site helped or hurt, rather than guessing.
</p>
<h3 className="text-xl font-medium mt-4 mb-2">Server logs</h3> <h3 className="text-xl font-medium mt-4 mb-2">Server logs</h3>
<p> <p>
@@ -1,10 +1,11 @@
'use client'; "use client";
import { useActionState } from 'react'; import { useActionState } from "react";
import { useFormStatus } from 'react-dom'; import { useFormStatus } from "react-dom";
import { submitContactForm } from '@/actions/submit-contact'; import { submitContactForm } from "@/actions/submit-contact";
import { TurnstileWidget } from './turnstile-widget'; import { TurnstileWidget } from "./turnstile-widget";
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from "react";
import { Button } from '@repo/ui/components/button'; import { Button } from "@repo/ui/components/button";
import { EVENTS, track } from "@/lib/analytics";
function SubmitButton({ label = "Send Message" }: { label?: string }) { function SubmitButton({ label = "Send Message" }: { label?: string }) {
const { pending } = useFormStatus(); const { pending } = useFormStatus();
@@ -21,50 +22,68 @@ function SubmitButton({ label = "Send Message" }: { label?: string }) {
} }
interface ContactFormProps { interface ContactFormProps {
source?: 'inquiry' | 'support'; source?: "inquiry" | "support";
} }
export function ContactForm({ source = 'inquiry' }: ContactFormProps) { export function ContactForm({ source = "inquiry" }: ContactFormProps) {
const [state, formAction] = useActionState(submitContactForm, { success: false, message: '' }); const [state, formAction] = useActionState(submitContactForm, {
const [token, setToken] = useState<string>(''); success: false,
message: "",
});
const [token, setToken] = useState<string>("");
const formRef = useRef<HTMLFormElement>(null); const formRef = useRef<HTMLFormElement>(null);
// Fires once per mounted form, on the first real edit. A focus would count a visitor who
// tabbed through on the way somewhere else; typing is intent.
const startedRef = useRef(false);
const isSupport = source === 'support'; const noteStart = () => {
if (startedRef.current) return;
startedRef.current = true;
track(EVENTS.contactStarted, { source });
};
const isSupport = source === "support";
// Reset form on successful submission // Reset form on successful submission
useEffect(() => { useEffect(() => {
if (state.success && formRef.current) { if (state.success && formRef.current) {
formRef.current.reset(); formRef.current.reset();
setToken(''); setToken("");
startedRef.current = false;
track(EVENTS.contactSubmitted, { source });
} }
}, [state.success]); }, [state.success, source]);
return ( return (
<form <form
ref={formRef} ref={formRef}
action={formAction} action={formAction}
onInput={noteStart}
className="space-y-8 w-full max-w-6xl mx-auto section-container" className="space-y-8 w-full max-w-6xl mx-auto section-container"
> >
<div className="absolute top-0 right-0 w-64 h-64 bg-primary/5 rounded-full blur-3xl -mr-32 -mt-32" /> <div className="absolute top-0 right-0 w-64 h-64 bg-primary/5 rounded-full blur-3xl -mr-32 -mt-32" />
<div className="absolute bottom-0 left-0 w-64 h-64 bg-blue-500/5 rounded-full blur-3xl -ml-32 -mb-32" /> <div className="absolute bottom-0 left-0 w-64 h-64 bg-blue-500/5 rounded-full blur-3xl -ml-32 -mb-32" />
<div className="text-left mb-10 relative z-10"> <div className="text-left mb-10 relative z-10">
<h3 className="text-3xl font-bold tracking-tight"> <h3 className="text-3xl font-bold tracking-tight">
{isSupport ? 'Send Feedback' : 'Project Inquiry'} {isSupport ? "Send Feedback" : "Project Inquiry"}
</h3> </h3>
<p className="text-muted-foreground text-lg mt-3 max-w-2xl text-balance"> <p className="text-muted-foreground text-lg mt-3 max-w-2xl text-balance">
{isSupport {isSupport
? 'Found a bug? Have a feature request? We read every message and typically respond within 24 hours.' ? "Found a bug? Have a feature request? We read every message and typically respond within 24 hours."
: 'Tell us about your technical challenges. From Medusa v2 migrations to real-time custom engines, we\'re ready to architect your solution.'} : "Tell us about your technical challenges. From Medusa v2 migrations to real-time custom engines, we're ready to architect your solution."}
</p> </p>
</div> </div>
<input type="hidden" name="source" value={source} /> <input type="hidden" name="source" value={source} />
<div className="grid md:grid-cols-2 gap-8 relative z-10"> <div className="grid md:grid-cols-2 gap-8 relative z-10">
<div className="space-y-3"> <div className="space-y-3">
<label htmlFor="name" className="text-sm font-semibold tracking-wide uppercase text-muted-foreground ml-1"> <label
{isSupport ? 'Your Name' : 'Full Name'} htmlFor="name"
className="text-sm font-semibold tracking-wide uppercase text-muted-foreground ml-1"
>
{isSupport ? "Your Name" : "Full Name"}
</label> </label>
<input <input
name="name" name="name"
@@ -75,12 +94,23 @@ export function ContactForm({ source = 'inquiry' }: ContactFormProps) {
aria-invalid={state?.errors?.name ? "true" : undefined} aria-invalid={state?.errors?.name ? "true" : undefined}
className="w-full p-4 border-2 border-border/50 rounded-2xl bg-background/50 focus:ring-4 focus:ring-primary/10 focus:border-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary transition-all text-lg" className="w-full p-4 border-2 border-border/50 rounded-2xl bg-background/50 focus:ring-4 focus:ring-primary/10 focus:border-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary transition-all text-lg"
/> />
{state?.errors?.name && <p id="name-error" className="text-red-500 text-sm px-1" role="alert">{state.errors.name}</p>} {state?.errors?.name && (
<p
id="name-error"
className="text-red-500 text-sm px-1"
role="alert"
>
{state.errors.name}
</p>
)}
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<label htmlFor="email" className="text-sm font-semibold tracking-wide uppercase text-muted-foreground ml-1"> <label
{isSupport ? 'Email' : 'Work Email'} htmlFor="email"
className="text-sm font-semibold tracking-wide uppercase text-muted-foreground ml-1"
>
{isSupport ? "Email" : "Work Email"}
</label> </label>
<input <input
name="email" name="email"
@@ -92,26 +122,49 @@ export function ContactForm({ source = 'inquiry' }: ContactFormProps) {
aria-invalid={state?.errors?.email ? "true" : undefined} aria-invalid={state?.errors?.email ? "true" : undefined}
className="w-full p-4 border-2 border-border/50 rounded-2xl bg-background/50 focus:ring-4 focus:ring-primary/10 focus:border-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary transition-all text-lg" className="w-full p-4 border-2 border-border/50 rounded-2xl bg-background/50 focus:ring-4 focus:ring-primary/10 focus:border-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary transition-all text-lg"
/> />
{state?.errors?.email && <p id="email-error" className="text-red-500 text-sm px-1" role="alert">{state.errors.email}</p>} {state?.errors?.email && (
<p
id="email-error"
className="text-red-500 text-sm px-1"
role="alert"
>
{state.errors.email}
</p>
)}
</div> </div>
</div> </div>
<div className="space-y-3 relative z-10"> <div className="space-y-3 relative z-10">
<label htmlFor="message" className="text-sm font-semibold tracking-wide uppercase text-muted-foreground ml-1"> <label
{isSupport ? 'Your Message' : 'Project Details'} htmlFor="message"
className="text-sm font-semibold tracking-wide uppercase text-muted-foreground ml-1"
>
{isSupport ? "Your Message" : "Project Details"}
</label> </label>
<textarea <textarea
name="message" name="message"
id="message" id="message"
placeholder={isSupport placeholder={
? "Describe the issue or suggestion. Include your device, OS version, and game version if reporting a bug..." isSupport
: "What are you building? Mention any specific constraints, timelines, or technologies..."} ? "Describe the issue or suggestion. Include your device, OS version, and game version if reporting a bug..."
: "What are you building? Mention any specific constraints, timelines, or technologies..."
}
required required
aria-describedby={state?.errors?.message ? "message-error" : undefined} aria-describedby={
state?.errors?.message ? "message-error" : undefined
}
aria-invalid={state?.errors?.message ? "true" : undefined} aria-invalid={state?.errors?.message ? "true" : undefined}
className="w-full p-6 border-2 border-border/50 rounded-2xl bg-background/50 h-56 resize-none focus:ring-4 focus:ring-primary/10 focus:border-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary transition-all text-lg leading-relaxed" className="w-full p-6 border-2 border-border/50 rounded-2xl bg-background/50 h-56 resize-none focus:ring-4 focus:ring-primary/10 focus:border-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary transition-all text-lg leading-relaxed"
/> />
{state?.errors?.message && <p id="message-error" className="text-red-500 text-sm px-1" role="alert">{state.errors.message}</p>} {state?.errors?.message && (
<p
id="message-error"
className="text-red-500 text-sm px-1"
role="alert"
>
{state.errors.message}
</p>
)}
</div> </div>
<input type="hidden" name="token" value={token} /> <input type="hidden" name="token" value={token} />
@@ -139,7 +192,9 @@ export function ContactForm({ source = 'inquiry' }: ContactFormProps) {
}`} }`}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-xl" aria-hidden="true">{state.success ? "\u2713" : "\u26A0"}</span> <span className="text-xl" aria-hidden="true">
{state.success ? "\u2713" : "\u26A0"}
</span>
<p className="font-medium">{state.message}</p> <p className="font-medium">{state.message}</p>
</div> </div>
</div> </div>
+35
View File
@@ -0,0 +1,35 @@
type Umami = { track: (name: string, data?: Record<string, unknown>) => void };
/**
* Record a named event, if and only if analytics are already running.
*
* There is no event in this codebase today, which is why every argument about the site's copy
* has been an argument about taste. Two events (a visitor starting the contact form, and one
* completing it) turn the next rewrite into a before-and-after.
*
* This deliberately does no consent checking of its own. `UmamiAnalytics` already declines to
* load the script under Global Privacy Control, and the script itself honours Do Not Track, so
* `window.umami` is simply absent for a visitor who opted out and every call here is a no-op.
* Re-implementing the gate would give it a second place to be wrong, and the two could disagree.
*
* Never throws: an analytics call is not worth breaking a form submission over.
*/
export function track(name: string, data?: Record<string, unknown>): void {
if (typeof window === "undefined") return;
const umami = (window as unknown as { umami?: Umami }).umami;
if (!umami?.track) return;
try {
umami.track(name, data);
} catch {
// Swallowed on purpose. See above.
}
}
/**
* The event names, in one place so a typo cannot quietly create a second funnel that looks
* empty. Values are the strings that appear in the dashboard.
*/
export const EVENTS = {
contactStarted: "contact-started",
contactSubmitted: "contact-submitted",
} as const;