Merge branch 'per-subdomain-contact-pages' (task 09)
# Conflicts: # src/routes/contact.tsx
This commit is contained in:
106
src/lib/contact-config.test.ts
Normal file
106
src/lib/contact-config.test.ts
Normal 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
122
src/lib/contact-config.ts
Normal 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`;
|
||||
}
|
||||
Reference in New Issue
Block a user