diff --git a/apps/api/prisma/migrations/20260908120000_contact_message/migration.sql b/apps/api/prisma/migrations/20260908120000_contact_message/migration.sql new file mode 100644 index 0000000..83d7506 --- /dev/null +++ b/apps/api/prisma/migrations/20260908120000_contact_message/migration.sql @@ -0,0 +1,23 @@ +-- Contact-form messages (issue #80). New table only — nothing to backfill. +-- `author_id` is nullable with ON DELETE SET NULL so a report survives its +-- sender's account deletion (same as ingredients.created_by_id). + +-- CreateTable +CREATE TABLE "contact_message" ( + "id" SERIAL NOT NULL, + "author_id" INTEGER, + "type" TEXT NOT NULL, + "message" TEXT NOT NULL, + "page_url" TEXT, + "user_agent" TEXT, + "app_version" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "contact_message_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "contact_message_created_at_idx" ON "contact_message"("created_at"); + +-- AddForeignKey +ALTER TABLE "contact_message" ADD CONSTRAINT "contact_message_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "user_profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 61c4e92..e9941ef 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -135,6 +135,8 @@ model UserProfile { /// Placeholder `Ingredient` rows this profile created by typing a free-text /// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`. createdIngredientPlaceholders Ingredient[] @relation("PlaceholderCreator") + /// Contact-form messages this profile has sent — see {@link ContactMessage}. + contactMessages ContactMessage[] @@map("user_profiles") } @@ -1189,3 +1191,37 @@ model TourProgress { @@id([userProfileId, scenarioId]) @@map("tour_progress") } + +// ----------------------------------------------------------------------------- +// Contact +// In-app "report a bug / suggestion" form — user-submitted, read-only for +// admins at /admin/messages. See issues #80 (this) and #81 (turn a message +// into a Forgejo issue). +// ----------------------------------------------------------------------------- + +/// A message sent via the app's contact form (bug, suggestion, other). +/// Written once by a signed-in user, never edited afterwards — consulted +/// read-only from the admin app. `authorId` is nullable + `onDelete: SetNull` +/// (same choice as `Ingredient.createdById`): a bug report keeps its value +/// even if the account that sent it is deleted in the meantime. +model ContactMessage { + id Int @id @default(autoincrement()) + authorId Int? @map("author_id") + /// "bug" | "suggestion" | "other" — a plain `String` backed by the shared + /// `CONTACT_MESSAGE_TYPES` const (same approach as + /// `TechStepTrainingSuggestion.status` / `AnalyticsEvent.type`): adding a + /// value is one line, no Postgres enum migration. + type String + message String + /// Technical context captured automatically at submit time, for triage — + /// all nullable (an older client, or a direct API call, can omit them). + pageUrl String? @map("page_url") + userAgent String? @map("user_agent") + appVersion String? @map("app_version") + createdAt DateTime @default(now()) @map("created_at") + + author UserProfile? @relation(fields: [authorId], references: [id], onDelete: SetNull) + + @@index([createdAt]) + @@map("contact_message") +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 5c93294..f308989 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -7,6 +7,7 @@ import { errorLogger } from "./middlewares/error-logger.js"; import { requestLogger } from "./middlewares/request-logger.js"; import { adminRouter } from "./modules/admin/admin.routes.js"; import { authRouter } from "./modules/auth/auth.routes.js"; +import { contactRouter } from "./modules/contact/contact.routes.js"; import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js"; import { houseRouter } from "./modules/house/house.routes.js"; import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js"; @@ -60,6 +61,10 @@ export function createServer(): ExpressServer { // admin page path can ever collide with an admin API path again. server.mountRouter("/admin/api", adminRouter); server.mountRouter("/house", houseRouter); + // In-app "signaler un bug / suggestion" form (issue #80) — session + // required, one POST. Admins read these rows via `/admin/api/contact`, + // never this router. + server.mountRouter("/contact", contactRouter); // Not user-facing — `services/tech-step-llm-worker` only, guarded by // `requireInternalWorker` on every route within (see that router's own // doc comment), never `requireAuth`. Mounted alongside the other routers diff --git a/apps/api/src/modules/contact/contact.routes.ts b/apps/api/src/modules/contact/contact.routes.ts new file mode 100644 index 0000000..57639c1 --- /dev/null +++ b/apps/api/src/modules/contact/contact.routes.ts @@ -0,0 +1,34 @@ +import { wrapAsyncHandler } from "@batch-cooking/express-tools"; +import { submitContactMessageSchema } from "@batch-cooking/shared"; +import { Router } from "express"; +import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; +import { submitContactMessage } from "./contact.service.js"; + +/** + * Router mounted at `/contact` in app.ts (issue #80). A single endpoint — + * `POST /` — for the in-app "signaler un bug / suggestion" form. Requires a + * session: only signed-in users can send a message, so every report is + * attributable (see `ContactMessage.authorId`). The admin-side read of + * these rows lives entirely in the admin router, not here. + */ +export const contactRouter = Router(); + +contactRouter.post( + "/", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const input = submitContactMessageSchema.parse(req.body); + // Read the UA from the request, never from the body — the schema omits + // it on purpose so it can't be spoofed. Header can be an array or + // absent; normalize to `string | null`. + const rawUserAgent = req.headers["user-agent"]; + const userAgent = + typeof rawUserAgent === "string" && rawUserAgent.length > 0 ? rawUserAgent : null; + await submitContactMessage(res.locals.userProfile.id, input, userAgent); + // 204, not 201-with-a-body: the form has nothing to do with the stored + // row, and the shared `ApiClient._request` treats only 204 as bodyless + // (a 201 with an empty body makes its `response.json()` throw). Same + // "mutation returns nothing" shape as `POST /auth/logout`. + res.status(204).end(); + }), +); diff --git a/apps/api/src/modules/contact/contact.service.ts b/apps/api/src/modules/contact/contact.service.ts new file mode 100644 index 0000000..1a11655 --- /dev/null +++ b/apps/api/src/modules/contact/contact.service.ts @@ -0,0 +1,50 @@ +import type { SubmitContactMessageInput } from "@batch-cooking/shared"; +import { prisma } from "../../db/prisma.js"; +import { analytics } from "../../lib/analytics.service.js"; + +/** + * Persists one contact-form submission (issue #80). Write-only from the + * user's side — there is no read/update/delete counterpart here: admins + * consult these rows through the admin app (`admin-contact.service.ts`, PR + * 2), never this module. + * + * `userAgent` comes straight from the request header, never the body — the + * shared `submitContactMessageSchema` deliberately omits it so the client + * can't spoof it. `pageUrl` / `appVersion` are client-supplied context and + * default to `null` when the (older, or non-web) client didn't send them. + * + * Returns nothing: the form only needs to know the write succeeded (a + * `201`), not the stored row. + */ +export async function submitContactMessage( + authorId: number, + input: SubmitContactMessageInput, + userAgent: string | null, +): Promise { + try { + await prisma.contactMessage.create({ + data: { + authorId, + type: input.type, + message: input.message, + pageUrl: input.pageUrl ?? null, + userAgent, + appVersion: input.appVersion ?? null, + }, + }); + // Fire-and-forget (see AnalyticsService's contract) — `admin-metrics` + // aggregates `AnalyticsEvent` by `type` generically, so this shows up in + // the dashboard series with no extra code; `context.type` lets it be + // broken down by bug/suggestion/other. + analytics.recordEvent("contact.submitted", { + actorType: "user", + actorId: authorId, + context: { type: input.type }, + }); + } catch (err) { + // Rethrown as-is — `wrapAsyncHandler` / the error middleware (which + // already logs it) handles it; this service layer just isn't allowed a + // bare `await` per the repo's async/try-catch convention. + throw err; + } +} diff --git a/apps/api/test-support/reset-db.ts b/apps/api/test-support/reset-db.ts index 67af1e0..b1d6cb8 100644 --- a/apps/api/test-support/reset-db.ts +++ b/apps/api/test-support/reset-db.ts @@ -49,6 +49,7 @@ export async function resetDatabase() { "recipe", "ingredients", "sources", "unit", "user_profiles", "diet", "house", "tour_progress", "tour_scenario_step", "tour_scenario", "tour_target", + "contact_message", "admin_users", "analytics_events", "worker_heartbeats", "backfill_state" RESTART IDENTITY CASCADE; `); diff --git a/apps/api/test/contact.test.ts b/apps/api/test/contact.test.ts new file mode 100644 index 0000000..833d970 --- /dev/null +++ b/apps/api/test/contact.test.ts @@ -0,0 +1,143 @@ +import { ErrorCode, type SignupInput } from "@batch-cooking/shared"; +import { faker } from "@faker-js/faker"; +import { expect } from "chai"; +import request from "supertest"; +import { createApp } from "../src/app.js"; +import { prisma } from "../src/db/prisma.js"; +import { resetDatabase } from "../test-support/reset-db.js"; + +/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */ +function buildSignupPayload(): SignupInput { + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + return { + firstName, + lastName, + email: faker.internet.email({ firstName, lastName }).toLowerCase(), + password: faker.internet.password({ length: 16 }), + }; +} + +/** A valid `POST /contact` body — message comfortably over the 10-char minimum. */ +function buildContactPayload() { + return { + type: "bug" as const, + message: "Le bouton « Envoyer » ne réagit pas sur la page planning.", + pageUrl: "/", + appVersion: "0.2.1", + }; +} + +describe("Contact", () => { + const app = createApp(); + + beforeEach(async () => { + await resetDatabase(); + }); + + after(async () => { + await prisma.$disconnect(); + }); + + describe("POST /contact", () => { + it("rejects requests without a session with 401 NOT_AUTHENTICATED", async () => { + const res = await request(app).post("/contact").send(buildContactPayload()); + + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); + expect(await prisma.contactMessage.count()).to.equal(0); + }); + + it("stores the message with its fields, attributed to the caller, and captures the user-agent from the header", async () => { + const agent = request.agent(app); + const signup = buildSignupPayload(); + await agent.post("/auth/signup").send(signup); + const author = await prisma.userProfile.findFirstOrThrow({ where: { email: signup.email } }); + + const res = await agent + .post("/contact") + .set("User-Agent", "BatchCookingApp/0.2.1 (Android 14)") + .send(buildContactPayload()); + + expect(res.status).to.equal(204); + + const rows = await prisma.contactMessage.findMany(); + expect(rows).to.have.lengthOf(1); + const [row] = rows; + expect(row.authorId).to.equal(author.id); + expect(row.type).to.equal("bug"); + expect(row.message).to.equal(buildContactPayload().message); + expect(row.pageUrl).to.equal("/"); + expect(row.appVersion).to.equal("0.2.1"); + // Read from the request header, never the body. + expect(row.userAgent).to.equal("BatchCookingApp/0.2.1 (Android 14)"); + }); + + it("ignores any user-agent sent in the body — the header is the only source", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + + const res = await agent + .post("/contact") + .set("User-Agent", "real-agent/1.0") + .send({ ...buildContactPayload(), userAgent: "spoofed-agent/9.9" }); + + expect(res.status).to.equal(204); + const row = await prisma.contactMessage.findFirstOrThrow(); + expect(row.userAgent).to.equal("real-agent/1.0"); + }); + + it("keeps pageUrl / appVersion null when the client omits them", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + + const res = await agent.post("/contact").send({ + type: "suggestion", + message: "Ajoutez un mode hors-ligne pour la liste de courses.", + }); + + expect(res.status).to.equal(204); + const row = await prisma.contactMessage.findFirstOrThrow(); + expect(row.type).to.equal("suggestion"); + expect(row.pageUrl).to.equal(null); + expect(row.appVersion).to.equal(null); + }); + + it("rejects a message shorter than 10 characters with 400 VALIDATION_ERROR", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + + // 5 chars — and the schema trims before counting, so leading/trailing + // whitespace can't pad it to the minimum. + const res = await agent.post("/contact").send({ type: "bug", message: " court " }); + + expect(res.status).to.equal(400); + expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); + expect(await prisma.contactMessage.count()).to.equal(0); + }); + + it("rejects a message longer than 5000 characters with 400 VALIDATION_ERROR", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + + const res = await agent.post("/contact").send({ type: "bug", message: "a".repeat(5001) }); + + expect(res.status).to.equal(400); + expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); + expect(await prisma.contactMessage.count()).to.equal(0); + }); + + it("rejects an unknown type with 400 VALIDATION_ERROR", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + + const res = await agent + .post("/contact") + .send({ type: "complaint", message: "Ceci est un message de test suffisamment long." }); + + expect(res.status).to.equal(400); + expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); + expect(await prisma.contactMessage.count()).to.equal(0); + }); + }); +}); diff --git a/apps/web/cypress/e2e/contact-page.cy.ts b/apps/web/cypress/e2e/contact-page.cy.ts new file mode 100644 index 0000000..e037d1c --- /dev/null +++ b/apps/web/cypress/e2e/contact-page.cy.ts @@ -0,0 +1,58 @@ +// Mocks the API via cy.intercept — this job doesn't run a live backend (see +// .github/workflows/ci.yml); apps/api's own Mocha suite covers real +// `POST /contact` behavior against a real database. The Gherkin journey +// (fill + send + confirmation) lives in contact.feature — this file only +// covers the page's initial render and its guards. + +const authenticatedProfile = { + id: 1, + firstName: "Alice", + lastName: "Martin", + email: "alice@example.com", + tokenVersion: 0, + houseId: null, + dietId: null, +}; + +describe("Contact page (/parametres/contact)", () => { + it("redirects to /login when signed out (RequireAuth)", () => { + cy.intercept("GET", "**/auth/me", { statusCode: 401 }); + + cy.visit("/parametres/contact"); + + cy.url().should("include", "/login"); + }); + + describe("signed in", () => { + beforeEach(() => { + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); + }); + + it("renders the form: a type select defaulting to Bug, a message box, a submit button", () => { + cy.visit("/parametres/contact"); + + cy.contains("h1", "Nous contacter").should("be.visible"); + cy.get("#contactType option").should("have.length", 3); + cy.get("#contactType").should("have.value", "bug"); + cy.get("#contactMessage").should("be.visible"); + cy.contains("button", "Envoyer").should("not.be.disabled"); + }); + + it("shows a translated error label, never the raw server message, when the submit fails", () => { + cy.intercept("POST", "**/contact", { + statusCode: 500, + body: { code: 5000, message: "raw internal boom" }, + }).as("submitContact"); + + cy.visit("/parametres/contact"); + cy.get("#contactMessage").type( + "Ceci est un message de test suffisamment long pour être valide.", + ); + cy.contains("button", "Envoyer").click(); + + cy.wait("@submitContact"); + cy.get(".field-error").should("be.visible"); + cy.contains("raw internal boom").should("not.exist"); + }); + }); +}); diff --git a/apps/web/cypress/e2e/contact.feature b/apps/web/cypress/e2e/contact.feature new file mode 100644 index 0000000..30ecbda --- /dev/null +++ b/apps/web/cypress/e2e/contact.feature @@ -0,0 +1,25 @@ +Feature: Contact form + As a signed-in user + I want to report a bug or send a suggestion from within the app + So that the team hears about it with the technical context attached + + Background: + Given I am signed in as "Alice" "Martin" + + Scenario: Sending a bug report shows a confirmation and forwards the page context + Given submitting a contact message will succeed + When I visit "/parametres/contact" + And I select "Bug" from the "contactType" field + And I fill in the "contactMessage" field with "Le bouton Envoyer ne réagit pas sur la page planning." + And I click the button "Envoyer" + Then the contact request should have been sent with type "bug" + And the contact request body should carry the current page URL and app version + And I should see "Merci ! Votre message a bien été envoyé." + + Scenario: A too-short message is rejected on the client, without a request + Given submitting a contact message will succeed + When I visit "/parametres/contact" + And I fill in the "contactMessage" field with "court" + And I click the button "Envoyer" + Then I should see "Le message doit faire au moins 10 caractères" + And no contact request should have been sent diff --git a/apps/web/cypress/e2e/contact.ts b/apps/web/cypress/e2e/contact.ts new file mode 100644 index 0000000..4dfd827 --- /dev/null +++ b/apps/web/cypress/e2e/contact.ts @@ -0,0 +1,31 @@ +import { Given, Then } from "@badeball/cypress-cucumber-preprocessor"; + +// Contact-form-specific steps (issue #80). Signing in and the generic +// select/type/click/see steps come from support/step_definitions/. +// Mocks the API via cy.intercept — this job runs no live backend (see +// .github/workflows/ci.yml); apps/api's Mocha suite covers `POST /contact` +// against a real database. + +Given("submitting a contact message will succeed", () => { + // 204, matching the real route — the client's `_request` treats only 204 + // as a bodyless success (a 201 with no body makes its `.json()` throw). + cy.intercept("POST", "**/contact", { statusCode: 204 }).as("submitContact"); +}); + +Then("the contact request should have been sent with type {string}", (type: string) => { + cy.wait("@submitContact").its("request.body.type").should("equal", type); +}); + +Then("the contact request body should carry the current page URL and app version", () => { + cy.get("@submitContact.all").should("have.length", 1); + cy.get("@submitContact") + .its("request.body") + .should((body: Record) => { + expect(body.pageUrl).to.equal("/parametres/contact"); + expect(body.appVersion).to.be.a("string").and.not.equal(""); + }); +}); + +Then("no contact request should have been sent", () => { + cy.get("@submitContact.all").should("have.length", 0); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index f221d9d..83f5586 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -27,6 +27,7 @@ import { RecipeFormPage } from "./pages/recipes/RecipeFormPage"; import { RecipesPage } from "./pages/recipes/RecipesPage"; import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage"; import { AppSettingsPage } from "./pages/settings/AppSettingsPage"; +import { ContactPage } from "./pages/settings/ContactPage"; import { CreditsPage } from "./pages/settings/CreditsPage"; import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage"; import { PreferencesPage } from "./pages/settings/PreferencesPage"; @@ -124,6 +125,7 @@ export function App() { ) } /> + } /> } /> } /> diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 503e8dd..fd98456 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -24,6 +24,7 @@ import { type SignupInput, type SourceView, type StepTechStepCorrectionView, + type SubmitContactMessageInput, type SubmitTechStepCorrectionInput, type SubmitTechStepCorrectionResult, type TechStepView, @@ -540,6 +541,19 @@ export class ApiClient { public getLatestMobileVersion(): Promise<{ version: string }> { return this._request("/mobile/latest-release"); } + + /** + * Sends a contact-form message (bug report / suggestion / other — issue + * #80). Session required; the message is stored server-side and read by + * admins at `/admin/messages`. Resolves with nothing on success (`204`), + * rejects with `VALIDATION_ERROR` if `message` is too short/long. The + * server reads the user-agent from the request header itself — the client + * only sends `type`, `message`, and the auto-captured `pageUrl` / + * `appVersion` context. + */ + public async submitContactMessage(input: SubmitContactMessageInput): Promise { + await this._request("/contact", { method: "POST", body: JSON.stringify(input) }); + } } /** Single shared instance — this client is stateless, no need for one per caller. */ diff --git a/apps/web/src/layouts/AppLayout.tsx b/apps/web/src/layouts/AppLayout.tsx index 69dca5b..48c5739 100644 --- a/apps/web/src/layouts/AppLayout.tsx +++ b/apps/web/src/layouts/AppLayout.tsx @@ -11,6 +11,7 @@ import { AccountIcon, AppSettingsIcon, ChevronLeftIcon, + ContactIcon, CreditsIcon, DietPreferencesIcon, HouseholdIcon, @@ -70,6 +71,7 @@ const SETTINGS_ITEMS: SettingsNavItem[] = [ { to: "/parametres/foyer", key: "household", Icon: HouseholdIcon }, { to: "/parametres/preferences-utilisateur", key: "userPreferences", Icon: UserPreferencesIcon }, { to: "/parametres/application", key: "application", Icon: AppSettingsIcon, nativeOnly: true }, + { to: "/parametres/contact", key: "contact", Icon: ContactIcon }, { to: "/parametres/credits", key: "credits", Icon: CreditsIcon }, ]; diff --git a/apps/web/src/layouts/nav-icons.tsx b/apps/web/src/layouts/nav-icons.tsx index 8e662b9..6e5c665 100644 --- a/apps/web/src/layouts/nav-icons.tsx +++ b/apps/web/src/layouts/nav-icons.tsx @@ -26,6 +26,10 @@ export { // even in a bottom-tab position, unlike on desktop where it usually // means "open a side drawer". Menu as MoreIcon, + // "Contact" settings entry (issue #80) — the in-app bug/suggestion form. + // A speech bubble reads as "send us a message"; distinct from the Rss + // "Sources" feed glyph and the generic Settings cog. + MessageSquare as ContactIcon, Palette as UserPreferencesIcon, Rss as SourcesIcon, Settings as SettingsIcon, diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index acdd7ce..9146130 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -123,6 +123,7 @@ "household": "Foyer", "userPreferences": "Préférences utilisateur", "application": "Paramètres d'application", + "contact": "Nous contacter", "credits": "Crédits" } }, @@ -435,6 +436,23 @@ "SYSTEM": "Système" } }, + "contact": { + "title": "Nous contacter", + "intro": "Un bug, une idée d'amélioration ? Dites-nous tout. Votre message est transmis à l'équipe avec quelques informations techniques (page, version de l'app) pour nous aider à le comprendre.", + "typeLabel": "Type de message", + "type": { + "bug": "Bug", + "suggestion": "Suggestion", + "other": "Autre" + }, + "messageLabel": "Votre message", + "messagePlaceholder": "Décrivez le problème ou votre suggestion le plus précisément possible…", + "charCount": "{{count}} / {{max}} caractères", + "submit": "Envoyer", + "submitting": "Envoi…", + "success": "Merci ! Votre message a bien été envoyé.", + "sendAnother": "Envoyer un autre message" + }, "credits": { "title": "Crédits", "icons": { diff --git a/apps/web/src/pages/settings/ContactPage.tsx b/apps/web/src/pages/settings/ContactPage.tsx new file mode 100644 index 0000000..f39d641 --- /dev/null +++ b/apps/web/src/pages/settings/ContactPage.tsx @@ -0,0 +1,131 @@ +import { + CONTACT_MESSAGE_TYPES, + type ContactMessageType, + ErrorCode, + submitContactMessageSchema, +} from "@batch-cooking/shared"; +import { type FormEvent, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ApiError, apiClient } from "../../api/client"; +import { errorMessageService } from "../../services/error-message.service"; +import { buildContactMessageInput } from "./contact"; +import "./settings-pages.scss"; +import "./contact-page.scss"; + +/** + * `message`'s server-side ceiling (see `submitContactMessageSchema`) — + * mirrored here to cap the `