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
|
||||
third-party tracking tools on the Site.
|
||||
</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>
|
||||
<p>
|
||||
|
||||
@@ -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<string>('');
|
||||
export function ContactForm({ source = "inquiry" }: ContactFormProps) {
|
||||
const [state, formAction] = useActionState(submitContactForm, {
|
||||
success: false,
|
||||
message: "",
|
||||
});
|
||||
const [token, setToken] = useState<string>("");
|
||||
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
|
||||
useEffect(() => {
|
||||
if (state.success && formRef.current) {
|
||||
formRef.current.reset();
|
||||
setToken('');
|
||||
setToken("");
|
||||
startedRef.current = false;
|
||||
track(EVENTS.contactSubmitted, { source });
|
||||
}
|
||||
}, [state.success]);
|
||||
}, [state.success, source]);
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={formRef}
|
||||
action={formAction}
|
||||
onInput={noteStart}
|
||||
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 bottom-0 left-0 w-64 h-64 bg-blue-500/5 rounded-full blur-3xl -ml-32 -mb-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="text-left mb-10 relative z-10">
|
||||
<h3 className="text-3xl font-bold tracking-tight">
|
||||
{isSupport ? 'Send Feedback' : 'Project Inquiry'}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-lg mt-3 max-w-2xl text-balance">
|
||||
{isSupport
|
||||
? '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.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-left mb-10 relative z-10">
|
||||
<h3 className="text-3xl font-bold tracking-tight">
|
||||
{isSupport ? "Send Feedback" : "Project Inquiry"}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-lg mt-3 max-w-2xl text-balance">
|
||||
{isSupport
|
||||
? "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."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="source" value={source} />
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8 relative z-10">
|
||||
<div className="space-y-3">
|
||||
<label htmlFor="name" className="text-sm font-semibold tracking-wide uppercase text-muted-foreground ml-1">
|
||||
{isSupport ? 'Your Name' : 'Full Name'}
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="text-sm font-semibold tracking-wide uppercase text-muted-foreground ml-1"
|
||||
>
|
||||
{isSupport ? "Your Name" : "Full Name"}
|
||||
</label>
|
||||
<input
|
||||
name="name"
|
||||
@@ -75,12 +94,23 @@ export function ContactForm({ source = 'inquiry' }: ContactFormProps) {
|
||||
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"
|
||||
/>
|
||||
{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 className="space-y-3">
|
||||
<label htmlFor="email" className="text-sm font-semibold tracking-wide uppercase text-muted-foreground ml-1">
|
||||
{isSupport ? 'Email' : 'Work Email'}
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="text-sm font-semibold tracking-wide uppercase text-muted-foreground ml-1"
|
||||
>
|
||||
{isSupport ? "Email" : "Work Email"}
|
||||
</label>
|
||||
<input
|
||||
name="email"
|
||||
@@ -92,30 +122,53 @@ export function ContactForm({ source = 'inquiry' }: ContactFormProps) {
|
||||
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"
|
||||
/>
|
||||
{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 className="space-y-3 relative z-10">
|
||||
<label htmlFor="message" className="text-sm font-semibold tracking-wide uppercase text-muted-foreground ml-1">
|
||||
{isSupport ? 'Your Message' : 'Project Details'}
|
||||
<label
|
||||
htmlFor="message"
|
||||
className="text-sm font-semibold tracking-wide uppercase text-muted-foreground ml-1"
|
||||
>
|
||||
{isSupport ? "Your Message" : "Project Details"}
|
||||
</label>
|
||||
<textarea
|
||||
name="message"
|
||||
id="message"
|
||||
placeholder={isSupport
|
||||
? "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..."}
|
||||
placeholder={
|
||||
isSupport
|
||||
? "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
|
||||
aria-describedby={state?.errors?.message ? "message-error" : undefined}
|
||||
aria-describedby={
|
||||
state?.errors?.message ? "message-error" : 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"
|
||||
/>
|
||||
{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>
|
||||
|
||||
<input type="hidden" name="token" value={token} />
|
||||
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-8 pt-4 relative z-10 border-t border-border/50">
|
||||
<div className="scale-100 flex items-center gap-3">
|
||||
<TurnstileWidget onVerify={setToken} />
|
||||
@@ -127,7 +180,7 @@ export function ContactForm({ source = 'inquiry' }: ContactFormProps) {
|
||||
<SubmitButton label={isSupport ? "Send Feedback" : "Send Message"} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{state?.message && (
|
||||
<div
|
||||
role="status"
|
||||
@@ -139,7 +192,9 @@ export function ContactForm({ source = 'inquiry' }: ContactFormProps) {
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
</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