diff --git a/src/components/ContactForm.tsx b/src/components/ContactForm.tsx new file mode 100644 index 0000000..c22993e --- /dev/null +++ b/src/components/ContactForm.tsx @@ -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 `` 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; + /** `

` 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; + /** `` — composes with the site `titleSuffix`. */ + pageTitle?: string; + /** ``. 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, "'"); + + const sendinblueData = { + sender: { ...CONTACT_SENDER }, + to: [{ email: CONTACT_RECIPIENT_EMAIL }], + htmlContent: `
Source: ${escapeHtml(subjectPrefix)}
Request Name: ${escapeHtml(name)}
Request Email: ${escapeHtml(email)}
Request Message: ${escapeHtml(message)}
`, + 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( + searchParams.success === "true" + ); + const [error, setError] = createSignal( + searchParams.error ? decodeURIComponent(String(searchParams.error)) : "" + ); + const [loading, setLoading] = createSignal(false); + const [user, setUser] = createSignal(null); + const [jsEnabled, setJsEnabled] = createSignal(false); + const [turnstileToken, setTurnstileToken] = createSignal(""); + const [turnstileWidgetId, setTurnstileWidgetId] = createSignal( + 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 ( +
+
{time.toFixed(0)}
+
+ ); + }; + + return ( + <> + + +
+
+
+ {effectiveHeading()} +
+ +
+ {props.subline} +
+
+ {props.children} +
+ {/* Hidden per-site subject prefix — consumed by the no-JS action. */} + +
+
+ + +
+
+
+