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:
553
src/components/ContactForm.tsx
Normal file
553
src/components/ContactForm.tsx
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
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;
|
||||
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`;
|
||||
}
|
||||
@@ -1,517 +1,134 @@
|
||||
import { createSignal, onMount, createEffect, Show } from "solid-js";
|
||||
import { useSearchParams, query, createAsync } from "@solidjs/router";
|
||||
import { Show, type JSX } from "solid-js";
|
||||
import { useSearchParams } 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 Input from "~/components/ui/Input";
|
||||
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";
|
||||
import { ContactForm } from "~/components/ContactForm";
|
||||
|
||||
const getContactData = query(async () => {
|
||||
"use server";
|
||||
const contactExp = getCookie("contactRequestSent");
|
||||
let remainingTime = 0;
|
||||
/**
|
||||
* Main-site contact page (`freno.me/contact`).
|
||||
*
|
||||
* 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);
|
||||
remainingTime = Math.max(0, (expires.getTime() - Date.now()) / 1000);
|
||||
}
|
||||
|
||||
return { remainingTime };
|
||||
}, "contact-data");
|
||||
|
||||
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 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
|
||||
/**
|
||||
* The Life-and-Lineage FAQ accordion.
|
||||
*
|
||||
* Rendered on the main-site contact page (it documents the mobile product,
|
||||
* which the main site has historically hosted marketing + support for) and on
|
||||
* the `lineage.freno.me/contact` subdomain page. Kept here as the canonical
|
||||
* definition; the lineage subdomain route re-imports and re-uses it.
|
||||
*/
|
||||
export function LineageContactQuestions(): JSX.Element {
|
||||
return (
|
||||
<div class="w-full py-12">
|
||||
<RevealDropDown title={"Questions about Life and Lineage?"}>
|
||||
<div>
|
||||
Feel free to use the form below, I will respond as quickly as
|
||||
possible, however, you may find an answer to your question in the
|
||||
following.
|
||||
</div>
|
||||
<ol>
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-2 pr-2">1.</span> Personal Information
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<div class="pb-2">
|
||||
You can find the entire privacy policy{" "}
|
||||
<A
|
||||
href="/privacy-policy/life-and-lineage"
|
||||
class="text-blue underline-offset-4 hover:underline"
|
||||
>
|
||||
here
|
||||
</A>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-2 pr-2">2.</span> Remote Backups
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<em>Life and Lineage</em> uses a per-user database approach for
|
||||
its remote storage, this provides better separation of users and
|
||||
therefore privacy, and it makes requesting the removal of your
|
||||
data simpler, you can even request the database dump if you so
|
||||
choose. This isn't particularly expensive, but not free for
|
||||
n users, so use of this feature requires a purchase of an
|
||||
IAP(in-app purchase) - this can be the specific IAP for the
|
||||
remote save feature, and any other IAP will also unlock this
|
||||
feature.
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-2 pr-2">3.</span> Cross Device Play
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
You can use the above mentioned remote-backups to save progress
|
||||
between devices/platforms.
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-2 pr-2">4.</span> Online Requirements
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
Currently, the only time you need to be online is for remote
|
||||
save access. There are plans for pvp, which will require an
|
||||
internet connection, but this is not implemented at time of
|
||||
writing.
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-2 pr-2">5.</span> Microtransactions
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
Microtransactions are not required to play or complete the game,
|
||||
the game can be fully completed without spending any money,
|
||||
however 2 of the classes(necromancer and ranger) are pay-walled.
|
||||
Microtransactions are supported cross-platform, so no need to
|
||||
pay for each device, you simply need to login to your
|
||||
gmail/apple/email account. This would require first creating a
|
||||
character, signing in under options{">"}remote backups first.
|
||||
</div>
|
||||
</div>
|
||||
</ol>
|
||||
</RevealDropDown>
|
||||
</div>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div class="w-full py-12">
|
||||
<RevealDropDown title={"Questions about Life and Lineage?"}>
|
||||
<div>
|
||||
Feel free to use the form below, I will respond as quickly as
|
||||
possible, however, you may find an answer to your question in the
|
||||
following.
|
||||
</div>
|
||||
<ol>
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-2 pr-2">1.</span> Personal Information
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<div class="pb-2">
|
||||
You can find the entire privacy policy{" "}
|
||||
<A
|
||||
href="/privacy-policy/life-and-lineage"
|
||||
class="text-blue underline-offset-4 hover:underline"
|
||||
>
|
||||
here
|
||||
</A>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-2 pr-2">2.</span> Remote Backups
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<em>Life and Lineage</em> uses a per-user database approach for
|
||||
its remote storage, this provides better separation of users and
|
||||
therefore privacy, and it makes requesting the removal of your
|
||||
data simpler, you can even request the database dump if you so
|
||||
choose. This isn't particularly expensive, but not free for
|
||||
n users, so use of this feature requires a purchase of an
|
||||
IAP(in-app purchase) - this can be the specific IAP for the
|
||||
remote save feature, and any other IAP will also unlock this
|
||||
feature.
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-2 pr-2">3.</span> Cross Device Play
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
You can use the above mentioned remote-backups to save progress
|
||||
between devices/platforms.
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-2 pr-2">4.</span> Online Requirements
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
Currently, the only time you need to be online is for remote
|
||||
save access. There are plans for pvp, which will require an
|
||||
internet connection, but this is not implemented at time of
|
||||
writing.
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-2 pr-2">5.</span> Microtransactions
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
Microtransactions are not required to play or complete the game,
|
||||
the game can be fully completed without spending any money,
|
||||
however 2 of the classes(necromancer and ranger) are pay-walled.
|
||||
Microtransactions are supported cross-platform, so no need to
|
||||
pay for each device, you simply need to login to your
|
||||
gmail/apple/email account. This would require first creating a
|
||||
character, signing in under options{">"}remote backups first.
|
||||
</div>
|
||||
</div>
|
||||
</ol>
|
||||
</RevealDropDown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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="Contact" description="Contact Me" />
|
||||
|
||||
<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"}>
|
||||
<div class="mt-4 -mb-4 text-center text-xl tracking-widest">
|
||||
(for this website or any of my apps...)
|
||||
</div>
|
||||
</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
|
||||
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!" : error()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<ContactForm
|
||||
subline={
|
||||
<Show when={viewer() !== "lineage"}>
|
||||
(for this website or any of my apps...)
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<LineageContactQuestions />
|
||||
</ContactForm>
|
||||
);
|
||||
}
|
||||
|
||||
20
src/routes/gaze/contact.tsx
Normal file
20
src/routes/gaze/contact.tsx
Normal 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 />;
|
||||
}
|
||||
20
src/routes/inputhalo/contact.tsx
Normal file
20
src/routes/inputhalo/contact.tsx
Normal 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 />;
|
||||
}
|
||||
29
src/routes/lineage/contact.tsx
Normal file
29
src/routes/lineage/contact.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/routes/nessa/contact.tsx
Normal file
21
src/routes/nessa/contact.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -21,7 +21,17 @@ import {
|
||||
APIError,
|
||||
verifyTurnstileToken
|
||||
} 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)
|
||||
const ALLOWED_S3_TYPES = ["blog", "attachments", "avatars", "users"] as const;
|
||||
@@ -330,7 +340,14 @@ export const miscRouter = createTRPCRouter({
|
||||
.string()
|
||||
.min(1)
|
||||
.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 }) => {
|
||||
@@ -377,14 +394,12 @@ export const miscRouter = createTRPCRouter({
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
const subject = buildContactSubject(input.subjectPrefix);
|
||||
const sendinblueData = {
|
||||
sender: {
|
||||
name: "freno.me",
|
||||
email: "michael@freno.me"
|
||||
},
|
||||
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"
|
||||
sender: { ...CONTACT_SENDER },
|
||||
to: [{ email: CONTACT_RECIPIENT_EMAIL }],
|
||||
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
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user