# UI Phase 13 — Public Front Door — Report (2026-07-19)

## The §3.1 decision (recorded first, per the phase's own gate)

Framed the three guest-browse tiers and decided **ship (a) landing + (b) static public pages now;
defer (c) guest search + public nurse profiles as a REQ-gated follow-up**. Full rationale recorded in
[product/notes/open-questions.md](../../../product/notes/open-questions.md) ("Decided — public
guest-browse depth" section). Tier (c)'s two endpoints are filed as REQ-066/REQ-067 (see Contracts
below) — nothing in this phase calls them; no guest search/profile screen was built.

## What was built

- **The guest front door itself** — `client/middleware.ts`: after the existing next-intl 307/308
  early-return, an **unauthenticated** exact match on `pathWithoutLocale === '/'` is
  `NextResponse.rewrite()`d to `/{locale}/welcome` (never a redirect — the browser URL and SEO
  canonical stay `/`). An **authenticated** hit on `/welcome` redirects to `/`. Every other path's
  behavior (locale detection, the private-route → `/login?next=` redirect, the `returnUrl` capture,
  the copied next-intl hreflang headers) is untouched — confirmed by direct testing, not just code
  reading (see "What is now testable").
- **`ROUTES.WELCOME` (`/welcome`)** added to `src/constants/routes.ts` and to `PUBLIC_PATHS`. A
  comment on `PUBLIC_PATHS` (and in the middleware) calls out the `startsWith`/`'/'` trap explicitly
  for the next agent who touches either file.
- **The landing** at `client/src/app/[locale]/(public-routes)/welcome/`:
  - `page.tsx` — thin RSC, `generateMetadata` (description, `alternates.canonical` → `/{locale}`,
    `openGraph`). Deliberately does **not** override `title` — it inherits the root layout's default
    (`"بالین‌یار"` / `"Balinyaar"`), which is also how view-source tells it apart from the customer
    home's own `shell.customer_app` title when both serve at the same `/{locale}` URL depending on
    auth state.
  - `WelcomeScreen.tsx` — an **async Server Component**, not a `'use client'` screen (no page in this
    app has needed that treatment before; here it's the point — zero query hooks anywhere in the
    tree). Sections, in order: hero (`BrandMark` + tagline + subtitle + CTA to `/login`), a static
    5-tile category grid (`CategoryTile`, static i18n labels — see the CategoryTile change below), a
    3-step how-it-works (step 2 renders the actual `<EscrowNotice />` component, never a paraphrase),
    a static trust/verification explainer (identity/license/INO/bank — written from
    `product/business/02-nurse-verification.md`, since `VerificationPanel` requires a live
    `nurseId`-keyed badge fetch and can't be reused on a zero-data page), a nurse-recruitment CTA
    linking to `/login?role=nurse`, and a footer (terms/privacy links, a static contact note, no
    duplicate locale switcher — `PublicLayout`'s corner strip already provides one for every public
    route including this one).
  - `opengraph-image.tsx` — `next/og`'s `ImageResponse`, a brand-mark composition (teal background,
    the logo shape reproduced in plain divs, terracotta accent dot, "Balinyaar" wordmark) at 1200×630.
    **Latin-only** — `ImageResponse`'s bundled fallback font doesn't cover Persian glyphs, and
    embedding a Mikhak font buffer wasn't verified working in the time available; flagged as a
    follow-up, not silently skipped.
- **`CategoryTile` gained an `href` prop** (`client/src/components/CategoryTile/CategoryTile.tsx`):
  when given, the tile renders via `ButtonBase`'s `component` swap to `AppLink` (the tile itself
  *becomes* the anchor) instead of a click handler — the same polymorphic pattern `AppButton` already
  uses, so there's no button nested inside a link (invalid HTML) and no new client wrapper component
  needed just to make a static tile navigate. Existing `onClick`/`selected` callers are unaffected.
  Test coverage added (`CategoryTile.test.tsx`): renders as a link with the given `href`, and no
  `button` role is present when `href` is set.
- **SEO/metadata infrastructure:**
  - `SITE_URL` constant (`client/src/config.ts`, from `NEXT_PUBLIC_SITE_URL`, falls back to
    `http://localhost:3000`) — the root layout's `generateMetadata` now sets
    `metadataBase: new URL(SITE_URL)`, so every child page's relative OG/canonical URLs resolve
    absolutely.
  - `src/app/robots.ts` **replaces** the old contradictory static `public/robots.txt` (deleted):
    allows the public surface, disallows every private route root (`nurse`, `admin`, `partner`,
    `bookings`, `patients`, `addresses`, `wallet`, `profile`, `support`, `notifications`,
    `select-role`, `onboarding`, `search`) for both locales via a wildcard pattern, points at
    `sitemap.xml`.
  - `src/app/sitemap.ts` — public routes only (`/`, `/login`, `/terms`, `/privacy`) × both locales,
    with `hreflang` alternates.

## What is now testable (and exactly how)

**Important caveat first:** the **dev server** (`npm run dev`, Turbopack) in this sandbox did not
reliably invoke middleware for the *literal* root path `/` — verified down to a minimal
`matcher: ['/']` control middleware that unconditionally returned a JSON body and still never fired
for `/` (a sibling `/ping` path with the identical matcher also silently fell back to a stale cached
render instead of hitting the handler). This reproduced even after full `.next` wipes and process
restarts, and is **not specific to this phase's logic** — a **production build** (`npm run build` +
`npm run start`) exhibits none of it and confirms the intended behavior end-to-end (below). Treat this
as a known dev-server quirk in this Next 16.2.9/Turbopack build, not a defect in the shipped code; a
human should re-confirm with `npm run dev` on their own machine before assuming it's universal.

