feat(contact): extract shared ContactForm and add per-subdomain contact pages

Refactor the monolithic main-site contact page into a reusable
<ContactForm> component that encapsulates the Turnstile widget, cooldown
timer, email-verification flow, and tRPC submission. The main
/contact route becomes a thin wrapper that supplies the site-specific
disclaimer subline and Life-and-Lineage FAQ accordion (now exported as
<LineageContactQuestions> for reuse).

Add per-subdomain contact routes (gaze, inputhalo, lineage, nessa) that
render the shared component, with site-aware subject prefix, recipient,
heading, and PageHead metadata derived via useSite() from CONTACT_CONTEXT.

Introduce src/lib/contact-config.ts (with tests) centralizing the contact
recipient/sender addresses and subject building. The misc.sendContactRequest
mutation accepts an optional subjectPrefix (defaulting to "freno.me") so
inbound call sites continue emitting byte-identical legacy subjects, while
subdomain submissions route distinct subjects (e.g. "[Gaze] Contact Request")
to the same inbox.
This commit is contained in:
2026-07-23 12:25:13 -04:00
parent 1cc16313d5
commit a7ad581ed2
9 changed files with 1016 additions and 513 deletions

View File

@@ -0,0 +1,553 @@
import {
createSignal,
onMount,
createEffect,
Show,
type JSX
} from "solid-js";
import { useSearchParams, query, createAsync } from "@solidjs/router";
import { action, redirect } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import { api } from "~/lib/api";
import { getClientCookie } from "~/lib/cookies.client";
import CountdownCircleTimer from "~/components/CountdownCircleTimer";
import Input from "~/components/ui/Input";
import { Button } from "~/components/ui/Button";
import { useCountdown } from "~/lib/useCountdown";
import { useSite } from "~/context/SiteContext";
import type { UserProfile } from "~/types/user";
import { getCookie, setCookie } from "vinxi/http";
import { z } from "zod";
import { env as clientEnv } from "~/env/client";
import {
fetchWithTimeout,
checkResponse,
fetchWithRetry,
NetworkError,
TimeoutError,
APIError,
verifyTurnstileToken
} from "~/server/fetch-utils";
import {
NETWORK_CONFIG,
COOLDOWN_TIMERS,
VALIDATION_CONFIG,
COUNTDOWN_CONFIG,
TURNSTILE_CONFIG
} from "~/config";
import {
CONTACT_RECIPIENT_EMAIL,
CONTACT_SENDER,
getContactContext,
buildContactSubject
} from "~/lib/contact-config";
/**
* Shared, site-aware contact form (task 09 — per-subdomain contact pages).
*
* Extracted verbatim-in-spirit from the legacy `src/routes/contact.tsx` so the
* main-site contact flow (`freno.me/contact`) keeps its exact Turnstile +
* cooldown + tRPC-submission behavior — the only substantive change is that
* the outbound email subject is now per-site (see `~/lib/contact-config.ts`)
* and `env` is resolved via a server-only dynamic import (the legacy
* top-level `env` reference was a latent runtime bug in the no-JS fallback).
*
* Site awareness:
* - Reads `useSite()` and resolves a default `ContactContext` from
* `CONTACT_CONTEXT[site().id]` (subjectPrefix, recipientLabel, heading,
* PageHead title + description). Props override the defaults.
* - Emits `<PageHead>` so every per-subdomain `/contact` route gets
* site-aware title / canonical / OG tags for free (task 02).
* - The Turnstile site key (`VITE_TURNSTILE_SITE_KEY`) is shared across all
* subdomains — ensure it is configured for `*.freno.me` in the Cloudflare
* Turnstile dashboard (see task notes).
*
* Email routing:
* - JS path: `api.misc.sendContactRequest.mutate({ …, subjectPrefix })` — the
* tRPC mutation builds the subject via `buildContactSubject`.
* - No-JS path: the `sendContactEmail` server action reads a hidden
* `subjectPrefix` form field and emits the identical subject. Both paths
* deliver to the single shared `CONTACT_RECIPIENT_EMAIL` inbox.
*
* Both redirect targets (`/contact?success=true`, `/contact?error=…`) are the
* PUBLIC browser path — correct on every subdomain origin since vercel.json
* host rewrites leave the browser URL clean (`nessa.freno.me/contact`).
*/
export interface ContactFormProps {
/**
* Outbound email subject prefix token. Defaults to the active site's
* `CONTACT_CONTEXT[siteId].subjectPrefix` (e.g. `"freno.me"` on main,
* `"[Nessa]"` on nessa).
*/
subjectPrefix?: string;
/** Display-only label for the recipient. Defaults to the site context. */
recipientLabel?: string;
/** `<h1>` heading. Defaults to the site context's `heading` (`"Contact"`). */
heading?: string;
/** Optional subline rendered under the heading (e.g. main-site disclaimer). */
subline?: JSX.Element;
/**
* Extra content rendered between the heading/subline and the form — used by
* the main site and the lineage subdomain to host the Life-and-Lineage Q&A
* accordion.
*/
children?: JSX.Element;
/** `<PageHead title>` — composes with the site `titleSuffix`. */
pageTitle?: string;
/** `<PageHead description>`. Defaults to the site context's description. */
pageDescription?: string;
}
// ───────────────────────────────────────────────────────────────────────────
// Server data query — cooldown cookie expiry (shared across all sites).
// ───────────────────────────────────────────────────────────────────────────
const getContactData = query(async () => {
"use server";
const contactExp = getCookie("contactRequestSent");
let remainingTime = 0;
if (contactExp) {
const expires = new Date(contactExp);
remainingTime = Math.max(0, (expires.getTime() - Date.now()) / 1000);
}
return { remainingTime };
}, "contact-data");
// ───────────────────────────────────────────────────────────────────────────
// No-JS fallback action. Behaves identically to the tRPC mutation so the
// contact form works even with JS disabled (progressive enhancement).
//
// `env` is resolved via a server-only dynamic import (the idiomatic pattern
// used by `account.tsx` / `blog/index.tsx`) — the legacy top-level `env`
// reference in the original `contact.tsx` was a latent runtime bug.
// ───────────────────────────────────────────────────────────────────────────
const sendContactEmail = action(async (formData: FormData) => {
"use server";
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const message = formData.get("message") as string;
const turnstileToken = formData.get("cf-turnstile-response") as string;
const subjectPrefix =
(formData.get("subjectPrefix") as string | null) || "freno.me";
const schema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Valid email is required"),
message: z
.string()
.min(1, "Message is required")
.max(VALIDATION_CONFIG.MAX_CONTACT_MESSAGE_LENGTH, "Message too long")
});
try {
schema.parse({ name, email, message });
} catch (err: any) {
return redirect(
`/contact?error=${encodeURIComponent(err.errors[0]?.message || "Invalid input")}`
);
}
const { env } = await import("~/env/server");
// Verify Cloudflare Turnstile token
const turnstileValid = await verifyTurnstileToken(
turnstileToken,
env.TURNSTILE_SECRET_KEY,
TURNSTILE_CONFIG.VERIFY_URL,
TURNSTILE_CONFIG.RESPONSE_TIMEOUT_MS
);
if (!turnstileValid) {
return redirect(
"/contact?error=Security verification failed. Please refresh and try again."
);
}
const contactExp = getCookie("contactRequestSent");
if (contactExp) {
const expires = new Date(contactExp);
const remaining = expires.getTime() - Date.now();
if (remaining > 0) {
return redirect(
"/contact?error=Please wait before sending another message"
);
}
}
const apiKey = env.SENDINBLUE_KEY;
const apiUrl = "https://api.sendinblue.com/v3/smtp/email";
const escapeHtml = (str: string) =>
str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
const sendinblueData = {
sender: { ...CONTACT_SENDER },
to: [{ email: CONTACT_RECIPIENT_EMAIL }],
htmlContent: `<html><head></head><body><div>Source: ${escapeHtml(subjectPrefix)}</div><div>Request Name: ${escapeHtml(name)}</div><div>Request Email: ${escapeHtml(email)}</div><div>Request Message: ${escapeHtml(message)}</div></body></html>`,
subject: buildContactSubject(subjectPrefix)
};
try {
await fetchWithRetry(
async () => {
const response = await fetchWithTimeout(apiUrl, {
method: "POST",
headers: {
accept: "application/json",
"api-key": apiKey,
"content-type": "application/json"
},
body: JSON.stringify(sendinblueData),
timeout: NETWORK_CONFIG.EMAIL_API_TIMEOUT_MS
});
await checkResponse(response);
return response;
},
{
maxRetries: NETWORK_CONFIG.MAX_RETRIES,
retryDelay: NETWORK_CONFIG.RETRY_DELAY_MS
}
);
const exp = new Date(Date.now() + COOLDOWN_TIMERS.CONTACT_REQUEST_MS);
setCookie("contactRequestSent", exp.toUTCString(), {
expires: exp,
path: "/"
});
return redirect("/contact?success=true");
} catch (error) {
let errorMessage =
"Failed to send message. You can reach me at michael@freno.me";
if (error instanceof TimeoutError) {
errorMessage =
"Email service timed out. Please try again or contact michael@freno.me";
} else if (error instanceof NetworkError) {
errorMessage =
"Network error. Please try again or contact michael@freno.me";
} else if (error instanceof APIError) {
errorMessage =
"Email service error. You can reach me at michael@freno.me";
}
return redirect(`/contact?error=${encodeURIComponent(errorMessage)}`);
}
});
export function ContactForm(props: ContactFormProps) {
const site = useSite();
const ctx = () => getContactContext(site().id);
// Effective values — props override the site-context defaults.
const effectiveSubjectPrefix = () => props.subjectPrefix ?? ctx().subjectPrefix;
const effectiveRecipientLabel = () =>
props.recipientLabel ?? ctx().recipientLabel;
const effectiveHeading = () => props.heading ?? ctx().heading;
const effectivePageTitle = () => props.pageTitle ?? ctx().pageTitle;
const effectivePageDescription = () =>
props.pageDescription ?? ctx().description;
const [searchParams] = useSearchParams();
// Load server data using createAsync
const contactData = createAsync(() => getContactData(), {
deferStream: true
});
const [emailSent, setEmailSent] = createSignal<boolean>(
searchParams.success === "true"
);
const [error, setError] = createSignal<string>(
searchParams.error ? decodeURIComponent(String(searchParams.error)) : ""
);
const [loading, setLoading] = createSignal<boolean>(false);
const [user, setUser] = createSignal<UserProfile | null>(null);
const [jsEnabled, setJsEnabled] = createSignal<boolean>(false);
const [turnstileToken, setTurnstileToken] = createSignal<string>("");
const [turnstileWidgetId, setTurnstileWidgetId] = createSignal<string | null>(
null
);
const { remainingTime, startCountdown, setRemainingTime } = useCountdown();
onMount(() => {
setJsEnabled(true);
// Load Cloudflare Turnstile script with explicit rendering.
// The site key is shared across all subdomains — ensure it is configured
// for `*.freno.me` in the Cloudflare Turnstile dashboard (task notes).
const script = document.createElement("script");
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
script.async = true;
script.defer = true;
script.onload = () => {
if (typeof window !== "undefined" && (window as any).turnstile) {
const container = document.getElementById("turnstile-widget-1");
if (container) {
const id = (window as any).turnstile.render(container, {
sitekey: clientEnv.VITE_TURNSTILE_SITE_KEY,
theme: "dark",
callback: (token: string) => {
setTurnstileToken(token);
},
"expired-callback": () => {
setTurnstileToken("");
}
});
setTurnstileWidgetId(id);
}
}
};
document.head.appendChild(script);
// Best-effort profile prefill. On subdomain sites there is no freno.me web
// auth (Nessa uses Clerk, Lineage uses its mobile JWT) so this resolves to
// null / 401 — the `.catch` swallows it and the fields stay blank.
api.user.getProfile
.query()
.then((userData) => {
if (userData) {
setUser(userData);
}
})
.catch(() => {});
});
createEffect(() => {
// Try server data first (more accurate)
const serverData = contactData();
if (serverData?.remainingTime && serverData.remainingTime > 0) {
const expirationTime = new Date(
Date.now() + serverData.remainingTime * 1000
);
startCountdown(expirationTime);
return;
}
// Fall back to client cookie if server data not available yet
const timer = getClientCookie("contactRequestSent");
if (timer) {
try {
startCountdown(timer);
} catch (e) {
console.error("Failed to start countdown from cookie:", e);
}
}
});
const sendEmailTrigger = async (e: Event) => {
if (!jsEnabled()) return;
e.preventDefault();
const form = e.target as unknown as HTMLFormElement;
const formData = new FormData(form);
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const message = formData.get("message") as string;
if (name && email && message) {
// Get fresh Turnstile token
let currentToken = turnstileToken();
if (
!currentToken &&
typeof window !== "undefined" &&
(window as any).turnstile
) {
const widgetEl = document.getElementById("turnstile-widget-1");
if (widgetEl) {
const id = turnstileWidgetId();
currentToken = (window as any).turnstile.getResponse(id || widgetEl);
}
}
if (!currentToken || currentToken.trim() === "") {
setError("Please complete the security check.");
setLoading(false);
return;
}
setLoading(true);
setError("");
setEmailSent(false);
try {
const res = await api.misc.sendContactRequest.mutate({
name,
email,
message,
turnstileToken: currentToken,
subjectPrefix: effectiveSubjectPrefix()
});
if (res.message === "email sent") {
setEmailSent(true);
setError("");
form.reset();
// Reset Turnstile widget
if (typeof window !== "undefined" && (window as any).turnstile) {
const widgetEl = document.getElementById("turnstile-widget-1");
if (widgetEl) {
const id = turnstileWidgetId();
(window as any).turnstile.reset(id || widgetEl);
}
}
setTurnstileToken("");
// Set countdown directly — cookie might not be readable immediately
const expirationTime = new Date(
Date.now() + COOLDOWN_TIMERS.CONTACT_REQUEST_MS
);
startCountdown(expirationTime);
}
} catch (err: any) {
setError(err.message || "An error occurred");
setEmailSent(false);
}
setLoading(false);
}
};
const renderTime = ({ remainingTime }: { remainingTime: number }) => {
const time = isNaN(remainingTime) ? 0 : Math.max(0, remainingTime);
return (
<div class="timer">
<div class="value">{time.toFixed(0)}</div>
</div>
);
};
return (
<>
<PageHead
title={effectivePageTitle()}
description={effectivePageDescription()}
/>
<div class="bg-base flex min-h-screen w-full justify-center">
<div class="w-full max-w-4xl px-4 pt-[20vh]">
<div class="text-center text-3xl tracking-widest">
{effectiveHeading()}
</div>
<Show when={props.subline}>
<div class="mt-4 -mb-4 text-center text-xl tracking-widest">
{props.subline}
</div>
</Show>
{props.children}
<form
onSubmit={sendEmailTrigger}
method="post"
action={sendContactEmail}
class="w-full"
>
{/* Hidden per-site subject prefix — consumed by the no-JS action. */}
<input
type="hidden"
name="subjectPrefix"
value={effectiveSubjectPrefix()}
/>
<div class="flex w-full flex-col justify-evenly">
<div class="mx-auto w-full justify-evenly md:flex md:flex-row">
<Input
type="text"
required
name="name"
value={user()?.displayName ?? ""}
title="Please enter your name"
label="Name"
containerClass="input-group md:mx-4"
class="w-full"
/>
<Input
type="email"
required
name="email"
value={user()?.email ?? ""}
title="Please enter a valid email address"
label="Email"
containerClass="input-group md:mx-4"
class="w-full"
/>
</div>
<div class="mx-auto w-full pt-6 md:pt-12">
<div class="textarea-group">
<textarea
required
name="message"
placeholder=" "
title="Please enter your message"
class="underlinedInput w-full bg-transparent"
rows={4}
maxlength={VALIDATION_CONFIG.MAX_CONTACT_MESSAGE_LENGTH}
/>
<span class="bar" />
<label class="underlinedInputLabel">Message</label>
</div>
</div>
<div class="mx-auto flex w-full justify-between pt-4">
<div id="turnstile-widget-1"></div>
<Show
when={
remainingTime() > 0 ||
(contactData()?.remainingTime ?? 0) > 0
}
fallback={
<Button type="submit" loading={loading()} class="w-36">
Send Message
</Button>
}
>
<Show
when={jsEnabled()}
fallback={
<div class="flex items-center justify-center text-sm text-zinc-400">
Please wait{" "}
{Math.ceil(contactData()?.remainingTime ?? 0)}s before
sending another message
</div>
}
>
<CountdownCircleTimer
duration={COUNTDOWN_CONFIG.CONTACT_FORM_DURATION_S}
initialRemainingTime={remainingTime()}
size={48}
strokeWidth={6}
onComplete={() => setRemainingTime(0)}
>
{renderTime}
</CountdownCircleTimer>
</Show>
</Show>
</div>
</div>
</form>
<div
class={`${
emailSent()
? "text-green-400"
: error() !== ""
? "text-red-400"
: "user-select opacity-0"
} flex justify-center text-center italic transition-opacity duration-300 ease-in-out`}
>
{emailSent()
? `Email sent to ${effectiveRecipientLabel()}!`
: error()}
</div>
</div>
</div>
</>
);
}
export default ContactForm;

