11 KiB
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 ("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 onpathWithoutLocale === '/'isNextResponse.rewrite()d to/{locale}/welcome(never a redirect — the browser URL and SEO canonical stay/). An authenticated hit on/welcomeredirects to/. Every other path's behavior (locale detection, the private-route →/login?next=redirect, thereturnUrlcapture, 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 tosrc/constants/routes.tsand toPUBLIC_PATHS. A comment onPUBLIC_PATHS(and in the middleware) calls out thestartsWith/'/'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 overridetitle— it inherits the root layout's default ("بالینیار"/"Balinyaar"), which is also how view-source tells it apart from the customer home's ownshell.customer_apptitle 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 fromproduct/business/02-nurse-verification.md, sinceVerificationPanelrequires a livenurseId-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'sImageResponse, 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.
CategoryTilegained anhrefprop (client/src/components/CategoryTile/CategoryTile.tsx): when given, the tile renders viaButtonBase'scomponentswap toAppLink(the tile itself becomes the anchor) instead of a click handler — the same polymorphic patternAppButtonalready 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. ExistingonClick/selectedcallers are unaffected. Test coverage added (CategoryTile.test.tsx): renders as a link with the givenhref, and nobuttonrole is present whenhrefis set.- SEO/metadata infrastructure:
SITE_URLconstant (client/src/config.ts, fromNEXT_PUBLIC_SITE_URL, falls back tohttp://localhost:3000) — the root layout'sgenerateMetadatanow setsmetadataBase: new URL(SITE_URL), so every child page's relative OG/canonical URLs resolve absolutely.src/app/robots.tsreplaces the old contradictory staticpublic/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 atsitemap.xml.src/app/sitemap.ts— public routes only (/,/login,/terms,/privacy) × both locales, withhreflangalternates.
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):
GET /(no cookies) →307→Location: /fa(next-intl's own redirect, untouched).GET /fa(no cookies) →200, response headerx-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.GET /fa/welcomedirectly →200, same landing content.GET /en(no cookies) →200,lang="en" dir="ltr", no Mikhak reference, title"Balinyaar".- With a valid, unexpired
access_tokencookie (forged payload for testing —isTokenAliveonly checksexp, never the signature, by design):GET /fa→200with<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). - Without the cookie:
GET /fa/bookings→307→/fa/login?next=%2Fbookings— the private-route gate andreturnUrlcapture are unchanged. GET /robots.txt→ the new allow/disallow rules +Sitemap: .../sitemap.xml.GET /sitemap.xml→ the 4 public paths × 2 locales withhreflangalternates.GET /faview-source →og:title,og:description,og:image(an absolute URL),og:image:width/height(1200×630),canonical→/fa.GETtheopengraph-imageroute 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, 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, andCategoryTile's newhrefmode. - Per-page metadata section —
metadataBase/SITE_URLand theopengraph-image.tsxconvention. - i18n section — the
welcomenamespace description. - Route Constants section — the
PUBLIC_PATHS/'/'trap, spelled out again at the point a future agent is most likely to be editing.
- Project Structure —
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.readFilethe existingsrc/app/fonts/Mikhak-Bold.woff2) and verify Satori/next/ogrenders 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 devwhether the matcher gotcha reproduces there too; if so, thematcher: ['/', '/((?!_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//privacyby 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
VerificationPanelneeds a livenurseIdbadge 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 theverificationnamespace) 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).