From 7208095b062f095f5a0c1a4f607d891f9466896f Mon Sep 17 00:00:00 2001
From: jwinterm
Date: Sat, 5 Sep 2026 23:43:57 -0400
Subject: [PATCH] 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.
---
apps/web/src/app/privacy/page.tsx | 7 +
.../src/components/contact/contact-form.tsx | 139 ++++++++++++------
apps/web/src/lib/analytics.ts | 35 +++++
3 files changed, 139 insertions(+), 42 deletions(-)
create mode 100644 apps/web/src/lib/analytics.ts
diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx
index a132563..901e45c 100644
--- a/apps/web/src/app/privacy/page.tsx
+++ b/apps/web/src/app/privacy/page.tsx
@@ -84,6 +84,13 @@ export default function PrivacyPage() {
collected from your visit. We do not use Google Analytics, advertising pixels, or similar
third-party tracking tools on the Site.
+
+ 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.
+
Server logs
diff --git a/apps/web/src/components/contact/contact-form.tsx b/apps/web/src/components/contact/contact-form.tsx
index 243a3cf..ce29bbe 100644
--- a/apps/web/src/components/contact/contact-form.tsx
+++ b/apps/web/src/components/contact/contact-form.tsx
@@ -1,10 +1,11 @@
-'use client';
-import { useActionState } from 'react';
-import { useFormStatus } from 'react-dom';
-import { submitContactForm } from '@/actions/submit-contact';
-import { TurnstileWidget } from './turnstile-widget';
-import { useState, useEffect, useRef } from 'react';
-import { Button } from '@repo/ui/components/button';
+"use client";
+import { useActionState } from "react";
+import { useFormStatus } from "react-dom";
+import { submitContactForm } from "@/actions/submit-contact";
+import { TurnstileWidget } from "./turnstile-widget";
+import { useState, useEffect, useRef } from "react";
+import { Button } from "@repo/ui/components/button";
+import { EVENTS, track } from "@/lib/analytics";
function SubmitButton({ label = "Send Message" }: { label?: string }) {
const { pending } = useFormStatus();
@@ -21,50 +22,68 @@ function SubmitButton({ label = "Send Message" }: { label?: string }) {
}
interface ContactFormProps {
- source?: 'inquiry' | 'support';
+ source?: "inquiry" | "support";
}
-export function ContactForm({ source = 'inquiry' }: ContactFormProps) {
- const [state, formAction] = useActionState(submitContactForm, { success: false, message: '' });
- const [token, setToken] = useState('');
+export function ContactForm({ source = "inquiry" }: ContactFormProps) {
+ const [state, formAction] = useActionState(submitContactForm, {
+ success: false,
+ message: "",
+ });
+ const [token, setToken] = useState("");
const formRef = useRef(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
useEffect(() => {
if (state.success && formRef.current) {
formRef.current.reset();
- setToken('');
+ setToken("");
+ startedRef.current = false;
+ track(EVENTS.contactSubmitted, { source });
}
- }, [state.success]);
+ }, [state.success, source]);
return (