View File

@@ -0,0 +1,106 @@
/**
* Unit tests for the per-site contact configuration (task 09).
*
* Mirrors the `meta.test.ts` / `nav-config.test.ts` testability pattern:
* `contact-config.ts` is a pure module (no solid-js / @solidjs/router /
* @solidjs/meta imports) so `bun:test` can resolve it directly.
*
* Asserts the task-09 acceptance criteria:
* - Each subdomain has a distinct `subjectPrefix` (email routing differs per
* subdomain).
* - The main site prefix stays `"freno.me"` so the legacy subject
* `"freno.me Contact Request"` is byte-identical post-refactor.
* - `buildContactSubject` composes the prefix + `" Contact Request"` for every
* subdomain — the exact strings the tRPC mutation + no-JS action emit.
*/
import { describe, it, expect } from "bun:test";
import {
CONTACT_CONTEXT,
CONTACT_RECIPIENT_EMAIL,
getContactContext,
buildContactSubject,
type ContactContext
} from "~/lib/contact-config";
import type { SiteId } from "~/lib/site-context";
const ALL_SITES: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo"];
describe("contact-config — CONTEXT map", () => {
it("defines a ContactContext for every SiteId", () => {
for (const id of ALL_SITES) {
expect(CONTACT_CONTEXT[id]).toBeDefined();
expect(CONTACT_CONTEXT[id].siteId).toBe(id);
}
});
it("getContactContext returns the matching entry", () => {
for (const id of ALL_SITES) {
expect(getContactContext(id)).toBe(CONTACT_CONTEXT[id]);
}
});
});
describe("contact-config — subject prefixes (email routing)", () => {
it("main site keeps the bare 'freno.me' prefix (backwards-compat subject)", () => {
expect(CONTACT_CONTEXT.main.subjectPrefix).toBe("freno.me");
});
it("each product subdomain uses a distinct bracketed prefix", () => {
expect(CONTACT_CONTEXT.nessa.subjectPrefix).toBe("[Nessa]");
expect(CONTACT_CONTEXT.lineage.subjectPrefix).toBe("[Lineage]");
expect(CONTACT_CONTEXT.gaze.subjectPrefix).toBe("[Gaze]");
expect(CONTACT_CONTEXT.inputhalo.subjectPrefix).toBe("[InputHalo]");
});
it("no two sites share a subjectPrefix (routing is unambiguous)", () => {
const prefixes = ALL_SITES.map((id) => CONTACT_CONTEXT[id].subjectPrefix);
expect(new Set(prefixes).size).toBe(prefixes.length);
});
});
describe("contact-config — buildContactSubject", () => {
it("main site subject is the legacy 'freno.me Contact Request' string", () => {
expect(buildContactSubject(CONTACT_CONTEXT.main.subjectPrefix)).toBe(
"freno.me Contact Request"
);
});
it("each subdomain subject is prefixed with its bracketed token", () => {
expect(buildContactSubject("[Nessa]")).toBe("[Nessa] Contact Request");
expect(buildContactSubject("[Lineage]")).toBe("[Lineage] Contact Request");
expect(buildContactSubject("[Gaze]")).toBe("[Gaze] Contact Request");
expect(buildContactSubject("[InputHalo]")).toBe(
"[InputHalo] Contact Request"
);
});
it("subjects differ per subdomain", () => {
const subjects = ALL_SITES.map((id) =>
buildContactSubject(CONTACT_CONTEXT[id].subjectPrefix)
);
expect(new Set(subjects).size).toBe(subjects.length);
});
});
describe("contact-config — recipient + branding", () => {
it("contact recipient is a single shared inbox across all sites", () => {
expect(CONTACT_RECIPIENT_EMAIL).toBe("michael@freno.me");
});
it("every site has a non-empty recipientLabel + heading + description", () => {
for (const id of ALL_SITES) {
const ctx: ContactContext = CONTACT_CONTEXT[id];
expect(ctx.recipientLabel.length).toBeGreaterThan(0);
expect(ctx.heading.length).toBeGreaterThan(0);
expect(ctx.description.length).toBeGreaterThan(0);
expect(ctx.pageTitle.length).toBeGreaterThan(0);
}
});
it("page title is the bare 'Contact' so the site suffix composes it", () => {
// <PageHead> appends site.titleSuffix → e.g. "Contact | Nessa".
for (const id of ALL_SITES) {
expect(CONTACT_CONTEXT[id].pageTitle).toBe("Contact");
}
});
});

