Merge branch 'per-subdomain-contact-pages' (task 09)

# Conflicts:
#	src/routes/contact.tsx
This commit is contained in:
2026-07-23 17:08:39 -04:00
9 changed files with 1014 additions and 513 deletions

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`;
}