- docs/architecture/: god-node map + report (2026-05-06), workflows.json/html + analysis snapshot - docs/plans/2026-05-13-public-landing-routing-refactor.md - docs/tutorials/build-a-page.md - abc-feat-self-serve-signup-phase-2-design-20260507-112020.md (root) Core dumps (core.144926, core.145678, docs/architecture/core.1392564) and agent .remember/ state are intentionally left untracked. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
478 lines
19 KiB
Markdown
478 lines
19 KiB
Markdown
# Tutorial: Build a Contact page
|
||
|
||
By the end of this tutorial, ResolutionFlow will have a working `/contact` page. A visitor can land on it, fill out a form, and see a thank-you state when it submits. You'll touch the router, build a page component, style it with the design system, manage form state, and link to it from the landing footer.
|
||
|
||
This is a **tutorial**, not a reference. It's one concrete path that's known to work. The point is to learn how the pieces fit together by actually building something. Don't substitute steps. After you finish, you'll be ready to read the code with confidence.
|
||
|
||
**Estimated time:** 30–45 minutes.
|
||
|
||
---
|
||
|
||
## What you'll know by the end
|
||
|
||
- Where new pages live in the codebase
|
||
- How the router lazy-loads page components
|
||
- How public pages differ from in-app pages
|
||
- How to apply the design system without inventing chrome
|
||
- How to wire form state, validation, and submit
|
||
- How to verify your work and ship a clean commit
|
||
|
||
---
|
||
|
||
## Before you start
|
||
|
||
You need:
|
||
|
||
- The frontend container running (`docker ps` should show `resolutionflow_frontend` listening on 5173). Vite hot-module-reload is what makes this tutorial pleasant. Every file save shows up in the browser within a second.
|
||
- An editor open at the repo root.
|
||
- A logged-out browser tab pointed at the dev server. The contact page is public, so you don't need an account to visit it. (If you've been logged in, open a private window or sign out.)
|
||
|
||
> Quick sanity check: navigate to `/landing` in the browser. If you see the marketing page, you're set up correctly. If you see anything else, fix that first.
|
||
|
||
---
|
||
|
||
## Step 1: Decide where the page lives
|
||
|
||
Two parts of the app could host a "contact" page: the **public marketing layer** (`/landing`, `/privacy`, `/terms`) or the **in-app shell** (`/account`, `/sessions`, etc.). The right answer depends on the audience.
|
||
|
||
A contact page is for visitors who *aren't* logged in: prospects, leads, support requests from people without accounts. So it belongs at the public layer, parallel to `/privacy` and `/terms`. No app shell, no sidebar, just a simple centered page.
|
||
|
||
**Decision:** route it at `/contact`, no auth required, model it after `frontend/src/pages/PrivacyPage.tsx` for layout.
|
||
|
||
---
|
||
|
||
## Step 2: Create the page component
|
||
|
||
Create a new file at `frontend/src/pages/ContactPage.tsx`. Start with the smallest possible skeleton so we can confirm the route works before adding form complexity.
|
||
|
||
```tsx
|
||
import { Link } from 'react-router-dom'
|
||
import { PageMeta } from '@/components/common/PageMeta'
|
||
|
||
export default function ContactPage() {
|
||
return (
|
||
<>
|
||
<PageMeta title="Contact" description="Get in touch with ResolutionFlow" />
|
||
<div className="min-h-screen bg-background text-foreground">
|
||
<div className="mx-auto max-w-xl px-6 py-16">
|
||
<Link
|
||
to="/landing"
|
||
className="mb-8 inline-block text-sm text-muted-foreground hover:text-foreground"
|
||
>
|
||
← Back to home
|
||
</Link>
|
||
<h1 className="text-3xl font-bold font-heading mb-3">Contact</h1>
|
||
<p className="text-muted-foreground">
|
||
Send us a note and we'll get back to you within one business day.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
```
|
||
|
||
A few things worth pointing out:
|
||
|
||
- **`PageMeta`** sets the document title and description. Every page should have one. It's how you keep tab titles informative without scattering `<Helmet>` calls everywhere.
|
||
- **`min-h-screen bg-background`** ensures the page fills the viewport with the brand background color. Critical for public pages that don't sit inside an app layout.
|
||
- **`mx-auto max-w-xl`** caps line length around 65–75 characters of body text, per the shared design laws. `max-w-xl` is ~36rem; for the form we'll keep at this width.
|
||
- **`font-heading`** maps to the heading font defined in `frontend/src/index.css`. Use it on H1s, not body text.
|
||
|
||
Save the file. Nothing visible happens yet: we haven't told the router that `/contact` exists.
|
||
|
||
---
|
||
|
||
## Step 3: Wire up the route
|
||
|
||
Open `frontend/src/router.tsx`. Near the top of the file, you'll see a list of `lazyWithRetry` imports for every page. Add yours, alphabetized in the public-page group:
|
||
|
||
```tsx
|
||
const ContactPage = lazyWithRetry(() => import('@/pages/ContactPage'))
|
||
```
|
||
|
||
`lazyWithRetry` is a thin wrapper around React's `lazy()` that retries once if the chunk fails to load (which can happen during a deploy). Use it for everything; never plain `lazy()`.
|
||
|
||
Now scroll down to the `sentryCreateBrowserRouter` array and add a route entry next to the other public ones (`/landing`, `/privacy`, `/terms`):
|
||
|
||
```tsx
|
||
{
|
||
path: '/contact',
|
||
element: page(ContactPage),
|
||
errorElement: <RouteError />,
|
||
},
|
||
```
|
||
|
||
The `page()` helper wraps the component in `<ErrorBoundary>` and `<Suspense fallback={<PageLoader />}>`. That gives you a graceful loader while the chunk loads and an error boundary if something throws. The `errorElement: <RouteError />` handles router-level errors (e.g., a 404 thrown deeper in the tree).
|
||
|
||
Save. Vite reloads. Navigate to `http://your-dev-host:5173/contact` (or whatever URL serves the dev frontend). You should see the heading, the description, and the back-to-home link.
|
||
|
||
> If you see a blank page or an error, check the browser console first. The two common mistakes here are: (1) wrong import path, (2) forgetting `export default`. Fix and re-save.
|
||
|
||
---
|
||
|
||
## Step 4: Add the form
|
||
|
||
Now we add the actual contact form. Replace the body of the page (everything inside `max-w-xl`) with the form scaffolding. Keep imports for now; we'll add more in the next step.
|
||
|
||
```tsx
|
||
<div className="mx-auto max-w-xl px-6 py-16">
|
||
<Link
|
||
to="/landing"
|
||
className="mb-8 inline-block text-sm text-muted-foreground hover:text-foreground"
|
||
>
|
||
← Back to home
|
||
</Link>
|
||
<h1 className="text-3xl font-bold font-heading mb-3">Contact</h1>
|
||
<p className="text-muted-foreground mb-10">
|
||
Send us a note and we'll get back to you within one business day.
|
||
</p>
|
||
|
||
<form className="space-y-5">
|
||
<div>
|
||
<label htmlFor="contact-name" className="block text-sm font-medium text-foreground">
|
||
Name
|
||
</label>
|
||
<input
|
||
id="contact-name"
|
||
type="text"
|
||
required
|
||
className="mt-1 block w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary/30 focus:outline-hidden focus:ring-1 focus:ring-primary/20"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label htmlFor="contact-email" className="block text-sm font-medium text-foreground">
|
||
Email
|
||
</label>
|
||
<input
|
||
id="contact-email"
|
||
type="email"
|
||
required
|
||
className="mt-1 block w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary/30 focus:outline-hidden focus:ring-1 focus:ring-primary/20"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label htmlFor="contact-message" className="block text-sm font-medium text-foreground">
|
||
Message
|
||
</label>
|
||
<textarea
|
||
id="contact-message"
|
||
rows={6}
|
||
required
|
||
className="mt-1 block w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary/30 focus:outline-hidden focus:ring-1 focus:ring-primary/20"
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-white hover:brightness-110 active:scale-[0.98]"
|
||
>
|
||
Send message
|
||
</button>
|
||
</form>
|
||
</div>
|
||
```
|
||
|
||
Notice what we **did not** do:
|
||
|
||
- No outer card wrapper (`rounded-2xl border bg-card p-6`). The page background and the centered `max-w-xl` container are enough structure. Wrapping a single form in a card adds chrome that says nothing. Per `PRODUCT.md`: *"Cards are the lazy answer."*
|
||
- No icons next to labels. The labels carry the meaning; icons would be decoration.
|
||
- No fancy gradient on the submit button. The accent color is reserved for ≤5% of the UI; one solid button is the pattern.
|
||
- No nested borders or shadows.
|
||
|
||
Save. The form renders. The fields are real HTML inputs: they accept focus, browser autofill works, validation messages appear if you submit empty.
|
||
|
||
> If your form fields look unstyled, check that the `className` strings copied without line breaks. Tailwind compiles class strings literally; a stray newline inside the quotes breaks every utility on that line.
|
||
|
||
The `inputClass` you see here is duplicated three times. That's intentional for the tutorial; repetition makes it easy to read. In real code you'd extract a constant once you have three matching calls. Look at `frontend/src/pages/account/ProfileSettingsPage.tsx` for the project's existing convention.
|
||
|
||
---
|
||
|
||
## Step 5: Manage form state
|
||
|
||
Right now the inputs are uncontrolled (the browser owns their values) and submitting reloads the page. We need React state so we can read the values, validate them, and prevent the default submit.
|
||
|
||
At the top of the file, add `useState`:
|
||
|
||
```tsx
|
||
import { useState } from 'react'
|
||
```
|
||
|
||
Inside the component, above the `return`, add three pieces of state and a submit handler:
|
||
|
||
```tsx
|
||
const [name, setName] = useState('')
|
||
const [email, setEmail] = useState('')
|
||
const [message, setMessage] = useState('')
|
||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
if (!name.trim() || !email.trim() || !message.trim()) return
|
||
|
||
setIsSubmitting(true)
|
||
try {
|
||
// Replaced with a real API call in Step 7.
|
||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||
// Success handling lands in Step 6.
|
||
} finally {
|
||
setIsSubmitting(false)
|
||
}
|
||
}
|
||
```
|
||
|
||
Then wire the inputs and the form:
|
||
|
||
```tsx
|
||
<form className="space-y-5" onSubmit={handleSubmit}>
|
||
<div>
|
||
<label htmlFor="contact-name" /* ... */>Name</label>
|
||
<input
|
||
id="contact-name"
|
||
type="text"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
required
|
||
className="..."
|
||
/>
|
||
</div>
|
||
{/* ... same pattern for email and message ... */}
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={isSubmitting}
|
||
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-white hover:brightness-110 active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
{isSubmitting ? 'Sending…' : 'Send message'}
|
||
</button>
|
||
</form>
|
||
```
|
||
|
||
What changed:
|
||
|
||
- **`value` + `onChange`** makes each input a controlled component. React owns the truth; the input mirrors it.
|
||
- **`e.preventDefault()`** stops the browser's default form submit (which would do a full page reload).
|
||
- **`isSubmitting`** disables the button during the in-flight request and swaps the label. Users get immediate feedback that something happened.
|
||
- **The trim() guards** catch empty submissions even when the browser's `required` attribute is bypassed (e.g., autofill anomalies).
|
||
|
||
Save. Try typing in the fields. Click Send message. The button briefly says "Sending…" then re-enables. Nothing user-visible happens after that yet. That's the next step.
|
||
|
||
---
|
||
|
||
## Step 6: Show a success state
|
||
|
||
When the submit succeeds, the form should disappear and a confirmation should take its place. That's both a clearer signal and a stronger feeling than a toast that vanishes after three seconds.
|
||
|
||
Add one more piece of state:
|
||
|
||
```tsx
|
||
const [submitted, setSubmitted] = useState(false)
|
||
```
|
||
|
||
Update the submit handler so it flips `submitted` on success:
|
||
|
||
```tsx
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
if (!name.trim() || !email.trim() || !message.trim()) return
|
||
|
||
setIsSubmitting(true)
|
||
try {
|
||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||
setSubmitted(true)
|
||
} finally {
|
||
setIsSubmitting(false)
|
||
}
|
||
}
|
||
```
|
||
|
||
Now branch the JSX so the form renders only when `!submitted`:
|
||
|
||
```tsx
|
||
{submitted ? (
|
||
<div className="rounded-lg border border-border bg-card/50 p-6">
|
||
<h2 className="text-lg font-semibold text-foreground">Message sent</h2>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
Thanks, {name.trim()}. We'll reply at{' '}
|
||
<span className="text-foreground">{email.trim()}</span> within one business day.
|
||
</p>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setName('')
|
||
setEmail('')
|
||
setMessage('')
|
||
setSubmitted(false)
|
||
}}
|
||
className="mt-4 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||
>
|
||
Send another message
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<form className="space-y-5" onSubmit={handleSubmit}>
|
||
{/* ...form contents... */}
|
||
</form>
|
||
)}
|
||
```
|
||
|
||
A few teaching moments here:
|
||
|
||
- **The success state is a single bordered region**, not a confetti card with a check icon. PRODUCT.md's tone is "competent, no fluff."
|
||
- **It echoes the user's name and email back** so they know the right address received their message. This is a small touch that builds trust.
|
||
- **There's a "Send another message" affordance** that resets the form. Don't trap users in success. Give them a way back.
|
||
|
||
Save. Submit the form. The fields disappear and the confirmation appears. Click "Send another message" and you're back to the empty form.
|
||
|
||
---
|
||
|
||
## Step 7: Wire it to a real API endpoint
|
||
|
||
So far the submit is a mock 600ms delay. To make it real, we need three things: an API endpoint, a frontend client function, and updated error handling.
|
||
|
||
The backend endpoint setup is its own tutorial; for now we'll add the frontend client and call a not-yet-existing path, so the call fails gracefully with a toast. When the backend lands, you change one line of your client and you're done.
|
||
|
||
Create `frontend/src/api/contact.ts`:
|
||
|
||
```ts
|
||
import { apiClient } from './client'
|
||
|
||
export const contactApi = {
|
||
submit: (data: { name: string; email: string; message: string }) =>
|
||
apiClient.post('/contact', data).then((r) => r.data),
|
||
}
|
||
```
|
||
|
||
That's the whole pattern. `apiClient` is a pre-configured Axios instance from `frontend/src/api/client.ts` with the base URL, auth, and error interceptors already wired. Every API module in `frontend/src/api/` follows this same shape. Read `frontend/src/api/betaFeedback.ts` to see another minimal example.
|
||
|
||
Now in `ContactPage.tsx`, swap the mock for a real call. Add to imports:
|
||
|
||
```tsx
|
||
import { contactApi } from '@/api/contact'
|
||
import { toast } from '@/lib/toast'
|
||
```
|
||
|
||
Update the submit handler:
|
||
|
||
```tsx
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
if (!name.trim() || !email.trim() || !message.trim()) return
|
||
|
||
setIsSubmitting(true)
|
||
try {
|
||
await contactApi.submit({
|
||
name: name.trim(),
|
||
email: email.trim(),
|
||
message: message.trim(),
|
||
})
|
||
setSubmitted(true)
|
||
} catch (err) {
|
||
console.error('Failed to send contact message:', err)
|
||
toast.error("We couldn't send your message. Please try again.")
|
||
} finally {
|
||
setIsSubmitting(false)
|
||
}
|
||
}
|
||
```
|
||
|
||
What this gets you:
|
||
|
||
- Backend errors (500, network failure, etc.) show a toast and keep the form filled. The user can retry without retyping.
|
||
- The success path only fires if the API call succeeds, with no false positives.
|
||
- `toast` comes from `@/lib/toast`, the project's wrapper around Sonner. It's themed and consistent with every other toast in the app.
|
||
|
||
Save. Submit the form. Because there's no `/contact` backend endpoint yet, the call will fail and you'll see an error toast. That's correct behavior. The frontend is doing exactly what it should. When someone implements the backend, no frontend change is required.
|
||
|
||
---
|
||
|
||
## Step 8: Link from the landing page
|
||
|
||
A page that nobody can reach isn't a page. Open `frontend/src/pages/LandingPage.tsx` and find the `<footer>` section near the bottom (search for `landing-footer`). Add a `Contact` link next to the existing Privacy and Terms links. The exact markup depends on the surrounding code, but the pattern looks like:
|
||
|
||
```tsx
|
||
<Link to="/contact" className="...">
|
||
Contact
|
||
</Link>
|
||
```
|
||
|
||
Match the styling of the adjacent links. Don't invent a new visual treatment. Consistency is what makes a footer feel like a footer.
|
||
|
||
Save. Reload `/landing`. The Contact link appears in the footer. Click it. The contact page loads.
|
||
|
||
---
|
||
|
||
## Step 9: Verify your work
|
||
|
||
You're not done until the toolchain agrees with you. Run all three from the repo root:
|
||
|
||
```bash
|
||
docker exec resolutionflow_frontend npx tsc --noEmit
|
||
docker exec resolutionflow_frontend npx eslint src/pages/ContactPage.tsx src/api/contact.ts
|
||
docker exec resolutionflow_frontend npx vite build
|
||
```
|
||
|
||
All three should pass with no errors. (Vite may print pre-existing chunk-size warnings; those are unrelated to your change.)
|
||
|
||
Then go through the page in the browser one more time:
|
||
|
||
- [ ] Empty submit attempts are blocked by the browser (`required` attribute) and by your `trim()` guard
|
||
- [ ] Filling all three fields and submitting shows "Sending…" briefly, then either a success state or an error toast (depending on whether the backend exists)
|
||
- [ ] The "Send another message" button on the success state clears the form and brings the inputs back
|
||
- [ ] The back-to-home link returns you to `/landing`
|
||
- [ ] The footer link from `/landing` brings you to `/contact`
|
||
|
||
If any of those don't work, fix them before continuing. Don't ship a tutorial-shaped bug.
|
||
|
||
---
|
||
|
||
## Step 10: Commit
|
||
|
||
The project's commit conventions live in `CLAUDE.md`. Follow them:
|
||
|
||
```bash
|
||
git add frontend/src/pages/ContactPage.tsx \
|
||
frontend/src/api/contact.ts \
|
||
frontend/src/router.tsx \
|
||
frontend/src/pages/LandingPage.tsx
|
||
git commit -m "feat(contact): add public Contact page with submit form
|
||
|
||
Add /contact at the public marketing layer (parallel to /privacy,
|
||
/terms). Single-column form with controlled inputs, success state
|
||
that echoes the submitter's name and email, error toast on submit
|
||
failure. Backend endpoint POST /contact is referenced but not yet
|
||
implemented; submits will toast-error until it lands.
|
||
|
||
Linked from the landing page footer.
|
||
"
|
||
```
|
||
|
||
If the project requires a co-author trailer (check `CLAUDE.md`), add it. Don't push directly to `main` if it's a protected branch; branch first, push, open a PR.
|
||
|
||
---
|
||
|
||
## What you learned
|
||
|
||
You touched every layer of a public-facing page:
|
||
|
||
- **Routing** (`router.tsx` + `lazyWithRetry` + `page()`)
|
||
- **Page composition** (`PageMeta` + layout primitives)
|
||
- **Design system tokens** (`bg-background`, `text-foreground`, `border-border`, `bg-primary`)
|
||
- **Form state** (controlled inputs, submit guards, in-flight feedback)
|
||
- **API clients** (`frontend/src/api/`, `apiClient`)
|
||
- **Error UX** (toast on failure, success state on… success)
|
||
- **Verification** (tsc, eslint, build, manual browser pass)
|
||
|
||
The pattern transfers. An in-app page (under `/account`, `/sessions`, etc.) is the same set of moves with one difference: it sits inside the app shell instead of standing alone, so the route is nested and you skip the `min-h-screen bg-background` outer wrapper.
|
||
|
||
---
|
||
|
||
## Where to go next
|
||
|
||
- **Read** `frontend/src/pages/account/ProfileSettingsPage.tsx` for the in-app form convention with shared `inputClass` and a save-changes pattern.
|
||
- **Read** `PRODUCT.md` and `DESIGN-SYSTEM.md` end-to-end. They're short and they're the source of truth for "is this design right?"
|
||
- **Try** building a second page on your own. Pick a simple one like a `/changelog` route that just lists releases. Apply what you learned without rereading this tutorial.
|
||
- **Skim** `frontend/src/pages/account/IntegrationsPage.tsx` once you're comfortable with the basics; it's a real working complex page that exercises forms, API state, optimistic updates, and modals together.
|