# 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 `` 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 `` 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).