Verified against a **production** server (`NEXT_PUBLIC_API_URL=... npm run build && npm run start`):

1. `GET /` (no cookies) → `307` → `Location: /fa` (next-intl's own redirect, untouched).
2. `GET /fa` (no cookies) → `200`, response header `x-middleware-rewrite: /fa/welcome`; body's
   `<title>` is the bare default `"بالین‌یار"` (not the customer app's `"اپلیکیشن خانواده | بالین‌یار"`)
   and contains the hero copy/CTA — the landing, not the customer home.
3. `GET /fa/welcome` directly → `200`, same landing content.
4. `GET /en` (no cookies) → `200`, `lang="en" dir="ltr"`, no Mikhak reference, title `"Balinyaar"`.
5. With a **valid, unexpired `access_token` cookie** (forged payload for testing — `isTokenAlive` only
   checks `exp`, never the signature, by design): `GET /fa` → `200` with `<title>` =
   `"اپلیکیشن خانواده | بالین‌یار"` — the **customer home**, confirming an authenticated `/` is
   untouched. `GET /fa/welcome` → `307` → `/fa/` (redirected away from the marketing page). `GET
   /fa/bookings` → `200` (private route now reachable).
6. Without the cookie: `GET /fa/bookings` → `307` → `/fa/login?next=%2Fbookings` — the private-route
   gate and `returnUrl` capture are unchanged.
7. `GET /robots.txt` → the new allow/disallow rules + `Sitemap: .../sitemap.xml`.
8. `GET /sitemap.xml` → the 4 public paths × 2 locales with `hreflang` alternates.
9. `GET /fa` view-source → `og:title`, `og:description`, `og:image` (an **absolute** URL),
   `og:image:width/height` (1200×630), `canonical` → `/fa`.
10. `GET` the `opengraph-image` route directly → `200`, `Content-Type: image/png`; verified the bytes
    are a real 1200×630 PNG (`PNG image data, 1200 x 630, 8-bit/color RGBA`).

`npm run check` (type + lint + `lint:copy`) is green. `npm run test:ci` — all 115 suites / 527 tests
pass, including the extended `CategoryTile.test.tsx`.

Not independently verified in this session (no running backend, no browser): the actual OTP
login→`RoleRouter` round trip after tapping the hero CTA (the login screen itself is unchanged by
this phase); visual dark-mode/RTL inspection of the landing (code follows the same token/RTL
conventions as every other screen, but wasn't eyeballed in a real browser).

## What is mocked / waiting on a real service

Nothing new. Tiers (a)+(b) are 100% static content — no service calls, no mock flags, no
`services/{domain}` seam touched.

## Contracts

- **Consumed:** none (no data fetching on this page).
- **Filed (proposals only, not built against):**
  - **REQ-066** — public (anonymous), rate-limited nurse-search read, for a future tier-(c) guest
    search screen.
  - **REQ-067** — public (anonymous), privacy-reviewed nurse-profile read, for a future tier-(c)
    guest profile screen.
  - Both in
    [for-backend.md](../frontend/requests/for-backend.md), status `open`; each explicitly says the
    frontend has not built anything against it.

## Docs updated

- `product/notes/open-questions.md` — the §3.1 decision + rationale (and the docs HTML was
  regenerated: `cd product && node build-docs.mjs`).
- `client/CLAUDE.md`:
  - Project Structure — `middleware.ts`'s description (the rewrite + the bare-root matcher-entry
    gotcha), `src/app/robots.ts`/`sitemap.ts`, the whole `(public-routes)/welcome/` subtree, and
    `CategoryTile`'s new `href` mode.
  - Per-page metadata section — `metadataBase`/`SITE_URL` and the `opengraph-image.tsx` convention.
  - i18n section — the `welcome` namespace description.
  - Route Constants section — the `PUBLIC_PATHS`/`'/'` trap, spelled out again at the point a future
    agent is most likely to be editing.

## Follow-ups for later phases

- **Tier (c)** (guest search + public nurse profiles) — REQ-066/067 need a backend phase plus an
  explicit privacy sign-off on the nurse-profile field list before any guest-facing search/profile
  screen is built. Do not build against a guessed shape.
- **OG image Persian variant** — currently Latin-only by deliberate scope cut (see `opengraph-image.tsx`'s
  own comment). A follow-up could embed a Mikhak font buffer (`fs.readFile` the existing
  `src/app/fonts/Mikhak-Bold.woff2`) and verify Satori/`next/og` renders Persian glyphs correctly in a
  real running server before shipping it — not verified in this session.
- **Dev-server root-path quirk** — if a future phase also needs middleware to act on the literal `/`
  segment, re-confirm on a real (non-sandboxed) `npm run dev` whether the matcher gotcha reproduces
  there too; if so, the `matcher: ['/', '/((?!_next|_vercel|api|.*\\..*).*)']` fix already applied here
  covers it, but worth a second look outside this session's environment.
- **Footer legal links** — phase 3 had already shipped `/terms`/`/privacy` by the time this phase ran,
  so the footer links to both (no graceful-omission gap to report).
- **Verification-explainer reuse** — phase 4/8 had already shipped, but their `VerificationPanel`
  needs a live `nurseId` badge query and can't be reused as-is on a zero-fetch landing; the trust
  section here is independently written static copy sourced from the same product doc. A future pass
  could extract a shared *static* label set (`step_identity_kyc`, `step_moh_competency_license`, …
  already exist in the `verification` namespace) if keeping the two copies in sync becomes a problem.

## Save memory note

See `MEMORY.md` → `ui_phase_13_public_front_door.md` for the durable cross-session note (the
rewrite-not-redirect mechanic, the `PUBLIC_PATHS`/`'/'` trap, the dev-server root-path quirk, tier (c)
status).
