From 94fdcbe0d1382e321165be030d8b68c511801bea Mon Sep 17 00:00:00 2001 From: hamid Date: Thu, 2 Jul 2026 01:19:21 +0330 Subject: [PATCH] frontend phase 0: app shells, design system & data/contract patterns Turn the starter into the Balinyaar foundation for the three actor experiences and lock in the patterns later phases copy. - Cleanup: remove toastDemo namespace, placeholder home page, and the two dead icons; fix BottomBar to use usePathname (locale-aware active tab). - Three actor shells under (private-routes), no layout above [locale]: customer (customer) group with the 5-tab bottom nav; nurse (/nurse) and admin (/admin) on the shared sidebar engine. Role model via constants/roles + useActorRole (defaults to customer until roles land in f1-b2). - services/{domain} reference (patients) with a mock behind a config seam, hierarchical query keys, deliberate staleTime, and mutation invalidation; shared ApiEnvelope/Paginated wire types + unwrap() in lib/api/types. - Money (integer-safe IRR/Toman) + Shamsi-date utils; toEnglishDigits helper. - Shared composites, each tested: OtpInput, PhoneNumberField, StepperHeader, StatusChip, PlaceholderScreen. - i18n: seed nav/common/shell/patients in both locales; document namespace conventions. Update client/CLAUDE.md Project Structure + fix ColorSchemeScript doc drift. Add phase report, STATUS, and REQ-001 (envelope/casing/pagination). Gate: npm run check + test:ci green (72 tests); build green with NEXT_PUBLIC_API_URL. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/CLAUDE.md | 88 +++++++++--- client/messages/en.json | 49 +++++-- client/messages/fa.json | 49 +++++-- .../(customer)/bookings/page.tsx | 8 ++ .../(private-routes)/(customer)/layout.tsx | 12 ++ .../(private-routes)/(customer)/page.tsx | 8 ++ .../(customer)/patients/page.tsx | 101 +++++++++++++ .../(customer)/profile/page.tsx | 8 ++ .../(customer)/wallet/page.tsx | 8 ++ .../(private-routes)/admin/layout.tsx | 11 ++ .../admin/notifications/page.tsx | 8 ++ .../[locale]/(private-routes)/admin/page.tsx | 8 ++ .../(private-routes)/admin/users/page.tsx | 8 ++ .../(private-routes)/nurse/layout.tsx | 11 ++ .../[locale]/(private-routes)/nurse/page.tsx | 8 ++ .../nurse/verification/page.tsx | 8 ++ .../(private-routes)/nurse/visits/page.tsx | 8 ++ .../app/[locale]/(private-routes)/page.tsx | 13 -- .../src/components/OtpInput/OtpInput.test.tsx | 49 +++++++ client/src/components/OtpInput/OtpInput.tsx | 135 ++++++++++++++++++ client/src/components/OtpInput/index.tsx | 4 + .../PhoneNumberField.test.tsx | 46 ++++++ .../PhoneNumberField/PhoneNumberField.tsx | 50 +++++++ .../src/components/PhoneNumberField/index.tsx | 5 + .../PlaceholderScreen.test.tsx | 32 +++++ .../PlaceholderScreen/PlaceholderScreen.tsx | 36 +++++ .../components/PlaceholderScreen/index.tsx | 4 + .../components/StatusChip/StatusChip.test.tsx | 27 ++++ .../src/components/StatusChip/StatusChip.tsx | 50 +++++++ client/src/components/StatusChip/index.tsx | 4 + .../StepperHeader/StepperHeader.test.tsx | 25 ++++ .../StepperHeader/StepperHeader.tsx | 30 ++++ client/src/components/StepperHeader/index.tsx | 4 + .../src/components/common/AppIcon/config.ts | 26 ++++ .../common/AppIcon/icons/CurrencyIcon.tsx | 29 ---- .../common/AppIcon/icons/YellowPlanIcon.tsx | 125 ---------------- client/src/components/index.tsx | 12 +- client/src/constants/index.ts | 1 + client/src/constants/roles.ts | 15 ++ client/src/constants/routes.ts | 16 +++ client/src/hooks/auth.ts | 18 +++ client/src/layout/AdminLayout.tsx | 37 +++++ client/src/layout/CustomerLayout.tsx | 64 +++++++++ client/src/layout/NurseLayout.tsx | 38 +++++ client/src/layout/components/BottomBar.tsx | 55 +++++-- client/src/layout/index.tsx | 5 +- client/src/lib/api/types.ts | 44 ++++++ client/src/services/auth/types.ts | 5 + .../src/services/patients/apis/clientApi.ts | 29 ++++ client/src/services/patients/apis/index.ts | 10 ++ client/src/services/patients/apis/mockApi.ts | 45 ++++++ client/src/services/patients/constants.ts | 8 ++ .../services/patients/hooks/useAddPatient.ts | 20 +++ .../services/patients/hooks/usePatients.ts | 17 +++ client/src/services/patients/index.ts | 2 + client/src/services/patients/keys.ts | 13 ++ client/src/services/patients/types.ts | 33 +++++ client/src/utils/date.ts | 34 +++++ client/src/utils/index.ts | 2 + client/src/utils/money.test.ts | 48 +++++++ client/src/utils/money.ts | 41 ++++++ client/src/utils/text.ts | 21 +++ dev/shared-working-context/frontend/STATUS.md | 15 +- .../frontend/requests/for-backend.md | 19 ++- .../reports/frontend-phase-0-report.md | 97 +++++++++++++ 65 files changed, 1632 insertions(+), 227 deletions(-) create mode 100644 client/src/app/[locale]/(private-routes)/(customer)/bookings/page.tsx create mode 100644 client/src/app/[locale]/(private-routes)/(customer)/layout.tsx create mode 100644 client/src/app/[locale]/(private-routes)/(customer)/page.tsx create mode 100644 client/src/app/[locale]/(private-routes)/(customer)/patients/page.tsx create mode 100644 client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx create mode 100644 client/src/app/[locale]/(private-routes)/(customer)/wallet/page.tsx create mode 100644 client/src/app/[locale]/(private-routes)/admin/layout.tsx create mode 100644 client/src/app/[locale]/(private-routes)/admin/notifications/page.tsx create mode 100644 client/src/app/[locale]/(private-routes)/admin/page.tsx create mode 100644 client/src/app/[locale]/(private-routes)/admin/users/page.tsx create mode 100644 client/src/app/[locale]/(private-routes)/nurse/layout.tsx create mode 100644 client/src/app/[locale]/(private-routes)/nurse/page.tsx create mode 100644 client/src/app/[locale]/(private-routes)/nurse/verification/page.tsx create mode 100644 client/src/app/[locale]/(private-routes)/nurse/visits/page.tsx delete mode 100644 client/src/app/[locale]/(private-routes)/page.tsx create mode 100644 client/src/components/OtpInput/OtpInput.test.tsx create mode 100644 client/src/components/OtpInput/OtpInput.tsx create mode 100644 client/src/components/OtpInput/index.tsx create mode 100644 client/src/components/PhoneNumberField/PhoneNumberField.test.tsx create mode 100644 client/src/components/PhoneNumberField/PhoneNumberField.tsx create mode 100644 client/src/components/PhoneNumberField/index.tsx create mode 100644 client/src/components/PlaceholderScreen/PlaceholderScreen.test.tsx create mode 100644 client/src/components/PlaceholderScreen/PlaceholderScreen.tsx create mode 100644 client/src/components/PlaceholderScreen/index.tsx create mode 100644 client/src/components/StatusChip/StatusChip.test.tsx create mode 100644 client/src/components/StatusChip/StatusChip.tsx create mode 100644 client/src/components/StatusChip/index.tsx create mode 100644 client/src/components/StepperHeader/StepperHeader.test.tsx create mode 100644 client/src/components/StepperHeader/StepperHeader.tsx create mode 100644 client/src/components/StepperHeader/index.tsx delete mode 100644 client/src/components/common/AppIcon/icons/CurrencyIcon.tsx delete mode 100644 client/src/components/common/AppIcon/icons/YellowPlanIcon.tsx create mode 100644 client/src/constants/roles.ts create mode 100644 client/src/layout/AdminLayout.tsx create mode 100644 client/src/layout/CustomerLayout.tsx create mode 100644 client/src/layout/NurseLayout.tsx create mode 100644 client/src/lib/api/types.ts create mode 100644 client/src/services/patients/apis/clientApi.ts create mode 100644 client/src/services/patients/apis/index.ts create mode 100644 client/src/services/patients/apis/mockApi.ts create mode 100644 client/src/services/patients/constants.ts create mode 100644 client/src/services/patients/hooks/useAddPatient.ts create mode 100644 client/src/services/patients/hooks/usePatients.ts create mode 100644 client/src/services/patients/index.ts create mode 100644 client/src/services/patients/keys.ts create mode 100644 client/src/services/patients/types.ts create mode 100644 client/src/utils/date.ts create mode 100644 client/src/utils/money.test.ts create mode 100644 client/src/utils/money.ts create mode 100644 dev/shared-working-context/reports/frontend-phase-0-report.md diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 52542c0..2679bd7 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -114,18 +114,42 @@ client/ │ └── [locale]/ │ ├── layout.tsx # ROOT RSC: renders + fonts + setRequestLocale + NextIntlClientProvider + ThemeProvider + AuthProvider (seeded via getServerAuthState) │ ├── (private-routes)/ - │ │ ├── layout.tsx # 'use client' — wraps PrivateLayout - │ │ └── page.tsx + │ │ ├── layout.tsx # 'use client' — wraps PrivateLayout (auth shell) + │ │ ├── (customer)/ # Customer (family) app — mobile-first, bottom-tab nav; no URL segment + │ │ │ ├── layout.tsx # 'use client' — wraps CustomerLayout + │ │ │ ├── page.tsx # / (home) + │ │ │ ├── bookings/page.tsx # /bookings + │ │ │ ├── patients/page.tsx # /patients — reference services/{domain} + Query screen + │ │ │ ├── wallet/page.tsx # /wallet + │ │ │ └── profile/page.tsx # /profile + │ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell + │ │ │ ├── layout.tsx # 'use client' — wraps NurseLayout + │ │ │ ├── page.tsx # /nurse (dashboard) + │ │ │ ├── verification/page.tsx # /nurse/verification + │ │ │ └── visits/page.tsx # /nurse/visits (EVV) + │ │ └── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell + │ │ ├── layout.tsx # 'use client' — wraps AdminLayout + │ │ ├── page.tsx # /admin (overview) + │ │ ├── users/page.tsx # /admin/users + │ │ └── notifications/page.tsx # /admin/notifications │ └── (public-routes)/ │ └── layout.tsx # 'use client' — wraps PublicLayout ├── components/ # Shared UI components (each with .test.tsx if imported >1 place) + │ ├── PlaceholderScreen/ # Empty-state scaffold for not-yet-built screens + │ ├── OtpInput/ # OTP code input (auto-advance, paste, RTL-safe) + │ ├── PhoneNumberField/ # Iranian mobile field (digit-normalizing, LTR-in-RTL) + │ ├── StepperHeader/ # Progress header for onboarding/verification flows + │ └── StatusChip/ # Semantic status chip (verified/pending/rejected/…) off --bal-* tokens ├── i18n/ │ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa' │ └── request.ts # getRequestConfig — loads messages/${locale}.json ├── layout/ - │ ├── PrivateLayout.tsx # 'use client' — authenticated shell; uses useTranslations('nav') + │ ├── PrivateLayout.tsx # authenticated wrapper (passthrough today); actor chrome lives in the shells below + │ ├── CustomerLayout.tsx # 'use client' — customer shell: TopBar + BottomBar (5-tab); useTranslations('nav') + │ ├── NurseLayout.tsx # 'use client' — nurse shell via TopBarAndSideBarLayout; useTranslations('nav') + │ ├── AdminLayout.tsx # 'use client' — admin shell via TopBarAndSideBarLayout (persistent sidebar) │ ├── PublicLayout.tsx # unauthenticated shell - │ ├── TopBarAndSideBarLayout.tsx # 'use client' — TopBar + SideBar composition + │ ├── TopBarAndSideBarLayout.tsx # 'use client' — TopBar + SideBar composition (nurse/admin engine) │ ├── config.ts │ ├── index.ts │ └── components/ @@ -139,6 +163,7 @@ client/ │ ├── api/ │ │ ├── client.ts # clientFetch — throws ApiError on error; use in hooks/client components │ │ ├── server.ts # serverFetch — throws ApiError on error; use in RSCs/Server Actions + │ │ ├── types.ts # ApiEnvelope + unwrap(), Paginated, PageParams — shared wire types │ │ └── errors.ts # ApiError class (status, message, code) │ ├── auth/ │ │ ├── token.ts # decodeJwtPayload / isTokenAlive — edge-safe, shared with middleware (no next/headers) @@ -152,28 +177,33 @@ client/ │ ├── client.ts # getClientCookie, setClientCookie, deleteClientCookie │ └── index.ts # Re-exports constants ONLY (never server/client) ├── services/ # Domain services — no top-level barrel; import directly from the file + │ ├── auth/ # Reference domain (login/logout/currentUser) + │ ├── patients/ # Reference domain for the mock-behind-a-seam pattern (§ services pattern) │ └── {domain}/ - │ ├── types.ts # Request/response types for this domain - │ ├── keys.ts # React Query key factory + │ ├── types.ts # Request/response types + the domain's Api interface (the seam) + │ ├── keys.ts # React Query key factory (hierarchical) + │ ├── constants.ts # Mock toggle + staleTime (when the domain has a mock) │ ├── apis/ - │ │ ├── clientApi.ts # Namespace object wrapping clientFetch calls - │ │ └── serverApi.ts # Namespace object wrapping serverFetch calls (only when needed) + │ │ ├── clientApi.ts # Real impl wrapping clientFetch (unwraps ApiEnvelope via unwrap()) + │ │ ├── mockApi.ts # In-memory impl behind the same interface (until the endpoint lands) + │ │ ├── serverApi.ts # serverFetch calls (only when an RSC needs it) + │ │ └── index.ts # Selects real vs mock by config — the seam hooks import │ └── hooks/ - │ └── use{Action}.ts # One hook per file — useQuery or useMutation + │ └── use{Action}.ts # One hook per file — useQuery (deliberate staleTime) or useMutation (invalidates) ├── context/ # React context providers │ └── auth/ # AuthContext — AuthProvider (server-seeded) + reducer + useAuth ├── theme/ - │ ├── ThemeProvider.tsx # MuiThemeProvider wrapper + ColorSchemeScript + ColorSchemeCookieSync + │ ├── ThemeProvider.tsx # MuiThemeProvider wrapper (RTL cache) + ColorSchemeCookieSync │ ├── colors.ts # BRAND, LIGHT_PALETTE, DARK_PALETTE │ ├── light.ts / dark.ts # LIGHT_THEME / DARK_THEME ThemeOptions (consumed by theme.ts) │ ├── direction.ts # getDirection(locale) → 'ltr' | 'rtl' │ ├── theme.ts # APP_THEME_LTR / APP_THEME_RTL (static, created once) │ ├── tokens.css # CSS custom properties — [data-mui-color-scheme] selectors │ ├── typography.ts # TYPOGRAPHY_LTR (Space Grotesk) / TYPOGRAPHY_RTL (Mikhak) - │ └── index.ts # Public re-exports (incl. ColorSchemeScript, ThemeProvider, getDirection) - ├── constants/ # App-wide constants (routes, events, etc.) - ├── hooks/ - ├── utils/ + │ └── index.ts # Public re-exports (ThemeProvider, getDirection, APP_THEME_*) — note: no ColorSchemeScript is exported/rendered today (doc drift below) + ├── constants/ # App-wide constants (routes.ts w/ actor paths, roles.ts, headers.ts) + ├── hooks/ # incl. auth.ts → useIsAuthenticated / useActorRole (role-aware chrome) + ├── utils/ # incl. money.ts (IRR/Toman, integer-safe) + date.ts (Shamsi display) + toEnglishDigits └── config.ts ``` @@ -227,8 +257,14 @@ async function MyServerComponent() { ``` **Established namespaces and where they're used:** -- `'nav'` — `PrivateLayout.tsx` (sidebar nav items) -- `'common'` — `DarkModeButton.tsx` (dark/light mode labels) +- `'nav'` — the actor shells (`CustomerLayout`/`NurseLayout`/`AdminLayout`) build their nav from here +- `'common'` — `DarkModeButton.tsx` (dark/light labels), shared words (loading, retry, currency_toman, …) +- `'shell'` — actor-shell titles + the not-yet-built placeholder body +- `'patients'` — the reference services/{domain} demo screen + +**Namespace conventions for the phases to come** (seed each when its feature lands, in both locale +files): `auth`, `onboarding`, `verification`, `search`, `booking`, `payment`, `bnpl`, `reviews`, +`notifications`, `admin`. 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. @@ -451,6 +487,26 @@ Central fetch primitives live in `src/lib/api/`: **Domain API calls** live in `src/services/{domain}/apis/clientApi.ts` (or `serverApi.ts`). Never call raw `fetch()` directly. +### The `services/{domain}` reference pattern (copy `auth` / `patients`) + +Every domain follows the same shape: `types.ts` (wire types + the domain's `Api` interface), `keys.ts` +(hierarchical React Query key factory), `apis/` (implementations + a selecting `index.ts`), `hooks/` +(one hook per file), and a barrel `index.ts` that re-exports **hooks only** (never `types`/`keys`/`apis`). + +- **Caching is deliberate:** set a `staleTime` on reads so revisiting a screen doesn't refetch; mutations + **invalidate** the affected list key (`queryClient.invalidateQueries`) or `setQueryData` — never leave the + cache stale. See `services/patients/hooks/*`. +- **Mock behind a seam:** when the backend endpoint isn't live, implement the domain's `Api` interface + twice — a real `clientApi.ts` and an in-memory `mockApi.ts` — and select in `apis/index.ts` by a config + flag (`USE_{DOMAIN}_MOCK`). Hooks import the selected `api`; the swap is one line. Record every mock in + `dev/shared-working-context/reports/mocks-registry.md`. +- **The wire envelope:** the server wraps responses in `ApiEnvelope` (`{ isSuccess, statusCode, + message, requestId, data }`, camelCase — see `lib/api/types.ts`). `clientFetch` returns the raw body, so + a real `clientApi` reads the payload via `unwrap()`. Types are derived from `dev/contracts/` + + `dev/contracts/openapi/swagger.v1.json`, mirroring the wire exactly. +- **Money & dates:** format via `@/utils` — `formatIrrToToman`/`formatIrr`/`parseIrr` (IRR strings, integer-safe + BigInt) and `formatShamsiDate`/`formatShamsiDateTime` (UTC ISO → Persian calendar). Money is never a float. + --- ## Auth Cookies & session state diff --git a/client/messages/en.json b/client/messages/en.json index bc4280d..7a8e131 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -1,24 +1,47 @@ { "nav": { "home": "Home", - "login": "Login" + "bookings": "Bookings", + "patients": "Patients", + "wallet": "Wallet", + "profile": "Profile", + "dashboard": "Dashboard", + "verification": "Verification", + "visits": "Visits", + "admin": "Admin", + "overview": "Overview", + "users": "Users", + "notifications": "Notifications", + "login": "Login", + "logout": "Logout" }, "common": { "dark_mode": "Dark mode", "light_mode": "Light mode", "direction_ltr": "Switch to LTR", - "direction_rtl": "Switch to RTL" + "direction_rtl": "Switch to RTL", + "coming_soon": "Coming soon", + "loading": "Loading…", + "retry": "Retry", + "add": "Add", + "cancel": "Cancel", + "currency_toman": "Toman" }, - "toastDemo": { - "title": "Toast Notifications Demo", - "subtitle": "Click a button to trigger each toast type", - "success_btn": "Success", - "error_btn": "Error", - "warning_btn": "Warning", - "info_btn": "Info", - "success_msg": "Profile saved successfully!", - "error_msg": "Failed to load data. Please try again.", - "warning_msg": "Your session will expire in 5 minutes.", - "info_msg": "A new version of the app is available." + "shell": { + "customer_app": "Family app", + "nurse_app": "Nurse view", + "admin_console": "Admin console", + "placeholder_body": "This area will be built in a later phase." + }, + "patients": { + "title": "Patients", + "subtitle": "A reference screen wired to the services/{domain} + React Query pattern (mocked data).", + "add": "Add patient", + "empty": "No patients yet. Add your first patient.", + "name_label": "Full name", + "gender_label": "Gender", + "gender_male": "Male", + "gender_female": "Female", + "added": "Patient added" } } diff --git a/client/messages/fa.json b/client/messages/fa.json index c2d074e..56a01be 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -1,24 +1,47 @@ { "nav": { "home": "خانه", - "login": "ورود" + "bookings": "رزروها", + "patients": "بیماران", + "wallet": "کیف‌پول", + "profile": "پروفایل", + "dashboard": "داشبورد", + "verification": "احراز هویت", + "visits": "ویزیت‌ها", + "admin": "مدیریت", + "overview": "نمای کلی", + "users": "کاربران", + "notifications": "اعلان‌ها", + "login": "ورود", + "logout": "خروج" }, "common": { "dark_mode": "حالت تاریک", "light_mode": "حالت روشن", "direction_ltr": "تغییر به چپ‌به‌راست", - "direction_rtl": "تغییر به راست‌به‌چپ" + "direction_rtl": "تغییر به راست‌به‌چپ", + "coming_soon": "به‌زودی", + "loading": "در حال بارگذاری…", + "retry": "تلاش مجدد", + "add": "افزودن", + "cancel": "انصراف", + "currency_toman": "تومان" }, - "toastDemo": { - "title": "نمایش اعلان‌های Toast", - "subtitle": "روی هر دکمه کلیک کنید تا نوع مربوطه نمایش داده شود", - "success_btn": "موفقیت", - "error_btn": "خطا", - "warning_btn": "هشدار", - "info_btn": "اطلاعات", - "success_msg": "پروفایل با موفقیت ذخیره شد!", - "error_msg": "بارگذاری اطلاعات ناموفق بود. لطفاً دوباره تلاش کنید.", - "warning_msg": "جلسه شما تا ۵ دقیقه دیگر منقضی می‌شود.", - "info_msg": "نسخه جدیدی از برنامه در دسترس است." + "shell": { + "customer_app": "اپلیکیشن خانواده", + "nurse_app": "نمای پرستار", + "admin_console": "کنسول مدیریت", + "placeholder_body": "این بخش در فازهای بعدی تکمیل می‌شود." + }, + "patients": { + "title": "بیماران", + "subtitle": "یک صفحهٔ مرجع که به الگوی services/{domain} و React Query متصل است (داده‌های آزمایشی).", + "add": "افزودن بیمار", + "empty": "هنوز بیماری ثبت نشده است. اولین بیمار را اضافه کنید.", + "name_label": "نام کامل", + "gender_label": "جنسیت", + "gender_male": "مرد", + "gender_female": "زن", + "added": "بیمار اضافه شد" } } diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/page.tsx new file mode 100644 index 0000000..d5bbd2e --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/page.tsx @@ -0,0 +1,8 @@ +import { getTranslations } from 'next-intl/server'; +import { PlaceholderScreen } from '@/components'; + +export default async function BookingsPage() { + const t = await getTranslations('nav'); + const tShell = await getTranslations('shell'); + return ; +} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/layout.tsx b/client/src/app/[locale]/(private-routes)/(customer)/layout.tsx new file mode 100644 index 0000000..88a1b6e --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/layout.tsx @@ -0,0 +1,12 @@ +'use client'; +import type { ReactNode } from 'react'; +import { CustomerLayout } from '@/layout'; + +/* + * Customer (family) route group — the primary mobile-first experience with the + * 5-tab bottom nav. A route group `(customer)` adds chrome without adding a URL + * segment, so these screens live at the app root (/, /bookings, /patients, …). + */ +export default function CustomerRouteLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/page.tsx new file mode 100644 index 0000000..a5fe37a --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/page.tsx @@ -0,0 +1,8 @@ +import { getTranslations } from 'next-intl/server'; +import { PlaceholderScreen } from '@/components'; + +export default async function CustomerHomePage() { + const t = await getTranslations('nav'); + const tShell = await getTranslations('shell'); + return ; +} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/patients/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/patients/page.tsx new file mode 100644 index 0000000..2fb876f --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/patients/page.tsx @@ -0,0 +1,101 @@ +'use client'; +import { ChangeEvent, useState } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; +import { Box, Chip, List, ListItem, ListItemText, MenuItem, Stack, TextField, Typography } from '@mui/material'; +import { useSnackbar } from 'notistack'; +import { AppButton, AppLoading } from '@/components'; +import { usePatients, useAddPatient } from '@/services/patients'; +import type { Gender } from '@/services/patients/types'; +import { formatShamsiDate } from '@/utils'; + +/** + * Reference screen for the services/{domain} + React Query pattern (§3.3). It reads the + * mocked patients list via usePatients (cached with a staleTime) and adds one via + * useAddPatient, whose onSuccess invalidates the list so the new row appears without a + * manual refetch — visible in the React Query Devtools. + */ +export default function PatientsPage() { + const t = useTranslations('patients'); + const locale = useLocale(); + const { enqueueSnackbar } = useSnackbar(); + + const { data, isLoading } = usePatients(); + const addPatient = useAddPatient(); + + const [name, setName] = useState(''); + const [gender, setGender] = useState('female'); + + const genderLabel = (value: Gender) => (value === 'male' ? t('gender_male') : t('gender_female')); + + const handleAdd = () => { + const fullName = name.trim(); + if (!fullName) return; + addPatient.mutate( + { fullName, gender }, + { + onSuccess: () => { + setName(''); + enqueueSnackbar(t('added'), { variant: 'success' }); + }, + } + ); + }; + + return ( + + + + {t('title')} + + + {t('subtitle')} + + + + + ) => setName(event.target.value)} + fullWidth + /> + ) => setGender(event.target.value as Gender)} + sx={{ minWidth: 140 }} + > + {t('gender_female')} + {t('gender_male')} + + + {t('add')} + + + + {isLoading ? ( + + ) : !data || data.items.length === 0 ? ( + {t('empty')} + ) : ( + + {data.items.map((patient) => ( + } + > + + + ))} + + )} + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx new file mode 100644 index 0000000..7558a49 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx @@ -0,0 +1,8 @@ +import { getTranslations } from 'next-intl/server'; +import { PlaceholderScreen } from '@/components'; + +export default async function ProfilePage() { + const t = await getTranslations('nav'); + const tShell = await getTranslations('shell'); + return ; +} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/wallet/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/wallet/page.tsx new file mode 100644 index 0000000..665acf2 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/wallet/page.tsx @@ -0,0 +1,8 @@ +import { getTranslations } from 'next-intl/server'; +import { PlaceholderScreen } from '@/components'; + +export default async function WalletPage() { + const t = await getTranslations('nav'); + const tShell = await getTranslations('shell'); + return ; +} diff --git a/client/src/app/[locale]/(private-routes)/admin/layout.tsx b/client/src/app/[locale]/(private-routes)/admin/layout.tsx new file mode 100644 index 0000000..8d1903e --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/admin/layout.tsx @@ -0,0 +1,11 @@ +'use client'; +import type { ReactNode } from 'react'; +import { AdminLayout } from '@/layout'; + +/* + * Admin / backoffice route group (/admin/…) — desktop-oriented ops console (f15) + * with a persistent sidebar. + */ +export default function AdminRouteLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/client/src/app/[locale]/(private-routes)/admin/notifications/page.tsx b/client/src/app/[locale]/(private-routes)/admin/notifications/page.tsx new file mode 100644 index 0000000..68be79a --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/admin/notifications/page.tsx @@ -0,0 +1,8 @@ +import { getTranslations } from 'next-intl/server'; +import { PlaceholderScreen } from '@/components'; + +export default async function AdminNotificationsPage() { + const t = await getTranslations('nav'); + const tShell = await getTranslations('shell'); + return ; +} diff --git a/client/src/app/[locale]/(private-routes)/admin/page.tsx b/client/src/app/[locale]/(private-routes)/admin/page.tsx new file mode 100644 index 0000000..94c85fd --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/admin/page.tsx @@ -0,0 +1,8 @@ +import { getTranslations } from 'next-intl/server'; +import { PlaceholderScreen } from '@/components'; + +export default async function AdminOverviewPage() { + const t = await getTranslations('nav'); + const tShell = await getTranslations('shell'); + return ; +} diff --git a/client/src/app/[locale]/(private-routes)/admin/users/page.tsx b/client/src/app/[locale]/(private-routes)/admin/users/page.tsx new file mode 100644 index 0000000..09d9861 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/admin/users/page.tsx @@ -0,0 +1,8 @@ +import { getTranslations } from 'next-intl/server'; +import { PlaceholderScreen } from '@/components'; + +export default async function AdminUsersPage() { + const t = await getTranslations('nav'); + const tShell = await getTranslations('shell'); + return ; +} diff --git a/client/src/app/[locale]/(private-routes)/nurse/layout.tsx b/client/src/app/[locale]/(private-routes)/nurse/layout.tsx new file mode 100644 index 0000000..bb5ff11 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/layout.tsx @@ -0,0 +1,11 @@ +'use client'; +import type { ReactNode } from 'react'; +import { NurseLayout } from '@/layout'; + +/* + * Nurse route group (/nurse/…) — its own shell (dashboard, verification, EVV visits). + * A real path segment keeps nurse screens namespaced under /nurse. + */ +export default function NurseRouteLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/client/src/app/[locale]/(private-routes)/nurse/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/page.tsx new file mode 100644 index 0000000..5460f1f --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/page.tsx @@ -0,0 +1,8 @@ +import { getTranslations } from 'next-intl/server'; +import { PlaceholderScreen } from '@/components'; + +export default async function NurseDashboardPage() { + const t = await getTranslations('nav'); + const tShell = await getTranslations('shell'); + return ; +} diff --git a/client/src/app/[locale]/(private-routes)/nurse/verification/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/verification/page.tsx new file mode 100644 index 0000000..e614552 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/verification/page.tsx @@ -0,0 +1,8 @@ +import { getTranslations } from 'next-intl/server'; +import { PlaceholderScreen } from '@/components'; + +export default async function NurseVerificationPage() { + const t = await getTranslations('nav'); + const tShell = await getTranslations('shell'); + return ; +} diff --git a/client/src/app/[locale]/(private-routes)/nurse/visits/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/visits/page.tsx new file mode 100644 index 0000000..f03fb89 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/visits/page.tsx @@ -0,0 +1,8 @@ +import { getTranslations } from 'next-intl/server'; +import { PlaceholderScreen } from '@/components'; + +export default async function NurseVisitsPage() { + const t = await getTranslations('nav'); + const tShell = await getTranslations('shell'); + return ; +} diff --git a/client/src/app/[locale]/(private-routes)/page.tsx b/client/src/app/[locale]/(private-routes)/page.tsx deleted file mode 100644 index 84dee0e..0000000 --- a/client/src/app/[locale]/(private-routes)/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { useTranslations } from 'next-intl' -import Box from '@mui/material/Box' -import Typography from '@mui/material/Typography' - -export default function HomePage() { - const t = useTranslations('toastDemo') - - return ( - - Balin yaar - - ) -} diff --git a/client/src/components/OtpInput/OtpInput.test.tsx b/client/src/components/OtpInput/OtpInput.test.tsx new file mode 100644 index 0000000..fe26042 --- /dev/null +++ b/client/src/components/OtpInput/OtpInput.test.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider } from '../../theme'; +import OtpInput from './OtpInput'; + +function Harness({ length = 4, onComplete }: { length?: number; onComplete?: (v: string) => void }) { + const [value, setValue] = useState(''); + return ( + + + + ); +} + +describe(' component', () => { + it('renders one box per length', () => { + render(); + expect(screen.getAllByRole('textbox')).toHaveLength(5); + }); + + it('accepts a digit into the first box', async () => { + const user = userEvent.setup(); + render(); + const boxes = screen.getAllByRole('textbox') as HTMLInputElement[]; + await user.type(boxes[0], '7'); + expect(boxes[0].value).toBe('7'); + }); + + it('normalizes Persian digits', async () => { + const user = userEvent.setup(); + render(); + const boxes = screen.getAllByRole('textbox') as HTMLInputElement[]; + await user.type(boxes[0], '۹'); + expect(boxes[0].value).toBe('9'); + }); + + it('calls onComplete with the full code once every box is filled', async () => { + const user = userEvent.setup(); + const onComplete = jest.fn(); + render(); + const boxes = screen.getAllByRole('textbox') as HTMLInputElement[]; + await user.type(boxes[0], '1'); + await user.type(boxes[1], '2'); + await user.type(boxes[2], '3'); + await user.type(boxes[3], '4'); + expect(onComplete).toHaveBeenCalledWith('1234'); + }); +}); diff --git a/client/src/components/OtpInput/OtpInput.tsx b/client/src/components/OtpInput/OtpInput.tsx new file mode 100644 index 0000000..680396b --- /dev/null +++ b/client/src/components/OtpInput/OtpInput.tsx @@ -0,0 +1,135 @@ +'use client'; +import { ChangeEvent, ClipboardEvent, FunctionComponent, KeyboardEvent, useEffect, useMemo, useRef } from 'react'; +import { Stack, TextField } from '@mui/material'; +import { digitsOnly } from '@/utils'; + +export interface OtpInputProps { + /** Number of digit boxes. */ + length?: number; + /** Controlled value — the digits entered so far. */ + value: string; + /** Emits the full concatenated value on every change. */ + onChange: (value: string) => void; + /** Fired once the value reaches `length` digits. */ + onComplete?: (value: string) => void; + disabled?: boolean; + autoFocus?: boolean; + error?: boolean; + /** Accessible label for the group. */ + 'aria-label'?: string; +} + +const DEFAULT_LENGTH = 5; +const BOX_SIZE = 48; + +/** + * One-time-code input: `length` single-digit boxes with auto-advance, backspace-to-previous, + * and paste distribution. Digits are normalized (Persian/Arabic → ASCII) and the group is + * forced LTR so codes read left-to-right even inside the RTL (`fa`) layout. + * @component OtpInput + */ +const OtpInput: FunctionComponent = ({ + length = DEFAULT_LENGTH, + value, + onChange, + onComplete, + disabled, + autoFocus, + error, + 'aria-label': ariaLabel, +}) => { + const inputRefs = useRef>([]); + + const chars = useMemo( + () => Array.from({ length }, (_, index) => value[index] ?? ''), + [value, length] + ); + + useEffect(() => { + if (autoFocus) inputRefs.current[0]?.focus(); + }, [autoFocus]); + + const focusBox = (index: number) => { + const target = inputRefs.current[index]; + if (target) { + target.focus(); + target.select(); + } + }; + + const emit = (next: string[]) => { + const joined = next.join(''); + onChange(joined); + if (next.every((char) => char !== '')) onComplete?.(joined); + }; + + const handleChange = (index: number, event: ChangeEvent) => { + const incoming = digitsOnly(event.target.value); + const next = [...chars]; + + if (!incoming) { + next[index] = ''; + emit(next); + return; + } + + if (incoming.length === 1) { + next[index] = incoming; + focusBox(index + 1); + } else { + // Paste / multi-char: fill sequentially from the current box. + for (let offset = 0; offset < incoming.length && index + offset < length; offset += 1) { + next[index + offset] = incoming[offset]; + } + focusBox(Math.min(index + incoming.length, length - 1)); + } + emit(next); + }; + + const handleKeyDown = (index: number, event: KeyboardEvent) => { + if (event.key === 'Backspace' && !chars[index]) { + focusBox(index - 1); + } + }; + + const handlePaste = (index: number, event: ClipboardEvent) => { + event.preventDefault(); + const pasted = digitsOnly(event.clipboardData.getData('text')); + if (!pasted) return; + const next = [...chars]; + for (let offset = 0; offset < pasted.length && index + offset < length; offset += 1) { + next[index + offset] = pasted[offset]; + } + focusBox(Math.min(index + pasted.length, length - 1)); + emit(next); + }; + + return ( + + {chars.map((char, index) => ( + handleChange(index, event as ChangeEvent)} + onKeyDown={(event) => handleKeyDown(index, event as KeyboardEvent)} + onPaste={(event) => handlePaste(index, event as ClipboardEvent)} + inputRef={(el: HTMLInputElement | null) => { + inputRefs.current[index] = el; + }} + slotProps={{ + htmlInput: { + inputMode: 'numeric', + maxLength: 1, + 'aria-label': `${ariaLabel ?? 'digit'} ${index + 1}`, + style: { textAlign: 'center', fontSize: '1.25rem', width: BOX_SIZE, padding: 8 }, + }, + }} + /> + ))} + + ); +}; + +export default OtpInput; diff --git a/client/src/components/OtpInput/index.tsx b/client/src/components/OtpInput/index.tsx new file mode 100644 index 0000000..98eb90b --- /dev/null +++ b/client/src/components/OtpInput/index.tsx @@ -0,0 +1,4 @@ +import OtpInput from './OtpInput'; + +export type { OtpInputProps } from './OtpInput'; +export { OtpInput as default, OtpInput }; diff --git a/client/src/components/PhoneNumberField/PhoneNumberField.test.tsx b/client/src/components/PhoneNumberField/PhoneNumberField.test.tsx new file mode 100644 index 0000000..befcfdf --- /dev/null +++ b/client/src/components/PhoneNumberField/PhoneNumberField.test.tsx @@ -0,0 +1,46 @@ +import { useState } from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider } from '../../theme'; +import PhoneNumberField, { isIranianMobile } from './PhoneNumberField'; + +function Harness({ initial = '' }: { initial?: string }) { + const [value, setValue] = useState(initial); + return ( + + + + ); +} + +describe(' component', () => { + it('normalizes Persian digits to ASCII', async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByRole('textbox') as HTMLInputElement; + await user.type(input, '۰۹۱۲'); + expect(input.value).toBe('0912'); + }); + + it('strips non-digit characters', async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByRole('textbox') as HTMLInputElement; + await user.type(input, 'a0b9c1'); + expect(input.value).toBe('091'); + }); + + it('caps the value at 11 digits', async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByRole('textbox') as HTMLInputElement; + await user.type(input, '0912345678999'); + expect(input.value).toBe('09123456789'); + }); + + it('validates Iranian mobile numbers', () => { + expect(isIranianMobile('09123456789')).toBe(true); + expect(isIranianMobile('0912345678')).toBe(false); + expect(isIranianMobile('19123456789')).toBe(false); + }); +}); diff --git a/client/src/components/PhoneNumberField/PhoneNumberField.tsx b/client/src/components/PhoneNumberField/PhoneNumberField.tsx new file mode 100644 index 0000000..275055b --- /dev/null +++ b/client/src/components/PhoneNumberField/PhoneNumberField.tsx @@ -0,0 +1,50 @@ +'use client'; +import { ChangeEvent, FunctionComponent } from 'react'; +import TextField, { TextFieldProps } from '@mui/material/TextField'; +import { digitsOnly } from '@/utils'; + +/** Iranian mobile numbers are 11 digits, e.g. 09123456789. */ +export const IRAN_MOBILE_LENGTH = 11; + +/** True when `value` is a well-formed Iranian mobile number (11 digits starting 09). */ +export function isIranianMobile(value: string): boolean { + return /^09\d{9}$/.test(value); +} + +export interface PhoneNumberFieldProps extends Omit { + /** Controlled value — normalized ASCII digits, no formatting. */ + value: string; + /** Emits the normalized digit string (Persian/Arabic digits converted, capped at 11). */ + onChange: (value: string) => void; +} + +/** + * Iranian mobile-number field. Normalizes Persian/Arabic digits to ASCII, strips + * non-digits, and caps length. Forces LTR digit entry so it stays correct inside the + * RTL (`fa`) layout. Text (label/placeholder/helperText) is passed in by the caller. + * @component PhoneNumberField + */ +const PhoneNumberField: FunctionComponent = ({ value, onChange, slotProps, ...rest }) => { + const handleChange = (event: ChangeEvent) => { + onChange(digitsOnly(event.target.value).slice(0, IRAN_MOBILE_LENGTH)); + }; + + return ( + + ); +}; + +export default PhoneNumberField; diff --git a/client/src/components/PhoneNumberField/index.tsx b/client/src/components/PhoneNumberField/index.tsx new file mode 100644 index 0000000..5e6bab9 --- /dev/null +++ b/client/src/components/PhoneNumberField/index.tsx @@ -0,0 +1,5 @@ +import PhoneNumberField from './PhoneNumberField'; + +export type { PhoneNumberFieldProps } from './PhoneNumberField'; +export { IRAN_MOBILE_LENGTH, isIranianMobile } from './PhoneNumberField'; +export { PhoneNumberField as default, PhoneNumberField }; diff --git a/client/src/components/PlaceholderScreen/PlaceholderScreen.test.tsx b/client/src/components/PlaceholderScreen/PlaceholderScreen.test.tsx new file mode 100644 index 0000000..8734efc --- /dev/null +++ b/client/src/components/PlaceholderScreen/PlaceholderScreen.test.tsx @@ -0,0 +1,32 @@ +import { FunctionComponent } from 'react'; +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '../../theme'; +import PlaceholderScreen, { PlaceholderScreenProps } from './PlaceholderScreen'; + +const ComponentToTest: FunctionComponent = (props) => ( + + + +); + +describe(' component', () => { + it('renders the title as a heading', () => { + render(); + expect(screen.getByRole('heading', { name: 'Bookings' })).toBeInTheDocument(); + }); + + it('renders the description when provided', () => { + render(); + expect(screen.getByText('Coming soon in a later phase.')).toBeInTheDocument(); + }); + + it('omits the description when not provided', () => { + render(); + expect(screen.queryByText(/phase/i)).not.toBeInTheDocument(); + }); + + it('renders the named icon', () => { + const { container } = render(); + expect(container.querySelector('[data-icon="patients"]')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/PlaceholderScreen/PlaceholderScreen.tsx b/client/src/components/PlaceholderScreen/PlaceholderScreen.tsx new file mode 100644 index 0000000..a2c2971 --- /dev/null +++ b/client/src/components/PlaceholderScreen/PlaceholderScreen.tsx @@ -0,0 +1,36 @@ +import { FunctionComponent } from 'react'; +import { Stack, Typography } from '@mui/material'; +import AppIcon from '../common/AppIcon'; + +export interface PlaceholderScreenProps { + /** Screen title (already translated by the caller). */ + title: string; + /** Optional supporting line (already translated). */ + description?: string; + /** Registered AppIcon name to show above the title. */ + icon?: string; +} + +/** + * Empty-state scaffold for screens whose real content lands in a later phase. + * Presentational only — the caller passes already-translated copy so the component + * stays i18n-agnostic and reusable across all three actor shells. + * @component PlaceholderScreen + */ +const PlaceholderScreen: FunctionComponent = ({ title, description, icon }) => ( + + {icon && } + + {title} + + {description && ( + + {description} + + )} + +); + +export default PlaceholderScreen; diff --git a/client/src/components/PlaceholderScreen/index.tsx b/client/src/components/PlaceholderScreen/index.tsx new file mode 100644 index 0000000..0a37e17 --- /dev/null +++ b/client/src/components/PlaceholderScreen/index.tsx @@ -0,0 +1,4 @@ +import PlaceholderScreen from './PlaceholderScreen'; + +export type { PlaceholderScreenProps } from './PlaceholderScreen'; +export { PlaceholderScreen as default, PlaceholderScreen }; diff --git a/client/src/components/StatusChip/StatusChip.test.tsx b/client/src/components/StatusChip/StatusChip.test.tsx new file mode 100644 index 0000000..e53731d --- /dev/null +++ b/client/src/components/StatusChip/StatusChip.test.tsx @@ -0,0 +1,27 @@ +import { FunctionComponent } from 'react'; +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '../../theme'; +import StatusChip, { StatusChipProps } from './StatusChip'; + +const ComponentToTest: FunctionComponent = (props) => ( + + + +); + +describe(' component', () => { + it('renders the label', () => { + render(); + expect(screen.getByText('Verified')).toBeInTheDocument(); + }); + + it('exposes the status via a data attribute', () => { + const { container } = render(); + expect(container.querySelector('[data-status="pending"]')).toBeInTheDocument(); + }); + + it('renders the status icon for rejected', () => { + const { container } = render(); + expect(container.querySelector('[data-icon="rejected"]')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/StatusChip/StatusChip.tsx b/client/src/components/StatusChip/StatusChip.tsx new file mode 100644 index 0000000..dcfe126 --- /dev/null +++ b/client/src/components/StatusChip/StatusChip.tsx @@ -0,0 +1,50 @@ +import { FunctionComponent } from 'react'; +import Chip, { ChipProps } from '@mui/material/Chip'; +import AppIcon from '../common/AppIcon'; + +export type StatusKind = 'verified' | 'active' | 'pending' | 'rejected' | 'info' | 'neutral'; + +interface StatusStyle { + bg: string; + fg: string; + icon: string; +} + +// Colors come from the semantic --bal-* tokens (both schemes defined in tokens.css), +// so the chip switches with the color scheme automatically. Never hard-code a hex here. +const STATUS_STYLE: Record = { + verified: { bg: 'var(--bal-success)', fg: 'var(--bal-success-contrast)', icon: 'verified' }, + active: { bg: 'var(--bal-success)', fg: 'var(--bal-success-contrast)', icon: 'verified' }, + pending: { bg: 'var(--bal-warning)', fg: 'var(--bal-warning-contrast)', icon: 'pending' }, + rejected: { bg: 'var(--bal-error)', fg: 'var(--bal-error-contrast)', icon: 'rejected' }, + info: { bg: 'var(--bal-info)', fg: 'var(--bal-info-contrast)', icon: 'info' }, + neutral: { bg: 'var(--bal-divider)', fg: 'var(--bal-text-secondary)', icon: 'info' }, +}; + +export interface StatusChipProps extends Omit { + /** Semantic status that drives the color and icon. */ + status: StatusKind; + /** Display text — already translated by the caller (labels are i18n keys off the code). */ + label: string; +} + +/** + * Brand-harmonized status chip (verified / pending / rejected / …). Composed from MUI + * Chip + the AppIcon registry; colors resolve from the --bal-* semantic tokens. + * @component StatusChip + */ +const StatusChip: FunctionComponent = ({ status, label, size = 'small', sx, ...rest }) => { + const style = STATUS_STYLE[status]; + return ( + } + sx={{ backgroundColor: style.bg, color: style.fg, fontWeight: 600, ...sx }} + {...rest} + /> + ); +}; + +export default StatusChip; diff --git a/client/src/components/StatusChip/index.tsx b/client/src/components/StatusChip/index.tsx new file mode 100644 index 0000000..bc36637 --- /dev/null +++ b/client/src/components/StatusChip/index.tsx @@ -0,0 +1,4 @@ +import StatusChip from './StatusChip'; + +export type { StatusChipProps, StatusKind } from './StatusChip'; +export { StatusChip as default, StatusChip }; diff --git a/client/src/components/StepperHeader/StepperHeader.test.tsx b/client/src/components/StepperHeader/StepperHeader.test.tsx new file mode 100644 index 0000000..d999f7e --- /dev/null +++ b/client/src/components/StepperHeader/StepperHeader.test.tsx @@ -0,0 +1,25 @@ +import { FunctionComponent } from 'react'; +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '../../theme'; +import StepperHeader, { StepperHeaderProps } from './StepperHeader'; + +const STEPS = ['Phone', 'Verify', 'Profile']; + +const ComponentToTest: FunctionComponent> = ({ steps = STEPS, activeStep = 0 }) => ( + + + +); + +describe(' component', () => { + it('renders every step label', () => { + render(); + STEPS.forEach((label) => expect(screen.getByText(label)).toBeInTheDocument()); + }); + + it('marks the active step', () => { + const { container } = render(); + // MUI flags the active step's icon with the Mui-active class. + expect(container.querySelector('.Mui-active')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/StepperHeader/StepperHeader.tsx b/client/src/components/StepperHeader/StepperHeader.tsx new file mode 100644 index 0000000..cff5a26 --- /dev/null +++ b/client/src/components/StepperHeader/StepperHeader.tsx @@ -0,0 +1,30 @@ +import { FunctionComponent } from 'react'; +import Stepper from '@mui/material/Stepper'; +import Step from '@mui/material/Step'; +import StepLabel from '@mui/material/StepLabel'; + +export interface StepperHeaderProps { + /** Ordered step labels — already translated by the caller. */ + steps: string[]; + /** Zero-based index of the active step. */ + activeStep: number; + /** Optional alternative-label layout (labels under the dots). Defaults to true. */ + alternativeLabel?: boolean; +} + +/** + * Progress header for multi-step flows (onboarding + verification). Wraps MUI Stepper; + * direction is handled by the RTL-aware theme, so it flips correctly at `/fa`. + * @component StepperHeader + */ +const StepperHeader: FunctionComponent = ({ steps, activeStep, alternativeLabel = true }) => ( + + {steps.map((label) => ( + + {label} + + ))} + +); + +export default StepperHeader; diff --git a/client/src/components/StepperHeader/index.tsx b/client/src/components/StepperHeader/index.tsx new file mode 100644 index 0000000..9e8370c --- /dev/null +++ b/client/src/components/StepperHeader/index.tsx @@ -0,0 +1,4 @@ +import StepperHeader from './StepperHeader'; + +export type { StepperHeaderProps } from './StepperHeader'; +export { StepperHeader as default, StepperHeader }; diff --git a/client/src/components/common/AppIcon/config.ts b/client/src/components/common/AppIcon/config.ts index d625175..ff25dc5 100644 --- a/client/src/components/common/AppIcon/config.ts +++ b/client/src/components/common/AppIcon/config.ts @@ -19,6 +19,19 @@ import PersonIcon from '@mui/icons-material/Person'; import ExitToAppIcon from '@mui/icons-material/ExitToApp'; import NotificationsIcon from '@mui/icons-material/NotificationsOutlined'; import DangerousIcon from '@mui/icons-material/Dangerous'; +import EventNoteIcon from '@mui/icons-material/EventNote'; +import GroupsIcon from '@mui/icons-material/Groups'; +import PeopleAltIcon from '@mui/icons-material/PeopleAlt'; +import WalletIcon from '@mui/icons-material/AccountBalanceWallet'; +import PersonOutlineIcon from '@mui/icons-material/AccountCircleOutlined'; +import DashboardIcon from '@mui/icons-material/Dashboard'; +import VerifiedUserIcon from '@mui/icons-material/VerifiedUser'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty'; +import CancelIcon from '@mui/icons-material/Cancel'; +import MedicalServicesIcon from '@mui/icons-material/MedicalServices'; +import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; +import AddIcon from '@mui/icons-material/Add'; /** * List of all available Icon names @@ -53,4 +66,17 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was - logout: ExitToAppIcon, notifications: NotificationsIcon, error: DangerousIcon, + bookings: EventNoteIcon, + patients: GroupsIcon, + users: PeopleAltIcon, + wallet: WalletIcon, + profile: PersonOutlineIcon, + dashboard: DashboardIcon, + verification: VerifiedUserIcon, + verified: CheckCircleIcon, + pending: HourglassEmptyIcon, + rejected: CancelIcon, + visits: MedicalServicesIcon, + admin: AdminPanelSettingsIcon, + add: AddIcon, }; diff --git a/client/src/components/common/AppIcon/icons/CurrencyIcon.tsx b/client/src/components/common/AppIcon/icons/CurrencyIcon.tsx deleted file mode 100644 index cd55be4..0000000 --- a/client/src/components/common/AppIcon/icons/CurrencyIcon.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { FunctionComponent } from 'react'; -import { IconProps } from '../utils'; - -const CurrencyIcon: FunctionComponent = (props) => { - return ( - - - - - - - - - ); -}; - -export default CurrencyIcon; diff --git a/client/src/components/common/AppIcon/icons/YellowPlanIcon.tsx b/client/src/components/common/AppIcon/icons/YellowPlanIcon.tsx deleted file mode 100644 index 11cebc6..0000000 --- a/client/src/components/common/AppIcon/icons/YellowPlanIcon.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { FunctionComponent } from 'react'; -import { IconProps } from '../utils'; - -const YellowPlaneIcon: FunctionComponent = (props) => { - const styleOpacityAndEnableBackground = { - opacity: 0.2, - // enableBackground: 'new' - }; - - return ( - - - - - - - - - - - - - - - - - - - ); -}; - -export default YellowPlaneIcon; diff --git a/client/src/components/index.tsx b/client/src/components/index.tsx index 1277447..e33dfce 100644 --- a/client/src/components/index.tsx +++ b/client/src/components/index.tsx @@ -1,5 +1,15 @@ export * from './common'; import UserInfo from './UserInfo'; +import PlaceholderScreen from './PlaceholderScreen'; +import OtpInput from './OtpInput'; +import PhoneNumberField from './PhoneNumberField'; +import StepperHeader from './StepperHeader'; +import StatusChip from './StatusChip'; -export { UserInfo }; +export { UserInfo, PlaceholderScreen, OtpInput, PhoneNumberField, StepperHeader, StatusChip }; +export type { PlaceholderScreenProps } from './PlaceholderScreen'; +export type { OtpInputProps } from './OtpInput'; +export type { PhoneNumberFieldProps } from './PhoneNumberField'; +export type { StepperHeaderProps } from './StepperHeader'; +export type { StatusChipProps, StatusKind } from './StatusChip'; diff --git a/client/src/constants/index.ts b/client/src/constants/index.ts index 5e201ac..d5e268b 100644 --- a/client/src/constants/index.ts +++ b/client/src/constants/index.ts @@ -1,2 +1,3 @@ export * from './headers'; +export * from './roles'; export * from './routes'; diff --git a/client/src/constants/roles.ts b/client/src/constants/roles.ts new file mode 100644 index 0000000..2152388 --- /dev/null +++ b/client/src/constants/roles.ts @@ -0,0 +1,15 @@ +/** + * The three Balinyaar actor experiences. Each maps to a route-group shell under + * `(private-routes)` and its own navigation. The real role list arrives with the + * server in f1-b2; until then `useActorRole()` defaults to CUSTOMER so the shells + * render gracefully without a role on the session. + */ +export const APP_ROLES = { + CUSTOMER: 'customer', + NURSE: 'nurse', + ADMIN: 'admin', +} as const; + +export type AppRole = (typeof APP_ROLES)[keyof typeof APP_ROLES]; + +export const DEFAULT_ROLE: AppRole = APP_ROLES.CUSTOMER; diff --git a/client/src/constants/routes.ts b/client/src/constants/routes.ts index 35ab15d..722dc41 100644 --- a/client/src/constants/routes.ts +++ b/client/src/constants/routes.ts @@ -1,6 +1,22 @@ export const ROUTES = { LOGIN: '/login', + + // Customer (family) app — mobile-first, bottom-tab nav HOME: '/', + BOOKINGS: '/bookings', + PATIENTS: '/patients', + WALLET: '/wallet', + PROFILE: '/profile', + + // Nurse app + NURSE: '/nurse', + NURSE_VERIFICATION: '/nurse/verification', + NURSE_VISITS: '/nurse/visits', + + // Admin / backoffice console + ADMIN: '/admin', + ADMIN_USERS: '/admin/users', + ADMIN_NOTIFICATIONS: '/admin/notifications', } as const; /** Paths (without locale prefix) that bypass auth in middleware. */ diff --git a/client/src/hooks/auth.ts b/client/src/hooks/auth.ts index f391151..2c5407d 100644 --- a/client/src/hooks/auth.ts +++ b/client/src/hooks/auth.ts @@ -1,4 +1,5 @@ import { useAuth } from '@/context/auth'; +import { APP_ROLES, DEFAULT_ROLE, type AppRole } from '@/constants'; /** * True when the current session is authenticated. @@ -11,3 +12,20 @@ export function useIsAuthenticated(): boolean { const [state] = useAuth(); return state.isAuthenticated; } + +// Precedence when a user holds several roles: the highest-privilege shell wins. +const ROLE_PRECEDENCE: AppRole[] = [APP_ROLES.ADMIN, APP_ROLES.NURSE, APP_ROLES.CUSTOMER]; + +/** + * The actor experience the current session should see, read from the session roles. + * + * Roles are seeded by the server in f1-b2; until then sessions carry no roles and + * this returns DEFAULT_ROLE (customer) so the shells degrade gracefully. Route-group + * layouts use it to drive role-aware navigation and (later) access guards. + */ +export function useActorRole(): AppRole { + const [state] = useAuth(); + const roles = state.currentUser?.roles; + if (!roles?.length) return DEFAULT_ROLE; + return ROLE_PRECEDENCE.find((role) => roles.includes(role)) ?? DEFAULT_ROLE; +} diff --git a/client/src/layout/AdminLayout.tsx b/client/src/layout/AdminLayout.tsx new file mode 100644 index 0000000..6aa6fc1 --- /dev/null +++ b/client/src/layout/AdminLayout.tsx @@ -0,0 +1,37 @@ +'use client'; +import { FunctionComponent, PropsWithChildren, useMemo } from 'react'; +import { useTranslations } from 'next-intl'; +import { ROUTES } from '@/constants'; +import { LinkToPage } from '@/utils'; +import TopBarAndSideBarLayout from './TopBarAndSideBarLayout'; + +/** + * Admin / backoffice shell — desktop-oriented ops console (f15). Uses the shared + * TopBar + SideBar engine with a persistent sidebar on desktop. + * @layout AdminLayout + */ +const AdminLayout: FunctionComponent = ({ children }) => { + const t = useTranslations('nav'); + const tShell = useTranslations('shell'); + + const sidebarItems: Array = useMemo( + () => [ + { title: t('overview'), path: ROUTES.ADMIN, icon: 'admin' }, + { title: t('users'), path: ROUTES.ADMIN_USERS, icon: 'users' }, + { title: t('notifications'), path: ROUTES.ADMIN_NOTIFICATIONS, icon: 'notifications' }, + ], + [t] + ); + + return ( + + {children} + + ); +}; + +export default AdminLayout; diff --git a/client/src/layout/CustomerLayout.tsx b/client/src/layout/CustomerLayout.tsx new file mode 100644 index 0000000..531a864 --- /dev/null +++ b/client/src/layout/CustomerLayout.tsx @@ -0,0 +1,64 @@ +'use client'; +import { FunctionComponent, PropsWithChildren, useMemo } from 'react'; +import { Box, Stack } from '@mui/material'; +import { useTranslations } from 'next-intl'; +import { ErrorBoundary } from '@/components'; +import { CONTENT_MAX_WIDTH } from '@/components/config'; +import { ROUTES } from '@/constants'; +import { LinkToPage } from '@/utils'; +import { useIsMobile } from '@/hooks'; +import { TopBar, BottomBar } from './components'; +import { DarkModeToggleButton } from './components/DarkModeButton'; +import { TOP_BAR_DESKTOP_HEIGHT, TOP_BAR_MOBILE_HEIGHT } from './config'; + +/** + * Customer (family) app shell — the primary, mobile-first experience. + * A slim TopBar, a scrollable content column constrained to reading width, and the + * 5-tab BottomBar (Home/Bookings/Patients/Wallet/Profile) from the wireframe. + * @layout CustomerLayout + */ +const CustomerLayout: FunctionComponent = ({ children }) => { + const t = useTranslations('nav'); + const tShell = useTranslations('shell'); + const onMobile = useIsMobile(); + + const bottomNavItems: Array = useMemo( + () => [ + { title: t('home'), path: ROUTES.HOME, icon: 'home' }, + { title: t('bookings'), path: ROUTES.BOOKINGS, icon: 'bookings' }, + { title: t('patients'), path: ROUTES.PATIENTS, icon: 'patients' }, + { title: t('wallet'), path: ROUTES.WALLET, icon: 'wallet' }, + { title: t('profile'), path: ROUTES.PROFILE, icon: 'profile' }, + ], + [t] + ); + + return ( + + + } /> + + + + {children} + + + + + ); +}; + +export default CustomerLayout; diff --git a/client/src/layout/NurseLayout.tsx b/client/src/layout/NurseLayout.tsx new file mode 100644 index 0000000..b6d213d --- /dev/null +++ b/client/src/layout/NurseLayout.tsx @@ -0,0 +1,38 @@ +'use client'; +import { FunctionComponent, PropsWithChildren, useMemo } from 'react'; +import { useTranslations } from 'next-intl'; +import { ROUTES } from '@/constants'; +import { LinkToPage } from '@/utils'; +import TopBarAndSideBarLayout from './TopBarAndSideBarLayout'; + +/** + * Nurse app shell — the "نمای پرستار" experience (verification, dashboard, EVV visits). + * Uses the shared TopBar + SideBar engine: a temporary drawer on mobile, persistent on + * desktop. Nav is role-scoped to the nurse routes. + * @layout NurseLayout + */ +const NurseLayout: FunctionComponent = ({ children }) => { + const t = useTranslations('nav'); + const tShell = useTranslations('shell'); + + const sidebarItems: Array = useMemo( + () => [ + { title: t('dashboard'), path: ROUTES.NURSE, icon: 'dashboard' }, + { title: t('verification'), path: ROUTES.NURSE_VERIFICATION, icon: 'verification' }, + { title: t('visits'), path: ROUTES.NURSE_VISITS, icon: 'visits' }, + ], + [t] + ); + + return ( + + {children} + + ); +}; + +export default NurseLayout; diff --git a/client/src/layout/components/BottomBar.tsx b/client/src/layout/components/BottomBar.tsx index 8169847..ab9fd30 100644 --- a/client/src/layout/components/BottomBar.tsx +++ b/client/src/layout/components/BottomBar.tsx @@ -1,7 +1,8 @@ 'use client'; -import { FunctionComponent, useCallback } from 'react'; -import { useRouter } from 'next/navigation'; -import { BottomNavigation, BottomNavigationAction } from '@mui/material'; +import { FunctionComponent, useCallback, useMemo } from 'react'; +import { usePathname, useRouter } from 'next/navigation'; +import { useLocale } from 'next-intl'; +import { BottomNavigation, BottomNavigationAction, Paper } from '@mui/material'; import { LinkToPage } from '@/utils'; import { AppIcon } from '@/components'; @@ -9,30 +10,56 @@ interface Props { items: Array; } +/** Prefixes an app-relative path with the active locale (e.g. `/bookings` → `/fa/bookings`). */ +function withLocale(locale: string, path: string) { + return path === '/' ? `/${locale}` : `/${locale}${path}`; +} + /** - * Renders horizontal Navigation Bar using MUI BottomNavigation component + * Shared horizontal navigation bar (customer app tabs) built on MUI BottomNavigation. + * Locale-aware: it highlights the active tab from the real pathname (via usePathname, + * not the global `location`) and pushes locale-prefixed routes. * @component BottomBar */ const BottomBar: FunctionComponent = ({ items }) => { const router = useRouter(); + const pathname = usePathname(); + const locale = useLocale(); + + // The active tab is the longest item path that prefixes the current pathname, + // so `/patients/123` still selects the `/patients` tab and `/` never over-matches. + const activePath = useMemo(() => { + const matches = items + .map((item) => item.path) + .filter((path): path is string => Boolean(path)) + .filter((path) => { + const prefixed = withLocale(locale, path); + return path === '/' ? pathname === prefixed : pathname.startsWith(prefixed); + }) + .sort((a, b) => b.length - a.length); + return matches[0] ?? false; + }, [items, pathname, locale]); const onNavigationChange = useCallback( (_event: unknown, newValue: string) => { - router.push(newValue); + router.push(withLocale(locale, newValue)); }, - [router] + [router, locale] ); return ( - - {items.map(({ title, path, icon }) => ( - } /> - ))} - + + {items.map(({ title, path, icon }) => ( + } /> + ))} + + ); }; diff --git a/client/src/layout/index.tsx b/client/src/layout/index.tsx index 37bd6e5..c999b8b 100644 --- a/client/src/layout/index.tsx +++ b/client/src/layout/index.tsx @@ -1,4 +1,7 @@ import PrivateLayout from './PrivateLayout'; import PublicLayout from './PublicLayout'; +import CustomerLayout from './CustomerLayout'; +import NurseLayout from './NurseLayout'; +import AdminLayout from './AdminLayout'; -export { PublicLayout, PrivateLayout }; +export { PublicLayout, PrivateLayout, CustomerLayout, NurseLayout, AdminLayout }; diff --git a/client/src/lib/api/types.ts b/client/src/lib/api/types.ts new file mode 100644 index 0000000..72a902d --- /dev/null +++ b/client/src/lib/api/types.ts @@ -0,0 +1,44 @@ +/** + * Shared wire types for the server's response envelope and paginated lists. + * + * The server wraps every response in `ApiResult` (see the b0 swagger snapshot: + * `dev/contracts/openapi/swagger.v1.json` → `components.schemas.ApiResult`). The + * observed wire casing is **camelCase** (`isSuccess`, `statusCode`, `serverTimeUtc`), + * not the snake_case the routing convention implies — always mirror the published + * swagger, not an assumption. + * + * IMPORTANT: `clientFetch`/`serverFetch` currently return the raw response body, so a + * real `clientApi` call gets the whole `ApiEnvelope` and must read `.data`. Use + * `unwrap()` for that. (Whether the fetch layer should unwrap centrally is filed in + * `dev/shared-working-context/frontend/requests/for-backend.md`.) + */ + +export interface ApiEnvelope { + isSuccess: boolean; + statusCode: number; + message?: string | null; + requestId?: string | null; + data?: T | null; +} + +/** Reads the payload out of the server envelope, throwing if the call was not a success. */ +export function unwrap(envelope: ApiEnvelope): T { + if (!envelope?.isSuccess || envelope.data == null) { + throw new Error(envelope?.message ?? 'Request did not succeed'); + } + return envelope.data; +} + +/** Standard paginated list payload (api-conventions §Pagination). Verify field casing per contract. */ +export interface Paginated { + items: T[]; + total: number; + page: number; + pageSize: number; +} + +/** Query params for a paginated list request. */ +export interface PageParams { + page?: number; + pageSize?: number; +} diff --git a/client/src/services/auth/types.ts b/client/src/services/auth/types.ts index e75ee35..8d726f4 100644 --- a/client/src/services/auth/types.ts +++ b/client/src/services/auth/types.ts @@ -8,7 +8,12 @@ export interface AuthTokens { refreshToken: string; } +import type { AppRole } from '@/constants'; + export interface User { id: string; username: string; + // Populated by the server in f1-b2. Optional until then; the shell defaults to + // the customer experience when roles are absent (see useActorRole). + roles?: AppRole[]; } diff --git a/client/src/services/patients/apis/clientApi.ts b/client/src/services/patients/apis/clientApi.ts new file mode 100644 index 0000000..bbbcc10 --- /dev/null +++ b/client/src/services/patients/apis/clientApi.ts @@ -0,0 +1,29 @@ +import { clientFetch } from '@/lib/api/client'; +import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types'; +import type { CreatePatientDto, Patient, PatientsApi } from '../types'; + +const BASE = '/patients'; + +/** + * Real HTTP implementation of the PatientsApi seam. Wired to `clientFetch`, which + * returns the raw server envelope — so each call reads the payload via `unwrap`. + * Not selected until USE_PATIENTS_MOCK is false and the endpoints exist. + */ +export const patientsClientApi: PatientsApi = { + list: async (params) => { + const query = new URLSearchParams(); + if (params?.page) query.set('page', String(params.page)); + if (params?.pageSize) query.set('page_size', String(params.pageSize)); + const qs = query.toString(); + const env = await clientFetch>>(`${BASE}${qs ? `?${qs}` : ''}`); + return unwrap(env); + }, + + create: async (dto: CreatePatientDto) => { + const env = await clientFetch>(BASE, { + method: 'POST', + body: JSON.stringify(dto), + }); + return unwrap(env); + }, +}; diff --git a/client/src/services/patients/apis/index.ts b/client/src/services/patients/apis/index.ts new file mode 100644 index 0000000..884e035 --- /dev/null +++ b/client/src/services/patients/apis/index.ts @@ -0,0 +1,10 @@ +import { USE_PATIENTS_MOCK } from '../constants'; +import type { PatientsApi } from '../types'; +import { patientsClientApi } from './clientApi'; +import { patientsMockApi } from './mockApi'; + +/** + * The selected PatientsApi implementation — the single seam hooks import. Selection is + * by config (USE_PATIENTS_MOCK), never by scattered `if (mock)` checks. + */ +export const patientsApi: PatientsApi = USE_PATIENTS_MOCK ? patientsMockApi : patientsClientApi; diff --git a/client/src/services/patients/apis/mockApi.ts b/client/src/services/patients/apis/mockApi.ts new file mode 100644 index 0000000..20c59a6 --- /dev/null +++ b/client/src/services/patients/apis/mockApi.ts @@ -0,0 +1,45 @@ +import { sleep } from '@/utils'; +import type { Paginated } from '@/lib/api/types'; +import type { CreatePatientDto, Patient, PatientsApi } from '../types'; + +const MOCK_LATENCY_MS = 400; + +// In-memory store. Seed timestamps are static strings (not Date.now) so repeated +// renders are stable; `create` stamps a real ISO time on the client. +let store: Patient[] = [ + { id: 2, fullName: 'زهرا محمدی', gender: 'female', createdAtUtc: '2026-05-12T08:30:00Z' }, + { id: 1, fullName: 'علی رضایی', gender: 'male', createdAtUtc: '2026-04-03T11:15:00Z' }, +]; +let nextId = 3; + +/** + * In-memory mock behind the PatientsApi seam — the template f1+ follow until the real + * `/patients` endpoints are merged. Mirrors the real shapes so swapping is a one-line + * change in constants.ts. + */ +export const patientsMockApi: PatientsApi = { + list: async (params): Promise> => { + await sleep(MOCK_LATENCY_MS); + const page = params?.page ?? 1; + const pageSize = params?.pageSize ?? 20; + const start = (page - 1) * pageSize; + return { + items: store.slice(start, start + pageSize), + total: store.length, + page, + pageSize, + }; + }, + + create: async (dto: CreatePatientDto): Promise => { + await sleep(MOCK_LATENCY_MS); + const patient: Patient = { + id: nextId++, + fullName: dto.fullName, + gender: dto.gender, + createdAtUtc: new Date().toISOString(), + }; + store = [patient, ...store]; + return patient; + }, +}; diff --git a/client/src/services/patients/constants.ts b/client/src/services/patients/constants.ts new file mode 100644 index 0000000..a1fa032 --- /dev/null +++ b/client/src/services/patients/constants.ts @@ -0,0 +1,8 @@ +/** + * When true, the domain is served by the in-memory mock (apis/mockApi.ts) behind the + * PatientsApi seam. Flip to false once the real `/patients` endpoints land — no hook or + * component changes are needed (see dev/shared-working-context/reports/mocks-registry.md). + */ +export const USE_PATIENTS_MOCK = true; + +export const PATIENTS_STALE_TIME = 60_000; diff --git a/client/src/services/patients/hooks/useAddPatient.ts b/client/src/services/patients/hooks/useAddPatient.ts new file mode 100644 index 0000000..87fc472 --- /dev/null +++ b/client/src/services/patients/hooks/useAddPatient.ts @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { patientsApi } from '../apis'; +import { patientKeys } from '../keys'; +import type { CreatePatientDto } from '../types'; + +/** + * Creates a patient and invalidates every patients list so the cache reflects the new + * row without a manual refetch. (setQueryData would also work when the API returns the + * full new list item and pagination is trivial — invalidation is the safe default.) + */ +export function useAddPatient() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (dto: CreatePatientDto) => patientsApi.create(dto), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: patientKeys.lists() }); + }, + }); +} diff --git a/client/src/services/patients/hooks/usePatients.ts b/client/src/services/patients/hooks/usePatients.ts new file mode 100644 index 0000000..ae1d303 --- /dev/null +++ b/client/src/services/patients/hooks/usePatients.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query'; +import type { PageParams } from '@/lib/api/types'; +import { patientsApi } from '../apis'; +import { patientKeys } from '../keys'; +import { PATIENTS_STALE_TIME } from '../constants'; + +/** + * Fetches the patients list. A deliberate staleTime keeps the list warm across remounts + * so navigating away and back doesn't refetch what's already cached. + */ +export function usePatients(params?: PageParams) { + return useQuery({ + queryKey: patientKeys.list(params), + queryFn: () => patientsApi.list(params), + staleTime: PATIENTS_STALE_TIME, + }); +} diff --git a/client/src/services/patients/index.ts b/client/src/services/patients/index.ts new file mode 100644 index 0000000..0552372 --- /dev/null +++ b/client/src/services/patients/index.ts @@ -0,0 +1,2 @@ +export { usePatients } from './hooks/usePatients'; +export { useAddPatient } from './hooks/useAddPatient'; diff --git a/client/src/services/patients/keys.ts b/client/src/services/patients/keys.ts new file mode 100644 index 0000000..746ef22 --- /dev/null +++ b/client/src/services/patients/keys.ts @@ -0,0 +1,13 @@ +import type { PageParams } from '@/lib/api/types'; + +/** + * React Query key factory for the patients domain. Hierarchical keys let a mutation + * invalidate every list (`patientKeys.lists()`) without touching unrelated caches. + */ +export const patientKeys = { + all: ['patients'] as const, + lists: () => [...patientKeys.all, 'list'] as const, + list: (params?: PageParams) => [...patientKeys.lists(), params ?? {}] as const, + details: () => [...patientKeys.all, 'detail'] as const, + detail: (id: number) => [...patientKeys.details(), id] as const, +}; diff --git a/client/src/services/patients/types.ts b/client/src/services/patients/types.ts new file mode 100644 index 0000000..878d986 --- /dev/null +++ b/client/src/services/patients/types.ts @@ -0,0 +1,33 @@ +import type { PageParams, Paginated } from '@/lib/api/types'; + +/** + * Patients domain — the reference `services/{domain}` implementation every later + * frontend phase copies. Enums cross the wire as stable string codes (money-and-types.md); + * mirror them as string-literal unions and never hardcode a display label off the code. + * + * Deriving these types from the contract: read the domain's shapes from the published + * `dev/contracts/domains/.md` + `dev/contracts/openapi/swagger.v1.json`, mirror + * the wire exactly (field names + casing), and map enums to unions here. Until the real + * `/patients` endpoints exist, the shapes below are the agreed target the mock honours. + */ + +export type Gender = 'male' | 'female'; + +export interface Patient { + id: number; + fullName: string; + gender: Gender; + /** UTC ISO-8601; display via formatShamsiDate. */ + createdAtUtc: string; +} + +export interface CreatePatientDto { + fullName: string; + gender: Gender; +} + +/** The domain's API seam. A mock and the real client both implement this interface. */ +export interface PatientsApi { + list(params?: PageParams): Promise>; + create(dto: CreatePatientDto): Promise; +} diff --git a/client/src/utils/date.ts b/client/src/utils/date.ts new file mode 100644 index 0000000..8a9b3de --- /dev/null +++ b/client/src/utils/date.ts @@ -0,0 +1,34 @@ +/** + * Date display helpers. Timestamps cross the wire as UTC ISO-8601; Shamsi (Persian + * calendar) display is a client concern (see money-and-types.md). We render via the + * Intl Persian calendar — no date library needed. + */ + +const SHAMSI_LOCALE = 'fa-IR-u-ca-persian'; + +/** Resolves the Intl locale for date formatting from the app locale. */ +function intlLocale(locale: string): string { + return locale === 'fa' ? SHAMSI_LOCALE : 'en-US'; +} + +/** Formats a UTC ISO timestamp as a localized date (Shamsi for `fa`, Gregorian for `en`). */ +export function formatShamsiDate( + iso: string | Date, + locale: string = 'fa', + options: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric' } +): string { + const date = iso instanceof Date ? iso : new Date(iso); + if (Number.isNaN(date.getTime())) return ''; + return new Intl.DateTimeFormat(intlLocale(locale), options).format(date); +} + +/** Formats a UTC ISO timestamp as a localized date + time. */ +export function formatShamsiDateTime(iso: string | Date, locale: string = 'fa'): string { + return formatShamsiDate(iso, locale, { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index 5995ab0..d07580f 100644 --- a/client/src/utils/index.ts +++ b/client/src/utils/index.ts @@ -1,5 +1,7 @@ +export * from './date'; export * from './environment'; export * from './localStorage'; +export * from './money'; export * from './navigation'; export * from './sessionStorage'; export * from './sleep'; diff --git a/client/src/utils/money.test.ts b/client/src/utils/money.test.ts new file mode 100644 index 0000000..c4e394e --- /dev/null +++ b/client/src/utils/money.test.ts @@ -0,0 +1,48 @@ +import { parseIrr, rialToToman, formatIrr, formatIrrToToman } from './money'; + +describe('money utils', () => { + describe('parseIrr', () => { + it('parses digit strings to bigint', () => { + expect(parseIrr('23300000')).toBe(BigInt(23300000)); + }); + + it('stays integer-safe beyond Number.MAX_SAFE_INTEGER', () => { + const huge = '9007199254740993'; // MAX_SAFE_INTEGER + 2 + expect(parseIrr(huge)).toBe(BigInt('9007199254740993')); + }); + + it('accepts integer numbers and bigints', () => { + expect(parseIrr(1500)).toBe(BigInt(1500)); + expect(parseIrr(BigInt(1500))).toBe(BigInt(1500)); + }); + + it('rejects non-integer input', () => { + expect(() => parseIrr('12.5')).toThrow(); + expect(() => parseIrr('abc')).toThrow(); + expect(() => parseIrr(12.5)).toThrow(); + }); + }); + + describe('rialToToman', () => { + it('divides Rials by 10', () => { + expect(rialToToman('23300000')).toBe(BigInt(2330000)); + }); + }); + + describe('formatting', () => { + it('groups Rials in en locale', () => { + expect(formatIrr('23300000', 'en')).toBe('23,300,000'); + }); + + it('converts and groups Toman in en locale', () => { + expect(formatIrrToToman('23300000', 'en')).toBe('2,330,000'); + }); + + it('renders Persian digits in fa locale', () => { + // fa-IR uses Persian digits and its own grouping separator. + const out = formatIrrToToman('23300000', 'fa'); + expect(out).not.toMatch(/[0-9]/); // no ASCII digits + expect(out).toContain('۲'); + }); + }); +}); diff --git a/client/src/utils/money.ts b/client/src/utils/money.ts new file mode 100644 index 0000000..ee7d87a --- /dev/null +++ b/client/src/utils/money.ts @@ -0,0 +1,41 @@ +/** + * Money helpers — IRR Rials, integer, no floats (see + * `dev/contracts/conventions/money-and-types.md`). + * + * Money crosses the wire as a string of digits (Rials) because IRR aggregates exceed + * JS's safe-integer range. We parse with BigInt and format for display. Toman is + * display-only (1 Toman = 10 Rials) and is derived here, never stored or sent. + */ + +const RIALS_PER_TOMAN = BigInt(10); +const INTEGER_STRING = /^-?\d+$/; + +/** Parses an IRR value (digit string, number, or bigint) into an integer-safe bigint. */ +export function parseIrr(value: string | number | bigint): bigint { + if (typeof value === 'bigint') return value; + if (typeof value === 'number') { + if (!Number.isInteger(value)) throw new Error(`IRR must be an integer, got ${value}`); + return BigInt(value); + } + const trimmed = value.trim(); + if (!INTEGER_STRING.test(trimmed)) throw new Error(`Invalid IRR integer string: "${value}"`); + return BigInt(trimmed); +} + +/** Converts Rials to Toman (integer division — IRR are whole Rials). */ +export function rialToToman(value: string | number | bigint): bigint { + return parseIrr(value) / RIALS_PER_TOMAN; +} + +/** Formats a grouped Rial amount (no unit). `fa` uses Persian digits. */ +export function formatIrr(value: string | number | bigint, locale: string = 'fa'): string { + return new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(parseIrr(value)); +} + +/** + * Formats an IRR amount as a grouped Toman number for display (no unit — pair it with + * the `common.currency_toman` label). `fa` renders Persian digits. + */ +export function formatIrrToToman(value: string | number | bigint, locale: string = 'fa'): string { + return new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(rialToToman(value)); +} diff --git a/client/src/utils/text.ts b/client/src/utils/text.ts index 5a8a1b1..4fe9c6e 100644 --- a/client/src/utils/text.ts +++ b/client/src/utils/text.ts @@ -47,3 +47,24 @@ export function randomColor() { const color = Math.floor(Math.random() * 16777215).toString(16); return '#' + color; } + +const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹'; +const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩'; + +/** + * Normalizes Persian (۰-۹) and Arabic (٠-٩) digits to ASCII 0-9. Essential for any + * numeric input (phone, OTP, national id) since Persian keyboards emit Persian digits. + */ +export function toEnglishDigits(input: string): string { + return input.replace(/[۰-۹٠-٩]/g, (ch) => { + const persian = PERSIAN_DIGITS.indexOf(ch); + if (persian > -1) return String(persian); + const arabic = ARABIC_DIGITS.indexOf(ch); + return arabic > -1 ? String(arabic) : ch; + }); +} + +/** Normalizes to ASCII digits and strips everything that isn't a digit. */ +export function digitsOnly(input: string): string { + return toEnglishDigits(input).replace(/\D/g, ''); +} diff --git a/dev/shared-working-context/frontend/STATUS.md b/dev/shared-working-context/frontend/STATUS.md index 3cd9046..3c01f87 100644 --- a/dev/shared-working-context/frontend/STATUS.md +++ b/dev/shared-working-context/frontend/STATUS.md @@ -12,4 +12,17 @@ for awareness. - **Requests filed:** frontend/requests/for-backend.md (yes/no) --> -_(no phases completed yet)_ +## frontend-phase-0 — Foundations: app shells, design system & data/contract patterns — 2026-07-02 +- **Shipped:** 3 actor shells (customer bottom-nav / nurse / admin sidebar) + role-aware routing under + `(private-routes)`; `useActorRole`; the `services/{domain}` reference (`patients`, mocked behind a + seam) with deliberate Query caching + invalidation; `lib/api/types.ts` (envelope/pagination); money + + Shamsi-date utils; shared composites `OtpInput`/`PhoneNumberField`/`StepperHeader`/`StatusChip`/ + `PlaceholderScreen` (each tested); i18n `nav`/`common`/`shell`/`patients` in both locales. Removed the + demo scaffolding; fixed the `BottomBar` pathname bug. +- **Consumes:** dev/contracts/conventions/* + openapi/swagger.v1.json (b0 = ping only). No feature + contract consumed yet. +- **Mocked client-side:** `services/patients` via `patientsMockApi` (USE_PATIENTS_MOCK=true) — template + for f1+. Swap is one line once real endpoints land. +- **Gate:** npm run check green · npm run test:ci green (72 tests) · npm run build green with + NEXT_PUBLIC_API_URL set. +- **Requests filed:** frontend/requests/for-backend.md — yes (REQ-001). diff --git a/dev/shared-working-context/frontend/requests/for-backend.md b/dev/shared-working-context/frontend/requests/for-backend.md index 5338017..1ab542a 100644 --- a/dev/shared-working-context/frontend/requests/for-backend.md +++ b/dev/shared-working-context/frontend/requests/for-backend.md @@ -12,4 +12,21 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a - **Status:** open | delivered in backend-phase-K --> -_(no requests yet)_ +## REQ-001 — Confirm response envelope, wire casing & pagination shape — filed by frontend-phase-0 — 2026-07-02 +- **Need:** Authoritative confirmation of three things the frontend types depend on: + 1. **Envelope unwrapping.** The b0 swagger shows every response wrapped in `ApiResult` + (`{ isSuccess, statusCode, message, requestId, data }`). The frontend's `clientFetch` currently + returns the **raw body**, so domain `clientApi`s read the payload via `unwrap()` (`data`). Confirm this + is the intended shape for all endpoints (i.e. payload always under `data`), so the pattern is correct + before f1+ copy it. + 2. **Wire casing.** Observed swagger properties are **camelCase** (`isSuccess`, `serverTimeUtc`) — not the + snake_case `api-conventions.md` implies for URL segments. Please confirm JSON body casing is camelCase + (and, if so, we can note it in the convention doc), or state where it differs. + 3. **Pagination payload.** `api-conventions.md` says lists return `items` + `total` (+ `page`/`page_size`). + Confirm the exact field names/casing on the wire (we've typed `Paginated` as + `{ items, total, page, pageSize }` in `client/src/lib/api/types.ts`). +- **Why:** These fix the shared `ApiEnvelope`/`Paginated` types and the `services/{domain}` reference + pattern every later frontend phase inherits. +- **Proposed shape:** `{ isSuccess: boolean, statusCode: number, message?: string, requestId?: string, data?: T }` + and `data: { items: T[], total: number, page: number, pageSize: number }` for lists. +- **Status:** open diff --git a/dev/shared-working-context/reports/frontend-phase-0-report.md b/dev/shared-working-context/reports/frontend-phase-0-report.md new file mode 100644 index 0000000..653ac3b --- /dev/null +++ b/dev/shared-working-context/reports/frontend-phase-0-report.md @@ -0,0 +1,97 @@ +# Frontend Phase 0 — Foundations: app shells, design system & data/contract patterns — Report (2026-07-02) + +## What was built + +**Cleanup (3.1)** +- Removed the `toastDemo` i18n namespace (both locales) and the placeholder home page. +- Deleted the two dead icons (`AppIcon/icons/CurrencyIcon.tsx`, `YellowPlanIcon.tsx`). +- Fixed `BottomBar` to read the route via `usePathname()` (was the global `location`) and made it + locale-aware (highlights the active tab, pushes locale-prefixed routes). +- Audit note "AppLoading missing from the `@/components` barrel" — verified it is already exported + (`components/common/index.tsx`); no change needed. + +**Actor shells + routing (3.2)** — three role-scoped experiences under `(private-routes)`, no layout +added above `[locale]`: +- **Customer (family)** — `(customer)` route group (no URL segment) at `/`, `/bookings`, `/patients`, + `/wallet`, `/profile`; `CustomerLayout` = slim TopBar + the 5-tab `BottomBar` from the wireframe. +- **Nurse** — `/nurse`, `/nurse/verification`, `/nurse/visits`; `NurseLayout` on the shared + `TopBarAndSideBarLayout` engine. +- **Admin** — `/admin`, `/admin/users`, `/admin/notifications`; `AdminLayout`, persistent desktop sidebar. +- Role model: `constants/roles.ts` (`AppRole`), optional `User.roles`, and `useActorRole()` (defaults to + `customer` until the server seeds roles in f1-b2). Nav is built per shell from `useTranslations('nav')`. + +**Data pattern + utils (3.3)** +- Reference domain `services/patients/` mirroring `auth`: `types.ts` (+ the `PatientsApi` seam interface), + `keys.ts` (hierarchical factory), `constants.ts` (mock toggle + staleTime), `apis/` (`clientApi` real, + `mockApi` in-memory, `index` selects by config), `hooks/` (`usePatients` with `staleTime`, + `useAddPatient` invalidates the list), barrel exporting hooks only. +- Shared wire types `lib/api/types.ts`: `ApiEnvelope` + `unwrap()`, `Paginated`, `PageParams`. +- Money/date utils in `@/utils`: `parseIrr`/`rialToToman`/`formatIrr`/`formatIrrToToman` (integer-safe + BigInt) and `formatShamsiDate`/`formatShamsiDateTime` (Intl Persian calendar — no date lib). Plus + `toEnglishDigits`/`digitsOnly` in `utils/text.ts`. + +**Shared composites (3.4)** — each in `src/components//` with a co-located `.test.tsx`, composed +from MUI/`App*` primitives, i18n-agnostic (labels passed by caller): +- `OtpInput` (auto-advance, backspace-to-previous, paste distribution, digit-normalizing, LTR-in-RTL), +- `PhoneNumberField` (Iranian mobile, normalizes Persian/Arabic digits, caps at 11, `isIranianMobile`), +- `StepperHeader` (MUI Stepper, RTL-aware), `StatusChip` (verified/pending/rejected/… off `--bal-*` tokens), +- `PlaceholderScreen` (empty-state used by every not-yet-built screen). +- Nurse/result card and price-breakdown were **deferred** to their feature phases (per the phase's "your call"). + +**i18n (3.5)** — seeded `nav`, `common`, `shell`, `patients` in both `en.json`/`fa.json` (in sync, +RTL-first). Documented the future namespace conventions in `client/CLAUDE.md`. + +## What is now testable (and exactly how) + +1. `cd client && npm run dev` → open `http://localhost:3000` (redirects to `/fa`). + - Customer shell: mobile 5-tab bottom nav (خانه/رزروها/بیماران/کیف‌پول/پروفایل); tapping switches routes + and highlights the active tab. + - Nurse shell: `/fa/nurse` — TopBar + sidebar (داشبورد/احراز هویت/ویزیت‌ها). + - Admin shell: `/fa/admin` — persistent sidebar on desktop (نمای کلی/کاربران/اعلان‌ها). + - Switch locale to `/en` → `dir` flips to LTR and all strings translate; dark-mode toggle still works. +2. **Reference data pattern:** `/fa/patients` shows the mocked list (~400 ms latency), an add form + (name + gender). Submitting adds the patient and the list updates **without a refetch** — open React + Query Devtools to watch `['patients','list',…]` cache + the invalidation on mutation success. +3. `npm run check` (type + lint) and `npm run test:ci` (72 tests, 12 suites) both pass. `npm run build` + passes when `NEXT_PUBLIC_API_URL` is set (see Follow-ups). + +## What is mocked / waiting on a real service + +- **Patients domain — client-side mock.** `services/patients/apis/mockApi.ts` (`patientsMockApi`) + implements the `PatientsApi` interface (`services/patients/types.ts`) in memory. Selected by + `USE_PATIENTS_MOCK = true` in `services/patients/constants.ts`. The real `clientApi.ts` is written + against `/patients` (GET list + POST create) and already unwraps the `ApiEnvelope`. **To make real:** + publish the `patients` contract + endpoints, set `USE_PATIENTS_MOCK = false` — no hook/component change. + (This is a frontend client-side mock, not a backend DI seam, so it is recorded here rather than in the + backend-owned `mocks-registry.md`.) +- This is the template f1+ copy for any domain whose backend phase hasn't merged. + +## Contracts + +- **Produced:** none (frontend consumes). +- **Consumed:** `dev/contracts/conventions/{api-conventions,money-and-types}.md` and the b0 + `openapi/swagger.v1.json` (only `ping` endpoints exist yet). Types-from-contract step is wired for the + `patients` reference (shapes mirror the intended wire; `ApiEnvelope`/`Paginated` in `lib/api/types.ts`). +- **Request filed:** `frontend/requests/for-backend.md` REQ-001 (confirm envelope unwrapping, wire casing, + pagination payload shape). + +## Docs updated + +- `client/CLAUDE.md`: *Project Structure* tree (new route groups, actor layouts, shared composites, + `services/patients`, `lib/api/types.ts`, `constants/roles.ts`, money/date utils); i18n namespaces + + future-namespace conventions; a new *services/{domain} reference pattern* subsection (caching, mock + seam, envelope, money/dates). Corrected the `ColorSchemeScript` doc drift in the two structure lines + that named it (it is neither exported from `@/theme` nor rendered). + +## Follow-ups for later phases + +- **Envelope unwrapping (REQ-001):** `clientFetch`/`serverFetch` currently return the raw body, so domain + `clientApi`s call `unwrap()`. If the team prefers central unwrapping, that touches the auth plumbing — + coordinate before changing. Wire casing observed is **camelCase**, not the snake_case api-conventions + implies; confirm and update the convention doc. +- **Role guards:** shells read `useActorRole()` but do not yet *guard* cross-actor access (any authed user + can open `/nurse`, `/admin`). Add real guards once roles land in **f1-b2**. +- **Login is username/password** today; phone-OTP arrives in **f1-b2** (use `OtpInput`/`PhoneNumberField`). +- **`npm run build` needs `NEXT_PUBLIC_API_URL`:** `@/config` uses `envRequired`, which throws at import. + Dev works via the committed `.env.development`; a production build must supply the var (as it always would + once any page imports the fetch layer). Not a code defect — an env expectation to note in CI.