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.
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;