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:
hamid
2026-07-02 01:19:21 +03:30
parent 2f2aec61a2
commit 94fdcbe0d1
65 changed files with 1632 additions and 227 deletions
+72 -16
View File
@@ -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
View File
@@ -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
View File
@@ -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');
});
});
+135
View File
@@ -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;
+4
View File
@@ -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;
+11 -1
View File
@@ -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
View File
@@ -1,2 +1,3 @@
export * from './headers';
export * from './roles';
export * from './routes';
+15
View File
@@ -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;
+16
View File
@@ -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. */
+18
View File
@@ -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;
}
+37
View File
@@ -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;
+64
View File
@@ -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;
+38
View File
@@ -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;
+41 -14
View File
@@ -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>
);
};
+4 -1
View File
@@ -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 };
+44
View File
@@ -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;
}
+5
View File
@@ -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,
});
}
+2
View File
@@ -0,0 +1,2 @@
export { usePatients } from './hooks/usePatients';
export { useAddPatient } from './hooks/useAddPatient';
+13
View File
@@ -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,
};
+33
View File
@@ -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>;
}
+34
View File
@@ -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',
});
}
+2
View File
@@ -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';
+48
View File
@@ -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('۲');
});
});
});
+41
View File
@@ -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));
}
+21
View File
@@ -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, '');
}
+14 -1
View File
@@ -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.