122
src/lib/contact-config.ts Normal file
View File

@@ -0,0 +1,122 @@
/**
* Per-site contact form configuration (task 09 — per-subdomain contact pages).
*
* Pure module — imports NOTHING from solid-js / @solidjs/router / @solidjs/meta —
* so it can be unit-tested in `bun:test` without spinning up the router / Meta
* provider, mirroring the testability pattern established by `page-head-meta.ts`
* and `nav-config.ts`.
*
* The shared `<ContactForm>` (`src/components/ContactForm.tsx`) reads the active
* `site` via `useSite()` and derives its `subjectPrefix`, recipient label,
* heading copy, and PageHead description from this map. Call sites may still
* override these defaults via props (e.g. to inject a site-specific subline or
* render a `children` block such as the Life-and-Lineage Q&A accordion).
*
* Email routing contract:
* - `subjectPrefix` is the bare prefix token placed in front of `" Contact
* Request"`. The main site keeps `"freno.me"` (no brackets) so the existing
* `"freno.me Contact Request"` subject is byte-identical after the refactor
* (backwards compatibility for any inbox filters / saved searches). Each
* product subdomain uses a bracketed token (`"[Nessa]"`, `"[Lineage]"`,
* `"[Gaze]"`, `"[InputHalo]"`) per the task spec so inbound mail can be
* routed / triaged by source product.
* - All mail is delivered to `michael@freno.me` (single owner across every
* product); `recipientLabel` is a display-only affordance, not an alternate
* SMTP recipient.
* - The tRPC `misc.sendContactRequest` mutation and the no-JS server action
* both receive this prefix and emit the identical subject — a single source
* of truth lives here.
*/
import type { SiteId } from "~/lib/site-context";
/** Canonical recipient for every contact submission (single product owner). */
export const CONTACT_RECIPIENT_EMAIL = "michael@freno.me";
/** Canonical sender identity shown on outbound contact mail. */
export const CONTACT_SENDER = { name: "freno.me", email: CONTACT_RECIPIENT_EMAIL };
export interface ContactContext {
siteId: SiteId;
/**
* Prefix token prepended to the outbound email subject. Bare `"freno.me"`
* for the main site (preserves the historical subject verbatim); bracketed
* `[Nessa]` / `[Lineage]` / `[Gaze]` / `[InputHalo]` for the product
* subdomains so mail can be triaged by source.
*/
subjectPrefix: string;
/** Display-only label for who receives the message (no SMTP routing effect). */
recipientLabel: string;
/** `<h1>` heading rendered at the top of the contact form. */
heading: string;
/** `<PageHead description>` for the per-site `/contact` page. */
description: string;
/**
* Page title passed to `<PageHead>`. Composes with the site `titleSuffix`
* (e.g. `"Contact" | Life and Lineage`). The main site keeps the bare
* `"Contact"` so its title remains `"Contact | Michael Freno"`.
*/
pageTitle: string;
}
export const CONTACT_CONTEXT: Record<SiteId, ContactContext> = {
main: {
siteId: "main",
subjectPrefix: "freno.me",
recipientLabel: "Michael Freno",
heading: "Contact",
description: "Contact Me",
pageTitle: "Contact"
},
nessa: {
siteId: "nessa",
subjectPrefix: "[Nessa]",
recipientLabel: "the Nessa team",
heading: "Contact",
description:
"Get in touch with the Nessa community platform — questions about clubs, challenges, the social feed, or events.",
pageTitle: "Contact"
},
lineage: {
siteId: "lineage",
subjectPrefix: "[Lineage]",
recipientLabel: "the Life and Lineage team",
heading: "Contact",
description:
"Contact the Life and Lineage team — questions about gameplay, remote backups, cross-device play, or account deletion.",
pageTitle: "Contact"
},
gaze: {
siteId: "gaze",
subjectPrefix: "[Gaze]",
recipientLabel: "the Gaze team",
heading: "Contact",
description:
"Get in touch with the Gaze team — questions, feedback, or support for the Gaze macOS app.",
pageTitle: "Contact"
},
inputhalo: {
siteId: "inputhalo",
subjectPrefix: "[InputHalo]",
recipientLabel: "the InputHalo team",
heading: "Contact",
description:
"Get in touch with the InputHalo team — questions, feedback, or support for the InputHalo app.",
pageTitle: "Contact"
}
};
/** Resolve the contact context for a given site id. */
export function getContactContext(siteId: SiteId): ContactContext {
return CONTACT_CONTEXT[siteId];
}
/**
* Build the outbound contact email subject for a given prefix token.
*
* Kept pure + exported so the tRPC mutation (`misc.sendContactRequest`) and the
* no-JS server action in `ContactForm.tsx` emit byte-identical subjects — and
* so the unit tests can assert the per-subdomain subject strings without
* driving the network.
*/
export function buildContactSubject(subjectPrefix: string): string {
return `${subjectPrefix} Contact Request`;
}

