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) <noreply@anthropic.com>
This commit is contained in:
+72
-16
@@ -114,18 +114,42 @@ client/
|
||||
│ └── [locale]/
|
||||
│ ├── layout.tsx # ROOT RSC: renders <html lang/dir> + 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<T> — throws ApiError on error; use in hooks/client components
|
||||
│ │ ├── server.ts # serverFetch<T> — throws ApiError on error; use in RSCs/Server Actions
|
||||
│ │ ├── types.ts # ApiEnvelope<T> + unwrap(), Paginated<T>, 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<T>` (`{ 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
|
||||
|
||||
+36
-13
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+36
-13
@@ -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": "بیمار اضافه شد"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <PlaceholderScreen icon="bookings" title={t('bookings')} description={tShell('placeholder_body')} />;
|
||||
}
|
||||
@@ -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 <CustomerLayout>{children}</CustomerLayout>;
|
||||
}
|
||||
@@ -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 <PlaceholderScreen icon="home" title={t('home')} description={tShell('placeholder_body')} />;
|
||||
}
|
||||
@@ -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<Gender>('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 (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ alignItems: { sm: 'flex-start' } }}>
|
||||
<TextField
|
||||
label={t('name_label')}
|
||||
value={name}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setName(event.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
label={t('gender_label')}
|
||||
value={gender}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setGender(event.target.value as Gender)}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
<MenuItem value="female">{t('gender_female')}</MenuItem>
|
||||
<MenuItem value="male">{t('gender_male')}</MenuItem>
|
||||
</TextField>
|
||||
<AppButton
|
||||
color="primary"
|
||||
startIcon="add"
|
||||
onClick={handleAdd}
|
||||
disabled={!name.trim() || addPatient.isPending}
|
||||
>
|
||||
{t('add')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
|
||||
{isLoading ? (
|
||||
<AppLoading />
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<Typography sx={{ color: 'text.secondary' }}>{t('empty')}</Typography>
|
||||
) : (
|
||||
<List>
|
||||
{data.items.map((patient) => (
|
||||
<ListItem
|
||||
key={patient.id}
|
||||
divider
|
||||
secondaryAction={<Chip size="small" label={genderLabel(patient.gender)} />}
|
||||
>
|
||||
<ListItemText primary={patient.fullName} secondary={formatShamsiDate(patient.createdAtUtc, locale)} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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 <PlaceholderScreen icon="profile" title={t('profile')} description={tShell('placeholder_body')} />;
|
||||
}
|
||||
@@ -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 <PlaceholderScreen icon="wallet" title={t('wallet')} description={tShell('placeholder_body')} />;
|
||||
}
|
||||
@@ -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 <AdminLayout>{children}</AdminLayout>;
|
||||
}
|
||||
@@ -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 <PlaceholderScreen icon="notifications" title={t('notifications')} description={tShell('placeholder_body')} />;
|
||||
}
|
||||
@@ -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 <PlaceholderScreen icon="admin" title={t('overview')} description={tShell('placeholder_body')} />;
|
||||
}
|
||||
@@ -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 <PlaceholderScreen icon="users" title={t('users')} description={tShell('placeholder_body')} />;
|
||||
}
|
||||
@@ -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 <NurseLayout>{children}</NurseLayout>;
|
||||
}
|
||||
@@ -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 <PlaceholderScreen icon="dashboard" title={t('dashboard')} description={tShell('placeholder_body')} />;
|
||||
}
|
||||
@@ -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 <PlaceholderScreen icon="verification" title={t('verification')} description={tShell('placeholder_body')} />;
|
||||
}
|
||||
@@ -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 <PlaceholderScreen icon="visits" title={t('visits')} description={tShell('placeholder_body')} />;
|
||||
}
|
||||
@@ -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 (
|
||||
<Box sx={{ p: 4 }}>
|
||||
<Typography>Balin yaar</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<ThemeProvider>
|
||||
<OtpInput length={length} value={value} onChange={setValue} onComplete={onComplete} aria-label="code" />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('<OtpInput/> component', () => {
|
||||
it('renders one box per length', () => {
|
||||
render(<Harness length={5} />);
|
||||
expect(screen.getAllByRole('textbox')).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('accepts a digit into the first box', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness length={4} />);
|
||||
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(<Harness length={4} />);
|
||||
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(<Harness length={4} onComplete={onComplete} />);
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<OtpInputProps> = ({
|
||||
length = DEFAULT_LENGTH,
|
||||
value,
|
||||
onChange,
|
||||
onComplete,
|
||||
disabled,
|
||||
autoFocus,
|
||||
error,
|
||||
'aria-label': ariaLabel,
|
||||
}) => {
|
||||
const inputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
|
||||
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<HTMLInputElement>) => {
|
||||
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<HTMLInputElement>) => {
|
||||
if (event.key === 'Backspace' && !chars[index]) {
|
||||
focusBox(index - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = (index: number, event: ClipboardEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<Stack direction="row" spacing={1} dir="ltr" role="group" aria-label={ariaLabel} sx={{ justifyContent: 'center' }}>
|
||||
{chars.map((char, index) => (
|
||||
<TextField
|
||||
key={index}
|
||||
value={char}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
onChange={(event) => handleChange(index, event as ChangeEvent<HTMLInputElement>)}
|
||||
onKeyDown={(event) => handleKeyDown(index, event as KeyboardEvent<HTMLInputElement>)}
|
||||
onPaste={(event) => handlePaste(index, event as ClipboardEvent<HTMLInputElement>)}
|
||||
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 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default OtpInput;
|
||||
@@ -0,0 +1,4 @@
|
||||
import OtpInput from './OtpInput';
|
||||
|
||||
export type { OtpInputProps } from './OtpInput';
|
||||
export { OtpInput as default, OtpInput };
|
||||
@@ -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 (
|
||||
<ThemeProvider>
|
||||
<PhoneNumberField value={value} onChange={setValue} label="Phone" />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('<PhoneNumberField/> component', () => {
|
||||
it('normalizes Persian digits to ASCII', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
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(<Harness />);
|
||||
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(<Harness />);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<TextFieldProps, 'onChange' | 'value' | 'type'> {
|
||||
/** 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<PhoneNumberFieldProps> = ({ value, onChange, slotProps, ...rest }) => {
|
||||
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(digitsOnly(event.target.value).slice(0, IRAN_MOBILE_LENGTH));
|
||||
};
|
||||
|
||||
return (
|
||||
<TextField
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
dir: 'ltr',
|
||||
inputMode: 'numeric',
|
||||
maxLength: IRAN_MOBILE_LENGTH,
|
||||
style: { textAlign: 'start' },
|
||||
},
|
||||
...slotProps,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhoneNumberField;
|
||||
@@ -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 };
|
||||
@@ -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<PlaceholderScreenProps> = (props) => (
|
||||
<ThemeProvider>
|
||||
<PlaceholderScreen {...props} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
describe('<PlaceholderScreen/> component', () => {
|
||||
it('renders the title as a heading', () => {
|
||||
render(<ComponentToTest title="Bookings" />);
|
||||
expect(screen.getByRole('heading', { name: 'Bookings' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the description when provided', () => {
|
||||
render(<ComponentToTest title="Bookings" description="Coming soon in a later phase." />);
|
||||
expect(screen.getByText('Coming soon in a later phase.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the description when not provided', () => {
|
||||
render(<ComponentToTest title="Wallet" />);
|
||||
expect(screen.queryByText(/phase/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the named icon', () => {
|
||||
const { container } = render(<ComponentToTest title="Patients" icon="patients" />);
|
||||
expect(container.querySelector('[data-icon="patients"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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<PlaceholderScreenProps> = ({ title, description, icon }) => (
|
||||
<Stack
|
||||
sx={{ alignItems: 'center', justifyContent: 'center', textAlign: 'center', gap: 1.5, py: 8, px: 2 }}
|
||||
>
|
||||
{icon && <AppIcon icon={icon} size={48} color="var(--bal-secondary)" />}
|
||||
<Typography variant="h5" component="h1">
|
||||
{title}
|
||||
</Typography>
|
||||
{description && (
|
||||
<Typography variant="body1" sx={{ color: 'text.secondary', maxWidth: 420 }}>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export default PlaceholderScreen;
|
||||
@@ -0,0 +1,4 @@
|
||||
import PlaceholderScreen from './PlaceholderScreen';
|
||||
|
||||
export type { PlaceholderScreenProps } from './PlaceholderScreen';
|
||||
export { PlaceholderScreen as default, PlaceholderScreen };
|
||||
@@ -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<StatusChipProps> = (props) => (
|
||||
<ThemeProvider>
|
||||
<StatusChip {...props} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
describe('<StatusChip/> component', () => {
|
||||
it('renders the label', () => {
|
||||
render(<ComponentToTest status="verified" label="Verified" />);
|
||||
expect(screen.getByText('Verified')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes the status via a data attribute', () => {
|
||||
const { container } = render(<ComponentToTest status="pending" label="Pending" />);
|
||||
expect(container.querySelector('[data-status="pending"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the status icon for rejected', () => {
|
||||
const { container } = render(<ComponentToTest status="rejected" label="Rejected" />);
|
||||
expect(container.querySelector('[data-icon="rejected"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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<StatusKind, StatusStyle> = {
|
||||
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<ChipProps, 'color' | 'icon' | 'label'> {
|
||||
/** 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<StatusChipProps> = ({ status, label, size = 'small', sx, ...rest }) => {
|
||||
const style = STATUS_STYLE[status];
|
||||
return (
|
||||
<Chip
|
||||
data-status={status}
|
||||
size={size}
|
||||
label={label}
|
||||
icon={<AppIcon icon={style.icon} size={16} color={style.fg} />}
|
||||
sx={{ backgroundColor: style.bg, color: style.fg, fontWeight: 600, ...sx }}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusChip;
|
||||
@@ -0,0 +1,4 @@
|
||||
import StatusChip from './StatusChip';
|
||||
|
||||
export type { StatusChipProps, StatusKind } from './StatusChip';
|
||||
export { StatusChip as default, StatusChip };
|
||||
@@ -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<Partial<StepperHeaderProps>> = ({ steps = STEPS, activeStep = 0 }) => (
|
||||
<ThemeProvider>
|
||||
<StepperHeader steps={steps} activeStep={activeStep} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
describe('<StepperHeader/> component', () => {
|
||||
it('renders every step label', () => {
|
||||
render(<ComponentToTest />);
|
||||
STEPS.forEach((label) => expect(screen.getByText(label)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('marks the active step', () => {
|
||||
const { container } = render(<ComponentToTest activeStep={1} />);
|
||||
// MUI flags the active step's icon with the Mui-active class.
|
||||
expect(container.querySelector('.Mui-active')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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<StepperHeaderProps> = ({ steps, activeStep, alternativeLabel = true }) => (
|
||||
<Stepper activeStep={activeStep} alternativeLabel={alternativeLabel} sx={{ py: 2 }}>
|
||||
{steps.map((label) => (
|
||||
<Step key={label}>
|
||||
<StepLabel>{label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
);
|
||||
|
||||
export default StepperHeader;
|
||||
@@ -0,0 +1,4 @@
|
||||
import StepperHeader from './StepperHeader';
|
||||
|
||||
export type { StepperHeaderProps } from './StepperHeader';
|
||||
export { StepperHeader as default, StepperHeader };
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { IconProps } from '../utils';
|
||||
|
||||
const CurrencyIcon: FunctionComponent<IconProps> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 36 36" {...props}>
|
||||
<path
|
||||
fill="#D99E82"
|
||||
d="M35.222 33.598c-.647-2.101-1.705-6.059-2.325-7.566-.501-1.216-.969-2.438-1.544-3.014-.575-.575-1.553-.53-2.143.058 0 0-2.469 1.675-3.354 2.783-1.108.882-2.785 3.357-2.785 3.357-.59.59-.635 1.567-.06 2.143.576.575 1.798 1.043 3.015 1.544 1.506.62 5.465 1.676 7.566 2.325.359.11 1.74-1.271 1.63-1.63z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA596E"
|
||||
d="M13.643 5.308c1.151 1.151 1.151 3.016 0 4.167l-4.167 4.168c-1.151 1.15-3.018 1.15-4.167 0L1.141 9.475c-1.15-1.151-1.15-3.016 0-4.167l4.167-4.167c1.15-1.151 3.016-1.151 4.167 0l4.168 4.167z"
|
||||
/>
|
||||
<path fill="#FFCC4D" d="M31.353 23.018l-4.17 4.17-4.163 4.165L7.392 15.726l8.335-8.334 15.626 15.626z" />
|
||||
<path
|
||||
fill="#292F33"
|
||||
d="M32.078 34.763s2.709 1.489 3.441.757c.732-.732-.765-3.435-.765-3.435s-2.566.048-2.676 2.678z"
|
||||
/>
|
||||
<path fill="#CCD6DD" d="M2.183 10.517l8.335-8.335 5.208 5.209-8.334 8.335z" />
|
||||
<path
|
||||
fill="#99AAB5"
|
||||
d="M3.225 11.558l8.334-8.334 1.042 1.042L4.267 12.6zm2.083 2.086l8.335-8.335 1.042 1.042-8.335 8.334z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurrencyIcon;
|
||||
@@ -1,125 +0,0 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { IconProps } from '../utils';
|
||||
|
||||
const YellowPlaneIcon: FunctionComponent<IconProps> = (props) => {
|
||||
const styleOpacityAndEnableBackground = {
|
||||
opacity: 0.2,
|
||||
// enableBackground: 'new'
|
||||
};
|
||||
|
||||
return (
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" xmlSpace="preserve" {...props}>
|
||||
<path
|
||||
style={{ fill: '#FFCE00' }}
|
||||
d="M142.21,493.991c12.991,12.991,34.057,12.991,47.05,0c12.991-12.991,12.991-34.057,0-47.048
|
||||
L65.057,322.742c-12.993-12.992-34.059-12.992-47.05,0s-12.989,34.055,0,47.048L142.21,493.991z"
|
||||
/>
|
||||
<circle style={{ fill: '#7D868C' }} cx="386.857" cy="125.141" r="35.932" />
|
||||
<path
|
||||
style={styleOpacityAndEnableBackground}
|
||||
d="M391.846,120.154c-9.556-9.556-18.727-17.187-27.59-22.948
|
||||
c-0.969,0.785-1.908,1.627-2.807,2.527c-14.033,14.034-14.033,36.786,0,50.816c14.031,14.034,36.786,14.034,50.816,0
|
||||
c0.908-0.907,1.754-1.853,2.546-2.829C409.07,138.902,401.453,129.759,391.846,120.154z"
|
||||
/>
|
||||
<path
|
||||
style={styleOpacityAndEnableBackground}
|
||||
d="M75.949,333.636c-15.914,18.935-28.002,33.038-32.3,37.337
|
||||
c-4.221,4.221-7.777,8.86-10.672,13.785l94.264,94.266c4.925-2.894,9.565-6.449,13.789-10.672
|
||||
c4.298-4.301,18.399-16.384,37.334-32.303L75.949,333.636z"
|
||||
/>
|
||||
<path
|
||||
style={{ fill: '#333E48' }}
|
||||
d="M384.037,127.964c-30.663-30.663-63.439-47.604-94.099-16.941
|
||||
C254.182,146.782,79.164,363.201,57.52,384.842c-19.227,19.231-19.23,50.409,0,69.636c19.23,19.233,50.409,19.228,69.636,0
|
||||
c21.644-21.641,238.063-196.657,273.82-232.416C431.639,191.4,414.698,158.626,384.037,127.964z"
|
||||
/>
|
||||
<circle style={{ fill: '#7D868C' }} cx="218.905" cy="293.104" r="35.93" />
|
||||
<path
|
||||
style={styleOpacityAndEnableBackground}
|
||||
d="M278.708,123.019c-25.32,28.083-74.009,86.548-119.486,141.296
|
||||
l88.463,88.463c54.748-45.477,113.215-94.162,141.296-119.487L278.708,123.019z"
|
||||
/>
|
||||
<g>
|
||||
<path
|
||||
style={{ fill: '#FFCE00' }}
|
||||
d="M384.46,458.665c27.283,27.281,71.513,27.279,98.795-0.003c27.28-27.279,27.283-71.511,0-98.793
|
||||
L152.13,28.746c-27.283-27.283-71.513-27.283-98.793,0c-27.283,27.281-27.285,71.514-0.002,98.793L384.46,458.665z"
|
||||
/>
|
||||
<path
|
||||
style={{ fill: '#FFCE00' }}
|
||||
d="M84.341,435.945c-2.121,0-4.241-0.809-5.857-2.426c-3.236-3.235-3.236-8.48-0.002-11.716
|
||||
l50.812-50.814c3.236-3.236,8.483-3.235,11.716-0.001c3.236,3.235,3.236,8.48,0,11.714l-50.812,50.814
|
||||
C88.58,435.136,86.459,435.945,84.341,435.945z"
|
||||
/>
|
||||
</g>
|
||||
<rect
|
||||
x="174.379"
|
||||
y="88.222"
|
||||
transform="matrix(-0.7071 -0.7071 0.7071 -0.7071 200.0443 399.0237)"
|
||||
style={styleOpacityAndEnableBackground}
|
||||
width="16.568"
|
||||
height="139.719"
|
||||
/>
|
||||
<rect
|
||||
x="117.601"
|
||||
y="31.432"
|
||||
transform="matrix(-0.7071 -0.7071 0.7071 -0.7071 143.2742 261.929)"
|
||||
style={styleOpacityAndEnableBackground}
|
||||
width="16.568"
|
||||
height="139.719"
|
||||
/>
|
||||
<rect
|
||||
x="345.637"
|
||||
y="259.464"
|
||||
transform="matrix(-0.7071 -0.7071 0.7071 -0.7071 371.313 812.4501)"
|
||||
style={styleOpacityAndEnableBackground}
|
||||
width="16.568"
|
||||
height="139.719"
|
||||
/>
|
||||
<rect
|
||||
x="402.427"
|
||||
y="316.259"
|
||||
transform="matrix(-0.7071 -0.7071 0.7071 -0.7071 428.1006 949.5629)"
|
||||
style={styleOpacityAndEnableBackground}
|
||||
width="16.568"
|
||||
height="139.719"
|
||||
/>
|
||||
<path
|
||||
style={{ fill: '#1E252B' }}
|
||||
d="M489.114,354.011L383.944,248.841c10.747-9.425,18.276-16.308,22.89-20.921
|
||||
c16.43-16.43,22.038-34.95,16.673-55.044c-1.363-5.108-3.454-10.308-6.267-15.628c0.296-0.281,0.596-0.553,0.885-0.842
|
||||
c2.167-2.167,4.076-4.523,5.724-7.024l41.209,41.209c1.618,1.617,3.739,2.426,5.858,2.426c2.12,0,4.24-0.808,5.858-2.426
|
||||
c3.235-3.236,3.235-8.48,0-11.716l-46.323-46.323c0.406-2.427,0.626-4.902,0.626-7.411c0-11.811-4.599-22.914-12.95-31.265
|
||||
c-8.351-8.352-19.454-12.953-31.265-12.953c-2.511,0-4.987,0.22-7.415,0.627L333.126,35.23c-3.235-3.235-8.48-3.236-11.716-0.001
|
||||
c-3.236,3.235-3.236,8.48-0.001,11.714l41.209,41.21c-2.503,1.647-4.858,3.555-7.025,5.722c-0.288,0.288-0.562,0.59-0.843,0.886
|
||||
c-5.319-2.812-10.518-4.902-15.627-6.267c-20.099-5.369-38.615,0.243-55.044,16.673c-4.61,4.611-11.492,12.142-20.921,22.893
|
||||
L157.989,22.89C143.229,8.129,123.605,0,102.733,0S62.237,8.129,47.48,22.888c-14.76,14.76-22.89,34.383-22.89,55.254
|
||||
c-0.001,20.873,8.127,40.497,22.887,55.254l114.564,114.564c-6.337,7.624-12.648,15.223-18.86,22.703
|
||||
c-19.346,23.293-38.189,45.983-53.839,64.649l-18.428-18.429c-7.848-7.848-18.284-12.17-29.383-12.17s-21.534,4.322-29.382,12.172
|
||||
c-16.198,16.199-16.198,42.559,0,58.761l25.683,25.684c-6.704,20.045-2.103,43.073,13.83,59.004
|
||||
c10.865,10.867,25.311,16.85,40.677,16.85c6.339,0,12.515-1.034,18.354-2.994l25.658,25.658c8.102,8.101,18.74,12.15,29.381,12.15
|
||||
c10.64,0,21.284-4.051,29.384-12.15c16.201-16.201,16.201-42.562-0.001-58.763l-18.43-18.43
|
||||
c18.659-15.643,41.335-34.476,64.615-53.811c7.49-6.221,15.101-12.541,22.736-18.887l114.566,114.564
|
||||
c14.757,14.756,34.379,22.885,55.251,22.887c0.002,0,0.002,0,0.004,0c20.87,0,40.495-8.129,55.254-22.89
|
||||
c14.758-14.759,22.887-34.381,22.888-55.253C512.002,388.394,503.872,368.77,489.114,354.011z M386.86,97.491
|
||||
c7.385,0,14.328,2.876,19.55,8.099c5.222,5.222,8.097,12.165,8.097,19.55c0,6.56-2.273,12.766-6.438,17.731
|
||||
c-4.96-6.686-10.995-13.587-18.174-20.765c-7.179-7.179-14.08-13.215-20.767-18.176C374.093,99.765,380.301,97.491,386.86,97.491z
|
||||
M295.795,116.881c12.28-12.282,24.689-16.218,39.053-12.38c12.86,3.434,27.034,13.026,43.329,29.323
|
||||
c16.296,16.295,25.886,30.468,29.32,43.329c3.836,14.362-0.098,26.772-12.382,39.054c-4.413,4.414-12.113,11.434-22.915,20.895
|
||||
l-97.303-97.303C284.365,128.993,291.385,121.29,295.795,116.881z M23.866,363.932c-9.74-9.742-9.74-25.593,0-35.333
|
||||
c4.717-4.718,10.992-7.317,17.666-7.317c6.675,0,12.949,2.599,17.668,7.317l19.438,19.441C65.414,363.702,55.648,375,51.662,378.986
|
||||
c-2.168,2.168-4.12,4.47-5.868,6.876L23.866,363.932z M183.401,452.801c9.742,9.741,9.742,25.59,0.001,35.331
|
||||
c-9.742,9.741-25.594,9.742-35.336,0l-21.927-21.926c2.417-1.763,4.717-3.717,6.873-5.872c3.985-3.985,15.285-13.751,30.947-26.976
|
||||
L183.401,452.801z M230.718,356.101c-53.485,44.418-99.676,82.779-109.419,92.521c-7.735,7.735-18.02,11.995-28.96,11.996
|
||||
c-10.939,0-21.225-4.26-28.961-11.997c-15.968-15.966-15.968-41.949,0-57.92c9.744-9.743,48.118-55.949,92.55-109.452
|
||||
c5.891-7.094,11.87-14.293,17.879-21.523l78.468,78.468C245.033,344.21,237.822,350.2,230.718,356.101z M477.397,452.804
|
||||
c-11.631,11.632-27.093,18.038-43.539,18.038h-0.003c-16.447-0.001-31.909-6.406-43.538-18.034L59.192,121.681
|
||||
c-11.631-11.628-18.036-27.09-18.034-43.538c0-16.447,6.406-31.91,18.037-43.541c11.631-11.629,27.093-18.034,43.539-18.034
|
||||
c16.447,0,31.91,6.405,43.54,18.036l331.124,331.123c11.63,11.629,18.036,27.093,18.036,43.54
|
||||
C495.434,425.713,489.029,441.175,477.397,452.804z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default YellowPlaneIcon;
|
||||
@@ -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';
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './headers';
|
||||
export * from './roles';
|
||||
export * from './routes';
|
||||
|
||||
@@ -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;
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<PropsWithChildren> = ({ children }) => {
|
||||
const t = useTranslations('nav');
|
||||
const tShell = useTranslations('shell');
|
||||
|
||||
const sidebarItems: Array<LinkToPage> = 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 (
|
||||
<TopBarAndSideBarLayout
|
||||
sidebarItems={sidebarItems}
|
||||
title={tShell('admin_console')}
|
||||
variant="sidebarPersistentOnDesktop"
|
||||
>
|
||||
{children}
|
||||
</TopBarAndSideBarLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLayout;
|
||||
@@ -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<PropsWithChildren> = ({ children }) => {
|
||||
const t = useTranslations('nav');
|
||||
const tShell = useTranslations('shell');
|
||||
const onMobile = useIsMobile();
|
||||
|
||||
const bottomNavItems: Array<LinkToPage> = 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 (
|
||||
<Stack sx={{ height: '100dvh' }}>
|
||||
<Stack component="header">
|
||||
<TopBar title={tShell('customer_app')} endNode={<DarkModeToggleButton />} />
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
overflowY: 'auto',
|
||||
width: '100%',
|
||||
maxWidth: CONTENT_MAX_WIDTH,
|
||||
mx: 'auto',
|
||||
px: 2,
|
||||
py: 2,
|
||||
// AppBar is position: fixed — offset content by the top-bar height.
|
||||
pt: `calc(${onMobile ? TOP_BAR_MOBILE_HEIGHT : TOP_BAR_DESKTOP_HEIGHT} + 8px)`,
|
||||
}}
|
||||
>
|
||||
<ErrorBoundary name="Customer">{children}</ErrorBoundary>
|
||||
</Box>
|
||||
|
||||
<BottomBar items={bottomNavItems} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerLayout;
|
||||
@@ -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<PropsWithChildren> = ({ children }) => {
|
||||
const t = useTranslations('nav');
|
||||
const tShell = useTranslations('shell');
|
||||
|
||||
const sidebarItems: Array<LinkToPage> = 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 (
|
||||
<TopBarAndSideBarLayout
|
||||
sidebarItems={sidebarItems}
|
||||
title={tShell('nurse_app')}
|
||||
variant="sidebarPersistentOnDesktop"
|
||||
>
|
||||
{children}
|
||||
</TopBarAndSideBarLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default NurseLayout;
|
||||
@@ -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<LinkToPage>;
|
||||
}
|
||||
|
||||
/** 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<Props> = ({ 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 (
|
||||
<BottomNavigation
|
||||
value={location.pathname} // Automatically highlights bottom navigation for current page
|
||||
showLabels // Always show labels on bottom navigation, otherwise label visible only for active page
|
||||
onChange={onNavigationChange}
|
||||
<Paper
|
||||
elevation={3}
|
||||
square
|
||||
component="nav"
|
||||
sx={{ borderTop: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
{items.map(({ title, path, icon }) => (
|
||||
<BottomNavigationAction key={`${title}-${path}`} label={title} value={path} icon={<AppIcon icon={icon} />} />
|
||||
))}
|
||||
</BottomNavigation>
|
||||
<BottomNavigation value={activePath} showLabels onChange={onNavigationChange}>
|
||||
{items.map(({ title, path, icon }) => (
|
||||
<BottomNavigationAction key={`${title}-${path}`} label={title} value={path} icon={<AppIcon icon={icon} />} />
|
||||
))}
|
||||
</BottomNavigation>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<T>` 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<T> {
|
||||
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<T>(envelope: ApiEnvelope<T>): 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<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/** Query params for a paginated list request. */
|
||||
export interface PageParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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<ApiEnvelope<Paginated<Patient>>>(`${BASE}${qs ? `?${qs}` : ''}`);
|
||||
return unwrap(env);
|
||||
},
|
||||
|
||||
create: async (dto: CreatePatientDto) => {
|
||||
const env = await clientFetch<ApiEnvelope<Patient>>(BASE, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
return unwrap(env);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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<Paginated<Patient>> => {
|
||||
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<Patient> => {
|
||||
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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { usePatients } from './hooks/usePatients';
|
||||
export { useAddPatient } from './hooks/useAddPatient';
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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/<domain>.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<Paginated<Patient>>;
|
||||
create(dto: CreatePatientDto): Promise<Patient>;
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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('۲');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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, '');
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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<T>` as
|
||||
`{ items, total, page, pageSize }` in `client/src/lib/api/types.ts`).
|
||||
- **Why:** These fix the shared `ApiEnvelope<T>`/`Paginated<T>` 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
|
||||
|
||||
@@ -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<T>` + `unwrap()`, `Paginated<T>`, `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/<Name>/` 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.
|
||||
Reference in New Issue
Block a user