Files
resolutionflow/docs/tutorials/build-a-page.md
Michael Chihlas e5b26245ca
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 6m45s
CI / e2e (pull_request) Successful in 10m13s
CI / backend (pull_request) Successful in 11m27s
docs: add architecture reports, public-landing routing plan, build-a-page tutorial, self-serve signup phase-2 design
- 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>
2026-05-13 23:59:29 -04:00

19 KiB
Raw Blame History

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: 3045 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.

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"
          >
            &larr; 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 6575 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:

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):

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

<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"
  >
    &larr; 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:

import { useState } from 'react'

Inside the component, above the return, add three pieces of state and a submit handler:

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:

<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:

const [submitted, setSubmitted] = useState(false)

Update the submit handler so it flips submitted on success:

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:

{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:

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:

import { contactApi } from '@/api/contact'
import { toast } from '@/lib/toast'

Update the submit handler:

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.


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:

<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:

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:

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.