View File

@@ -1,319 +1,39 @@
import { createSignal, onMount, createEffect, Show } from "solid-js"; import { Show, type JSX } from "solid-js";
import { useSearchParams, query, createAsync } from "@solidjs/router"; import { useSearchParams } from "@solidjs/router";
import { A } from "@solidjs/router"; import { A } from "@solidjs/router";
import { action, redirect } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import { api } from "~/lib/api";
import { getClientCookie } from "~/lib/cookies.client";
import CountdownCircleTimer from "~/components/CountdownCircleTimer";
import RevealDropDown from "~/components/RevealDropDown"; import RevealDropDown from "~/components/RevealDropDown";
import Input from "~/components/ui/Input"; import { ContactForm } from "~/components/ContactForm";
import { Button } from "~/components/ui/Button";
import { useCountdown } from "~/lib/useCountdown";
import type { UserProfile } from "~/types/user";
import { getCookie, setCookie } from "vinxi/http";
import { z } from "zod";
import { env as clientEnv } from "~/env/client";
import {
fetchWithTimeout,
checkResponse,
fetchWithRetry,
NetworkError,
TimeoutError,
APIError,
verifyTurnstileToken
} from "~/server/fetch-utils";
import {
NETWORK_CONFIG,
COOLDOWN_TIMERS,
VALIDATION_CONFIG,
COUNTDOWN_CONFIG,
TURNSTILE_CONFIG
} from "~/config";
const getContactData = query(async () => { /**
"use server"; * Main-site contact page (`freno.me/contact`).
const contactExp = getCookie("contactRequestSent"); *
let remainingTime = 0; * Refactored (task 09) to render the shared `<ContactForm>` — the form logic,
* Turnstile widget, cooldown timer, email-verification flow, and tRPC
* submission all live in the shared component now. This route remains a thin
* wrapper that supplies:
* - the main-site-specific disclaimer subline (hidden when
* `?viewer=lineage`, preserving the legacy behavior), and
* - the Life-and-Lineage Q&A accordion rendered above the form.
*
* The shared component emits `<PageHead title="Contact" description="Contact Me" />`
* (derived from `CONTACT_CONTEXT.main`), matching the pre-refactor metadata
* exactly. The outbound email subject stays `"freno.me Contact Request"`
* (`buildContactSubject("freno.me")`), so inbox filters / saved searches are
* unaffected.
*
* Acceptance: `localhost:3000/contact` still works identically after the
* refactor.
*/
if (contactExp) { /**
const expires = new Date(contactExp); * The Life-and-Lineage FAQ accordion.
remainingTime = Math.max(0, (expires.getTime() - Date.now()) / 1000); *
} * Rendered on the main-site contact page (it documents the mobile product,
* which the main site has historically hosted marketing + support for) and on
return { remainingTime }; * the `lineage.freno.me/contact` subdomain page. Kept here as the canonical
}, "contact-data"); * definition; the lineage subdomain route re-imports and re-uses it.
*/
const sendContactEmail = action(async (formData: FormData) => { export function LineageContactQuestions(): JSX.Element {
"use server";
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const message = formData.get("message") as string;
const turnstileToken = formData.get("cf-turnstile-response") as string;
const schema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Valid email is required"),
message: z
.string()
.min(1, "Message is required")
.max(VALIDATION_CONFIG.MAX_CONTACT_MESSAGE_LENGTH, "Message too long")
});
try {
schema.parse({ name, email, message });
} catch (err: any) {
return redirect(
`/contact?error=${encodeURIComponent(err.errors[0]?.message || "Invalid input")}`
);
}
// Verify Cloudflare Turnstile token
const turnstileValid = await verifyTurnstileToken(
turnstileToken,
env.TURNSTILE_SECRET_KEY,
TURNSTILE_CONFIG.VERIFY_URL,
TURNSTILE_CONFIG.RESPONSE_TIMEOUT_MS
);
if (!turnstileValid) {
return redirect(
"/contact?error=Security verification failed. Please refresh and try again."
);
}
const contactExp = getCookie("contactRequestSent");
if (contactExp) {
const expires = new Date(contactExp);
const remaining = expires.getTime() - Date.now();
if (remaining > 0) {
return redirect(
"/contact?error=Please wait before sending another message"
);
}
}
const apiKey = env.SENDINBLUE_KEY;
const apiUrl = "https://api.sendinblue.com/v3/smtp/email";
const sendinblueData = {
sender: {
name: "freno.me",
email: "michael@freno.me"
},
to: [{ email: "michael@freno.me" }],
htmlContent: `<html><head></head><body><div>Request Name: ${name}</div><div>Request Email: ${email}</div><div>Request Message: ${message}</div></body></html>`,
subject: "freno.me Contact Request"
};
try {
await fetchWithRetry(
async () => {
const response = await fetchWithTimeout(apiUrl, {
method: "POST",
headers: {
accept: "application/json",
"api-key": apiKey,
"content-type": "application/json"
},
body: JSON.stringify(sendinblueData),
timeout: NETWORK_CONFIG.EMAIL_API_TIMEOUT_MS
});
await checkResponse(response);
return response;
},
{
maxRetries: NETWORK_CONFIG.MAX_RETRIES,
retryDelay: NETWORK_CONFIG.RETRY_DELAY_MS
}
);
const exp = new Date(Date.now() + COOLDOWN_TIMERS.CONTACT_REQUEST_MS);
setCookie("contactRequestSent", exp.toUTCString(), {
expires: exp,
path: "/"
});
return redirect("/contact?success=true");
} catch (error) {
let errorMessage =
"Failed to send message. You can reach me at michael@freno.me";
if (error instanceof TimeoutError) {
errorMessage =
"Email service timed out. Please try again or contact michael@freno.me";
} else if (error instanceof NetworkError) {
errorMessage =
"Network error. Please try again or contact michael@freno.me";
} else if (error instanceof APIError) {
errorMessage =
"Email service error. You can reach me at michael@freno.me";
}
return redirect(`/contact?error=${encodeURIComponent(errorMessage)}`);
}
});
export default function ContactPage() {
const [searchParams] = useSearchParams();
const viewer = () => searchParams.viewer ?? "default";
// Load server data using createAsync
const contactData = createAsync(() => getContactData(), {
deferStream: true
});
const [emailSent, setEmailSent] = createSignal<boolean>(
searchParams.success === "true"
);
const [error, setError] = createSignal<string>(
searchParams.error ? decodeURIComponent(String(searchParams.error)) : ""
);
const [loading, setLoading] = createSignal<boolean>(false);
const [user, setUser] = createSignal<UserProfile | null>(null);
const [jsEnabled, setJsEnabled] = createSignal<boolean>(false);
const [turnstileToken, setTurnstileToken] = createSignal<string>("");
const [turnstileWidgetId, setTurnstileWidgetId] = createSignal<string | null>(
null
);
const { remainingTime, startCountdown, setRemainingTime } = useCountdown();
onMount(() => {
setJsEnabled(true);
// Load Cloudflare Turnstile script with explicit rendering
const script = document.createElement("script");
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
script.async = true;
script.defer = true;
script.onload = () => {
if (typeof window !== "undefined" && (window as any).turnstile) {
const container = document.getElementById("turnstile-widget-1");
if (container) {
const id = (window as any).turnstile.render(container, {
sitekey: clientEnv.VITE_TURNSTILE_SITE_KEY,
theme: "dark",
callback: (token: string) => {
setTurnstileToken(token);
},
"expired-callback": () => {
setTurnstileToken("");
}
});
setTurnstileWidgetId(id);
}
}
};
document.head.appendChild(script);
api.user.getProfile
.query()
.then((userData) => {
if (userData) {
setUser(userData);
}
})
.catch(() => {});
});
createEffect(() => {
// Try server data first (more accurate)
const serverData = contactData();
if (serverData?.remainingTime && serverData.remainingTime > 0) {
const expirationTime = new Date(
Date.now() + serverData.remainingTime * 1000
);
startCountdown(expirationTime);
return;
}
// Fall back to client cookie if server data not available yet
const timer = getClientCookie("contactRequestSent");
if (timer) {
try {
startCountdown(timer);
} catch (e) {
console.error("Failed to start countdown from cookie:", e);
}
}
});
const sendEmailTrigger = async (e: Event) => {
if (!jsEnabled()) return;
e.preventDefault();
const form = e.target as unknown as HTMLFormElement;
const formData = new FormData(form);
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const message = formData.get("message") as string;
if (name && email && message) {
// Get fresh Turnstile token
let currentToken = turnstileToken();
if (
!currentToken &&
typeof window !== "undefined" &&
(window as any).turnstile
) {
const widgetEl = document.getElementById("turnstile-widget-1");
if (widgetEl) {
const id = turnstileWidgetId();
currentToken = (window as any).turnstile.getResponse(id || widgetEl);
}
}
if (!currentToken || currentToken.trim() === "") {
setError("Please complete the security check.");
setLoading(false);
return;
}
setLoading(true);
setError("");
setEmailSent(false);
try {
const res = await api.misc.sendContactRequest.mutate({
name,
email,
message,
turnstileToken: currentToken
});
if (res.message === "email sent") {
setEmailSent(true);
setError("");
form.reset();
// Reset Turnstile widget
if (typeof window !== "undefined" && (window as any).turnstile) {
const widgetEl = document.getElementById("turnstile-widget-1");
if (widgetEl) {
const id = turnstileWidgetId();
(window as any).turnstile.reset(id || widgetEl);
}
}
setTurnstileToken("");
// Set countdown directly - cookie might not be readable immediately
const expirationTime = new Date(
Date.now() + COOLDOWN_TIMERS.CONTACT_REQUEST_MS
);
startCountdown(expirationTime);
}
} catch (err: any) {
setError(err.message || "An error occurred");
setEmailSent(false);
}
setLoading(false);
}
};
const LineageQuestionsDropDown = () => {
return ( return (
<div class="w-full py-12"> <div class="w-full py-12">
<RevealDropDown title={"Questions about Life and Lineage?"}> <RevealDropDown title={"Questions about Life and Lineage?"}>
@@ -394,124 +114,21 @@ export default function ContactPage() {
</RevealDropDown> </RevealDropDown>
</div> </div>
); );
}; }
const renderTime = ({ remainingTime }: { remainingTime: number }) => { export default function ContactPage() {
const time = isNaN(remainingTime) ? 0 : Math.max(0, remainingTime); const [searchParams] = useSearchParams();
return ( const viewer = () => searchParams.viewer ?? "default";
<div class="timer">
<div class="value">{time.toFixed(0)}</div>
</div>
);
};
return ( return (
<> <ContactForm
<PageHead title="Contact" description="Contact Me" /> subline={
<div class="bg-base flex min-h-screen w-full justify-center">
<div class="w-full max-w-4xl px-4 pt-[20vh]">
<div class="text-center text-3xl tracking-widest">Contact</div>
<Show when={viewer() !== "lineage"}> <Show when={viewer() !== "lineage"}>
<div class="mt-4 -mb-4 text-center text-xl tracking-widest">
(for this website or any of my apps...) (for this website or any of my apps...)
</div>
</Show> </Show>
<LineageQuestionsDropDown />
<form
onSubmit={sendEmailTrigger}
method="post"
action={sendContactEmail}
class="w-full"
>
<div class="flex w-full flex-col justify-evenly">
<div class="mx-auto w-full justify-evenly md:flex md:flex-row">
<Input
type="text"
required
name="name"
value={user()?.displayName ?? ""}
title="Please enter your name"
label="Name"
containerClass="input-group md:mx-4"
class="w-full"
/>
<Input
type="email"
required
name="email"
value={user()?.email ?? ""}
title="Please enter a valid email address"
label="Email"
containerClass="input-group md:mx-4"
class="w-full"
/>
</div>
<div class="mx-auto w-full pt-6 md:pt-12">
<div class="textarea-group">
<textarea
required
name="message"
placeholder=" "
title="Please enter your message"
class="underlinedInput w-full bg-transparent"
rows={4}
maxlength={VALIDATION_CONFIG.MAX_CONTACT_MESSAGE_LENGTH}
/>
<span class="bar" />
<label class="underlinedInputLabel">Message</label>
</div>
</div>
<div class="mx-auto flex w-full justify-between pt-4">
<div id="turnstile-widget-1"></div>
<Show
when={
remainingTime() > 0 ||
(contactData()?.remainingTime ?? 0) > 0
}
fallback={
<Button type="submit" loading={loading()} class="w-36">
Send Message
</Button>
} }
> >
<Show <LineageContactQuestions />
when={jsEnabled()} </ContactForm>
fallback={
<div class="flex items-center justify-center text-sm text-zinc-400">
Please wait{" "}
{Math.ceil(contactData()?.remainingTime ?? 0)}s before
sending another message
</div>
}
>
<CountdownCircleTimer
duration={COUNTDOWN_CONFIG.CONTACT_FORM_DURATION_S}
initialRemainingTime={remainingTime()}
size={48}
strokeWidth={6}
onComplete={() => setRemainingTime(0)}
>
{renderTime}
</CountdownCircleTimer>
</Show>
</Show>
</div>
</div>
</form>
<div
class={`${
emailSent()
? "text-green-400"
: error() !== ""
? "text-red-400"
: "user-select opacity-0"
} flex justify-center text-center italic transition-opacity duration-300 ease-in-out`}
>
{emailSent() ? "Email Sent!" : error()}
</div>
</div>
</div>
</>
); );
} }

View File

@@ -0,0 +1,20 @@
import { ContactForm } from "~/components/ContactForm";
/**
* Gaze contact page (`gaze.freno.me/contact`).
*
* Thin wrapper over the shared `<ContactForm>` (task 09). Site awareness —
* subject prefix `[Gaze]`, recipient label, heading, and PageHead metadata —
* is derived from `useSite()` inside the component via `CONTACT_CONTEXT.gaze`,
* so this route needs no explicit props.
*
* vercel.json rewrites `gaze.freno.me/*` → the internal `/gaze/*` route
* prefix; the browser URL stays `gaze.freno.me/contact`.
*
* Acceptance: `gaze.localhost:3000/contact` renders the contact form with
* Gaze branding; submissions email `michael@freno.me` with subject
* `[Gaze] Contact Request`.
*/
export default function GazeContactPage() {
return <ContactForm />;
}

View File

@@ -0,0 +1,20 @@
import { ContactForm } from "~/components/ContactForm";
/**
* InputHalo contact page (`inputhalo.freno.me/contact`).
*
* Thin wrapper over the shared `<ContactForm>` (task 09). Site awareness —
* subject prefix `[InputHalo]`, recipient label, heading, and PageHead
* metadata — is derived from `useSite()` inside the component via
* `CONTACT_CONTEXT.inputhalo`, so this route needs no explicit props.
*
* vercel.json rewrites `inputhalo.freno.me/*` → the internal `/inputhalo/*`
* route prefix; the browser URL stays `inputhalo.freno.me/contact`.
*
* Acceptance: `inputhalo.localhost:3000/contact` renders the contact form
* with InputHalo branding; submissions email `michael@freno.me` with subject
* `[InputHalo] Contact Request`.
*/
export default function InputHaloContactPage() {
return <ContactForm />;
}

View File

@@ -0,0 +1,29 @@
import { ContactForm } from "~/components/ContactForm";
import { LineageContactQuestions } from "~/routes/contact";
/**
* Life and Lineage contact page (`lineage.freno.me/contact`).
*
* Thin wrapper over the shared `<ContactForm>` (task 09). Site awareness —
* subject prefix `[Lineage]`, recipient label, heading, and PageHead metadata
* — is derived from `useSite()` inside the component via
* `CONTACT_CONTEXT.lineage`.
*
* Renders the Life-and-Lineage FAQ accordion (re-imported from the main-site
* contact route so the canonical definition lives in one place) above the
* form — the lineage subdomain is the natural home for product support Q&A.
*
* vercel.json rewrites `lineage.freno.me/*` → the internal `/lineage/*` route
* prefix; the browser URL stays `lineage.freno.me/contact`.
*
* Acceptance: `lineage.localhost:3000/contact` renders the contact form with
* Life and Lineage branding; submissions email `michael@freno.me` with subject
* `[Lineage] Contact Request`.
*/
export default function LineageContactPage() {
return (
<ContactForm>
<LineageContactQuestions />
</ContactForm>
);
}

View File

@@ -0,0 +1,21 @@
import { ContactForm } from "~/components/ContactForm";
/**
* Nessa contact page (`nessa.freno.me/contact`).
*
* Thin wrapper over the shared `<ContactForm>` (task 09). Site awareness —
* subject prefix `[Nessa]`, recipient label, heading, and PageHead metadata —
* is derived from `useSite()` inside the component via
* `CONTACT_CONTEXT.nessa`, so this route needs no explicit props.
*
* vercel.json rewrites `nessa.freno.me/*` → the internal `/nessa/*` route
* prefix; the browser URL stays `nessa.freno.me/contact` so the canonical and
* Turnstile origin resolve correctly.
*
* Acceptance: `nessa.localhost:3000/contact` renders the contact form with
* Nessa branding; submissions email `michael@freno.me` with subject
* `[Nessa] Contact Request`.
*/
export default function NessaContactPage() {
return <ContactForm />;
}

View File

@@ -21,7 +21,17 @@ import {
APIError, APIError,
verifyTurnstileToken verifyTurnstileToken
} from "~/server/fetch-utils"; } from "~/server/fetch-utils";
import { NETWORK_CONFIG, COOLDOWN_TIMERS, VALIDATION_CONFIG, TURNSTILE_CONFIG } from "~/config"; import {
NETWORK_CONFIG,
COOLDOWN_TIMERS,
VALIDATION_CONFIG,
TURNSTILE_CONFIG
} from "~/config";
import {
CONTACT_RECIPIENT_EMAIL,
CONTACT_SENDER,
buildContactSubject
} from "~/lib/contact-config";
// Allowed S3 key types — prevents path traversal via type parameter (p8-008) // Allowed S3 key types — prevents path traversal via type parameter (p8-008)
const ALLOWED_S3_TYPES = ["blog", "attachments", "avatars", "users"] as const; const ALLOWED_S3_TYPES = ["blog", "attachments", "avatars", "users"] as const;
@@ -330,7 +340,14 @@ export const miscRouter = createTRPCRouter({
.string() .string()
.min(1) .min(1)
.max(VALIDATION_CONFIG.MAX_CONTACT_MESSAGE_LENGTH), .max(VALIDATION_CONFIG.MAX_CONTACT_MESSAGE_LENGTH),
turnstileToken: z.string().min(1, "Please complete the security check") turnstileToken: z.string().min(1, "Please complete the security check"),
/**
* Per-site subject prefix injected into the outbound email subject
* (task 09). Defaults to `"freno.me"` so existing callers (pre-task-09
* main-site contact form) keep emitting the byte-identical legacy
* subject `"freno.me Contact Request"`.
*/
subjectPrefix: z.string().min(1).max(50).optional().default("freno.me")
}) })
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
@@ -377,14 +394,12 @@ export const miscRouter = createTRPCRouter({
.replace(/"/g, "&quot;") .replace(/"/g, "&quot;")
.replace(/'/g, "&#039;"); .replace(/'/g, "&#039;");
const subject = buildContactSubject(input.subjectPrefix);
const sendinblueData = { const sendinblueData = {
sender: { sender: { ...CONTACT_SENDER },
name: "freno.me", to: [{ email: CONTACT_RECIPIENT_EMAIL }],
email: "michael@freno.me" htmlContent: `<html><head></head><body><div>Source: ${escapeHtml(input.subjectPrefix)}</div><div>Request Name: ${escapeHtml(input.name)}</div><div>Request Email: ${escapeHtml(input.email)}</div><div>Request Message: ${escapeHtml(input.message)}</div></body></html>`,
}, subject
to: [{ email: "michael@freno.me" }],
htmlContent: `<html><head></head><body><div>Request Name: ${escapeHtml(input.name)}</div><div>Request Email: ${escapeHtml(input.email)}</div><div>Request Message: ${escapeHtml(input.message)}</div></body></html>`,
subject: "freno.me Contact Request"
}; };
try { try {