From a589498d2eef6826ef59e9f448a477090cf455a3 Mon Sep 17 00:00:00 2001 From: Aleksei Vesnin Date: Fri, 25 Sep 2026 18:44:49 +0300 Subject: [PATCH 1/2] Move the contact page, form, client and schema to support paths Co-Authored-By: Claude Opus 5.5 --- src/app/{contact => support}/page.tsx | 0 .../{contact/contact-form.tsx => support/support-form.tsx} | 0 src/lib/api/{contact.ts => support.ts} | 0 src/lib/{contact.ts => support.ts} | 0 src/lib/validations/{contact.test.ts => support.test.ts} | 0 src/lib/validations/{contact.ts => support.ts} | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename src/app/{contact => support}/page.tsx (100%) rename src/components/{contact/contact-form.tsx => support/support-form.tsx} (100%) rename src/lib/api/{contact.ts => support.ts} (100%) rename src/lib/{contact.ts => support.ts} (100%) rename src/lib/validations/{contact.test.ts => support.test.ts} (100%) rename src/lib/validations/{contact.ts => support.ts} (100%) diff --git a/src/app/contact/page.tsx b/src/app/support/page.tsx similarity index 100% rename from src/app/contact/page.tsx rename to src/app/support/page.tsx diff --git a/src/components/contact/contact-form.tsx b/src/components/support/support-form.tsx similarity index 100% rename from src/components/contact/contact-form.tsx rename to src/components/support/support-form.tsx diff --git a/src/lib/api/contact.ts b/src/lib/api/support.ts similarity index 100% rename from src/lib/api/contact.ts rename to src/lib/api/support.ts diff --git a/src/lib/contact.ts b/src/lib/support.ts similarity index 100% rename from src/lib/contact.ts rename to src/lib/support.ts diff --git a/src/lib/validations/contact.test.ts b/src/lib/validations/support.test.ts similarity index 100% rename from src/lib/validations/contact.test.ts rename to src/lib/validations/support.test.ts diff --git a/src/lib/validations/contact.ts b/src/lib/validations/support.ts similarity index 100% rename from src/lib/validations/contact.ts rename to src/lib/validations/support.ts From 7e2356e054b778c4bf4167569b46e10f349a2cdc Mon Sep 17 00:00:00 2001 From: Aleksei Vesnin Date: Fri, 25 Sep 2026 18:45:02 +0300 Subject: [PATCH 2/2] Rename the support modules' exports, post to /support, and point links and prose at the page Co-Authored-By: Claude Opus 5.5 --- .env.example | 4 +-- DECISIONS.md | 28 ++++++++++---------- SECURITY.md | 2 +- src/app/layout.tsx | 2 +- src/app/page.tsx | 2 +- src/app/privacy/page.tsx | 24 ++++++++--------- src/app/support/page.tsx | 22 +++++++-------- src/app/terms/page.tsx | 6 ++--- src/components/layout/footer.render.test.tsx | 3 ++- src/components/layout/footer.tsx | 4 +-- src/components/support/support-form.tsx | 28 ++++++++++---------- src/lib/api/support.ts | 28 ++++++++++---------- src/lib/runtime-config.ts | 2 +- src/lib/support.ts | 6 ++--- src/lib/validations/support.test.ts | 20 +++++++------- src/lib/validations/support.ts | 10 +++---- 16 files changed, 95 insertions(+), 96 deletions(-) diff --git a/.env.example b/.env.example index 788a8dbf..91fe8a4b 100644 --- a/.env.example +++ b/.env.example @@ -31,7 +31,7 @@ # entry is the real caller, so anything a browser put in front of it is ignored. Serve # this container directly with nothing in front and list it anyway, and the reverse holds: # the caller supplies the only entry in the chain, the API takes it, and every per-IP rate -# limit the API has - magic-link requests and the contact form among them - is bypassable +# limit the API has - magic-link requests and the support form among them - is bypassable # by changing one header. Leaving it unset costs one shared rate-limit bucket for # everybody, which is the cheaper of the two mistakes. @@ -60,7 +60,7 @@ NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1 # origin: scheme and host, no trailing path. # SITE_URL=https://dives.example.com -# Optional. Address offered on /contact as a fallback if a form submission fails. +# Optional. Address offered on /support as a fallback if a form submission fails. # Display only - the form posts to the API, which decides where the message actually # goes (CONTACT_FORM_EMAIL there); setting this does not change delivery. Leave it # unset unless it matches that address: a failed submission then points at the GitHub diff --git a/DECISIONS.md b/DECISIONS.md index ee857d27..233a3770 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1093,20 +1093,20 @@ in tenths of a bar) and are divided in `toChannelSeries`. Divided, never multipl exactly the noise the integer encoding removes. `PROFILE_CHANNELS` holds the divisors and mirrors the API's `DEPTH_SCALE`/`TEMPERATURE_SCALE`/`PRESSURE_SCALE`; the two lists are a pair. -## The contact form posts to the API, and the page it lives on claims only what exists +## The support form posts to the API, and the page it lives on claims only what exists -`contactAPI.sendMessage` posts to `POST /contact`; the API forwards it to its `CONTACT_FORM_EMAIL`, +`supportAPI.sendRequest` posts to `POST /support`; the API forwards it to its `CONTACT_FORM_EMAIL`, and nothing here decides the recipient. `NEXT_PUBLIC_CONTACT_EMAIL` is display only — the `mailto:` fallback in the error state — optional, with no default: `contact@opendiving.app` would hand a self-hosted instance's visitors an address -that cannot see their server. Unset, `ContactForm` points at `FALLBACK_ISSUES_URL` in -`lib/contact.ts`, since a broken form is this repo's bug. Set it only where the operator reads the +that cannot see their server. Unset, `SupportForm` points at `FALLBACK_ISSUES_URL` in +`lib/support.ts`, since a broken form is this repo's bug. Set it only where the operator reads the mailbox. -`CONTACT_CATEGORIES` in `lib/api/contact.ts` mirrors `ContactCategory` in the API's -`schemas/contact.py`; the backend 422s anything else, so adding a category changes both sides. -`contactSchema` duplicates the API's length bounds to fail before a round-trip. +`SUPPORT_CATEGORIES` in `lib/api/support.ts` mirrors `SupportCategory` in the API's +`schemas/support.py`; the backend 422s anything else, so adding a category changes both sides. +`supportSchema` duplicates the API's length bounds to fail before a round-trip. The form prefills empty fields from `useAuth()`; `defaultValues` cannot, since the user arrives after the auth bootstrap resolves. The page is a Server Component for `metadata`; only the form is @@ -1471,7 +1471,7 @@ Two other contrast rules hold. The landing page's stats strip does not use colour (3.67:1 in dark mode, `--primary` being mid-grey there); in-copy links are a plain underline inheriting the surrounding colour. -`npx @axe-core/cli --tags="wcag2a,wcag2aa,wcag21aa"` over `/`, `/signin`, `/contact`, `/privacy` and +`npx @axe-core/cli --tags="wcag2a,wcag2aa,wcag21aa"` over `/`, `/signin`, `/support`, `/privacy` and `/terms` is the check that covers contrast; the `code-quality` workflow scans only `/`, so the other four are re-checked by hand after any change to `globals.css`. @@ -3553,7 +3553,7 @@ An install is one compose file naming both `web` and `api`, so neither component it without the other carrying a copy. It lives in `opendiving/opendiving` with its docs, and this README links there rather than paraphrasing: two copies diverge, and the wrong one is the one the reader found first. `README.md`'s _Full self-hosting docs_ goes to `.../opendiving/tree/main/docs`; -`landing-page.tsx`'s `SELF_HOSTING_URL` and the contact page's _Self-hosting quickstart_ land on +`landing-page.tsx`'s `SELF_HOSTING_URL` and the support page's _Self-hosting quickstart_ land on `https://github.com/opendiving/opendiving` itself, which carries pitch and commands. What stays here the bundle has no reason to know: building this image yourself, and `NEXT_PUBLIC_API_URL` as a build arg for split-origin deployments. The README says where the project's instance is named (the front @@ -3620,7 +3620,7 @@ by construction, thread and advisory together. A repository setting (Settings a file. `security@opendiving.app` is the second channel and a real inbox. An address must pass "has a maintainer created it and agreed to read it", not "does it look plausible" (so `lib/runtime-config.ts` leaves `CONTACT_EMAIL` unset, and `conduct@opendiving.app` in -`CODE_OF_CONDUCT.md` is conduct only). The contact form's `security` category (`CONTACT_CATEGORIES`) +`CODE_OF_CONDUCT.md` is conduct only). The support form's `security` category (`SUPPORT_CATEGORIES`) is no third channel on any instance, even the project's own: an unauthenticated public form posting to whatever address the instance configured, without advisory-thread privacy. No supported-versions table and no SLA: `publish-image.yml` aliases one tag and nothing is backported, so "the latest @@ -4315,7 +4315,7 @@ disclosed by hand in §10.1. ## Privacy page: §6.3 enumerates every email, and the enumeration is exhaustive on purpose §6.3 lists everything a diver receives, in three groups — mail following an action on this site, -security notices, the gear-service digest — with contact-form mail parenthesised as mail about you, +security notices, the gear-service digest — with support-form mail parenthesised as mail about you, sent to `CONTACT_FORM_EMAIL`. Any new `send_*` function in the api owes this section a line; `app/privacy/page.test.tsx` pins only the first group's count against its list. @@ -4366,7 +4366,7 @@ Two sentences at the page's edges stay as they are. §7's "Personal information deleted within 30 days" is byte-identical to what ships, because documents in the API repo, including a config default, are written against that number. §13 promises nothing about delivery: `CONTACT_EMAIL` in this repo is display-only while the API's `CONTACT_FORM_EMAIL` decides where a -submission goes, and the two can disagree. §13 links the contact page as "how to reach whoever runs +submission goes, and the two can disagree. §13 links the support page as "how to reach whoever runs this copy" without asserting a form works, and renders neither a project-owned address nor the public issue tracker — a diver filing an erasure request in public, to people who are not the controller, is the outcome it must not inherit. @@ -4453,7 +4453,7 @@ correct — it depends on the page's tree. The columns are group labels over lin `footer.render.test.tsx` pins zero headings from the component. `CardTitle` (`ui/card.tsx`) takes `as` (`"h2" | "h3" | "h4"`, default `h3`), tag only; -`contact/page.tsx` passes `as="h2"` because its cards are top-level sections. +`support/page.tsx` passes `as="h2"` because its cards are top-level sections. CI misses both: `code-quality.yml` passes `--include="main"`, the footer is `
`'s sibling, and `heading-order` is `best-practice`, outside `--tags`. `@axe-core/cli` pins a ChromeDriver major; @@ -5271,7 +5271,7 @@ framing ("this copy", "the operator of this copy") resolves on either kind of in The pages announce no project-operated instance and nothing about aggregation or telemetry (new collection owing its own disclosure). Terms §9 limits the author's liability and §10 indemnifies the writing; neither transfers to the same party as operator, and the AGPL finding stands. No project -address is printed: the one that can act is the operator's, which the contact page reaches. +address is printed: the one that can act is the operator's, which the support page reaches. Claims to hunt are about identity ("a different party", "not parties to these Terms"), not servers; read each section whole. diff --git a/SECURITY.md b/SECURITY.md index 41e26a17..296a5996 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,7 +10,7 @@ owners. Work out which one you have before you send it anywhere. its operator can see it, fix it, or tell the people using it; where that operator is not this project, its maintainers have no access to the instance and no way to reach its users. A misconfigured deployment, an exposed database, a stale image on someone's box: report it to whoever -runs that server. Its contact form, if it has one, reaches that operator and nobody else. +runs that server. Its support form, if it has one, reaches that operator and nobody else. **The code** is a flaw in this repository that would affect anyone running it. That one is ours, and the rest of this file is about it. diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 19d99178..88bc4bd9 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -18,7 +18,7 @@ const inter = Inter({ subsets: ["latin"] }); // the build fails on it. `false` allows a blocking route, and the root is the only // placement that reaches the routes needing it: both non-root layouts and every page // behind the auth guard are Client Components, which cannot carry this export, and the -// handful of Server Component pages that could - `/`, `/contact`, `/privacy`, `/terms`, +// handful of Server Component pages that could - `/`, `/support`, `/privacy`, `/terms`, // `/admin` - are not the ones that need it. export const instant = false; diff --git a/src/app/page.tsx b/src/app/page.tsx index 66e89971..c957357e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import { LandingPage } from "@/components/layout/landing-page"; // A Server Component purely so it can export `metadata` - the same split -// `contact/page.tsx` uses, and the reason the page itself lives in +// `support/page.tsx` uses, and the reason the page itself lives in // `components/layout/landing-page.tsx` with the `"use client"` on it. The landing // page gates its whole render on `useRedirectIfAuthenticated`, so it cannot be a // Server Component itself. diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx index d5f68f01..ea6313dc 100644 --- a/src/app/privacy/page.tsx +++ b/src/app/privacy/page.tsx @@ -30,7 +30,7 @@ function StorageKey({ name }: { name: string }) { // client island. The island renders a stable server-side state and resolves the // real one after hydration, which is what every storage consumer here does. export default async function PrivacyPage() { - // Read here rather than in a client component for the reason `/contact` reads it + // Read here rather than in a client component for the reason `/support` reads it // here: this is a Server Component, so the instance's configuration is legible // without shipping it to the browser. The Google section below exists only where // an instance has Google sign-in turned on. @@ -235,8 +235,8 @@ export default async function PrivacyPage() { service records
  • - Contact Messages: Whatever you write on the - contact page, if this copy has one configured, and the address + Support Requests: Whatever you write on the + support page, if this copy has one configured, and the address you give to reply to
  • @@ -334,16 +334,16 @@ export default async function PrivacyPage() {
  • Rate-limit counters: To stop sign-in, the - contact form and the invite-request form being hammered, this + support form and the invite-request form being hammered, this server counts recent requests in a short-lived store. The counters are keyed three ways — by IP address, by the - email address a sign-in link, a contact message or an invitation + email address a sign-in link, a support request or an invitation was requested for, and by account id for things you can only do signed in, such as exporting your data, importing a logbook, changing your username or email, registering a passkey, inviting somebody, and looking up place and species names. Each counter is a number and expires by itself: after 15 minutes on the - sign-in and account paths, after an hour on the contact form, + sign-in and account paths, after an hour on the support form, the invite-request form, exports, logbook imports, and the place- and species-name lookups. Other counters exist that hold no identifier at all — they cap how often this server as a @@ -429,7 +429,7 @@ export default async function PrivacyPage() { to or removed from your account, tell your old address when your email address is changed, and — if you have set a service schedule on a piece of gear — remind you when it comes - due. If you use the contact form, deliver what you wrote to + due. If you use the support form, deliver what you wrote to whoever runs this copy. Section 6.3 lists all of these and says which arrive without you asking
  • @@ -929,7 +929,7 @@ export default async function PrivacyPage() {

    This copy of OpenDiving sends you three kinds of email, and the - whole list is here. (It also delivers a contact-form message to + whole list is here. (It also delivers a support-form message to whoever runs this copy, which is mail about you rather than to you.)

    @@ -1463,10 +1463,10 @@ export default async function PrivacyPage() { else entirely; an author who does not run this copy holds none of your data and could not answer for whoever does. The{" "} - contact page + support page {" "} is how this copy offers to reach them.

    @@ -1475,7 +1475,7 @@ export default async function PrivacyPage() { On this copy they are named rather than described:{" "} Who runs this copy at the top of this page gives the operator’s name and an address that reaches them, so a - request need not go through the contact page at all. That + request need not go through the support page at all. That address is the operator’s, in the role that can act on it — which leaves the next paragraph exactly as it stands.

    @@ -1484,7 +1484,7 @@ export default async function PrivacyPage() { Two things this page will not do, both deliberately. It will not print an address belonging to the OpenDiving project as the software’s author, because the address that can act on a - privacy request is the operator’s, and the contact page + privacy request is the operator’s, and the support page above is already it — on a copy the project runs itself, that page reaches the project too, in the role that can answer. Sent to the author instead, the request reaches people with no diff --git a/src/app/support/page.tsx b/src/app/support/page.tsx index c5938290..475828b8 100644 --- a/src/app/support/page.tsx +++ b/src/app/support/page.tsx @@ -6,8 +6,8 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { ContactForm } from "@/components/contact/contact-form"; -import { ISSUE_TRACKERS } from "@/lib/contact"; +import { SupportForm } from "@/components/support/support-form"; +import { ISSUE_TRACKERS } from "@/lib/support"; import { runtimeConfig } from "@/lib/runtime-config"; import Link from "next/link"; import { Metadata } from "next"; @@ -15,12 +15,12 @@ import { AlertCircle, Anchor, Bug, Heart, Mail, Shield } from "lucide-react"; export const metadata: Metadata = { // The root layout's `title.template` appends " | OpenDiving". - title: "Contact", + title: "Support", description: - "Get in touch with the people who build OpenDiving - report a bug, request a feature, or send a message that reaches a real inbox.", + "Get support from the people who build OpenDiving - report a bug, request a feature, or send a message that reaches a real inbox.", }; -export default function ContactPage() { +export default function SupportPage() { // Display-only, and deliberately without a default: this can't route mail on its own - // the API's `CONTACT_FORM_EMAIL` decides where a submission actually goes - so // defaulting it to the project's own address would hand a self-hosted instance's @@ -31,13 +31,11 @@ export default function ContactPage() { return (
    -

    - Get in touch -

    +

    Support

    - OpenDiving is an open-source dive log built by volunteers. There is no - support desk - but the form below reaches a real inbox, and most - things get fixed faster in the open, on GitHub. + OpenDiving is an open-source dive log built by volunteers. The form + below reaches a real inbox, and most things get fixed faster in the + open, on GitHub.

    @@ -162,7 +160,7 @@ export default function ContactPage() { - +
    diff --git a/src/app/terms/page.tsx b/src/app/terms/page.tsx index 61b4e132..b78b9881 100644 --- a/src/app/terms/page.tsx +++ b/src/app/terms/page.tsx @@ -609,10 +609,10 @@ export default async function TermsPage() { what these Terms mean for you, or the Service being down — the person to ask is whoever runs this copy of OpenDiving. The{" "} - contact page + support page {" "} is how this copy offers to reach them.

    @@ -638,7 +638,7 @@ export default async function TermsPage() { )} A question about your own account belongs to whoever runs this - copy, and the contact page above is how to reach them — on a + copy, and the support page above is how to reach them — on a copy the project runs itself, that page reaches the project in the role that can act. Sent to the author instead, such a question reaches people who cannot. How quickly the operator of this copy diff --git a/src/components/layout/footer.render.test.tsx b/src/components/layout/footer.render.test.tsx index f5f2d781..5b92935c 100644 --- a/src/components/layout/footer.render.test.tsx +++ b/src/components/layout/footer.render.test.tsx @@ -20,7 +20,8 @@ describe("Footer", () => { for (const name of ["Platform", "Resources", "Support"]) { const group = screen.getByRole("navigation", { name }); - expect(group).toContainElement(screen.getByText(name)); + // The Support column also holds a link reading "Support", so match the label. + expect(group).toContainElement(screen.getByText(name, { selector: "p" })); } }); diff --git a/src/components/layout/footer.tsx b/src/components/layout/footer.tsx index e46c3d14..dc925347 100644 --- a/src/components/layout/footer.tsx +++ b/src/components/layout/footer.tsx @@ -109,8 +109,8 @@ export function Footer() {

    • - - Contact + + Support
    • diff --git a/src/components/support/support-form.tsx b/src/components/support/support-form.tsx index 09ae7e40..0f19d9a0 100644 --- a/src/components/support/support-form.tsx +++ b/src/components/support/support-form.tsx @@ -23,18 +23,18 @@ import { } from "@/components/ui/select"; import { useAuth } from "@/contexts/AuthContext"; import { - CONTACT_CATEGORIES, - CONTACT_CATEGORY_LABELS, - contactAPI, -} from "@/lib/api/contact"; -import { FALLBACK_ISSUES_URL } from "@/lib/contact"; -import { contactSchema, ContactInput } from "@/lib/validations/contact"; + SUPPORT_CATEGORIES, + SUPPORT_CATEGORY_LABELS, + supportAPI, +} from "@/lib/api/support"; +import { FALLBACK_ISSUES_URL } from "@/lib/support"; +import { supportSchema, SupportInput } from "@/lib/validations/support"; import { getApiErrorMessage } from "@/lib/api/error"; import { MailCheck, Send } from "lucide-react"; import { ButtonSpinner } from "@/components/ui/button-spinner"; import { StatusMessage } from "@/components/ui/status-message"; -interface ContactFormProps { +interface SupportFormProps { // Address offered when a send fails, so a broken API isn't a dead end. Optional // because it's display-only and can't be derived from anything here - the API owns // the real recipient (`CONTACT_FORM_EMAIL`). An instance that hasn't set @@ -43,13 +43,13 @@ interface ContactFormProps { fallbackEmail?: string; } -export function ContactForm({ fallbackEmail }: ContactFormProps) { +export function SupportForm({ fallbackEmail }: SupportFormProps) { const { user } = useAuth(); const [sent, setSent] = useState(false); const [error, setError] = useState(null); - const form = useForm({ - resolver: zodResolver(contactSchema), + const form = useForm({ + resolver: zodResolver(supportSchema), defaultValues: { name: "", email: "", @@ -69,10 +69,10 @@ export function ContactForm({ fallbackEmail }: ContactFormProps) { if (!form.getValues("email")) form.setValue("email", user.email); }, [user, form]); - const onSubmit = async (values: ContactInput) => { + const onSubmit = async (values: SupportInput) => { try { setError(null); - await contactAPI.sendMessage(values); + await supportAPI.sendRequest(values); setSent(true); } catch (err) { setError( @@ -189,9 +189,9 @@ export function ContactForm({ fallbackEmail }: ContactFormProps) { - {CONTACT_CATEGORIES.map((category) => ( + {SUPPORT_CATEGORIES.map((category) => ( - {CONTACT_CATEGORY_LABELS[category]} + {SUPPORT_CATEGORY_LABELS[category]} ))} diff --git a/src/lib/api/support.ts b/src/lib/api/support.ts index d46b929e..b62c2ceb 100644 --- a/src/lib/api/support.ts +++ b/src/lib/api/support.ts @@ -1,13 +1,13 @@ import { apiClient } from "./client"; /** - * A closed vocabulary shared with the API's `ContactCategory` (see - * `schemas/contact.py`) - the backend rejects anything else, and the label it puts in + * A closed vocabulary shared with the API's `SupportCategory` (see + * `schemas/support.py`) - the backend rejects anything else, and the label it puts in * the subject line of the forwarded email is derived from the slug there, not here. * Every option maps to something this app actually does; don't add one for a channel * that doesn't exist. */ -export const CONTACT_CATEGORIES = [ +export const SUPPORT_CATEGORIES = [ "support", "bug", "feature", @@ -18,10 +18,10 @@ export const CONTACT_CATEGORIES = [ "other", ] as const; -export type ContactCategory = (typeof CONTACT_CATEGORIES)[number]; +export type SupportCategory = (typeof SUPPORT_CATEGORIES)[number]; -/** Human-readable category names for the contact form's picker. */ -export const CONTACT_CATEGORY_LABELS: Record = { +/** Human-readable category names for the support form's picker. */ +export const SUPPORT_CATEGORY_LABELS: Record = { support: "Help using OpenDiving", bug: "Bug report", feature: "Feature request", @@ -32,24 +32,24 @@ export const CONTACT_CATEGORY_LABELS: Record = { other: "Other", }; -export interface ContactMessage { +export interface SupportRequest { name: string; email: string; - category: ContactCategory; + category: SupportCategory; subject: string; message: string; } /** - * Contact-form submission. Rate-limited server-side, so a rejected send is a 429 the + * Support-form submission. Rate-limited server-side, so a rejected send is a 429 the * caller should surface rather than retry. */ -export const contactAPI = { - // Forwards a contact-form submission to whoever runs this instance. Unauthenticated - // on both sides - someone who can't sign in is exactly the person who needs it - and +export const supportAPI = { + // Forwards a support request to whoever runs this instance. Unauthenticated on both + // sides - someone who can't sign in is exactly the person who needs it - and // rate-limited server-side by email and by IP. - async sendMessage(message: ContactMessage): Promise<{ message: string }> { - const response = await apiClient.post("/contact", message); + async sendRequest(request: SupportRequest): Promise<{ message: string }> { + const response = await apiClient.post("/support", request); return response.data; }, }; diff --git a/src/lib/runtime-config.ts b/src/lib/runtime-config.ts index 7a0aa08a..9b2e9f36 100644 --- a/src/lib/runtime-config.ts +++ b/src/lib/runtime-config.ts @@ -53,7 +53,7 @@ export interface RuntimeConfig extends PublicConfig { */ siteUrl: string; /** - * Shown on `/contact` as a fallback when a submission fails. Display only - the API's + * Shown on `/support` as a fallback when a submission fails. Display only - the API's * `CONTACT_FORM_EMAIL` is what actually routes mail - so it is deliberately unset by * default rather than pointing a self-hoster's visitors at this project's inbox. */ diff --git a/src/lib/support.ts b/src/lib/support.ts index 7f0fca2f..9dca5744 100644 --- a/src/lib/support.ts +++ b/src/lib/support.ts @@ -1,5 +1,5 @@ -// The public issue trackers offered on `/contact` - one per repository, since a bug in -// the iOS app isn't the web app's to fix. Shared with `ContactForm`, which falls back to +// The public issue trackers offered on `/support` - one per repository, since a bug in +// the iOS app isn't the web app's to fix. Shared with `SupportForm`, which falls back to // one of them when a submission can't get through. export const ISSUE_TRACKERS = [ { @@ -14,6 +14,6 @@ export const ISSUE_TRACKERS = [ ] as const; // Where a visitor is pointed when the form itself fails and no fallback address is -// configured. The form lives in the web app, so "the contact form is broken" belongs on +// configured. The form lives in the web app, so "the support form is broken" belongs on // the web app's tracker regardless of what the message was going to be about. export const FALLBACK_ISSUES_URL = ISSUE_TRACKERS[0].href; diff --git a/src/lib/validations/support.test.ts b/src/lib/validations/support.test.ts index 20fbd9c9..477869a6 100644 --- a/src/lib/validations/support.test.ts +++ b/src/lib/validations/support.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { contactSchema } from "./contact"; +import { supportSchema } from "./support"; const valid = { name: "Jacques Cousteau", @@ -14,13 +14,13 @@ const firstIssue = (result: { error?: { issues: { path: PropertyKey[]; message: string }[] }; }) => result.error?.issues[0]; -describe("contactSchema", () => { +describe("supportSchema", () => { it("accepts a complete message", () => { - expect(contactSchema.safeParse(valid).success).toBe(true); + expect(supportSchema.safeParse(valid).success).toBe(true); }); it("requires a name", () => { - const result = contactSchema.safeParse({ ...valid, name: "" }); + const result = supportSchema.safeParse({ ...valid, name: "" }); expect(result.success).toBe(false); expect(firstIssue(result)?.message).toBe("Please tell us who you are"); @@ -28,7 +28,7 @@ describe("contactSchema", () => { it("requires a valid email, since it's the only way to reply", () => { expect( - contactSchema.safeParse({ ...valid, email: "jacques@" }).success, + supportSchema.safeParse({ ...valid, email: "jacques@" }).success, ).toBe(false); }); @@ -36,12 +36,12 @@ describe("contactSchema", () => { // The vocabulary is closed on both sides - an unknown slug is a 422, and the // subject line of the forwarded email is derived from it server-side. expect( - contactSchema.safeParse({ ...valid, category: "partnership" }).success, + supportSchema.safeParse({ ...valid, category: "partnership" }).success, ).toBe(false); }); it("rejects a one-word message", () => { - const result = contactSchema.safeParse({ ...valid, message: "broken" }); + const result = supportSchema.safeParse({ ...valid, message: "broken" }); expect(result.success).toBe(false); expect(firstIssue(result)?.message).toBe("Please add a little more detail"); @@ -49,13 +49,13 @@ describe("contactSchema", () => { it("mirrors the API's upper bounds rather than discovering them via a 422", () => { expect( - contactSchema.safeParse({ ...valid, subject: "s".repeat(151) }).success, + supportSchema.safeParse({ ...valid, subject: "s".repeat(151) }).success, ).toBe(false); expect( - contactSchema.safeParse({ ...valid, message: "m".repeat(5001) }).success, + supportSchema.safeParse({ ...valid, message: "m".repeat(5001) }).success, ).toBe(false); expect( - contactSchema.safeParse({ ...valid, name: "n".repeat(101) }).success, + supportSchema.safeParse({ ...valid, name: "n".repeat(101) }).success, ).toBe(false); }); }); diff --git a/src/lib/validations/support.ts b/src/lib/validations/support.ts index fe561a70..21e85e94 100644 --- a/src/lib/validations/support.ts +++ b/src/lib/validations/support.ts @@ -1,16 +1,16 @@ import { z } from "zod"; -import { CONTACT_CATEGORIES } from "@/lib/api/contact"; +import { SUPPORT_CATEGORIES } from "@/lib/api/support"; -// Mirrors the API's `ContactMessageRequest` (see `schemas/contact.py`) field for +// Mirrors the API's `SupportRequest` (see `schemas/support.py`) field for // field, including the length bounds - the server rejects anything outside them with // a 422, and a form that only finds that out after a round-trip is a worse form. -export const contactSchema = z.object({ +export const supportSchema = z.object({ name: z .string() .min(1, "Please tell us who you are") .max(100, "Name cannot exceed 100 characters"), email: z.string().email("Please enter a valid email address"), - category: z.enum(CONTACT_CATEGORIES), + category: z.enum(SUPPORT_CATEGORIES), subject: z .string() .min(3, "Subject must be at least 3 characters") @@ -21,4 +21,4 @@ export const contactSchema = z.object({ .max(5000, "Message cannot exceed 5000 characters"), }); -export type ContactInput = z.input; +export type SupportInput = z.input;