mirror of
https://github.com/Such-Software/such.software.git
synced 2026-10-17 05:32:05 +02:00
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:
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user