ui phase 13
This commit is contained in:
+21
-5
@@ -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 <html>, 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 <html lang/dir> + 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 `<title>` 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').
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} بالینیار"
|
||||
}
|
||||
}
|
||||
|
||||
+39
-24
@@ -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|.*\\..*).*)'],
|
||||
};
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
User-agent: *
|
||||
Disallow: /private/
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
@@ -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}`]),
|
||||
),
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user