From 12ce7fa7de29cdc1cec5c26525be328a98ae830d Mon Sep 17 00:00:00 2001 From: hamid Date: Mon, 20 Jul 2026 01:35:15 +0330 Subject: [PATCH] ui phase 13 --- client/CLAUDE.md | 26 ++- client/messages/en.json | 35 ++++ client/messages/fa.json | 35 ++++ client/middleware.ts | 63 +++--- client/public/robots.txt | 5 - .../(public-routes)/welcome/WelcomeScreen.tsx | 182 ++++++++++++++++++ .../welcome/opengraph-image.tsx | 85 ++++++++ .../[locale]/(public-routes)/welcome/page.tsx | 30 +++ client/src/app/[locale]/layout.tsx | 4 + client/src/app/robots.ts | 41 ++++ client/src/app/sitemap.ts | 20 ++ .../CategoryTile/CategoryTile.test.tsx | 16 ++ .../components/CategoryTile/CategoryTile.tsx | 97 ++++++---- client/src/config.ts | 7 + client/src/constants/routes.ts | 12 +- .../frontend/requests/for-backend.md | 37 ++++ .../reports/ui-phase-13-report.md | 162 ++++++++++++++++ product/business/10-payouts.html | 22 +++ product/notes/open-questions.html | 27 +++ product/notes/open-questions.md | 38 ++++ .../Baya.Web.Api/appsettings.Development.json | 4 +- 21 files changed, 868 insertions(+), 80 deletions(-) delete mode 100644 client/public/robots.txt create mode 100644 client/src/app/[locale]/(public-routes)/welcome/WelcomeScreen.tsx create mode 100644 client/src/app/[locale]/(public-routes)/welcome/opengraph-image.tsx create mode 100644 client/src/app/[locale]/(public-routes)/welcome/page.tsx create mode 100644 client/src/app/robots.ts create mode 100644 client/src/app/sitemap.ts create mode 100644 dev/shared-working-context/reports/ui-phase-13-report.md diff --git a/client/CLAUDE.md b/client/CLAUDE.md index b89f954..e8ea544 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -105,13 +105,15 @@ client/ ├── messages/ # Translation files (add keys to BOTH files) │ ├── en.json │ └── fa.json -├── middleware.ts # next-intl routing middleware (locale detection + redirect) +├── middleware.ts # next-intl routing middleware (locale detection + redirect) + (ui-phase-13) the guest front-door: after the i18n 307/308 early-return, an UNAUTHENTICATED exact-match on '/' is `NextResponse.rewrite()`d to `/{locale}/welcome` (never a redirect, so the URL/SEO canonical stays '/'); an AUTHENTICATED hit on `/welcome` redirects to '/'. The bare-root matcher entry (`'/'`, alongside the existing catch-all regex) is load-bearing — this Next 16/Turbopack build did not reliably invoke middleware for the literal root through the negative-lookahead pattern alone (verified in dev; a production `next build && next start` confirmed the intended behavior end-to-end, so this is a dev-server-only quirk, not a logic bug) ├── next.config.mjs # createNextIntlPlugin wires i18n into Next.js └── src/ ├── app/ │ ├── globals.css │ ├── fonts/ # Local font files (woff2) — Mikhak for fa │ ├── global-error.tsx # Special file above [locale] — replaces the root layout on a root-level crash; renders its own , so it CANNOT use next-intl. The one sanctioned static-string exception (minimal, bilingual fa+en). + │ ├── robots.ts # (ui-phase-13) replaces the old static `public/robots.txt` (deleted) — allows the public surface, disallows every private route root for every locale, points at sitemap.xml + │ ├── sitemap.ts # (ui-phase-13) public routes only ('/', /login, /terms, /privacy) × both locales, with hreflang alternates │ └── [locale]/ │ ├── layout.tsx # ROOT RSC: renders + fonts + setRequestLocale + NextIntlClientProvider + ThemeProvider + AuthProvider (seeded via getServerAuthState) + generateMetadata (the '%s | برند' title template) │ ├── error.tsx # Branded, localized error boundary for the whole [locale] segment — reset() retries, a "go home" link escapes @@ -222,7 +224,11 @@ client/ │ │ ├── page.tsx # Thin RSC — generateMetadata (auth.customer_title) + renders LoginScreen │ │ └── LoginScreen.tsx # 'use client' — the actual LoginFlow body │ ├── terms/page.tsx # /terms — draft Terms of Service (ui-phase-3; DRAFT COPY, needs human/legal review before launch) - │ └── privacy/page.tsx # /privacy — draft Privacy Policy (ui-phase-3; DRAFT COPY, needs human/legal review before launch) + │ ├── privacy/page.tsx # /privacy — draft Privacy Policy (ui-phase-3; DRAFT COPY, needs human/legal review before launch) + │ └── welcome/ # /welcome — ui-phase-13 public front door; middleware REWRITES an unauthenticated '/' here (URL stays '/'), so this is what a guest, and every crawler, actually sees at the root + │ ├── page.tsx # Thin RSC — generateMetadata (description/OG/`alternates.canonical` pointing at `/{locale}`; no `title` override, so it inherits the root layout's default "بالین‌یار"/"Balinyaar" — that's how it's told apart from the customer home's own `shell.customer_app` title in view-source) + renders WelcomeScreen + │ ├── WelcomeScreen.tsx # Async Server Component (no 'use client') — hero (BrandMark) + static category grid (CategoryTile via its new `href` prop, §3.1 tier a+b — NOT the live catalog) + how-it-works (step 2 reuses EscrowNotice verbatim) + a static trust/verification explainer + nurse-recruitment CTA + footer; zero query hooks anywhere in the tree — first paint is the whole page + │ └── opengraph-image.tsx # `next/og` ImageResponse — a static brand-mark composition (Latin-only; Persian would need an embedded Mikhak font buffer, left for a follow-up) on the brand teal/cream, served as the page's absolute `og:image` ├── components/ # Shared UI components (each with .test.tsx if imported >1 place) │ ├── common/ # Foundational primitives (import from @/components or @/components/common) │ │ ├── AppButton/, AppIconButton/, AppIcon/, AppLink/, AppAlert/, AppLoading/ # house-default MUI wrappers (see frontend-designer skill §4) @@ -258,7 +264,7 @@ client/ │ ├── PatientForm/ # A4 patient form (first/last name, age/gender/conditions/relation) — reused create+edit; ui-phase-9 split the single full-name field into first/last (lastName falls back to firstName when blank — the wire requires it) and added an `onDirtyChange` prop for `FormDialogShell`'s discard-confirm │ ├── PatientCard/ # E1 care-circle summary card (composes the shared PatientHeader, now with its avatar) + edit/archive actions; ui-phase-9 replaced the invisible tap target with a pressable `ButtonBase` surface (hover/press + a trailing chevron) and added an optional `lastVisitLabel` teaser (never populated without a `patientId` on the cached bookings list — REQ-057) │ ├── BankStatusPanel/ # Nurse bank-account ownership state (pending/verified/mismatch), masked IBAN - │ ├── CategoryTile/ # f4 tappable service-category tile (icon+label; `selected` state for the builder) — Home grid + builder step 1 (tested) + │ ├── CategoryTile/ # f4 tappable service-category tile (icon+label; `selected` state for the builder) — Home grid + builder step 1; ui-phase-13 added an `href` mode (renders as the anchor itself via `ButtonBase`'s `component` swap to `AppLink` — never a button nested inside a link) for the public landing's static category grid (tested) │ ├── PriceDisplay/ # f4 price renderer: money-util Toman + i18n unit label + unit-aware estimated total (never a total from price alone) (tested) │ ├── VariantCard/ # f4 nurse offering card: display_name, PriceDisplay, active/deactivated distinction, edit/deactivate (no delete); ui-phase-8 added `interactive={false}` for a read-only preview use (the builder's live listing preview + the profile preview) (tested) │ ├── ActivationChecklist/ # ui-phase-8 — the unified go-live tracker (`useActivationChecklist` hook, self-fetching): five already-cached queries folded into rows — two-tier honesty (identity/profile/services/coverage drive search visibility; bank drives "getting paid", labelled separately and never gates search) — collapses to a compact «فعال در جستجو» state once everything passes AND accepting-bookings is on; mounted on `/nurse/services` and the dashboard's `DashboardActivationSlot`, one shared component; `useActivationChecklist` is also consumed directly by `PublishGate` so the go-live gate and the checklist never compute the conditions twice (tested) @@ -430,6 +436,12 @@ root template; `page.tsx` itself never renders `` or touches `document.ti pages (customer home, `/login`, `/search`, `/bookings`, `/nurse`, `/admin`, `/partner`) have adopted this so far — the rest is deferred to the area phases (3–11). +**`metadataBase` (ui-phase-13):** the root layout's `generateMetadata` sets `metadataBase: new URL(SITE_URL)` +(`SITE_URL` — `src/config.ts`, sourced from `NEXT_PUBLIC_SITE_URL`, never hard-coded) so every child +page's relative OG/canonical URLs resolve to an absolute one. The public `/welcome` landing is the first +consumer: its `generateMetadata` sets `alternates.canonical` + `openGraph`, and a co-located +`opengraph-image.tsx` (`next/og`'s `ImageResponse`) supplies `og:image`. + ```tsx import type { Metadata } from 'next'; import { getTranslations } from 'next-intl/server'; @@ -505,10 +517,11 @@ async function MyServerComponent() { - `'legal'` — ui-phase-3's `/terms`/`/privacy` static pages: `terms_title`/`privacy_title`, `draft_banner` (the human/legal-review flag shown on-page), `terms_intro`/`privacy_intro`, and `terms_sections`/`privacy_sections` (arrays of `{title, body}` read via `t.raw`, not flat keys — the one namespace with structured JSON values). Consumed only by the two legal pages - `'admin'` — the f15 backoffice consoles: verification queue/case, refund panel, payout dashboard/detail, review moderation, config editor + change-history, holiday manager, support-alert board, audit viewer, admin ticket queue/thread, RBAC grid, and admin-side partner management. Includes the **Persian legal terms** (پروانه تأسیس / مسئول فنی / نماد اعتماد الکترونیکی) and the enum-label prefixes keyed off the stable code (`step_*`/`agg_*`/`atype_*`/`astatus_*`/`sev_*`/`htype_*`/`dtype_*`/`batch_status_*`/`pstatus_*`/`channel_*`/`rstatus_*`/`mstatus_*`/`center_state_*`/`role_*`/`tcat_*`/`tstatus_*`). Consumed by the `/admin/*` screens + the `@/components/admin` composites - `'partner'` — the f15 partner-center portal (a separate authz scope): center home/onboarding-state, sponsored nurses/bookings, and the merchant-of-record settlement/invoice view (سامانه مودیان, commission/VAT decomposition). Consumed by the `/partner/*` screens + `PartnerSettlementRow` +- `'welcome'` — the ui-phase-13 public landing: hero subtitle/CTA, the five static category-grid labels (`category_*`, distinct from the live `catalog`-namespace category names — this grid is marketing copy, not the catalog API), the how-it-works steps, the trust/verification explainer (`trust_*`, a static counterpart to `VerificationPanel`'s live-badge version), the nurse-recruitment CTA, and the footer (`footer_*`, incl. a plain `{year, number}`-formatted copyright line). Consumed only by `WelcomeScreen.tsx` **Namespace conventions for the phases to come** (seed each when its feature lands, in both locale -files): none — **MVP namespaces complete** (f15 seeded `admin` + `partner`). Keep top-level keys as -namespaces and both files in sync. +files): none — **MVP namespaces complete** (f15 seeded `admin` + `partner`; ui-phase-13 seeded `welcome` +post-MVP). Keep top-level keys as namespaces and both files in sync. **Never hard-code UI strings in English.** Any user-visible text must have a translation key in both locale files. @@ -984,6 +997,9 @@ PUBLIC_PATHS = [ROUTES.LOGIN, ...] // paths that bypass middleware auth check Import from the barrel: `import { ROUTES, PUBLIC_PATHS } from '@/constants'`. To add a new public route, append it to `PUBLIC_PATHS` — the middleware picks it up automatically. +**Never append `ROUTES.HOME` ('/') itself** — `PUBLIC_PATHS` is matched with `startsWith`, so `'/'` +would silently make every route public. The guest-facing root (ui-phase-13) is handled by an +exact-match `NextResponse.rewrite()` in `middleware.ts` instead, to `ROUTES.WELCOME` ('/welcome'). --- diff --git a/client/messages/en.json b/client/messages/en.json index be1aa26..bf358f2 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -1970,5 +1970,40 @@ { "title": "Changes to this policy", "body": "We may update this policy as the service evolves. Material changes will be announced in the app before they take effect." }, { "title": "Contact", "body": "Questions about this policy can be sent through the support ticket system in the app." } ] + }, + "welcome": { + "meta_description": "Balinyaar connects families with verified home-care nurses — secure escrow payment and peace-of-mind care.", + "hero_subtitle": "Verified nurses for the people you love, with secure, transparent payment.", + "hero_cta": "Get started", + "categories_title": "Services you can book", + "category_elderly": "Elderly care", + "category_post_surgery": "Post-surgery care", + "category_infant": "Infant care", + "category_chronic": "Chronic conditions", + "category_companionship": "Companionship & daily care", + "how_it_works_title": "How Balinyaar works", + "step1_title": "Search a verified nurse", + "step1_body": "Filter by city, district, and service to choose from verified nurses.", + "step2_title": "Secure escrow payment", + "step3_title": "Peace-of-mind care", + "step3_body": "The nurse logs the completed visit, and is paid only once it is confirmed.", + "trust_title": "What Balinyaar verifies before a booking", + "trust_intro": "Before a nurse can be booked on Balinyaar, they pass the following steps.", + "trust_identity_title": "Identity verification", + "trust_identity_body": "An automated match of national ID, mobile number, and a live selfie against civil-registry records.", + "trust_license_title": "Professional competency license", + "trust_license_body": "The Ministry of Health's official license to practice home nursing.", + "trust_ino_title": "Nursing Organization membership", + "trust_ino_body": "Cross-checked against the Iranian Nursing Organization as a second source.", + "trust_bank_title": "Bank account ownership check", + "trust_bank_body": "Each nurse is paid only into their own verified bank account.", + "nurse_cta_title": "Are you a nurse? Join Balinyaar", + "nurse_cta_body": "List your own services at your own price, and start receiving bookings once you're verified.", + "nurse_cta_button": "Get started as a nurse", + "footer_terms": "Terms of Service", + "footer_privacy": "Privacy Policy", + "footer_contact_title": "Support", + "footer_contact_body": "For any questions, reach us through the in-app support ticket system after you sign in.", + "footer_copyright": "© {year, number} Balinyaar" } } diff --git a/client/messages/fa.json b/client/messages/fa.json index 24558a2..7ca5c25 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -1970,5 +1970,40 @@ { "title": "تغییر این سیاست", "body": "ممکن است این سیاست با تحول خدمت به‌روزرسانی شود. تغییرات مهم پیش از اعمال، در اپلیکیشن اطلاع‌رسانی می‌شود." }, { "title": "تماس با ما", "body": "سوالات درباره این سیاست را می‌توانید از طریق سامانه تیکت پشتیبانی در اپلیکیشن ارسال کنید." } ] + }, + "welcome": { + "meta_description": "بالین‌یار پرستاران تأییدشده را برای مراقبت در منزل به خانواده‌ها متصل می‌کند؛ پرداخت امن امانی و مراقبت با خیال راحت.", + "hero_subtitle": "پرستاران تأییدشده برای مراقبت از عزیزان شما، با پرداختی امن و شفاف.", + "hero_cta": "شروع کنید", + "categories_title": "خدماتی که می‌توانید رزرو کنید", + "category_elderly": "مراقبت سالمندی", + "category_post_surgery": "مراقبت پس از جراحی", + "category_infant": "مراقبت نوزاد", + "category_chronic": "بیماری‌های مزمن", + "category_companionship": "همراهی و مراقبت روزانه", + "how_it_works_title": "بالین‌یار چگونه کار می‌کند؟", + "step1_title": "جستجوی پرستار تأییدشده", + "step1_body": "بر اساس شهر، منطقه و نوع خدمت، از میان پرستاران تأییدشده انتخاب کنید.", + "step2_title": "پرداخت امن امانی", + "step3_title": "مراقبت با خیال راحت", + "step3_body": "پرستار انجام ویزیت را ثبت می‌کند و دستمزد او تنها پس از تأیید انجام کار، پرداخت می‌شود.", + "trust_title": "آنچه بالین‌یار پیش از رزرو تأیید می‌کند", + "trust_intro": "پیش از آنکه پرستاری در بالین‌یار قابل‌رزرو شود، مراحل زیر را می‌گذراند.", + "trust_identity_title": "احراز هویت", + "trust_identity_body": "تطبیق خودکار کد ملی، شماره موبایل و تصویر زنده با ثبت احوال.", + "trust_license_title": "پروانه صلاحیت حرفه‌ای", + "trust_license_body": "مجوز رسمی وزارت بهداشت برای فعالیت پرستاری در منزل.", + "trust_ino_title": "عضویت نظام پرستاری", + "trust_ino_body": "بررسی عضویت در سازمان نظام پرستاری کشور، به‌عنوان منبع دوم تأیید.", + "trust_bank_title": "تأیید مالکیت حساب بانکی", + "trust_bank_body": "دستمزد هر پرستار تنها به حساب بانکی تأییدشدهٔ خود او واریز می‌شود.", + "nurse_cta_title": "پرستار هستید؟ به بالین‌یار بپیوندید", + "nurse_cta_body": "خدمات خود را با قیمت دلخواه ثبت کنید و پس از تأیید صلاحیت، رزرو دریافت کنید.", + "nurse_cta_button": "شروع به‌عنوان پرستار", + "footer_terms": "شرایط استفاده", + "footer_privacy": "حریم خصوصی", + "footer_contact_title": "پشتیبانی", + "footer_contact_body": "برای هر سوالی، پس از ورود از طریق سامانه تیکت پشتیبانی اپلیکیشن با ما در تماس باشید.", + "footer_copyright": "© {year, number} بالین‌یار" } } diff --git a/client/middleware.ts b/client/middleware.ts index c93e44a..a645bb3 100644 --- a/client/middleware.ts +++ b/client/middleware.ts @@ -17,33 +17,43 @@ export default function middleware(request: NextRequest) { const { pathname } = request.nextUrl; - // Strip the locale segment to get the actual path (e.g. /fa/login → /login) - const pathWithoutLocale = '/' + pathname.split('/').slice(2).join('/'); - - const isPublic = PUBLIC_PATHS.some((p) => pathWithoutLocale.startsWith(p)); - - if (!isPublic) { - const token = request.cookies.get(COOKIE_NAMES.ACCESS_TOKEN)?.value; - if (!isTokenAlive(token)) { - const locale = request.cookies.get('NEXT_LOCALE')?.value ?? routing.defaultLocale; - const loginUrl = new URL(`/${locale}${ROUTES.LOGIN}`, request.url); - // Carry the attempted (locale-stripped) destination so a deep link — an SMS booking link, a - // shared nurse profile — survives the round trip through login instead of dumping the user - // on their role home. Validated same-origin + role-permitting on the way back out - // (resolvePostLoginDestination in services/auth/routing.ts); '/' is the default anyway. - const next = pathWithoutLocale + request.nextUrl.search; - if (next && next !== '/') { - loginUrl.searchParams.set(RETURN_URL_PARAM, next); - } - return NextResponse.redirect(loginUrl); - } - } - // Detect locale from the normalized URL (next-intl always puts it at position 1) const locale = routing.locales.find( (l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`), ) ?? routing.defaultLocale; + // Strip the locale segment to get the actual path (e.g. /fa/login → /login) + const pathWithoutLocale = '/' + pathname.split('/').slice(2).join('/'); + + const isPublic = PUBLIC_PATHS.some((p) => pathWithoutLocale.startsWith(p)); + const isAuthenticated = isTokenAlive(request.cookies.get(COOKIE_NAMES.ACCESS_TOKEN)?.value); + + // Guest front door (ui-phase-13): an unauthenticated hit on the exact root is REWRITTEN — never + // redirected — to the public landing, so the URL/SEO canonical stays '/'. Exact match only: + // never add ROUTES.HOME ('/') to PUBLIC_PATHS itself, whose `startsWith` check below would + // otherwise silently un-gate every route (§ the routes.ts comment on PUBLIC_PATHS). + if (!isAuthenticated && pathWithoutLocale === ROUTES.HOME) { + return NextResponse.rewrite(new URL(`/${locale}${ROUTES.WELCOME}`, request.url)); + } + + // A signed-in visitor landing on the marketing page directly gets their real home instead. + if (isAuthenticated && pathWithoutLocale === ROUTES.WELCOME) { + return NextResponse.redirect(new URL(`/${locale}${ROUTES.HOME}`, request.url)); + } + + if (!isPublic && !isAuthenticated) { + const loginUrl = new URL(`/${locale}${ROUTES.LOGIN}`, request.url); + // Carry the attempted (locale-stripped) destination so a deep link — an SMS booking link, a + // shared nurse profile — survives the round trip through login instead of dumping the user + // on their role home. Validated same-origin + role-permitting on the way back out + // (resolvePostLoginDestination in services/auth/routing.ts); '/' is the default anyway. + const next = pathWithoutLocale + request.nextUrl.search; + if (next && next !== '/') { + loginUrl.searchParams.set(RETURN_URL_PARAM, next); + } + return NextResponse.redirect(loginUrl); + } + const requestHeaders = new Headers(request.headers); requestHeaders.set(HEADER_NAMES.LOCALE, locale); @@ -58,6 +68,11 @@ export default function middleware(request: NextRequest) { } export const config = { - // Match all pathnames except internal Next.js paths, API routes, and static files - matcher: ['/((?!_next|_vercel|api|.*\\..*).*)'], + // Match all pathnames except internal Next.js paths, API routes, and static files. The bare + // root '/' is listed explicitly alongside the catch-all regex — Next's matcher does not + // reliably invoke middleware for the literal root path through the negative-lookahead pattern + // alone (verified empirically in this Next 16/Turbopack build: '/' skipped middleware entirely + // and 404'd, while every other path matched fine). This is load-bearing for ui-phase-13's + // guest-front-door rewrite, which only fires on an exact '/' match. + matcher: ['/', '/((?!_next|_vercel|api|.*\\..*).*)'], }; diff --git a/client/public/robots.txt b/client/public/robots.txt deleted file mode 100644 index 874bf1f..0000000 --- a/client/public/robots.txt +++ /dev/null @@ -1,5 +0,0 @@ -User-agent: * -Disallow: /private/ - -User-agent: * -Allow: / diff --git a/client/src/app/[locale]/(public-routes)/welcome/WelcomeScreen.tsx b/client/src/app/[locale]/(public-routes)/welcome/WelcomeScreen.tsx new file mode 100644 index 0000000..1cc4de4 --- /dev/null +++ b/client/src/app/[locale]/(public-routes)/welcome/WelcomeScreen.tsx @@ -0,0 +1,182 @@ +import { getLocale, getTranslations } from 'next-intl/server'; +import { Box, Container, Divider, Stack, Typography } from '@mui/material'; +import { AppButton, AppIcon, AppLink, CategoryTile, EscrowNotice, SurfaceCard } from '@/components'; +import BrandMark from '@/components/auth/BrandMark'; +import { ROUTES } from '@/constants'; + +const CATEGORY_ICON_KEYS = ['elderly', 'post_surgery', 'infant', 'chronic', 'companionship'] as const; + +const HOW_IT_WORKS_STEPS = [ + { icon: 'search', titleKey: 'step1_title', bodyKey: 'step1_body' }, + { icon: 'lock', titleKey: 'step2_title', bodyKey: null }, + { icon: 'verified', titleKey: 'step3_title', bodyKey: 'step3_body' }, +] as const; + +const TRUST_ROWS = [ + { icon: 'identity', titleKey: 'trust_identity_title', bodyKey: 'trust_identity_body' }, + { icon: 'license', titleKey: 'trust_license_title', bodyKey: 'trust_license_body' }, + { icon: 'verification', titleKey: 'trust_ino_title', bodyKey: 'trust_ino_body' }, + { icon: 'bank', titleKey: 'trust_bank_title', bodyKey: 'trust_bank_body' }, +] as const; + +/** + * The public front door (ui-phase-13) — a marketing landing served AT `/` for a guest visitor via + * a middleware rewrite (see `middleware.ts`; the URL/canonical stays `/`, never `/welcome`). + * Deliberately a Server Component with no query hooks anywhere in the tree: first paint is the + * whole product here, so the page must never wait on an API call (§3.5 — RSC-first, zero + * client-side data fetching). The only client leaves it composes (`CategoryTile`, `EscrowNotice`, + * `BrandMark`, `AppLink`/`AppButton`) are static-content components that fetch nothing. + * @component WelcomeScreen + */ +export default async function WelcomeScreen() { + const locale = await getLocale(); + const t = await getTranslations('welcome'); + const tLegal = await getTranslations('legal'); + + const loginHref = `/${locale}${ROUTES.LOGIN}`; + const nurseLoginHref = `/${locale}${ROUTES.LOGIN}?role=nurse`; + const termsHref = `/${locale}${ROUTES.TERMS}`; + const privacyHref = `/${locale}${ROUTES.PRIVACY}`; + + return ( + <Container maxWidth="sm" sx={{ py: { xs: 4, md: 6 } }}> + <Stack sx={{ gap: { xs: 6, md: 8 } }}> + {/* Hero */} + <Stack component="header" sx={{ alignItems: 'center', textAlign: 'center', gap: 2 }}> + <BrandMark withTagline /> + <Typography variant="body1" sx={{ color: 'text.secondary', maxWidth: 420 }}> + {t('hero_subtitle')} + </Typography> + <AppButton to={loginHref} color="primary" variant="contained" size="large" sx={{ mt: 1, px: 4 }}> + {t('hero_cta')} + </AppButton> + </Stack> + + {/* Category grid — static i18n content, not the live catalog (§3.1 tier a+b decision) */} + <Stack component="section" sx={{ gap: 2 }}> + <Typography variant="h6" component="h2" sx={{ fontWeight: 700 }}> + {t('categories_title')} + </Typography> + <Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}> + {CATEGORY_ICON_KEYS.map((key) => ( + <CategoryTile key={key} label={t(`category_${key}`)} iconKey={key} href={loginHref} /> + ))} + </Box> + </Stack> + + {/* How it works */} + <Stack component="section" sx={{ gap: 2.5 }}> + <Typography variant="h6" component="h2" sx={{ fontWeight: 700 }}> + {t('how_it_works_title')} + </Typography> + <Stack sx={{ gap: 2.5 }}> + {HOW_IT_WORKS_STEPS.map((step, index) => ( + <Stack key={step.titleKey} direction="row" sx={{ gap: 2, alignItems: 'flex-start' }}> + <Box + sx={{ + flexShrink: 0, + width: 40, + height: 40, + borderRadius: '50%', + bgcolor: 'var(--bal-primary-soft)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }} + > + <AppIcon icon={step.icon} size={22} color="var(--bal-primary)" aria-hidden="true" /> + </Box> + <Stack sx={{ gap: 0.75, flex: 1, pt: 0.5 }}> + <Typography variant="subtitle1" sx={{ fontWeight: 700 }}> + {index + 1}. {t(step.titleKey)} + </Typography> + {step.bodyKey ? ( + <Typography variant="body2" sx={{ color: 'text.secondary' }}> + {t(step.bodyKey)} + </Typography> + ) : ( + // Step 2 (escrow) reuses the product-mandated verbatim EscrowNotice copy — + // never a paraphrase (§3.2). + <EscrowNotice /> + )} + </Stack> + </Stack> + ))} + </Stack> + </Stack> + + {/* Trust / verification explainer */} + <Stack component="section" sx={{ gap: 2 }}> + <Typography variant="h6" component="h2" sx={{ fontWeight: 700 }}> + {t('trust_title')} + </Typography> + <SurfaceCard padding="lg"> + <Stack sx={{ gap: 2.5 }}> + <Typography variant="body2" sx={{ color: 'text.secondary' }}> + {t('trust_intro')} + </Typography> + {TRUST_ROWS.map((row) => ( + <Stack key={row.titleKey} direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}> + <AppIcon + icon={row.icon} + size={20} + color="var(--bal-trust)" + style={{ flexShrink: 0, marginTop: 2 }} + aria-hidden="true" + /> + <Stack sx={{ gap: 0.25 }}> + <Typography variant="subtitle2" sx={{ fontWeight: 700 }}> + {t(row.titleKey)} + </Typography> + <Typography variant="body2" sx={{ color: 'text.secondary' }}> + {t(row.bodyKey)} + </Typography> + </Stack> + </Stack> + ))} + </Stack> + </SurfaceCard> + </Stack> + + {/* Nurse recruitment */} + <SurfaceCard padding="lg" sx={{ bgcolor: 'var(--bal-primary-soft)', borderColor: 'var(--bal-primary)' }}> + <Stack sx={{ gap: 1.5, alignItems: 'flex-start' }}> + <Typography variant="h6" component="h2" sx={{ fontWeight: 700 }}> + {t('nurse_cta_title')} + </Typography> + <Typography variant="body2" sx={{ color: 'text.secondary' }}> + {t('nurse_cta_body')} + </Typography> + <AppButton to={nurseLoginHref} color="primary" variant="outlined"> + {t('nurse_cta_button')} + </AppButton> + </Stack> + </SurfaceCard> + + {/* Footer */} + <Stack component="footer" sx={{ gap: 2 }}> + <Divider /> + <Stack direction="row" sx={{ gap: 3, flexWrap: 'wrap' }}> + <AppLink to={termsHref} color="text.secondary" underline="hover"> + {tLegal('terms_title')} + </AppLink> + <AppLink to={privacyHref} color="text.secondary" underline="hover"> + {tLegal('privacy_title')} + </AppLink> + </Stack> + <Stack sx={{ gap: 0.5 }}> + <Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary' }}> + {t('footer_contact_title')} + </Typography> + <Typography variant="caption" sx={{ color: 'text.secondary' }}> + {t('footer_contact_body')} + </Typography> + </Stack> + <Typography variant="caption" sx={{ color: 'text.secondary' }}> + {t('footer_copyright', { year: new Date().getFullYear() })} + </Typography> + </Stack> + </Stack> + </Container> + ); +} diff --git a/client/src/app/[locale]/(public-routes)/welcome/opengraph-image.tsx b/client/src/app/[locale]/(public-routes)/welcome/opengraph-image.tsx new file mode 100644 index 0000000..4d72a6d --- /dev/null +++ b/client/src/app/[locale]/(public-routes)/welcome/opengraph-image.tsx @@ -0,0 +1,85 @@ +import { ImageResponse } from 'next/og'; +import { BRAND } from '@/theme/colors'; + +export const alt = 'Balinyaar — trust-first home nursing marketplace'; +export const size = { width: 1200, height: 630 }; +export const contentType = 'image/png'; + +/** + * The landing's og:image (ui-phase-13) — a static composition built from the phase-0 brand mark + * (the rounded-square lockup + terracotta accent dot from `public/img/logo.svg`, reproduced in + * plain divs since Satori/`ImageResponse` renders a constrained CSS subset, not arbitrary SVG) on + * the brand teal/cream. Kept Latin-only (no Persian glyphs) — `ImageResponse`'s bundled fallback + * font only covers Latin; rendering the Persian tagline here would need an embedded Mikhak font + * buffer, deliberately left for a follow-up once that's verified in a running server. + */ +export default function Image() { + return new ImageResponse( + ( + <div + style={{ + width: '100%', + height: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 40, + backgroundColor: BRAND.teal, + }} + > + <div + style={{ + width: 168, + height: 168, + borderRadius: 42, + backgroundColor: BRAND.tealDeep, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + position: 'relative', + }} + > + <div + style={{ + width: 26, + height: 92, + borderRadius: 13, + backgroundColor: BRAND.creamSoft, + position: 'absolute', + left: 54, + }} + /> + <div + style={{ + width: 62, + height: 62, + borderRadius: '50%', + border: `13px solid ${BRAND.creamSoft}`, + position: 'absolute', + right: 40, + }} + /> + <div + style={{ + width: 22, + height: 22, + borderRadius: '50%', + backgroundColor: BRAND.terracotta, + position: 'absolute', + right: 34, + top: 34, + }} + /> + </div> + <div style={{ display: 'flex', fontSize: 76, fontWeight: 700, color: BRAND.creamSoft }}> + Balinyaar + </div> + <div style={{ display: 'flex', fontSize: 32, color: BRAND.tealOnDarkLight }}> + Trust-first home nursing + </div> + </div> + ), + { ...size }, + ); +} diff --git a/client/src/app/[locale]/(public-routes)/welcome/page.tsx b/client/src/app/[locale]/(public-routes)/welcome/page.tsx new file mode 100644 index 0000000..ee3ad95 --- /dev/null +++ b/client/src/app/[locale]/(public-routes)/welcome/page.tsx @@ -0,0 +1,30 @@ +import type { Metadata } from 'next'; +import { getTranslations } from 'next-intl/server'; +import WelcomeScreen from './WelcomeScreen'; + +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: 'welcome' }); + const tCommon = await getTranslations({ locale, namespace: 'common' }); + + // The middleware REWRITES an unauthenticated '/' to this route (never a redirect), so this is + // the content served at both '/{locale}' and '/{locale}/welcome' — canonicalize on the former, + // the URL a guest and every crawler actually sees. + const canonicalPath = `/${locale}`; + + return { + description: t('meta_description'), + alternates: { canonical: canonicalPath }, + openGraph: { + title: tCommon('brand'), + description: t('meta_description'), + type: 'website', + locale: locale === 'fa' ? 'fa_IR' : 'en_US', + url: canonicalPath, + }, + }; +} + +export default function Page() { + return <WelcomeScreen />; +} diff --git a/client/src/app/[locale]/layout.tsx b/client/src/app/[locale]/layout.tsx index 78553c4..c66fb2c 100644 --- a/client/src/app/[locale]/layout.tsx +++ b/client/src/app/[locale]/layout.tsx @@ -12,6 +12,7 @@ import { BRAND } from '@/theme/colors'; import { NotistackProvider } from '@/lib/toast'; import { QueryProvider } from '@/lib/query/QueryProvider'; import { routing } from '@/i18n/routing'; +import { SITE_URL } from '@/config'; import '../globals.css'; import '@/theme/tokens.css'; @@ -80,6 +81,9 @@ export async function generateMetadata({ const t = await getTranslations({ locale: safeLocale, namespace: 'common' }); return { + // Absolute origin for every relative URL in child metadata (OG images, canonicals) — sourced + // from an env constant, never hard-coded, so it's correct per deployment (ui-phase-13). + metadataBase: new URL(SITE_URL), title: { template: safeLocale === 'fa' ? '%s | بالین‌یار' : '%s | Balinyaar', default: safeLocale === 'fa' ? 'بالین‌یار' : 'Balinyaar', diff --git a/client/src/app/robots.ts b/client/src/app/robots.ts new file mode 100644 index 0000000..4f327fa --- /dev/null +++ b/client/src/app/robots.ts @@ -0,0 +1,41 @@ +import type { MetadataRoute } from 'next'; +import { SITE_URL } from '@/config'; + +/** + * Every top-level route segment that sits behind the auth middleware, for every actor (customer/ + * nurse/admin/partner). Kept in sync by hand with `src/constants/routes.ts` — there is no single + * "private roots" export there (only a `PUBLIC_PATHS` allow-list), and deriving one automatically + * would need to walk every `ROUTES.*` entry down to its first segment, which is more machinery + * than this short, reviewable list (ui-phase-13). + */ +const PRIVATE_ROOT_SEGMENTS = [ + 'select-role', + 'onboarding', + 'search', + 'bookings', + 'patients', + 'addresses', + 'wallet', + 'profile', + 'support', + 'notifications', + 'nurse', + 'admin', + 'partner', +]; + +/** + * Replaces the starter `public/robots.txt` (two contradictory `User-agent: *` blocks) with a real + * policy: allow the public marketing/auth surface, disallow every private root for every locale. + * @route /robots.txt + */ +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: '*', + allow: '/', + disallow: PRIVATE_ROOT_SEGMENTS.map((segment) => `/*/${segment}`), + }, + sitemap: `${SITE_URL}/sitemap.xml`, + }; +} diff --git a/client/src/app/sitemap.ts b/client/src/app/sitemap.ts new file mode 100644 index 0000000..f29fc99 --- /dev/null +++ b/client/src/app/sitemap.ts @@ -0,0 +1,20 @@ +import type { MetadataRoute } from 'next'; +import { routing } from '@/i18n/routing'; +import { ROUTES } from '@/constants'; +import { SITE_URL } from '@/config'; + +/** Public, unauthenticated pages only (ui-phase-13) — mirrors `PUBLIC_PATHS` plus the locale root + * itself (the guest landing an unauthenticated `/` rewrites to). */ +const PUBLIC_SITEMAP_PATHS: string[] = ['', ROUTES.LOGIN, ROUTES.TERMS, ROUTES.PRIVACY]; + +/** @route /sitemap.xml */ +export default function sitemap(): MetadataRoute.Sitemap { + return PUBLIC_SITEMAP_PATHS.map((path) => ({ + url: `${SITE_URL}/${routing.defaultLocale}${path}`, + alternates: { + languages: Object.fromEntries( + routing.locales.map((locale) => [locale, `${SITE_URL}/${locale}${path}`]), + ), + }, + })); +} diff --git a/client/src/components/CategoryTile/CategoryTile.test.tsx b/client/src/components/CategoryTile/CategoryTile.test.tsx index 1fc8886..eae48f8 100644 --- a/client/src/components/CategoryTile/CategoryTile.test.tsx +++ b/client/src/components/CategoryTile/CategoryTile.test.tsx @@ -1,8 +1,17 @@ import { FunctionComponent } from 'react'; import { fireEvent, render, screen } from '@testing-library/react'; +import mockRouter from 'next-router-mock'; import { ThemeProvider } from '../../theme'; import CategoryTile, { CategoryTileProps } from './CategoryTile'; +// CategoryTile renders as an AppLink (not a button) when `href` is given (ui-phase-13's landing +// tiles) — AppLink reads next/navigation's usePathname, so it needs the same local mock AppLink's +// own test file uses. +jest.mock('next/navigation', () => ({ + ...jest.requireActual('next/navigation'), + usePathname: () => mockRouter.asPath, +})); + const ComponentToTest: FunctionComponent<CategoryTileProps> = (props) => ( <ThemeProvider> <CategoryTile {...props} /> @@ -36,4 +45,11 @@ describe('<CategoryTile/> component', () => { render(<ComponentToTest label="Elderly Care" iconKey="elderly" selected />); expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true'); }); + + it('renders as a link (not a button) when href is given, for the public landing', () => { + render(<ComponentToTest label="Elderly Care" iconKey="elderly" href="/fa/login" />); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/fa/login'); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); }); diff --git a/client/src/components/CategoryTile/CategoryTile.tsx b/client/src/components/CategoryTile/CategoryTile.tsx index 8e3fc86..9e6c5f6 100644 --- a/client/src/components/CategoryTile/CategoryTile.tsx +++ b/client/src/components/CategoryTile/CategoryTile.tsx @@ -1,6 +1,7 @@ import { FunctionComponent } from 'react'; import { Box, ButtonBase, Typography } from '@mui/material'; import AppIcon from '../common/AppIcon'; +import AppLink from '../common/AppLink'; /** * Service-category `iconKey`s we render with a dedicated icon. Any other key (or a missing one from @@ -22,59 +23,71 @@ export interface CategoryTileProps { onClick?: () => void; /** Selected state (the nurse builder's category step); the Home grid leaves it unset. */ selected?: boolean; + /** + * Renders the tile as a link to this internal path instead of a click handler — the ui-phase-13 + * static landing, whose tiles carry intent straight to `/login` rather than firing a search + * action. Mutually exclusive with `onClick`/`selected` (a link tile is never a toggle). + */ + href?: string; } /** * A tappable tile for one service category — the customer Home grid (data-driven, one per - * `service_category`) and the nurse builder's category step (with `selected`). Icon in a soft-teal - * disc over the localised label; the whole tile is a button so it's keyboard- and screen-reader- - * accessible. RTL-safe (no directional hard-coding). + * `service_category`), the nurse builder's category step (with `selected`), and the public landing + * (with `href`, linking straight to login). Icon in a soft-teal disc over the localised label; the + * whole tile is a single interactive element (a button, or — via `href` — the anchor itself + * through `ButtonBase`'s `component` swap, never a button nested inside a link) so it stays + * keyboard- and screen-reader-accessible. RTL-safe (no directional hard-coding). * @component CategoryTile */ -const CategoryTile: FunctionComponent<CategoryTileProps> = ({ label, iconKey, onClick, selected = false }) => ( - <ButtonBase - focusRipple - onClick={onClick} - aria-pressed={selected} - data-selected={selected} - data-category-icon={resolveIcon(iconKey)} - sx={{ - width: '100%', - height: '100%', - minHeight: 116, - p: 2, - borderRadius: 2, - border: '1px solid', - borderColor: selected ? 'var(--bal-primary)' : 'divider', - bgcolor: selected ? 'var(--bal-primary-soft)' : 'background.paper', - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - gap: 1, - textAlign: 'center', - transition: 'border-color 120ms ease, transform 120ms ease', - '&:hover': { borderColor: 'var(--bal-primary)' }, - '&:active': { transform: 'scale(0.98)' }, - }} - > - <Box +const CategoryTile: FunctionComponent<CategoryTileProps> = ({ label, iconKey, onClick, selected = false, href }) => { + const interactionProps = href + ? { component: AppLink, to: href } + : { onClick, 'aria-pressed': selected, 'data-selected': selected }; + + return ( + <ButtonBase + focusRipple + data-category-icon={resolveIcon(iconKey)} + {...interactionProps} sx={{ - width: 48, - height: 48, - borderRadius: '50%', - bgcolor: 'var(--bal-primary-soft)', + width: '100%', + height: '100%', + minHeight: 116, + p: 2, + borderRadius: 2, + border: '1px solid', + borderColor: selected ? 'var(--bal-primary)' : 'divider', + bgcolor: selected ? 'var(--bal-primary-soft)' : 'background.paper', display: 'flex', + flexDirection: 'column', alignItems: 'center', justifyContent: 'center', + gap: 1, + textAlign: 'center', + transition: 'border-color 120ms ease, transform 120ms ease', + '&:hover': { borderColor: 'var(--bal-primary)' }, + '&:active': { transform: 'scale(0.98)' }, }} > - <AppIcon icon={resolveIcon(iconKey)} size={28} color="var(--bal-primary)" /> - </Box> - <Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3 }}> - {label} - </Typography> - </ButtonBase> -); + <Box + sx={{ + width: 48, + height: 48, + borderRadius: '50%', + bgcolor: 'var(--bal-primary-soft)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }} + > + <AppIcon icon={resolveIcon(iconKey)} size={28} color="var(--bal-primary)" /> + </Box> + <Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3 }}> + {label} + </Typography> + </ButtonBase> + ); +}; export default CategoryTile; diff --git a/client/src/config.ts b/client/src/config.ts index 59373fd..46807e8 100644 --- a/client/src/config.ts +++ b/client/src/config.ts @@ -9,6 +9,13 @@ export const PUBLIC_URL = process.env.NEXT_PUBLIC_PUBLIC_URL; // Variant 2: .env export const API_URL = envRequired(process.env.NEXT_PUBLIC_API_URL); +/** + * The public web origin (no trailing slash), used only for absolute-URL metadata (OG tags, + * `metadataBase`, `robots.ts`/`sitemap.ts`) — never for API calls. Falls back to localhost so + * dev/CI keep working without the env var set; set it for real in production. + */ +export const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000').replace(/\/$/, ''); + /** * The Neshan **web** key (client-embeddable maps/search), separate from the server's * `NeshanGeocoder` server key (refinement phase 8). Optional — `AddressMapPicker` falls back to diff --git a/client/src/constants/routes.ts b/client/src/constants/routes.ts index a3911b9..25f8c1a 100644 --- a/client/src/constants/routes.ts +++ b/client/src/constants/routes.ts @@ -8,6 +8,10 @@ export const ROUTES = { // Customer (family) app — mobile-first, bottom-tab nav HOME: '/', + // Public marketing landing (ui-phase-13) — served AT '/' for a guest via a middleware rewrite + // (never a redirect, so the URL/SEO canonical stays '/'). An authenticated hit on this path + // itself redirects to '/'. Never add ROUTES.HOME ('/') to PUBLIC_PATHS — see the note there. + WELCOME: '/welcome', // First-login "who is care for?" flow (A3→A4); re-enterable from the patient list. ONBOARDING: '/onboarding', // Search & discovery (f6) — C1 filter screen; the Home search bar + category tiles navigate here. @@ -179,8 +183,12 @@ export const notificationsPath = (role: 'customer' | 'nurse' | 'admin'): string return ROUTES.NOTIFICATIONS; }; -/** Paths (without locale prefix) that bypass auth in middleware. */ -export const PUBLIC_PATHS: string[] = [ROUTES.LOGIN, ROUTES.TERMS, ROUTES.PRIVACY]; +/** + * Paths (without locale prefix) that bypass auth in middleware. **Never add `ROUTES.HOME` ('/') + * here** — the middleware matches with `startsWith`, so `'/'` would silently make every route + * public. The guest-facing root is handled by an exact-match rewrite in `middleware.ts` instead. + */ +export const PUBLIC_PATHS: string[] = [ROUTES.LOGIN, ROUTES.TERMS, ROUTES.PRIVACY, ROUTES.WELCOME]; /** * Query param the middleware appends when it redirects an unauthenticated deep link to login diff --git a/dev/shared-working-context/frontend/requests/for-backend.md b/dev/shared-working-context/frontend/requests/for-backend.md index 560888e..20ba620 100644 --- a/dev/shared-working-context/frontend/requests/for-backend.md +++ b/dev/shared-working-context/frontend/requests/for-backend.md @@ -1076,3 +1076,40 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a - **Proposed shape:** see **Need** above; masked phone follows the existing `maskIranMobile` convention (`"0912•••1234"`, first-4/last-4) — never the full number. - **Status:** open + +## REQ-066 — Public (anonymous), rate-limited nurse-search read for guest browse (tier c) — filed by ui-phase-13 — 2026-07-19 +- **Need:** An **unauthenticated** variant of the f6 nurse-search read — proposed + `GET api/v1/public/nurse_search?category_id=&province_id=&city_id=&district_id=&gender=&page=&pageSize=` + (no auth header required) → the same shape `search/nurses` already returns + (`NurseSearchResultDto[]` + pagination), preserving every existing invariant: **verified-only** + results (the `is_searchable` gate f7 already enforces), no customer-context fields (no distance- + from-my-address, no "your last booking with"), and **rate-limited per IP** (this is the one read in + the whole app reachable with zero login, so it is also the one most exposed to scraping/abuse). +- **Why:** ui-phase-13 framed three guest-browse tiers (§3.1 of the phase file) and the product + decision (recorded in `product/notes/open-questions.md`) was to ship (a) landing + (b) static public + pages **now** and defer (c) guest search + public nurse profiles as a REQ-gated follow-up — this + route is what a future phase would need to build a real (not placeholder) guest search results + screen. Nothing in this phase calls it; it is filed so the decision and its cost are on record, not + guessed at when someone picks tier (c) up later. +- **Proposed shape:** identical response shape to the existing (authenticated) `search/nurses`, served + from a route that bypasses the auth middleware entirely (a new, explicitly public controller/route — + not a "make search/nurses optionally-authenticated" flag, which would risk quietly widening what an + authenticated call can already see). +- **Status:** open + +## REQ-067 — Public (anonymous), privacy-reviewed nurse-profile read for guest browse (tier c) — filed by ui-phase-13 — 2026-07-19 +- **Need:** An **unauthenticated** nurse-profile read, deliberately narrower than the authenticated + `GET nurses/{id}/profile` (REQ-012): `{ nurseId, displayName, avatarUrl, bio, yearsExperience, + averageRating, totalReviews, isVerified, credentialTypes: string[], services: [{ variantId, + displayName, priceIrr, priceUnit, sessionCount? }] }`. **Never** phone, exact coverage + area/district, document/credential numbers, or patient-identifying data from any linked booking — + this is the field set a logged-out stranger may see, which is why it needs an explicit privacy + review before it's built, not an assumption that "the authenticated shape minus a filter" is safe. +- **Why:** same tier-(c) framing as REQ-066 — a guest who found a nurse via public search needs a + profile to land on before the "درخواست رزرو" CTA hands them to `/login`. Filed as a proposal only; + the phase explicitly defers building any guest-facing profile screen against it (§3.4 of + `dev/post-phase/ui/ui-phase-13-public-front-door.md`). +- **Proposed shape:** as above. A privacy sign-off on the exact field list (not just an engineering + guess) should happen before this is implemented — flagging here so it isn't skipped when tier (c) + is picked up. +- **Status:** open diff --git a/dev/shared-working-context/reports/ui-phase-13-report.md b/dev/shared-working-context/reports/ui-phase-13-report.md new file mode 100644 index 0000000..49f1dc6 --- /dev/null +++ b/dev/shared-working-context/reports/ui-phase-13-report.md @@ -0,0 +1,162 @@ +# 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). diff --git a/product/business/10-payouts.html b/product/business/10-payouts.html index 90fb555..e0d5e38 100644 --- a/product/business/10-payouts.html +++ b/product/business/10-payouts.html @@ -38,6 +38,28 @@ <li><strong>MVP:</strong> weekly batches; EVV + dispute-window gating; per-session accrual for engagements; <code>nurse_clawbacks</code> with next-batch netting and write-off; unique booking↔payout link; <code>iranian_holidays</code>-aware scheduling; verified-IBAN payouts with reconciliation references.</li> <li><strong>DEFERRED:</strong> on-demand / instant nurse withdrawal; per-nurse configurable payout frequency; automated clawback recovery beyond netting.</li> </ul> +<h2 id="d1-rules-confirmed-in-build-backend-b13">(d1) Rules confirmed in build (backend b13) <a class="anchor" href="#d1-rules-confirmed-in-build-backend-b13" aria-hidden="true">#</a></h2> +<p>These fill gaps the requirements left open; the payout engine was built to them:</p> +<ul> +<li>**Clawback netting recovers <em>whole</em> clawbacks up to a batch's earnings.** A <code>nurse_clawbacks</code> row is atomic —</li> +</ul> +<p> it is recovered in full or not at all — so a batch nets the largest set of whole pending clawbacks (oldest first) that fits within the nurse's earnings that week; <code>net_amount = gross_earnings − clawback_applied ≥ 0</code> (never negative). A single clawback <strong>larger than a batch's earnings</strong> stays fully <code>pending</code> and recovers from a later, larger batch (it is not partially recovered). The recovery is a real ledger movement (<code>DEBIT nurse_payable / CREDIT nurse_clawback_receivable</code>), not just a status flag, so the derived balances reconcile.</p> +<ul> +<li><strong>A booking with an active (non-failed/-rejected) refund is held out of payout batches</strong> — its money was (partly)</li> +</ul> +<p> reversed, so paying its frozen <code>nurse_payout_amount</code> would overpay. It is excluded until resolved (this is the operational reading of "no open dispute", since there is no separate dispute table yet).</p> +<ul> +<li><strong>PAYA vs SATNA</strong> is chosen per payout by the <code>payout_satna_threshold_irr</code> config (SATNA for net amounts at/above</li> +</ul> +<p> the threshold, else PAYA).</p> +<ul> +<li><strong>Optional BNPL settlement gate:</strong> <code>require_bnpl_settlement_for_payout</code> (config, <strong>default off</strong>) — when on, a</li> +</ul> +<p> BNPL-paid booking is payout-eligible only once its provider settlement (<code>settled_at</code>) is received.</p> +<ul> +<li>The weekly <strong>cron trigger is DEFERRED</strong> — batches are admin-triggered; the cadence lives in</li> +</ul> +<p> <code>nurse_payout_interval_days</code> (default 7) for the future scheduler.</p> <h2 id="d-supporting-database-entities">(d) Supporting database entities <a class="anchor" href="#d-supporting-database-entities" aria-hidden="true">#</a></h2> <p><code>nurse_payout_batches</code>, <code>nurse_payouts</code> (with <code>gross_earnings_irr</code>, <code>clawback_applied_irr</code>, <code>net_amount_irr</code>, <code>iban_snapshot</code>), <code>nurse_payout_booking_links</code> (unique per booking), <strong><code>nurse_clawbacks</code></strong>, <code>ledger_entries</code>, <strong><code>iranian_holidays</code></strong>, <code>bookings.dispute_window_ends_at</code>, <code>nurse_bank_accounts</code>.</p> <blockquote><p><strong>Related:</strong> Data model — <a href="../data-model/07-payouts.html">Payouts</a>.</p> diff --git a/product/notes/open-questions.html b/product/notes/open-questions.html index c1c3723..31308b5 100644 --- a/product/notes/open-questions.html +++ b/product/notes/open-questions.html @@ -48,6 +48,33 @@ <p> <strong>(Under BNPL, who pays the nurse?)</strong> → Resolved. <strong>Balinyaar</strong> pays the nurse, on its own weekly schedule, from <code>gross − balinyaar_commission</code> — identical to a card booking; the BNPL commission is a platform expense, never the nurse's. See <a href="../payments/cancellation-and-payout.html">Q2 — who pays the nurse & when</a>.</p> <hr> <p>See also the launch-blocking items to confirm with counsel / providers: <a href="../research/go-to-market.html">research open questions</a> and <a href="../data-model/index.html">data-model open items</a>.</p> +<hr> +<h2 id="decided-public-guest-browse-depth-ui-phase-13-2026-07-19">Decided — public guest-browse depth (ui-phase-13, 2026-07-19) <a class="anchor" href="#decided-public-guest-browse-depth-ui-phase-13-2026-07-19" aria-hidden="true">#</a></h2> +<p><strong>Question:</strong> how deep should the anonymous (logged-out) web experience go, now that the entire public surface is a bare <code>/login</code>?</p> +<p>Three tiers were framed:</p> +<ul> +<li><strong>(a) Landing only</strong> — a marketing page at <code>/</code>; zero data, zero new endpoints.</li> +<li><strong>(b) Landing + static public pages</strong> — (a) plus category/how-it-works/trust content; still zero</li> +</ul> +<p> data (a static i18n category grid, not the live catalog).</p> +<ul> +<li><strong>(c) Guest search + public nurse profiles</strong> — read-only anonymous variants of the search-results</li> +</ul> +<p> and nurse-profile screens. Requires <strong>public read endpoints that don't exist today</strong> (search and profile reads sit behind the auth middleware + cookie-bearing <code>clientFetch</code>) plus a privacy review of which nurse fields may be shown logged-out (never phone, exact address, or document data).</p> +<p><strong>Decision: ship (a)+(b) now; tier (c) is deferred as a REQ-gated follow-up</strong>, not built against placeholder endpoints. Rationale:</p> +<ul> +<li>(a)+(b) already close the biggest gap — a family can see the brand, the trust story (identity/</li> +</ul> +<p> license/INO/bank verification), the escrow guarantee, and the service categories before creating an account, all with <strong>zero new backend surface</strong>.</p> +<ul> +<li>(c) is a materially bigger commitment: a new anonymous, rate-limited search read and a</li> +</ul> +<p> privacy-reviewed public nurse-profile shape are backend work with real abuse/PII exposure considerations (scraping, competitor intel, nurse safety) — the kind of decision that shouldn't be made by inferring it from a frontend phase.</p> +<ul> +<li>What (c) would need, if approved later: an anonymous <code>nurse_search</code> read (verified-only invariant</li> +</ul> +<p> preserved, rate-limited, no customer-context fields) and a privacy-reviewed public nurse-profile read (display name, photo, verified-badge state, rating aggregate, service/price rows only — never phone, exact coverage area, or document data). Filed as REQ-066/REQ-067 in <a href="../../dev/shared-working-context/frontend/requests/for-backend.html">for-backend.md</a>; guest-search routes and the guest→login handoff at the booking CTA stay unbuilt until those land.</p> +<p>Implemented as: a public landing rewritten in at <code>/</code> (middleware rewrite, not a redirect, so the URL/ canonical stays <code>/</code>) with hero, static category grid, how-it-works, a trust/verification explainer, a nurse-recruitment CTA, and a footer — see <a href="../../dev/shared-working-context/reports/ui-phase-13-report.html">ui-phase-13-report.md</a> for the full build record.</p> <a class="back-to-top" href="#">↑ Back to top</a> </div></main> </div> diff --git a/product/notes/open-questions.md b/product/notes/open-questions.md index e60c003..7140f71 100644 --- a/product/notes/open-questions.md +++ b/product/notes/open-questions.md @@ -45,3 +45,41 @@ research and built into the model. See also the launch-blocking items to confirm with counsel / providers: [research open questions](../research/go-to-market.md) and [data-model open items](../data-model/index.md). + +--- + +## Decided — public guest-browse depth (ui-phase-13, 2026-07-19) + +**Question:** how deep should the anonymous (logged-out) web experience go, now that the entire +public surface is a bare `/login`? + +Three tiers were framed: +- **(a) Landing only** — a marketing page at `/`; zero data, zero new endpoints. +- **(b) Landing + static public pages** — (a) plus category/how-it-works/trust content; still zero + data (a static i18n category grid, not the live catalog). +- **(c) Guest search + public nurse profiles** — read-only anonymous variants of the search-results + and nurse-profile screens. Requires **public read endpoints that don't exist today** (search and + profile reads sit behind the auth middleware + cookie-bearing `clientFetch`) plus a privacy review + of which nurse fields may be shown logged-out (never phone, exact address, or document data). + +**Decision: ship (a)+(b) now; tier (c) is deferred as a REQ-gated follow-up**, not built against +placeholder endpoints. Rationale: +- (a)+(b) already close the biggest gap — a family can see the brand, the trust story (identity/ + license/INO/bank verification), the escrow guarantee, and the service categories before creating + an account, all with **zero new backend surface**. +- (c) is a materially bigger commitment: a new anonymous, rate-limited search read and a + privacy-reviewed public nurse-profile shape are backend work with real abuse/PII exposure + considerations (scraping, competitor intel, nurse safety) — the kind of decision that shouldn't be + made by inferring it from a frontend phase. +- What (c) would need, if approved later: an anonymous `nurse_search` read (verified-only invariant + preserved, rate-limited, no customer-context fields) and a privacy-reviewed public nurse-profile + read (display name, photo, verified-badge state, rating aggregate, service/price rows only — never + phone, exact coverage area, or document data). Filed as REQ-066/REQ-067 in + [for-backend.md](../../dev/shared-working-context/frontend/requests/for-backend.md); guest-search + routes and the guest→login handoff at the booking CTA stay unbuilt until those land. + +Implemented as: a public landing rewritten in at `/` (middleware rewrite, not a redirect, so the URL/ +canonical stays `/`) with hero, static category grid, how-it-works, a trust/verification explainer, +a nurse-recruitment CTA, and a footer — see +[ui-phase-13-report.md](../../dev/shared-working-context/reports/ui-phase-13-report.md) for the +full build record. diff --git a/server/src/API/Baya.Web.Api/appsettings.Development.json b/server/src/API/Baya.Web.Api/appsettings.Development.json index 2ca0064..70778fe 100644 --- a/server/src/API/Baya.Web.Api/appsettings.Development.json +++ b/server/src/API/Baya.Web.Api/appsettings.Development.json @@ -1,7 +1,7 @@ { "ConnectionStrings": { - "SqlServer": "Server=87.107.152.16,1433;Database=Baya;User Id=sa;Password=N8@s5Taw1zWeh@#Hm;TrustServerCertificate=True;Encrypt=False;", - "logDb":"Server=87.107.152.16,1433;Database=Baya_Logs;User Id=sa;Password=N8@s5Taw1zWeh@#Hm;TrustServerCertificate=True;Encrypt=False;" + "SqlServer": "Server=87.107.152.16,1433;Database=Baya;User Id=hamid_root_un_sa;Password=N8@s5Taw1zWeh@#Hm;TrustServerCertificate=True;Encrypt=False;", + "logDb":"Server=87.107.152.16,1433;Database=Baya_Logs;User Id=hamid_root_un_sa;Password=N8@s5Taw1zWeh@#Hm;TrustServerCertificate=True;Encrypt=False;" }, "IdentitySettings": { "SecretKey": "SET_VIA_USER_SECRETS_OR_ENV",