Compare commits

..

29 Commits

Author SHA1 Message Date
hamid 5e4d19ac4f make builds sequential for better performing 2026-08-02 23:54:57 +03:30
hamid 71ca986dcd remove blocker phases after done 2026-08-02 23:42:39 +03:30
hamid 10d160358f blocker phase end 2026-08-02 23:42:12 +03:30
hamid 184b202f00 blocker phase 12 2026-08-02 23:24:28 +03:30
hamid 66a60ce874 3 blocker phases 2026-08-02 23:12:44 +03:30
hamid 90e0cdcc34 phase 7 blockers 2026-08-02 22:30:24 +03:30
hamid 9a55846df3 blocker phase 6, catalog admin 2026-08-02 22:09:45 +03:30
hamid e3c988e961 remove docs 2026-08-02 21:34:02 +03:30
hamid 340012b2f8 blocker phase 5 2026-08-02 21:33:10 +03:30
hamid 08949a24de blocker fix phase 4 2026-08-02 21:27:34 +03:30
hamid dd3e39dec5 blocker phase 3 addresses 2026-08-02 21:15:57 +03:30
hamid 2cd1075286 fix blocker 1 - super admin 2026-08-02 21:03:26 +03:30
hamid 42f38f5a72 blocker fix phases added 2026-08-02 20:37:14 +03:30
hamid fb58ca54e1 create mvp path 2026-08-02 20:01:31 +03:30
hamid 72ab290da1 cleanup phase 7 2026-08-02 18:58:46 +03:30
hamid 51e86a1e5f cleanup phases 6 2026-08-02 18:48:32 +03:30
hamid e2db97392a cleanup phase 5 2026-08-02 18:33:43 +03:30
hamid cd8144e653 cleanup docs phase 4 2026-08-02 18:08:40 +03:30
hamid b876490246 cleanup phases 3 2026-08-02 17:18:36 +03:30
hamid c841bded26 doc clean up phase 2 2026-07-30 12:49:46 +03:30
hamid c889c46110 cleanup phase 1 2026-07-30 02:26:52 +03:30
hamid d3ec723119 cleanup phases 0 done 2026-07-29 23:20:46 +03:30
hamid c99e3f4a6e making the mess clean plans added 2026-07-29 22:46:38 +03:30
hamid 96b57eb1b8 client docker file update for ignoring optional deps 2026-07-28 23:40:11 +03:30
hamid 5885280b49 remove user-secrets approach & prepare a pilot deploy 2026-07-28 23:18:54 +03:30
hamid 630c7907ec integrate telegram bot 2026-07-28 22:25:15 +03:30
hamid e6a8f93a1e manual improvement 2 & add telegram bot 2026-07-27 23:58:16 +03:30
hamid baa3cc63cd manual improvement 1 2026-07-27 22:27:04 +03:30
hamid bd06ef0016 start of manual testing 2026-07-27 00:54:22 +03:30
619 changed files with 13980 additions and 73293 deletions
+167
View File
@@ -0,0 +1,167 @@
---
name: backend-feature
description: >-
Add a feature to the Balinyaar .NET server — a command, a query, or both — end to end: the Application
slice, the controller, EF configuration/migration if it touches a table, tests, and the doc updates it
triggers. Use when implementing a new endpoint or extending an existing one anywhere under server/.
---
# Balinyaar Backend Feature
The sequence for shipping one CQRS slice, from the Application layer to a green gate.
**Precedence.** This skill is the **procedure** — what order to do things in. The **rules** within each
step live in `docs/rules/server/` and this skill defers to them; it doesn't restate them.
| For | Read |
|-----|------|
| The dispatcher, folder shape, `OperationResult`, the controller skeleton, authorization | [docs/rules/server/cqrs.md](../../../archive/docs/rules/server/cqrs.md) |
| Projects, layers, the seam catalogue, startup wiring | [docs/rules/server/structure.md](../../../archive/docs/rules/server/structure.md) |
| EF Core, migrations, soft-delete, audit, config-as-rows, state machines, snapshots, uniqueness | [docs/rules/server/persistence.md](../../../archive/docs/rules/server/persistence.md) |
| Anything on the money path — ledger, refunds, BNPL, payouts, invoices | [docs/rules/server/money.md](../../../archive/docs/rules/server/money.md) |
| Auth, JWE, sessions, field encryption, tenancy | [docs/rules/server/identity.md](../../../archive/docs/rules/server/identity.md) |
| C# style, naming, async, testing | [docs/rules/server/conventions.md](../../../archive/docs/rules/server/conventions.md) |
| The gate, and what "done" means | [docs/rules/shared/git-and-gates.md](../../../archive/docs/rules/shared/git-and-gates.md) |
**Stack:** ASP.NET Core (.NET 10), Clean Architecture, CQRS on `martinothamar/Mediator` (a source generator —
**not MediatR**; there is no `IMediator` anywhere in this codebase), EF Core, FluentValidation, Mapster.
---
## 1. Scope it before writing anything
- **Which area?** The Application feature areas mirror the Domain entity folders — `Identity`, `Geography`,
`Catalog`, `Verification`, `Search`, `Booking` (singular, pre-payment) / `Bookings` (plural, post-payment),
`Payments`, `Refunds`, `Invoices`, `Bnpl`, `Payouts`, `Reviews`, `PatientCareRecords`, `Messaging`,
`PartnerCenters`, `Configuration`, `Audit`, `Analytics`, `Holidays`, `Notifications`, `SupportAlerts`. Full
list and the schema-per-area mapping: [structure.md](../../../archive/docs/rules/server/structure.md) §2.
- **Command or query, or both?** A command mutates; a query reads. Most features are a matched pair (create
+ get, or update + list).
- **Find a sibling to mirror.** Grep the area's existing folder —
`Features/<Area>/{Commands,Queries}/` — for a feature shaped like the one you're adding. Copying a live
pattern beats inventing a new one.
- **Does it touch money?** (ledger, refunds, invoices, BNPL, payouts) → read
[money.md](../../../archive/docs/rules/server/money.md) **first**. The invariants there (integer IRR, balanced
ledger postings, webhook idempotency, snapshot-at-compute-time) are not suggestions.
- **Does it add or change a table?** → read [persistence.md](../../../archive/docs/rules/server/persistence.md) §57
before modeling it (soft-delete filters, forward-only status machines, snapshot fields, uniqueness
patterns all have a house pattern — don't reinvent one).
- **Does it need a new external dependency** (a vendor, a rail)? It becomes an interface in
`Application/Contracts/`, mock in `CrossCutting/Seams/`, real in `CrossCutting/Seams/Real/`, selected by a
`Seams:<rail>:Provider` config key that **falls closed to the mock**. See
[structure.md](../../../archive/docs/rules/server/structure.md) §3.
---
## 2. The Application slice
```
Baya.Application/Features/<Area>/
├── Commands/<VerbNoun>Command/
│ ├── <VerbNoun>Command.cs record : IRequest<OperationResult<T>>
│ ├── <VerbNoun>Command.Handler.cs internal sealed class : IRequestHandler<…>
│ └── <VerbNoun>Command.Validator.cs AbstractValidator<Command> (omit when there is nothing to validate)
└── Queries/<VerbNoun>Query/
├── <VerbNoun>Query.cs
├── <VerbNoun>Query.Handler.cs
└── <VerbNoun>Query.Result.cs record Result(…) ← the DTO returned
```
1. Create the folder, one type per file, file name matching the type name.
2. The request is a `record`; the handler is `internal sealed`; return `OperationResult<T>` — never throw
for an expected failure (`SuccessResult`/`FailureResult`/`NotFoundResult`/`ConflictResult` map to
200/400/404/409). Let a genuinely unexpected exception propagate to the global `ExceptionHandler`.
3. Add a FluentValidation validator if the request takes input. **Never validate a route-supplied id in the
body command** — route values aren't bound into it.
4. Query: `AsNoTracking()` + `.Select()` straight to the DTO — never hydrate an entity graph to map it in
memory. Command: use `Include` only when you need navigation properties loaded to mutate the aggregate,
access the DB through `IUnitOfWork`, and `CommitAsync` once at the end.
Full rules and the validator/OperationResult examples: [cqrs.md](../../../archive/docs/rules/server/cqrs.md) §13.
---
## 3. Persistence — only if you added or changed a table
1. One `IEntityTypeConfiguration<T>` in `Persistence/Configuration/<Area>Config/`.
2. A soft-deletable entity **must** declare `HasQueryFilter(o => !o.IsDeleted)` — without it, deleted rows
leak into every query that doesn't explicitly exclude them.
3. A lifecycle `status` column is a forward-only machine: `const string` codes, a private setter, cohesive
transition methods, a static allowed-edges table. The handler pre-checks and returns a clean `409` — it
never throws for "already moved."
4. A row that represents a past agreement (a price, an address, a policy, a deadline) is a **snapshot**
frozen at compute time, never re-derived from a later edit to its source.
5. Money-critical constants (rates, deadlines, tolerances) are read via `IPlatformConfig.GetConfig<T>`
**never hardcoded**, and never re-read for an already-priced row.
```bash
dotnet ef migrations add <Name> --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api
dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api
```
Full patterns, with the exact uniqueness/snapshot/state-machine tables:
[persistence.md](../../../archive/docs/rules/server/persistence.md).
---
## 4. The controller
```csharp
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Display(Description = "One-line description shown in Swagger")]
[Authorize(ConstantPolicies.DynamicPermission)] // or [Authorize], or omit for public
public sealed class MyFeatureController(ISender sender) : BaseController
{
[HttpPost("[action]")]
[ProducesOkApiResponseType<MyCommandResult>]
public async Task<IActionResult> CreateSomething(MyCommand command, CancellationToken ct)
=> OperationResult(await sender.Send(command, ct));
}
```
- `sealed`, inject `ISender` via the primary constructor, always `base.OperationResult(result)` — never
`Ok()`/`BadRequest()`/`NotFound()` directly.
- Never hardcode a route string. If the method name doesn't read cleanly as the URL segment
`SnakeCaseParameterTransformer` will produce, rename the method instead.
- Pick the narrowest authorization that fits: none (truly public) → `[Authorize]` (any authenticated user) →
`[Authorize(ConstantPolicies.DynamicPermission)]` (role/claim-gated admin action). Table and rate-limiting
notes: [cqrs.md](../../../archive/docs/rules/server/cqrs.md) §4.
---
## 5. Tests
1. **Handler unit test** (xUnit + NSubstitute + FluentAssertions), Arrange-Act-Assert, named
`{MethodUnderTest}_{Scenario}_{ExpectedOutcome}`. Test the handler directly, not the controller.
2. **At least one `WebApplicationFactory<Program>` integration test** in `Baya.Test.Api` for the area,
covering: happy path → 200, unauthenticated → 401, validation failure → 400 with field detail.
3. The recurring-job scheduler is dormant under `Testing`, so a background tick can't make an integration
test flaky — you don't need to account for it.
Examples and the full testing convention: [conventions.md](../../../archive/docs/rules/server/conventions.md) §8.
---
## 6. Docs this feature triggers — in the same change
- **[`docs/integration/domains/<domain>.md`](../../../archive/docs/integration/domains/index.md)** — add the new
endpoint with its verdict (`wired`/`unwired`/`phantom`), matching the client `services/` domain it belongs
to.
- **The OpenAPI snapshot** — regenerate `docs/integration/openapi/swagger.v1.json` per
[openapi/README.md](../../../archive/docs/integration/openapi/README.md) and update its provenance table (date,
commit, path/operation counts) in the same change. A snapshot with stale provenance is what that
convention exists to prevent.
- **[`docs/status/backlog.md`](../../../archive/docs/status/backlog.md)** — tick the row if this closes a filed
item. Never delete a row; a ticked row is the record that it shipped.
- **A reference file in `docs/rules/server/`** — only if the feature introduces a genuinely new reusable
pattern, seam, or base class. Don't add prose for a feature that just follows the existing pattern.
---
## 7. Before you call it done
Run the server gate — `dotnet build Baya.sln` (**zero new warnings**) and `dotnet test Baya.sln` — and read
your diff as if reviewing the PR. Full "what done means" checklist:
[git-and-gates.md](../../../archive/docs/rules/shared/git-and-gates.md) §2.
+107
View File
@@ -0,0 +1,107 @@
---
name: flow-testing
description: >-
Boot both sides of Balinyaar locally and walk a real user journey end to end — the right seeded account,
the right flow doc, and knowing whether you just proved the real path or a mock answering. Use before
claiming a fix or feature works, or when asked to test, verify, or demo a flow.
---
# Balinyaar Flow Testing
Exercising a flow proves something only if you know which half of the stack actually answered. This is the
procedure; the facts it points at (ports, accounts, known failure modes) live in
[docs/flows/testing-setup.md](../../../archive/docs/flows/testing-setup.md) and are kept current there — don't copy
them here, they will drift.
---
## 1. Boot it
The five-minute path, verbatim from [testing-setup.md](../../../archive/docs/flows/testing-setup.md#the-five-minute-path):
```bash
# API — mock SMS or request_otp 500s
cd server
Seams__Sms__Provider=mock dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj
# client
cd client && npm install && npm run dev # http://localhost:3000/fa
# read the OTP — the console does NOT print it
curl http://localhost:5002/api/v1/dev/last_otp/09120000010
```
No database setup: the committed dev config points at an already-seeded remote SQL Server. If anything here
doesn't match reality when you run it, **testing-setup.md is wrong and needs a fix in the same change** — it
carries a `Last verified` stamp for exactly this reason.
---
## 2. Check the mock-vs-real map *before* you conclude anything
A flow "working" through a mocked domain proves the UI, not the server. Before testing:
1. Open [docs/integration/domains/index.md](../../../archive/docs/integration/domains/index.md) — the census table
names which of the 22 client `services/` domains are real vs **mock** (currently 15 real, 7 mock:
`admin`, `bnpl`, `partnerCenter`, `patientRecords`, `payouts`, `refunds`, `verification`).
2. A mocked domain is a `USE_<DOMAIN>_MOCK` flag in `client/src/services/<domain>/constants.ts` — check it
directly if you need certainty for the exact domain you're touching.
3. State your finding in terms of which one you exercised: "the booking flow works end-to-end against the
real server" is a different claim from "the admin console renders correctly against its mock" — never
report the second as if it were the first.
---
## 3. Pick the right seeded account
Demo accounts, their roles, and what each one demonstrates are tabulated in
[testing-setup.md](../../../archive/docs/flows/testing-setup.md#demo-accounts) — read it there rather than assuming
a phone number. One standing gap to route around: **the seeded admin accounts (`…020` `super_admin`,
`…021` `finance`) get 403 on every real admin endpoint** (a `DynamicPermission` / role-literal mismatch).
The admin backoffice is only testable against the client's mock; don't spend time trying to walk it against
the real API without first checking whether that gap has been closed.
---
## 4. Walk the flow
[docs/flows/index.md](../../../archive/docs/flows/index.md) is the atlas — one file per user-meaningful journey,
each answering exactly three questions: what it does, what's mocked *for that journey specifically*, and how
to test it. Open the one file that matches what you're testing rather than guessing the steps; it's the
one place gap numbers and REQ references for that journey are tracked.
---
## 5. Two things that will silently invalidate your test
- **The scheduler is live while you test.** `booking_request_expiry` runs every 60 seconds (hardcoded) and
flips an un-actioned request to `expired_no_response` / `payment_deadline_expired` out from under you. Act
on a request promptly, or create a fresh one rather than trying to reuse an old test artifact.
- **The OTP endpoints are rate-limited together.** `request_otp` and `verify_otp` share one bucket, 5 calls
per 60 s per IP — a login is 2 calls, so that's **two logins per minute, total**. Space scripted logins
≥ 40 s apart (see [testing-setup.md](../../../archive/docs/flows/testing-setup.md#scripting-logins) for a working
script) or you'll 429 and misread it as a bug.
---
## 6. When the seeded world has aged out
There is no in-app reseed — both seeders guard on natural keys, so re-running never refreshes stale dates.
If the scenario you need (an "upcoming" booking, an open dispute window, a pending request) no longer exists
because the world was seeded days ago:
- **Fastest fix:** create the scenario fresh yourself (customer → search → booking request → accept → pay) —
this is the intended way to exercise booking-request and checkout-and-payment anyway.
- **Full reseed:** only against a **local** database — `docker compose down -v && docker compose up -d` under
`server/`, then boot. **Never drop the shared remote database** casually; it backs the live demo deployment
and other people's sessions.
---
## 7. Report what you actually saw
Name the account you used, the domain's mock/real status, and the exact response (status code, error
message) rather than "it worked" — the troubleshooting table in
[testing-setup.md](../../../archive/docs/flows/testing-setup.md#troubleshooting) exists because several failure
modes here look identical to an unrelated bug (a rate limit looks like a crash; `/healthz/ready` failing on
Windows looks like the app is down). Check it before filing something as a new defect.
+176 -71
View File
@@ -12,9 +12,22 @@ description: >-
# Balinyaar Frontend Designer
Build UI that looks like Balinyaar and behaves correctly in both locales and both
color schemes on the first try. This skill is the design contract; the engineering
contract (providers, fetch, cookies, routing) lives in [client/CLAUDE.md](../../../client/CLAUDE.md) — read it
before touching layout/provider/data code, **don't restate it**, and never violate it.
color schemes on the first try.
**Precedence.** This skill is the **design** contract — brand, tone, and the visual
decisions. The **engineering** contract is [client/CLAUDE.md](../../../client/CLAUDE.md)
(hard rules) plus [docs/rules/client/](../../../archive/docs/rules/client/) (one reference file
per area). Where the two overlap — tokens, typography, the component library, shells,
icons — **`docs/rules/client/` is authoritative and this skill defers to it.** Read the
relevant one before touching layout, provider, or data code; don't restate it here, and
never violate it.
| For | Read |
|-----|------|
| Tokens, palette, dark mode, RTL, fonts, motion | [docs/rules/client/theme.md](../../../archive/docs/rules/client/theme.md) |
| The `App*` library, shells, navigation, icons | [docs/rules/client/components.md](../../../archive/docs/rules/client/components.md) |
| Copy and Persian orthography | [docs/rules/client/i18n.md](../../../archive/docs/rules/client/i18n.md) |
| Forms | [docs/rules/client/forms.md](../../../archive/docs/rules/client/forms.md) |
**Stack:** Next.js 16 (App Router, Turbopack) · React 19 · MUI v9 (`@mui/material`) ·
Emotion (RTL via `stylis-plugin-rtl`) · next-intl v4 · notistack. Everything below
@@ -83,9 +96,15 @@ Colors exist in **two mirrored places** that must stay in sync. Pick the right o
**Beyond color**`tokens.css` also defines non-palette tokens (`colors.ts` never needs
these; they're define-only in CSS):
- **Radius** — `--bal-radius-sm` (4px, controls: buttons/inputs), `--bal-radius-md`
(10px = `theme.shape.borderRadius`, the house default: cards/paper), `--bal-radius-lg`
(16px: dialogs). Reference the token/constant, never invent a new radius.
- **Radius** — `--bal-radius-sm` (6px, controls: buttons/inputs), `--bal-radius-md`
(8px = `theme.shape.borderRadius`, the house default: cards/paper), `--bal-radius-lg`
(12px: dialogs). Reference the token, **never a numeric `sx={{ borderRadius: n }}`**
that multiplies the shape unit, which is how the login card once ended up a 30px pill.
`MuiPaper` pins the md step so a Paper can't drift past it. `--bal-radius-pill` (999px)
is for shapes that genuinely *are* pills — the floating bottom nav, a segmented
control's active chip — never for a card.
- **Frame canvas** — `--bal-frame-canvas`, the backdrop `AppFrame` paints *outside* the
phone-width app column. Never a surface a component draws on.
- **Elevation** — `--bal-shadow-1/2/3`, teal-tinted (black-teal in dark mode) shadow
steps that back `theme.ts`'s `shadows` array — every MUI elevation (Paper, Dialog,
Menu, Popover, AppBar) resolves through these, never MUI's default grey stack.
@@ -100,14 +119,24 @@ these; they're define-only in CSS):
- **Money emphasis** — `--bal-money-emphasis`, an AA-contrast-safe color for emphasized
money text. `--bal-secondary` (terracotta) fails AA contrast at small sizes on light
backgrounds — never use it for money text, use this token instead.
- **Soft fills** — every brand and semantic color has a `-soft` variant
(`--bal-primary-soft`, `--bal-warning-soft`, …) for a tinted background. Reach for it
before hand-mixing an alpha over a surface.
- **Avatar** — `--bal-avatar-1..6` (+ each `-contrast`), the six warm pairs
`InitialsAvatar` picks from by a deterministic name hash. Add a seventh to **both**
scheme blocks or don't add one.
- **Map** — `--bal-pin-shadow`, the address-picker pin.
Full catalogue, with what each group backs:
[docs/rules/client/theme.md](../../../archive/docs/rules/client/theme.md) §2.
---
## 3. Typography & fonts
- `shape.borderRadius: 10` (set in `src/theme/theme.ts`) — the house corner radius
- `shape.borderRadius: 8` (set in `src/theme/theme.ts`) — the house corner radius
(= `--bal-radius-md`). Don't override per-component unless deliberate; the radius
*scale* is `--bal-radius-sm` (4, controls) / `-md` (10, cards) / `-lg` (16, dialogs).
*scale* is `--bal-radius-sm` (6, controls) / `-md` (8, cards) / `-lg` (12, dialogs).
- **Weight system — never write `fontWeight: 600`.** Mikhak and Space Grotesk both load
only 400/500/700 (no 600 face), so a requested 600 silently renders full Bold. Use
**700** for headings (`h1``h6`) and buttons/strong emphasis, **500** for lighter
@@ -143,15 +172,23 @@ wrapper over the bare MUI component — the wrappers carry the house defaults.
| `AppIconButton` | icon-only actions | takes an icon `name`, `title`, `to`/`onClick` |
| `AppIcon` | any icon | `icon="home"` by registered name (§6); `size`, `color` props |
| `AppLink` | internal/external links | locale-aware Next navigation; default underline `hover` |
| `AppAlert` | inline alerts | default `severity="error"`, `variant="filled"` |
| `AppImage` | images | wrapper around next/image conventions |
| `AppAlert` | inline alerts | defaults to a calm `severity="info"`, `variant="standard"` — pass `severity="error"` explicitly when it really is an error |
| `AppLoading` | loading state | default circular, `primary`, `3rem` |
| `ErrorBoundary` | wrapping fault-prone subtrees | already wraps page content in the shell |
| `ProfileSummary` | the identity card in chrome | avatar+name+masked phone+role label+optional `TrustBadge`; vertical or `compact` horizontal chip |
Defaults for these live in `src/components/config.ts` (`APP_BUTTON_VARIANT`,
`APP_ICON_SIZE = 24`, `CONTENT_MAX_WIDTH = 800`, `CONTENT_MIN_WIDTH = 320`, alert/link/
loading defaults). Change a default there, not per-call-site.
`APP_ICON_SIZE = 24`, `APP_ICON_STROKE_WIDTH = 1.75`, `APP_BUTTON_ICON_SIZE = 20`,
`CONTENT_MAX_WIDTH = 480`, `CONTENT_MIN_WIDTH = 320`, alert/link/loading defaults).
Change a default there, not per-call-site.
Beyond the `App*` wrappers there is a **state kit**`EmptyState`, `ErrorState`,
`QueryStateGate`, `PageHeader`, `ConfirmDialog`, `SurfaceCard`, `AccentCard`, `Money`,
`StatusTimeline`, the `Jalali*` date inputs, `StickyActionBar`, `Pager`, `NavHubList`,
`InitialsAvatar`, `FormDialogShell` — with **one pattern per state**. Never hand-roll a
dashed-border "nothing here" block or a per-screen pager; and **an error state is never an
empty state.** Catalogue in
[docs/rules/client/components.md](../../../archive/docs/rules/client/components.md).
For layout/spacing use MUI primitives directly: `Box`, `Stack`, `Container`, `Grid`,
`Paper`, `Card`. Use the `spacing`/`sx` system (theme spacing unit = 8px) — never inline
@@ -160,80 +197,131 @@ pixel margins for rhythm.
**New shared component?** Put it in `src/components/<Name>/<Name>.tsx` with an
`index.tsx` barrel, follow the `App*` prop-spreading + JSDoc style of `AppButton.tsx`,
and add a co-located `.test.tsx` (mandatory for anything imported in >1 place — see
CLAUDE.md "Unit Testing"; wrap with `<ThemeProvider>`, never mock MUI).
[docs/rules/client/testing.md](../../../archive/docs/rules/client/testing.md); wrap with
`<ThemeProvider>`, never mock MUI). If it goes at the top of the `@/components/common`
barrel, prefer **caller-owned copy** (required `title`/`body`/`retryLabel` string props)
over calling `useTranslations` inside it — `next-intl` is ESM-only and poisons every test
that transitively imports the barrel. `ErrorBoundary`/`ErrorState` are the model;
[components.md](../../../archive/docs/rules/client/components.md) has the why.
**Any form with more than one field is a react-hook-form form**, bound through the
`@/components/common/form` wrappers (`RhfTextField`, `RhfChipSelect`,
`RhfJalaliDateField`, `RhfControlGroup`) and grouped into `FormSection`s. A single-field
control is state, not a form. Full pattern:
[docs/rules/client/forms.md](../../../archive/docs/rules/client/forms.md).
---
## 5. Layout & page shells
- **Four per-actor shells**, each wrapped in `RoleGuard` (don't touch): `CustomerLayout`
(mobile-first — contextual `TopBar`: brand lockup on the 5 root tabs, title + back
chevron on pushed routes, an inline desktop top-nav at `≥md` replacing the mobile
`BottomBar`), `NurseLayout` / `AdminLayout` / `PartnerLayout` (all three share
`TopBarAndSideBarLayout`, `src/layout/`). All chrome navigation goes through
`@/i18n/navigation` (`Link`/`usePathname`/`useRouter`) — never a raw `next/link` or a
manual `` `/${locale}` `` prefix.
- `TopBarAndSideBarLayout`: a fixed `TopBar` (title from `useRouteTitle()`, the
route→title map in `layout/routeTitle.tsx`) + a `SideBar` rendered as **two Drawers
sharing one content tree** — mobile `temporary` and desktop `variant="permanent"` —
switched purely by `sx` breakpoint, not `useIsMobile()`; the permanent Drawer is a
normal flex sibling of the main column, so desktop reserves its own width with no
manual offset math and no post-hydration layout jump. Optional `identity` (TopBar
chip — admin/partner), `sidebarIdentity` (sidebar card — nurse's `ProfileSummary`),
and `mobileBottomBar` slots.
- Sidebar nav items are `{ title, path, icon, group? }` arrays (`@/utils`'s
`LinkToPage`) built with `useTranslations('nav')`; a shared `group` string on
consecutive items renders a `ListSubheader` section (see `NurseLayout`/`AdminLayout`).
Selection is computed once via the shared `matchActivePath` (longest-prefix,
winner-takes-all) helper — reuse it for any new nav list, never hand-roll
`pathname.startsWith`.
- **Public screens** use `PublicLayout` — a minimal corner strip (logo +
`LocaleSwitcher` + dark toggle), no sidebar/bottom bar; the step content (`AuthCard`)
carries its own larger `BrandMark`, so don't duplicate a big lockup in the shell.
**There is one layout: a phone.** `AppFrame` (`src/layout/AppFrame.tsx`) renders every
screen inside a centered `APP_FRAME_MAX_WIDTH` (480px) column on a `--bal-frame-canvas`
backdrop, at **every viewport**. A wider window gets more canvas, never a wider app —
design one set of states, verify one set of states. Do not add a `≥md` branch that widens
a shell, restores a sidebar, or lays a screen out in columns.
- `AppFrame` owns four structural guarantees, and is the only place any of them is
solved: the width cap; the **frame, not the document, owns the scroll** (a single
scrolling `<main>` fills the frame, with the bars pinned **`position: absolute`** over
it — never `fixed`, which would break out of the centered column — and `<main>`
reserving each bar's exact height as padding, so no page needs a top offset);
`overflowX: hidden` + `minWidth: 0`, so an over-wide child clips rather than dragging
the app sideways; and, above `sm`, the column **floats** as a rounded shadowed card
with a gutter all round (edge-to-edge on a phone). Genuinely wide content (a data
table) scrolls **inside its own container** — see `AdminDataTable`'s `TableContainer`.
- `AppFrame` also publishes **`--bal-chrome-top` / `--bal-chrome-bottom`** on the scroll
container (already including `env(safe-area-inset-*)`, and `0px` in a chrome-free
shell), so any `position: sticky` element can clear the bars without importing a
constant. `StickyActionBar` is the reference consumer — don't recompute an offset.
- **One authenticated shell**: `MobileShell` = `AppFrame` + a contextual `TopBar` (brand
lockup on a tab's own path, back chevron + `useRouteTitle()` on anything deeper) +
`BottomBar` + `ErrorBoundary` + `RouteFadeIn`. The four actor layouts (`CustomerLayout`
/ `NurseLayout` / `AdminLayout` / `PartnerLayout`, each wrapped in `RoleGuard`) supply
only `tabs` and `headerActions`. Add a destination by adding a tab or a hub row — never
by forking the shell.
- **The chrome is light, not structural.** The top bar is *not* an `AppBar` — no filled
surface, no rule, no elevation of its own; `AppFrame` wraps both bars in the shared
`FLOATING_BAR_SX`, so the header is the bottom bar mirrored: inset from the frame edges,
fully rounded (`--bal-radius-pill`), elevated. Neither should read as a slab sealing off
an edge of a 480px screen. The bottom bar is **icon-only** (at five tabs the caption was
the widest thing in it and cost a whole line — the label survives as `aria-label`/
`title`), each tab a fixed 44px circle laid out `space-around`.
- **A stateful card carries its state in its content, not a stripe.** `AccentCard`'s
colored edge stripe was removed — a column of them read as a row of loose vertical rules
down the RTL edge of the screen. `tone` survives as the semantic label (reaching the DOM
as `data-accent-tone`); the `StatusChip`, icon and copy inside carry the state.
**Do not reintroduce the stripe.**
- **Navigation is the bottom bar. There is no drawer.** Tabs are `LinkToPage` arrays
(`@/utils`) built with `useTranslations('nav')`, 35 of them, and by convention the last
is a settings/«بیشتر» hub. Active state comes from the shared `matchActivePath`
(longest-prefix, winner-takes-all) over each tab's own path **plus its `matchPaths`
claims — use `matchPaths` when a tab owns a destination outside its own URL subtree
(`/nurse/finance` owning `/nurse/earnings`). Never hand-roll `pathname.startsWith`.
- **A nav group's root is a real page**, not a drawer section: a short summary of that
domain (read only off queries that already answer it — never a fabricated figure) over a
`NavHubList` of its destinations. See `/nurse/practice`, `/nurse/finance`,
`/admin/trust`, `/admin/system`.
- **Chrome carries no preferences.** Language and appearance live in `SettingsPanel`
(`@/components/settings`), mounted in each actor's settings hub and nowhere else. The
top bar is for identity, the page title, and at most a notification bell. Appearance is a
three-way segmented control (light/dark/**system**) — never a boolean switch, which cannot
express the app's own default.
- **Public screens** use `PublicLayout` — the frame and nothing else, **no top bar**; the
step content (`AuthCard`) carries the only brand mark on screen. `FocusedLayout` is the
framed chrome-free shell for can't-tab-away flows (onboarding, `/select-role`).
- All chrome navigation goes through `@/i18n/navigation` (`Link`/`usePathname`/
`useRouter`) — never a raw `next/link` or a manual `` `/${locale}` `` prefix. (Inside a
*page*, `AppLink`/`AppButton`'s `to` is a plain `next/link` and still needs the prefix.)
- Page content is auto-wrapped in `ErrorBoundary` inside every shell.
- Shell dimensions are constants in `src/layout/config.ts` (`SIDE_BAR_WIDTH = 240px`,
top-bar `56px` mobile / `64px` desktop). Respect them; don't hard-code.
- Shell dimensions are constants in `src/layout/config.ts` (`APP_FRAME_MAX_WIDTH`,
`TOP_BAR_HEIGHT`). Respect them; don't hard-code.
- A page is `src/app/[locale]/(private|public-routes)/…/page.tsx`. Keep page bodies to
composition + content; push reusable visuals into `src/components/`.
- Constrain reading width with `CONTENT_MAX_WIDTH` (800) for text-heavy views; full-bleed
is fine for dashboards/tables.
- Prefer MUI breakpoints in `sx` (`{ xs: …, md: … }`) for responsive branching over
`useIsMobile()` (`@/hooks`) — the latter is JS/post-hydration and is what caused the
desktop SSR flash `TopBarAndSideBarLayout` now avoids; reach for it only for genuinely
non-structural, JS-only behavior.
- `CONTENT_MAX_WIDTH` mirrors the frame width — a page column can never be wider than the
frame containing it.
- Prefer MUI breakpoints in `sx` for the little responsive branching that remains over
`useIsMobile()` (`@/hooks`) — the latter is JS/post-hydration and caused a real SSR
flash; reach for it only for genuinely non-structural, JS-only behavior.
---
## 6. Icons
Icons are a **name registry**, not free imports. `src/components/common/AppIcon/config.ts`
maps lowercase names → MUI/SVG components. Render with `<AppIcon icon="home" />` or pass
the name to `AppButton`/`AppIconButton` (`icon="search"`).
maps lowercase names → components. Render with `<AppIcon icon="home" />` or pass the name
to `AppButton`/`AppIconButton` (`icon="search"`).
**One visual family: MUI `*Rounded`.** Every registered icon is the `Rounded` variant of
`@mui/icons-material` (warmer, softer strokes than the old filled/outlined mix — fits
"clinical-but-human"). When adding an icon, import the `*Rounded` version; don't mix in a
Filled/Outlined/Sharp/TwoTone icon next to it. ~90 names are registered today, spanning
navigation, catalog, verification, booking, payments, admin, and messaging — read
`AppIcon/config.ts` directly for the full list rather than duplicating it here (it drifts
too fast for a skill doc to track reliably); the two structural rules below don't.
**One visual family: Lucide.** Every registered icon comes from `lucide-react` — a
contemporary outline family on a 24px grid with round caps/joins, which reads far lighter
than the filled glyphs this registry used to carry at the small sizes a phone-width app
actually uses. `@mui/icons-material` is **no longer a dependency**; never reintroduce it.
The house stroke weight is `APP_ICON_STROKE_WIDTH` (1.75 — Lucide ships at 2, which
competes with Mikhak's lighter Persian strokes).
**`size` actually resizes now.** `AppIcon` drives size via `style.fontSize` (the basis for
MUI SvgIcon's internal `1em` sizing) instead of `width`/`height` attributes, which MUI's
own CSS used to beat. `<AppIcon icon="verified" size={48} />` renders 48px — no more
silent 24px flattening.
**The mapping is semantic, not incidental.** A name describes the domain concept
("verification", "earnings", "coverage") and the glyph depicts *that*, so swapping the
underlying glyph never leaks into call sites. Related concepts share a visual root on
purpose: trust/verification names are shields, money names are coins or cards, clinical
names are a pulse or a cross. ~110 names are registered — read `AppIcon/config.ts` for the
list rather than duplicating it here; the structural rules below are what won't drift.
**`size` drives real `width`/`height`.** Lucide sizes off SVG attributes, so
`<AppIcon icon="verified" size={48} />` is 48px with no `fontSize`/`1em` indirection.
Icons also default to `flexShrink: 0` — an icon squashed by a flex sibling was the one
layout bug this component kept quietly reintroducing on narrow rows.
**Directional icons mirror automatically.** Icons authored for LTR that must flip under
RTL (`back`, `chevron_start`) are registered in `AppIcon/config.ts`'s `DIRECTIONAL_ICONS`
set. `AppIcon` stamps `data-icon-directional` on those, and one CSS rule
(`app/globals.css`) does `[dir='rtl'] [data-icon-directional] { transform: scaleX(-1); }`.
Adding a new directional icon is a one-line registry addition — never hand-roll a
per-component flip.
RTL (`back`, `chevron_start`, `chevron_end`, `forward`, `send`) are registered in `AppIcon/config.ts`'s
`DIRECTIONAL_ICONS` set. `AppIcon` stamps `data-icon-directional` on those, and one CSS
rule (`app/globals.css`) does
`[dir='rtl'] [data-icon-directional] { transform: scaleX(-1); }`. Adding a new directional
icon is a one-line registry addition — never hand-roll a per-component flip.
**Need a new icon:** import the `*Rounded` version into `config.ts`, add a **lowercase**
**Need a new icon:** import it from `lucide-react` into `config.ts`, add a **lowercase**
key to `ICONS`, then reference by that name. Custom SVGs (the brand mark) go in
`AppIcon/icons/`. An unregistered name logs a dev-only warning and falls back to
`default` — never pass a raw MUI icon where a name is expected.
`AppIcon/icons/` and must accept the same `size`/`color`/`strokeWidth` contract
(`AppIcon/utils.ts`'s `IconProps`). An unregistered name logs a dev-only warning and falls
back to `default` — never pass a raw icon component where a name is expected.
---
@@ -253,11 +341,16 @@ Every screen/component you produce must satisfy **all** of these:
switches automatically. Verify on both schemes — never assume a light background.
4. **Tokens, not hexes.** No raw color literals in `sx`/`styled`/components (§2).
5. **Constants, not magic values.** Cookie names, routes, repeated dimensions, event
names → named constants (CLAUDE.md "Constants").
names → named constants ([components.md](../../../archive/docs/rules/client/components.md) §5).
6. **Use the wrappers** (§4) and the **icon registry** (§6) before bare MUI.
7. **Shared component ⇒ co-located test** (§4).
8. **MUI v9 API only.** No v5/v6-era props (e.g. `Stack` `useFlexGap`, `storageWindow`).
Avoid deprecated APIs that throw.
9. **Persian copy follows the style guide** — «بالین‌یار» with a ZWNJ, تأیید with a hamza,
جستجو in one form, formal شما. `npm run lint:copy` fails the gate on a banned variant.
Glossary and the full rules: [i18n.md](../../../archive/docs/rules/client/i18n.md) §4.
10. **A screen never fabricates a figure.** A summary reads only off a query that already
answers it; a count still in flight is omitted, never faked or defaulted.
---
@@ -273,13 +366,15 @@ Every screen/component you produce must satisfy **all** of these:
5. **Verify the four axes:** `/fa` (RTL) and `/en` (LTR) × light and dark. The default
route is `/fa` — start there.
6. **Tests** for any new shared component; **never** add a layout above `[locale]`
(breaks locale/dir — see CLAUDE.md).
7. Data/fetch/auth/cookies/toasts → follow CLAUDE.md (`serverFetch`/`clientFetch`,
(breaks locale/dir — see [structure.md](../../../archive/docs/rules/client/structure.md)).
7. Data/fetch/auth/cookies/toasts → follow
[services.md](../../../archive/docs/rules/client/services.md) and
[auth.md](../../../archive/docs/rules/client/auth.md) (`serverFetch`/`clientFetch`,
`@/lib/cookies/*`, `dispatchToast`/`useSnackbar`). Don't reinvent these.
---
## 9. Anti-patterns (design-specific — CLAUDE.md has the full engineering list)
## 9. Anti-patterns (design-specific — `docs/rules/client/` has the full engineering list)
- Hard-coded hex/rgb in components → use palette keys or `--bal-*` tokens.
- MUI default success/error colors for feedback → use `--bal-*` semantic tokens.
@@ -290,6 +385,14 @@ Every screen/component you produce must satisfy **all** of these:
- Raw MUI icon where a registry name is expected → register it in `AppIcon/config.ts`.
- New shared component without a `.test.tsx`, or mocking MUI in tests.
- Re-introducing `src/app/layout.tsx` / any layout above `[locale]`.
- A `≥md` branch that widens a shell, restores a sidebar, or goes multi-column → there is
one layout, and it is a phone (§5).
- A numeric `sx={{ borderRadius: n }}` → it multiplies the shape unit; use the radius token.
- `fontWeight: 600` → neither face loads it, so it silently renders full Bold. 700/500/400.
- Reintroducing `AccentCard`'s edge stripe, a drawer, a top-bar theme/locale toggle, or a
caption under a bottom-nav icon → each was deliberately removed.
- A hand-rolled empty/error/loading block, or a per-screen pager → use the state kit (§4).
- A second `prefers-reduced-motion` branch → there is exactly one, in `globals.css`.
---
@@ -320,4 +423,6 @@ pushing code back into Figma.
| Layout shells | `client/src/layout/` |
| Layout dimensions | `client/src/layout/config.ts` |
| Messages (i18n) | `client/messages/{en,fa}.json` |
| Engineering contract | `client/CLAUDE.md` |
| Persian copy lint | `client/scripts/check-copy.mjs` |
| Engineering hard rules | `client/CLAUDE.md` |
| Engineering reference (per area) | `docs/rules/client/` |
-26
View File
@@ -1,26 +0,0 @@
# Git hooks
Repo-managed git hooks (they live in version control, unlike `.git/hooks`).
## Enable (once per clone)
```bash
git config core.hooksPath .githooks
```
## `pre-commit` — secret scan
A fast, dependency-free backstop for the root `CLAUDE.md` rule **"Never commit secrets"**
(refinement-phase-5). It rejects a commit that stages:
- the historically-leaked SQL Server host `87.107.152.16`,
- the retired hardcoded admin password `qw123321`,
- a **real** connection-string password in any `appsettings*.json` (only the `SET_VIA_USER_SECRETS_OR_ENV`
placeholder is allowed — real values belong in user-secrets / environment variables),
- private-key material or an AWS access-key id, anywhere.
It scans only staged additions, so it is quick. It is **not** a replacement for a full scanner
(gitleaks / trufflehog) in CI — it is the local first line of defence.
Bypass a false positive with `git commit --no-verify` (use sparingly, and only when you are certain the
flagged line is not a secret).
-62
View File
@@ -1,62 +0,0 @@
#!/usr/bin/env bash
#
# Balinyaar secret-scanning pre-commit hook (refinement-phase-5).
# Blocks a commit that stages an obvious credential. This is a fast, dependency-free backstop for the
# root CLAUDE.md rule "Never commit secrets" — not a replacement for gitleaks/trufflehog in CI.
#
# Enable once per clone: git config core.hooksPath .githooks
# Bypass a false positive: git commit --no-verify (use sparingly, and only when you are certain)
#
set -euo pipefail
# Committed placeholders are allowed — real values are not. Keep in sync with StartupSecretsGuard.
PLACEHOLDER='SET_VIA_USER_SECRETS_OR_ENV'
# Only scan added/changed lines in text files that are staged.
staged=$(git diff --cached --name-only --diff-filter=ACM)
[ -z "$staged" ] && exit 0
violations=0
report() { printf ' ✖ %s\n' "$1"; violations=$((violations + 1)); }
while IFS= read -r file; do
# Skip this hook, lockfiles, and binaries.
case "$file" in
.githooks/*) continue ;;
*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf|*.dll|*.exe|*.snk) continue ;;
esac
[ -f "$file" ] || continue
added=$(git diff --cached -U0 -- "$file" | grep '^+' | grep -v '^+++' || true)
[ -z "$added" ] && continue
# The historically-leaked SQL Server host — must never reappear.
echo "$added" | grep -Eq '87\.107\.152\.16' && report "$file: leaked SQL Server host 87.107.152.16"
# The retired hardcoded admin password.
echo "$added" | grep -Eq 'qw123321' && report "$file: hardcoded admin password 'qw123321'"
# A real (non-placeholder) connection-string password in a committed appsettings file.
case "$file" in
*appsettings*.json)
echo "$added" \
| grep -Ei 'Password=[^;"'"'"' ]+' \
| grep -viq "Password=${PLACEHOLDER}" \
&& report "$file: connection-string password must be '${PLACEHOLDER}' (real value belongs in user-secrets/env)"
;;
esac
# Private keys and common cloud tokens, anywhere.
echo "$added" | grep -Eq -- '-----BEGIN (RSA|EC|OPENSSH|PRIVATE) .*PRIVATE KEY-----' && report "$file: private key material"
echo "$added" | grep -Eq 'AKIA[0-9A-Z]{16}' && report "$file: AWS access key id"
done <<< "$staged"
if [ "$violations" -gt 0 ]; then
echo ""
echo "Commit blocked: $violations potential secret(s) staged. Move the real value to user-secrets"
echo "(Development) or an environment variable (deploy) and commit only the '${PLACEHOLDER}' placeholder."
echo "See dev/post-phase/refinement/RUNBOOK.md. To override a false positive: git commit --no-verify"
exit 1
fi
exit 0
+6
View File
@@ -6,5 +6,11 @@ The canonical guidance for AI coding agents in this repository lives in **[CLAUD
- Frontend → [client/CLAUDE.md](client/CLAUDE.md)
- Backend → [server/CLAUDE.md](server/CLAUDE.md)
Those hold the **hard rules**. For current-state product truth — what to test, what's blocking launch,
what's missing — start at **[mvp/README.md](mvp/README.md)**. The engineering reasoning behind the hard
rules, and the full business-requirement docs, were archived on 2026-08-02 into
**[archive/docs/rules/](archive/docs/rules/index.md)** and **[archive/product/](archive/product/index.md)**
respectively — reference material, not actively maintained.
`CLAUDE.md` is the single source of truth at every level of this repo; these `AGENTS.md` files are
just pointers so the convention is discoverable under either name.
+102 -71
View File
@@ -1,114 +1,145 @@
# Balinyaar — Repository Guide (root)
This is the **shared, repo-wide** guide for AI coding agents. It is intentionally short.
Everything specific to one side of the stack lives in that project's own `CLAUDE.md`.
The **shared, repo-wide** guide for AI coding agents. It is intentionally short. Everything specific to one
side of the stack lives in that project's own `CLAUDE.md`.
> **Read the guide for the side you are editing — and only that one.**
> Working in `client/`? Read [client/CLAUDE.md](client/CLAUDE.md).
> Working in `server/`? Read [server/CLAUDE.md](server/CLAUDE.md) (+ [server/CONVENTIONS.md](server/CONVENTIONS.md)).
> Working in `server/`? Read [server/CLAUDE.md](server/CLAUDE.md).
> You almost never need both. A frontend change does not touch server files, and vice-versa.
> `AGENTS.md` files in this repo are thin pointers to the `CLAUDE.md` in the same folder.
> `CLAUDE.md` is the single source of truth at every level.
> Last verified: 2026-08-02 against commit `51e86a1`.
---
## What Balinyaar is
Balinyaar is a **trust-first home-nursing marketplace in Iran**. Independent nurses (and
nursing-company employees) list configurable services; families search, book, pay, and review.
The platform holds funds in an escrow-style ledger and pays nurses out weekly after a confirmed
check-out.
Balinyaar is a **trust-first home-nursing marketplace in Iran**. Independent nurses (and nursing-company
employees) list configurable services; families search, book, pay, and review. The platform holds funds in an
escrow-style ledger and pays nurses out weekly after a confirmed check-out.
Product/domain knowledge — business rules, the database model, payments/BNPL, escrow, the
verification pipeline — is **not** in the code. It lives in [`product/`](product/), organized as a
**structured docs tree** (one topic per file; start at [product/index.md](product/index.md) or its
[README](product/README.md)):
**Start at [`mvp/README.md`](mvp/README.md) for the current state of the product**, in three short,
non-technical files: how to manually test any user journey, what's broken and blocking a real launch, and
what's missing that isn't clearly scheduled. That folder is the live, load-bearing answer to "what's next."
| Folder | What it covers |
| --- | --- |
| [product/overview/](product/overview/platform-summary.md) | What Balinyaar is, the four cross-cutting ground truths, Persian glossary. **Read first.** |
| [product/business/](product/business/index.md) | The 14 functional/business requirement areas, one file each |
| [product/data-model/](product/data-model/index.md) | The ~54-table SQL Server schema across 13 domains + [diagrams](product/data-model/diagrams.md) |
| [product/payments/](product/payments/index.md) | BNPL, escrow ledger, settlement, VAT, integrations (with sources) |
| [product/research/](product/research/index.md) | Market/legal/verification research & go-to-market (EN) |
| [product/notes/](product/notes/open-questions.md) | Living notes: open questions, future ideas |
| [product/fa/](product/fa/index.html) | Farsi versions (research report + verification flow) |
Deeper product/business knowledge — the full business-requirement write-ups, the ~54-table database model,
payments/BNPL research, market/legal research — was consolidated into [`archive/product/`](archive/product/index.md)
during the 2026-08-02 documentation cleanup. It is **reference material, not required reading**: correct as
of that date, but not actively maintained going forward. Read it when `mvp/` doesn't answer your question in
enough depth — e.g. designing a new table, or needing the full reasoning behind a business rule.
**Read the relevant `product/` doc before designing any schema, API, or feature.** Don't infer
business rules from code — the code is young and the docs are the source of truth.
> **Docs format:** the `.md` files are canonical; matching `.html` files are a generated, cross-linked
> browsing view (`cd product && node build-docs.mjs`). Edit the Markdown and regenerate — never
> hand-edit the `.html`. If you add/rename a `.md`, update the `NAV` manifest in `product/build-docs.mjs`.
**Never infer business rules from code alone** — the code is young. If `mvp/` and `archive/product/` both go
silent on a money, auth, tenancy, or clinical-data rule, say so rather than guessing.
---
## Repository layout
This is **two independent projects in one repo**. There is no root-level build, package, or
solution — each project is built, linted, and run on its own.
This is **two independent projects in one repo**, plus their documentation. There is no root-level build,
package, or solution — each project is built, linted, and run on its own.
| Path | Project | Stack | Guide |
| Path | What it is | Stack | Guide |
| --- | --- | --- | --- |
| [`client/`](client/) | Web frontend | Next.js 16 (App Router) · React 19 · TypeScript · MUI v9 · next-intl | [client/CLAUDE.md](client/CLAUDE.md) |
| [`server/`](server/) | Backend API | ASP.NET Core (.NET 10) · Clean Architecture · CQRS · EF Core | [server/CLAUDE.md](server/CLAUDE.md) |
| [`product/`](product/) | Product docs | Markdown | — (see table above) |
| [`dev/`](dev/) | Build plan (not app code) | Markdown | [dev/README.md](dev/README.md) |
| [`mvp/`](mvp/README.md) | **Current truth** — plain-language test flows, launch blockers, missing MVP features | Markdown | [mvp/README.md](mvp/README.md) |
| [`archive/`](archive/README.md) | Everything else: business docs, engineering rules/contracts/flow-atlas, and the executed build history. **Reference/history, not instruction** — nothing to build from it, and nothing here is kept current | Markdown | [archive/README.md](archive/README.md) |
| [`telegram-otp-bot/`](telegram-otp-bot/) | OTP relay (standalone, the pre-launch demo rail) | Node 18+, zero deps | [telegram-otp-bot/README.md](telegram-otp-bot/README.md) |
| [`deploy/`](deploy/) | Reverse-proxy config | Caddyfile | [DEPLOY.md](DEPLOY.md) |
The two communicate over **HTTP/JSON** (optionally gRPC). The client reads the API base URL from
`NEXT_PUBLIC_API_URL`; the server listens on `https://localhost:5002` by default.
`AGENTS.md` files in this repo are thin pointers to the `CLAUDE.md` in the same folder. **`CLAUDE.md` is the
single source of truth at every level.**
[`dev/`](dev/README.md) holds the **phased build plan** that takes the repo from its current baseline to
the MVP: a chain of agent-runnable prompt files split into a `backend/` and a `frontend/` track
([dev/phases/](dev/phases/README.md)), the cross-project API [`contracts/`](dev/contracts/README.md), and
a [`shared-working-context/`](dev/shared-working-context/README.md) that lets a backend agent and a
frontend agent run in parallel without touching the same files. It is planning/tooling, **not** a third
project — there is nothing to build in it.
The two projects communicate over **HTTP/JSON** (optionally gRPC). The client reads the API base URL from
`NEXT_PUBLIC_API_URL`; the server listens on `http://localhost:5002` by default.
**Deployment** is three Docker containers — one `Dockerfile` per project directory, orchestrated by the root
[`docker-compose.yml`](docker-compose.yml) — behind an existing Caddy reverse proxy on the external `caddy_net`
network, serving `balinyaar.ir` (client) and `api.balinyaar.ir` (server). The database is **not**
containerised; it is a remote SQL Server. Full runbook: [DEPLOY.md](DEPLOY.md).
`archive/` holds the executed build history, the former `docs/` (engineering rules, API contracts, the
per-flow test atlas, status/backlog) and the former `product/` (business requirements, data model, research)
— consolidated there on 2026-08-02 so the live tree stays focused on MVP work. **Anything in it is a record,
not an instruction.**
---
## Where the rules live
Three tiers. Open the `CLAUDE.md` for the side you are editing, then **one** reference file for the area you
are touching.
| Tier | Where | What |
| --- | --- | --- |
| **Hard rules** | this file · [client/CLAUDE.md](client/CLAUDE.md) · [server/CLAUDE.md](server/CLAUDE.md) | Constraints whose violation breaks the build, the gate, or a business invariant |
| **Reference** (archived) | [`archive/docs/rules/`](archive/docs/rules/index.md) | The *how* and the *why*, as of 2026-08-02 — 3 shared files, 8 client, 6 server, plus the documentation convention. Not actively maintained; read it on demand, don't expect it to track later changes |
| **Procedure** | `.claude/skills/` | Playbooks: **frontend-designer** (the design contract for `client/` UI), **backend-feature** (adding a server feature), **flow-testing** (walking a flow end to end) |
Start at [archive/docs/rules/index.md](archive/docs/rules/index.md) — it maps "working on X" to the one file
to open.
**Precedence when two sources disagree:** `archive/product/` (business truth) → the relevant `CLAUDE.md`
(engineering truth) → `archive/docs/rules/` (the reasoning behind it) → the task in front of you. **Never
silently guess on money, auth, tenancy, or clinical-data rules** — do the safe thing, and say so.
---
## Working agreements (apply to both projects)
1. **Stay within one project per change** unless the task explicitly spans both.
2. **Match the surrounding style.** Mirror existing patterns; don't introduce new ones. Each
project documents its conventions in its own `CLAUDE.md`.
2. **Match the surrounding style.** Mirror existing patterns; don't introduce new ones. Each project documents
its conventions in its own `CLAUDE.md`.
3. **Run that project's own checks before declaring work done:**
- client: `npm run check` (type + lint), plus `npm run test:ci` if you touched a tested component.
- server: `dotnet build Baya.sln` and `dotnet test Baya.sln`.
4. **Read the product docs before changing behavior.** Business rules are decisions, not guesses.
5. **Don't reintroduce template/starter scaffolding.** Both projects were derived from open-source
starters; their branding, demo/showcase pages, and `_TITLE_`/`_DESCRIPTION_` placeholders were
intentionally removed. Don't add them back.
6. **Never commit secrets.** Use `.env` (client) and `appsettings.*.json` / user-secrets (server).
Real connection strings, keys, and tokens never enter git.
7. **Keep docs honest, and keep the architecture map current.** If you change how something works,
update the `CLAUDE.md` that describes it in the same change. Each level documents its architecture
in one canonical place — **this file's "Repository layout"** (repo), **client/CLAUDE.md "Project
Structure"** (frontend), **server/CLAUDE.md "Project map"** (backend). When a change alters that
structure — adds, removes, or renames a project, layer, route group, provider, or major folder, or
changes a cross-project / cross-layer boundary — update the matching architecture section in the
same change. Stale instructions are worse than none.
- client: `cd client && npm run check` (type + lint + copy), plus `npm run test:ci` if you touched a tested
component.
- server: `cd server && dotnet build Baya.sln` (**zero new warnings**) and `dotnet test Baya.sln`.
- What "done" means in full: [archive/docs/rules/shared/git-and-gates.md](archive/docs/rules/shared/git-and-gates.md).
4. **Read [`mvp/`](mvp/README.md) (and `archive/product/` for depth) before changing behavior.** Business rules are decisions, not guesses.
5. **Don't reintroduce template/starter scaffolding.** Both projects were derived from open-source starters;
their branding, demo/showcase pages, and `_TITLE_`/`_DESCRIPTION_` placeholders were intentionally removed.
Don't add them back.
6. **Configuration lives in files, not a secret store.** `dotnet user-secrets` is **not used** — the
`<UserSecretsId>` was removed from `Baya.Web.Api.csproj`, so that store **is not even read**. Any
instruction anywhere to set a value with it is stale. Server config (including keys) lives in
`appsettings.*.json`; client config in `.env.development` / `.env.production`; the deployment's
container-specific overrides in `docker-compose.yml`.
This is a deliberate pre-launch trade for a demo deployment — **the repo therefore contains live
credentials.** Before onboarding real users, rotate them and move the secret half out of git (see
[DEPLOY.md](DEPLOY.md) "Going to Production"). **One value is load-bearing and must never change:**
`Seams:FieldEncryption:Key` / `:HashKey` decrypt all existing PII and derive the phone-lookup hash.
7. **Keep docs honest, and keep the architecture map current.** If you change how something works, update the
doc that describes it in the **same** change. Each level documents its architecture in one canonical place —
**this file's "Repository layout"** (repo), **client/CLAUDE.md "Project structure"** (frontend),
**server/CLAUDE.md "Project map"** (backend). When a change alters that structure — adds, removes, or
renames a project, layer, route group, provider, or major folder, or changes a cross-project / cross-layer
boundary — update the matching section in the same change. The full anti-drift convention (what to update
when X changes, the `> Last verified:` stamp, length budgets) is
[archive/docs/rules/documentation.md](archive/docs/rules/documentation.md). **Stale instructions are worse than none.**
8. **Write clean, self-documenting code.**
- **No dead code.** Remove unused variables, imports/usings, parameters, and private members —
don't leave them behind and don't suppress the warning. The client enforces this with ESLint
(`@typescript-eslint/no-unused-vars` as an *error*); on the server they are build warnings and
the gate is zero new warnings. Per-project specifics live in each project's `CLAUDE.md` /
`CONVENTIONS.md`.
- **Comment the *why*, not the *what*.** Don't write verbose comments that restate what the code
already says. Add a comment only where a non-obvious decision, constraint, business rule, or
trade-off isn't evident from the code itself. Prefer a clearer name over a comment.
- **No dead code.** Remove unused variables, imports/usings, parameters, and private members — don't leave
them behind and don't suppress the warning. The client enforces this with ESLint
(`@typescript-eslint/no-unused-vars` as an *error*); on the server they are build warnings and the gate is
zero new warnings.
- **Comment the *why*, not the *what*.** Don't write verbose comments that restate what the code already
says. Add a comment only where a non-obvious decision, constraint, business rule, or trade-off isn't
evident from the code itself. Prefer a clearer name over a comment.
- Details and worked examples: [archive/docs/rules/shared/code-quality.md](archive/docs/rules/shared/code-quality.md).
9. **A mock is only sanctioned behind a DI-registered seam**, selected by configuration, defaulting to the
mock, and recorded in [`mvp/blockers.md`](mvp/blockers.md) (or `archive/docs/status/` for the full historical
ledger). Never an `if (mock)` branch scattered through the code.
---
## Naming
- The **server**'s C# namespaces, projects, and solution all use the `Baya*` prefix
(`Baya.Web.Api`, `Baya.sln`). Keep new server code under the `Baya.*` convention.
- The **server**'s C# namespaces, projects, and solution all use the `Baya*` prefix (`Baya.Web.Api`,
`Baya.sln`). Keep new server code under the `Baya.*` convention.
- The **client** package is `balinyaar-client`; the `@/*` import alias maps to `client/src/*`.
The product/brand name is **Balinyaar**; the server's `Baya*` prefix is a legacy code namespace —
do not rename it without explicit instruction.
The product/brand name is **Balinyaar** — «بالین‌یار» in Persian copy, with a ZWNJ, always. The server's
`Baya*` prefix is a legacy code namespace: **do not rename it without explicit instruction.** Full
conventions: [archive/docs/rules/shared/naming.md](archive/docs/rules/shared/naming.md).
---
@@ -119,5 +150,5 @@ do not rename it without explicit instruction.
cd client && npm install && npm run dev # http://localhost:3000
# Backend
cd server && dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj # https://localhost:5002/swagger
cd server && dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj # http://localhost:5002/swagger
```
+208
View File
@@ -0,0 +1,208 @@
# Deploying Balinyaar
A first, shareable deployment of the whole stack under **balinyaar.ir**, in Docker, behind an existing
Caddy reverse proxy that terminates TLS.
> **This file is the deploy *procedure*.** The runtime dependency graph — every edge, what breaks when it is
> down, and where it is configured — is [archive/docs/integration/topology.md](archive/docs/integration/topology.md)
> (archived reference, not actively maintained), and
> every configuration key on both sides is
> [archive/docs/integration/config-matrix.md](archive/docs/integration/config-matrix.md). Read those to answer
> "what talks to what" or "where is this value set"; read this one to actually ship.
| Host | Serves | Container |
| --- | --- | --- |
| `balinyaar.ir`, `www.balinyaar.ir` | Next.js web client | `balinyaar-web:3000` |
| `api.balinyaar.ir` | ASP.NET Core API | `balinyaar-api:8080` |
| *(internal only)* | Telegram OTP relay | `balinyaar-otp-relay:5010` |
The **database is not containerised** — it is the remote SQL Server already configured in
[server/src/API/Baya.Web.Api/appsettings.Development.json](server/src/API/Baya.Web.Api/appsettings.Development.json).
Nothing needs to be provisioned for it; the API just needs network reach to `87.107.152.16:1433`.
---
## Configuration model
**There is no `dotnet user-secrets` any more.** The `<UserSecretsId>` was removed from
`Baya.Web.Api.csproj`, so the API no longer reads that store at all — a stale `secrets.json` on a dev
machine is now inert and can be deleted. Every value lives in a file in the repo:
| What | Where |
| --- | --- |
| API config + secrets (DB, JWE keys, field-encryption keys, Telegram key, CORS, trusted proxies) | `server/src/API/Baya.Web.Api/appsettings.Development.json` |
| The two values that differ between a laptop and the container network | `docker-compose.yml``api.environment` |
| Client build-time config (API URL, site origin) | `client/.env.production` |
| Telegram relay config (bot token, chat ids, API key, proxy) | `docker-compose.yml``otp-relay.environment` |
The placeholder string `SET_VIA_USER_SECRETS_OR_ENV` in the base `appsettings.json` names that removed
store; the *name* is a historical artifact, kept only because it is the sentinel `StartupSecretsGuard`
rejects. **The mechanism is appsettings files and environment variables** — see
[archive/docs/integration/config-matrix.md](archive/docs/integration/config-matrix.md), which lists every key,
its default, and who reads it.
The API runs as **`ASPNETCORE_ENVIRONMENT=Development`**, so `appsettings.Development.json` is the file
that actually loads. There is **no `appsettings.Production.json` in the repo at all**, and adding one would
be ignored until the environment name changes too — put changes in the Development file, or change the
environment name first.
The relay's shared secret appears twice and the two must match: `Seams:Sms:Telegram:ApiKey` in the
appsettings file and `API_KEY` in the compose file. It was rotated away from the value in
`telegram-otp-bot/.env.example`, which is published in git and in that project's README —
`TelegramSmsSender` now refuses to authenticate with it. **If you run the relay locally**, copy the
appsettings value into your own `telegram-otp-bot/.env`.
> ⚠️ **`Seams:FieldEncryption:Key` and `:HashKey` must never change.** Every encrypted column in that
> database — phone numbers, addresses, IBANs, clinical notes — was written with those exact values, and
> `users.PhoneHash`, which every login looks up, is derived from `HashKey`. Rotating either makes the
> existing data unreadable and locks every account out. The JWE keys (`IdentitySettings:SecretKey` /
> `Encryptkey`) are safe to rotate; doing so only signs everyone out.
---
## What running as Development means
This was a deliberate choice so the demo and lifecycle seeders populate the shared database and the
screens aren't empty. It has real consequences, all of which are fine for a pre-launch demo among
people you trust, and none of which are acceptable once strangers can reach the site:
- **The developer exception page is public.** Any unhandled 500 on `api.balinyaar.ir` returns a stack
trace and configuration detail to the caller.
- **`GET /api/v1/dev/last_otp/{phone}` is live.** Anyone who knows a registered phone number can read
its login code and sign in as that user. This is the single biggest exposure.
- **Swagger is served** at `api.balinyaar.ir/swagger`.
- **The seeders re-run on every container boot** (idempotent, so this is safe — they no-op on data that
already exists) and **migrations auto-apply on boot** rather than as a separate step.
- **gRPC reflection is enabled**, and the demo `bookings/convert` payment-capture simulator is wired.
### Going to Production later
1. Set `ASPNETCORE_ENVIRONMENT: Production` in `docker-compose.yml`.
2. **Create** `appsettings.Production.json` (it does not exist) with the same content as the Development file, but with **real**
`IdentitySettings:SecretKey` / `Encryptkey``StartupSecretsGuard` rejects anything containing
`not-for-production` outside Development, so the current dev keys will refuse to boot (by design).
Keep `Seams:FieldEncryption` byte-identical.
3. Run migrations as a one-shot instead of on boot:
`docker compose run --rm api dotnet Baya.Web.Api.dll migrate`
4. Swap the OTP rail: `Seams:Sms:Provider``kavenegar`, with `Seams:Sms:ApiKey`/`Sender` filled in.
The Telegram relay broadcasts every code to a fixed recipient list, which stops being acceptable the
moment someone outside that list can request one.
---
## First deploy
### 1. Confirm the Caddy network exists
The compose file joins `caddy_net` as an **external** network — it does not create it.
```bash
docker network ls | grep caddy_net
```
### 2. Add the Balinyaar block to your Caddyfile
Copy from [deploy/Caddyfile](deploy/Caddyfile) into the Caddyfile your Caddy container already loads:
```caddyfile
balinyaar.ir, www.balinyaar.ir {
encode zstd gzip
reverse_proxy balinyaar-web:3000
}
api.balinyaar.ir {
encode zstd gzip
reverse_proxy balinyaar-api:8080
}
```
Caddy obtains and renews the certificates for both hostnames itself. Reload it:
```bash
docker exec <caddy-container> caddy reload --config /etc/caddy/Caddyfile
```
### 3. Point DNS at the host
`balinyaar.ir`, `www.balinyaar.ir` and `api.balinyaar.ir` all need an A record on the server's public IP
**before** Caddy can complete the ACME challenge.
### 4. Confirm the proxy container is up
The relay's hop to `api.telegram.org` is filtered in Iran and goes out through the proxy already on
`caddy_net`, configured as `TELEGRAM_PROXY_URL: http://hysteria-client:8081`. If that container has a
different name or port, change it in `docker-compose.yml` — a wrong value fails the relay at boot with a
clear message rather than silently per-OTP.
### 5. Build and start
```bash
docker compose up -d --build
docker compose ps
docker compose logs -f api
```
The API's first boot applies any pending migrations and runs the seeders against the remote database, so
it takes noticeably longer than later ones.
---
## Verifying
```bash
curl https://api.balinyaar.ir/healthz/live # process is up
curl https://api.balinyaar.ir/healthz/ready # + database and object storage reachable
curl -I https://balinyaar.ir # the public landing page
docker compose logs otp-relay | head # should print the bot's @username and the proxy label
```
A full login round-trip is the real check: request an OTP from the site and confirm the code arrives in
the Telegram chat. If it doesn't, `docker compose logs otp-relay` names the failing hop — a proxy error
and a Telegram API rejection look different.
---
## Redeploying
```bash
git pull
docker compose up -d --build
```
Rebuild the client whenever a `NEXT_PUBLIC_*` value in `client/.env.production` changes — those are
compiled into the browser bundle, so restarting the container alone changes nothing.
## Known wrinkle: the client lockfile is Windows-generated
`client/package-lock.json` is produced on Windows, where npm filters out wasm32-only optional packages
and therefore never records their transitive dependencies (`@emnapi/core`, `@emnapi/runtime`). On Linux
npm *does* want them, so a bare `npm ci` fails with:
```
npm error `npm ci` can only install packages when your package.json and package-lock.json ... are in sync.
npm error Missing: @emnapi/runtime@1.11.3 from lock file
```
The client Dockerfile works around this by completing the lock inside the image before installing. To fix
it permanently, regenerate the lock **on Linux** once and commit the result:
```bash
cd client
docker run --rm -v "$PWD:/app" -w /app node:24-alpine npm install --package-lock-only --no-audit --no-fund
```
Then drop the `npm install --package-lock-only` line from `client/Dockerfile`, leaving just `npm ci`.
Note `--omit=optional` is **not** a valid shortcut here: Turbopack resolves `@parcel/watcher`'s native
binary through `optionalDependencies`, so omitting them breaks `next build` with
`No prebuild or local build of @parcel/watcher found`.
## Persisted state
Two named volumes survive rebuilds. Uploaded verification documents live in the first one; losing it
means the admin verification queue shows broken documents.
| Volume | Holds |
| --- | --- |
| `api-object-storage` | Uploaded verification documents (local-disk `IObjectStorage` seam) |
| `api-logs` | Serilog JSON file sink |
View File
+18
View File
@@ -0,0 +1,18 @@
node_modules
.next
out
coverage
.swc
graphify-out
*.tsbuildinfo
# Local-only env files — .env.production IS copied, it is the deployed build's input.
.env
.env.local
.env.*.local
Dockerfile
.dockerignore
CLAUDE.md
AGENTS.md
README.md
+24
View File
@@ -0,0 +1,24 @@
# Deployed (balinyaar.ir) values, read by `next build` when NODE_ENV=production.
#
# Every NEXT_PUBLIC_* value here is INLINED INTO THE CLIENT BUNDLE AT BUILD TIME — it is public by
# definition, and changing one requires rebuilding the image, not restarting the container.
# `.env.development` still owns the local `npm run dev` loop and is untouched by this file.
# Enables analytics and public resources.
NEXT_PUBLIC_ENV = production
# Off in a deployed build — `true` prints the resolved @/config (incl. the API URL) to the browser console.
NEXT_PUBLIC_DEBUG = false
# Public origin of the web app.
NEXT_PUBLIC_PUBLIC_URL = https://balinyaar.ir
# Absolute origin used only for metadata (OG tags, metadataBase, robots.ts, sitemap.ts) — never for API calls.
NEXT_PUBLIC_SITE_URL = https://balinyaar.ir
# The API, reached from the BROWSER — so it is the public hostname Caddy serves, never the container name.
NEXT_PUBLIC_API_URL = https://api.balinyaar.ir
# Neshan **web** key (client-embeddable maps/search) from https://platform.neshan.org. Unset: the address
# map-pin picker falls back to its bounded-canvas grid stand-in. Rebuild the client image after setting it.
# NEXT_PUBLIC_NESHAN_KEY = your-neshan-web-key
+1 -1
View File
@@ -23,6 +23,6 @@ NEXT_PUBLIC_API_URL = https://localhost:5002
# Neshan **web** key (client-embeddable maps/search/reverse-geocode) — get one from
# https://platform.neshan.org (a separate key from the server's NeshanGeocoder key, which lives in
# server appsettings/user-secrets, never here). Leave unset to keep the address map-pin picker's
# server appsettings, never here). Leave unset to keep the address map-pin picker's
# bounded-canvas grid fallback (dev/CI/jsdom all work without a key).
# NEXT_PUBLIC_NESHAN_KEY = your-neshan-web-key
+6 -3
View File
@@ -1,10 +1,13 @@
# AGENTS.md — Balinyaar Web Client
The canonical agent guide for the frontend is **[CLAUDE.md](CLAUDE.md)** (same folder). It is the
engineering contract: stack, commands, lint/type gates, routing, providers, data fetching, theming,
i18n, cookies, and the rules every change must follow.
The canonical agent guide for the frontend is **[CLAUDE.md](CLAUDE.md)** (same folder): stack,
commands, the quality gates, the project structure, and the hard rules every change must follow.
- Current product truth (what to test, what's blocking, what's missing) → [../mvp/](../mvp/README.md)
- Reference rules, archived, read on demand per area → [../archive/docs/rules/client/](../archive/docs/rules/client/)
(structure · theme · components · forms · i18n · services · auth · testing)
- Repo-wide context → [../CLAUDE.md](../CLAUDE.md)
- Business rules in depth (archived) → [../archive/product/](../archive/product/index.md)
- Human setup/run instructions → [README.md](README.md)
- UI/design work → the **frontend-designer** skill
+145 -985
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
# Balinyaar web client — build context is `client/` (see the root docker-compose.yml).
#
# NEXT_PUBLIC_* values are inlined into the browser bundle by `next build`, so the API URL and site origin
# are BUILD-time inputs, not runtime env vars — setting them in compose would do nothing. They come from the
# committed .env.production, which `next build` reads because it runs with NODE_ENV=production; change a value
# there and rebuild the image.
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# package-lock.json is generated on Windows, where npm filters out the wasm32-only optional packages and so
# never records their transitive deps (@emnapi/core, @emnapi/runtime). On Linux npm does want them, and a
# bare `npm ci` dies on the lockfile-sync check. --omit=optional is NOT the fix: Turbopack's @parcel/watcher
# resolves its native binary through optionalDependencies, so omitting them breaks `next build` outright.
#
# So: complete the lock here, on the platform that can actually see those packages, then install from it.
# --package-lock-only reuses every version already pinned in the committed lock and only ADDS the missing
# Linux-side entries, so this stays effectively reproducible rather than a free-for-all `npm install`.
# Drop the first command once the committed lock is generated on Linux (see DEPLOY.md).
RUN npm install --package-lock-only --no-audit --no-fund \
&& npm ci --no-audit --no-fund
FROM node:24-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:24-alpine AS final
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# `output: 'standalone'` traces the runtime dependencies into .next/standalone; static assets and public/
# are deliberately NOT included in that trace and must be copied alongside it, or every asset 404s.
COPY --from=build --chown=node:node /app/.next/standalone ./
COPY --from=build --chown=node:node /app/.next/static ./.next/static
COPY --from=build --chown=node:node /app/public ./public
USER node
EXPOSE 3000
CMD ["node", "server.js"]
-116
View File
@@ -1,116 +0,0 @@
# Persian (fa) copy style guide
One page, binding for `messages/fa.json`. `scripts/check-copy.mjs` (`npm run lint:copy`, part of
`npm run check`) enforces the banned-variant rules below so these decisions cannot silently regress.
This file does not repeat `en.json` conventions beyond what's noted in §7 — the English catalog is
hand-written and reviewed for idiom, not linted.
## 1. Brand name
**«بالین‌یار» — ZWNJ (``) between بالین and یار, always.** Never a plain space («بالین یار»).
The brand name appears in money/trust copy (login, escrow, refunds) as often as anywhere else — an
unstable brand mark there is the worst place to be inconsistent.
## 2. تأیید — hamza, always
Write **تأیید** (with hamza) and its derived forms — **تأییدشده**, **تأییدیه**, **تأیید کردن** — every
time, never تایید/تاییدشده/تاییدیه (hamza-less). This is the single most frequent word in a
verification product; one spelling, no exceptions, in every namespace (booking, payment, auth,
verification, admin, payouts, bnpl, refunds, legal — all of them).
## 3. جستجو — one form
Standard form: **جستجو** (no ZWNJ, one word). Not «جست‌وجو» / «جست و جو». Applies to the noun and any
compound (`در جستجو`, `نتایج جستجو`).
## 4. ZWNJ (نیم‌فاصله) rules
Use ZWNJ (``) — never a plain space or no separator — in:
- **می‌ + verb stem**: می‌شود، می‌کند، می‌پردازید، می‌ماند (never میشود/می شود).
- **Plural ها**: مراقب‌ها-style compounds keep the ZWNJ before ها when the base ends in a consonant that
would otherwise misread (`شب‌ها` not `شبها`); a plain plural on a word already ending in a vowel/silent-h
takes the ZWNJ too (`بچه‌ها`).
- **Compound past-participle adjectives**: تأییدشده، لغوشده، ردشده، منتشرشده، پرداخت‌شده — the doer/state
compound is one ZWNJ-joined word, not two spaced words («تایید شده») and not fused with no separator.
- Brand name itself (§1) is the other load-bearing ZWNJ case.
## 5. Punctuation & quotes
- Persian text uses «...» guillemets for quoted terms/labels in prose (as this document does), and
Persian «،» / «؛» for commas/semicolons *inside translated sentences* where the surrounding punctuation
is itself Persian prose (most UI strings use plain Latin `,`/`;` today for simplicity in short labels —
don't retrofit existing short strings, but prefer «،»/«؛» in new multi-clause sentences).
- English (`en.json`) uses **straight** apostrophes (`don't`, `couldn't`) throughout — never curly
(``, ``). One admin-namespace holdout (curly `don't`/`couldn't`) is fixed by this phase; don't
reintroduce curly quotes when editing English copy.
## 6. Domain glossary
- **بیمار** — the care recipient, used consistently everywhere except one parenthetical. Do not adopt
«مددجو» — it appeared exactly once (`booking.patient_label`) and has been dropped in favor of the
99%-majority «بیمار».
- **پرستار** — the caregiver, always (never «مراقب» as a noun for the person — «مراقب» only survives as
an adjective/role qualifier, e.g. `booking.gender_label` "جنسیت مراقب" meaning "the caregiver's gender").
- **رزرو** — a confirmed, paid booking. **درخواست رزرو** — a pre-payment request. Never conflate the two;
a `booking_request` is never called «رزرو» before it converts.
- **ویزیت** — one scheduled visit/session within a booking.
- **شبا** — IBAN, always («شماره شبا» for the field label, «شبا» alone elsewhere).
## 7. Shell naming system
One metaphor per audience class, not four:
- **End-user shells** (family, nurse — the apps people book/work through day to day) → **«اپلیکیشن»**:
«اپلیکیشن خانواده», «اپلیکیشن پرستار».
- **Back-office shells** (staff consoles — admin, partner-center) → **«کنسول»**: «کنسول مدیریت»,
«کنسول همکار».
- Never «نما» (view) or «پرتال» (portal) for a whole shell name — those read as one-off inconsistent
metaphors. (`booking.evv_nurse_view` "نمای پرستار" is a different thing — a chip labeling *whose
perspective* a shared booking-detail screen is rendered from, not a shell name; it correctly keeps
«نما» in that narrower sense.)
## 8. Verification pipeline vs. the identity step
**«تأیید صلاحیت»** names the whole 7-step nurse trust pipeline (nav entry, the verification hub's title,
its start/progress/approved states, the admin queue). **«احراز هویت»** stays the name of the one KYC
step inside it (national-ID + civil-registry + liveness selfie) — both on the nurse side
(`verification.step_identity_kyc`) and the admin side (`admin.step_identity_kyc`). A nurse who passed the
KYC step but still sees a pipeline titled «احراز هویت» incomplete in the nav used to read as a
contradiction; they no longer share a name.
## 9. Status vocabulary — one nurse-facing form, one admin-facing form
For "this step/item was rejected/failed" style states that appear on both a nurse-facing screen and an
admin-facing screen for the *same underlying concept* (a verification step's outcome):
- **Nurse-facing**: «رد شد» (`verification.status_failed`) — a short declarative sentence-style status,
matching the register of its sibling `status_passed` ("تأییدشده")/`status_in_review` ("در حال بررسی").
- **Admin-facing**: «ردشده» (`admin.step_failed`, `admin.agg_rejected`, `admin.rstatus_rejected`,
`admin.mstatus_rejected`) — the compound-adjective state form, matching the admin namespace's own
`step_passed`/`agg_approved`/`center_state_verified` ("تأییدشده") pattern.
This does **not** extend to unrelated money-failure vocabulary (`payouts.pstatus_failed`,
`refunds.rstatus_failed`, `admin.batch_status_failed` all legitimately use «ناموفق» — a transfer/payment
*failing* is a different concept from a document being *rejected*, and conflating them would blur a real
distinction).
## 10. Digits policy
Persian digits (۰۱۲۳۴۵۶۷۸۹) everywhere on `/fa` — both hard-coded literals (`"۲۴ ساعت"`) and
interpolated numbers. For an interpolated `{count}`/`{hours}`/… inside an ICU message, use the ICU
`number` sub-format (`{count, number}`) or a plain `#` inside a `plural` block — next-intl formats both
through the active locale (`fa` → Persian digits) automatically. When a raw number is interpolated at a
call site instead of through ICU (e.g. built into a larger string in code, not a message placeholder),
route it through `formatNumber` (`@/utils`) — never template a raw JS number directly into Persian text.
## 11. Register
Formal شما throughout, with polite imperatives (کنید) for actions and instructions. Already consistent
across the whole catalog — this codifies it so a future addition can't drift into informal تو/imperative
stems (نکن, برو).
## 12. Policy numbers
Legally/financially sensitive numbers that the admin config panel can change (the dispute-window hours,
cancellation lead-time hours, refund ETA days) are **never hard-coded into a message string**. The
message key takes a parameter (`{hours}`, `{minDays}`/`{maxDays}`) and the call site interpolates from
`client/src/constants/policy.ts` (single-sourced, REQ-065 tracks the eventual public config-read that
replaces the constants file). A config edit must never again silently make the UI copy lie.
+179 -28
View File
@@ -23,6 +23,7 @@
"payouts": "Payouts",
"reviews": "Reviews",
"config": "Configuration",
"catalog": "Catalog",
"holidays": "Holidays",
"alerts": "Alerts",
"audit": "Audit log",
@@ -62,14 +63,18 @@
"currency_toman": "Toman",
"brand": "Balinyaar",
"brand_tagline": "Home care you can trust",
"open_sidebar": "Open menu",
"switch_locale": "Switch to {locale}",
"page_prev": "Previous",
"page_next": "Next",
"page_indicator": "Page {page} of {total}",
"discard_title": "Discard changes?",
"discard_body": "Your unsaved changes will be lost.",
"discard_confirm": "Discard"
"discard_confirm": "Discard",
"language": "Language",
"appearance": "Appearance",
"theme_light": "Light",
"theme_dark": "Dark",
"theme_system": "System"
},
"shell": {
"customer_app": "Family app",
@@ -185,7 +190,11 @@
"nurseProfile": {
"title": "Nurse profile",
"subtitle": "What families see when they find you.",
"photo": "Profile photo",
"section_intro_title": "Your introduction",
"section_intro_description": "The photo and the words families see first.",
"section_experience_title": "Experience & education",
"section_experience_description": "Your background is how families decide with confidence.",
"section_specializations_description": "The areas you have the most experience in.",
"photo_hint": "A clear photo of your face.",
"upload": "Upload photo",
"uploading": "Uploading…",
@@ -216,6 +225,7 @@
"education_other": "Other",
"education_level_other_label": "Enter your education level",
"education_field_other_label": "Enter your field of study",
"education_other_required": "Fill in the “other” value.",
"specializations_label": "Specialties",
"preview_cta": "Preview my public profile",
"preview_title": "My public profile",
@@ -300,6 +310,10 @@
"line_hint": "Building, street, unit — the detail a nurse needs to find the door.",
"line_required": "Enter the street address",
"city_required": "Select a city",
"recipient_name_label": "Recipient name",
"recipient_name_required": "Enter who the nurse should ask for",
"recipient_phone_label": "Recipient phone",
"recipient_phone_invalid": "Enter a valid Iranian mobile number",
"set_primary_toggle": "Set as primary address",
"map_hint": "Tap or drag the marker to the patient's exact location.",
"map_required": "Drop a pin on the map",
@@ -378,11 +392,9 @@
"category_locked": "The category can't be changed after a service is created.",
"options_title": "Set the service options",
"options_subtitle": "Pick one option for each required item.",
"options_none": "This category has no options to configure.",
"options_missing_named": "Still to choose: {groups}",
"required_badge": "Required",
"optional_badge": "Optional",
"options_incomplete": "Answer every required item to continue.",
"price_title": "Set the price and unit",
"price_label": "Price (Toman)",
"price_hint": "Enter the price in Toman.",
"price_required": "Enter a valid price greater than zero.",
@@ -398,6 +410,10 @@
"preview_untitled": "New service",
"summary_options": "Options",
"summary_none": "No options",
"section_recap_title": "Selected service",
"section_recap_description": "Review your choices before you price it.",
"section_price_title": "Price & unit",
"section_listing_title": "How it appears in search",
"next": "Next",
"submit_create": "Add service",
"submit_save": "Save changes",
@@ -463,6 +479,7 @@
"unnamed_nurse": "Nurse",
"unnamed_service": "Service",
"completed_visits": "{count, number} successful visits",
"more_services_count": "+{count, plural, one {# more service} other {# more services}}",
"reviews_count": "({count, plural, =0 {no reviews} one {# review} other {# reviews}})",
"distance_km": "{km} km",
"price_from": "from",
@@ -727,8 +744,8 @@
"accept_confirm_cta": "Yes, accept the request"
},
"dashboard": {
"greeting": "Hi, {name}",
"retry": "Retry",
"title": "Today",
"next_visit_title": "Next visit",
"next_visit_empty": "No visits scheduled for today.",
"next_visit_starts_in": "starts {relative}",
@@ -762,6 +779,11 @@
"total_payable_label": "Total to pay",
"secure_gateway_notice": "Secure payment via bank gateway",
"cta_pay": "Continue to payment",
"gateway_title": "Redirecting to the payment gateway",
"gateway_hint": "This is a demo page standing in for the bank gateway.",
"gateway_reference_label": "Reference",
"gateway_pay_success": "Pay (demo)",
"gateway_pay_fail": "Cancel",
"bnpl_option": "Or pay in installments",
"state_initiating": "Starting payment…",
"state_redirecting": "Redirecting to the payment gateway…",
@@ -839,12 +861,12 @@
"customer_switch": "Are you a family? Family sign in",
"rate_limited": "Too many attempts — please try again shortly",
"otp_title": "Enter the verification code",
"otp_sent_to": "Code sent to {phone}",
"otp_sent_to": "Code sent to <phone></phone>",
"otp_verify_customer": "Verify and continue",
"otp_verify_nurse": "Verify and sign in",
"otp_invalid": "The code is incorrect or has expired",
"otp_locked": "Too many attempts. Request a new code to continue.",
"resend_in": "Resend code in {time}",
"resend_in": "Resend code in <time></time>",
"resend": "Resend code",
"change_number": "Change number",
"routing_title": "Signing you in…",
@@ -916,29 +938,34 @@
"group_review": "Review",
"payoff_title": "This is the badge families will see",
"payoff_subtitle": "It fills in as you complete each group below.",
"identity_title": "Verify identity",
"identity_subtitle": "Your national ID, a photo of your ID card, and a liveness selfie.",
"identity_step_number_title": "Your national ID",
"national_id_label": "National ID",
"national_id_hint": "Enter your 10-digit national ID.",
"national_id_invalid": "Enter a valid national ID.",
"card_label": "National-ID card image",
"card_hint": "Photograph your ID card in good light, clearly readable.",
"card_recommended": "Uploading the ID card image is recommended.",
"card_recommended_short": "Recommended",
"capture_hint_card": "Good lighting, no blur, all four corners of the card inside the frame.",
"selfie_label": "Liveness selfie",
"selfie_hint": "Take a selfie for face verification.",
"capture_hint_selfie": "Good lighting, face centered and unobstructed.",
"capture_done": "Captured",
"required_badge": "Required",
"auto_registry_note": "An automatic civil-registry check is performed.",
"error_national_id_mismatch": "The national ID didn't match the civil registry. Please check and try again.",
"error_shared_sim": "This SIM doesn't appear to be registered in your name. Please try again with a SIM registered to you.",
"error_shahkar_mismatch": "The mobile-to-national-ID match failed. Please check and try again.",
"identity_submit": "Submit & verify",
"identity_submitting": "Verifying…",
"identity_needs_selfie": "Capture the liveness selfie to continue.",
"identity_submitted": "Identity submitted — verification started",
"back_to_checklist": "Back to checklist",
"credentials_title": "Professional credentials",
"credentials_subtitle": "Your documents are reviewed by our team after upload.",
"credentials_needs_start": "Start verification from the status checklist first.",
"credentials_documents_title": "Professional documents",
"credentials_documents_description": "Upload each document — a reviewer checks it.",
"credentials_documents_count": "{done} of {total}",
"ino_number_label": "Nursing-council number",
"ino_number_hint": "Enter your nursing-council membership number.",
"ino_number_required": "Enter your nursing-council number.",
@@ -957,10 +984,12 @@
"specialty_wound_care": "Wound care",
"specialty_add_placeholder": "Another specialty",
"specialty_add": "Add",
"registry_details_label": "Credential details (optional)",
"issuing_authority_label": "Issuing authority",
"issued_at_label": "Issue date",
"expires_at_label": "Expiry date",
"registry_details_title": "Credential details",
"registry_details_description": "The issuer and dates speed the review up.",
"summary_none": "Nothing recorded",
"manual_review_note": "These documents are reviewed manually by our team — not an instant approval.",
"credentials_submit": "Submit credentials",
"credentials_submitting": "Submitting…",
@@ -1423,6 +1452,7 @@
"category_label": "Category",
"subject_label": "Subject",
"message_label": "Message",
"message_required": "Write your message.",
"submit": "Send",
"submitting": "Sending…",
"cancel": "Cancel",
@@ -1664,6 +1694,45 @@
"hol_bank_hint": "When on, payouts falling on this day shift to the next business day.",
"hol_saved": "Holiday saved.",
"hol_year": "Year",
"status_active": "Active",
"status_inactive": "Inactive",
"cat_title": "Catalog",
"cat_subtitle": "Categories and the pricing dimensions nurses build their offerings from.",
"cat_empty": "No categories yet.",
"cat_col_name": "Category",
"cat_col_order": "Order",
"cat_col_status": "Status",
"cat_add": "Add category",
"cat_edit": "Edit",
"cat_activate": "Activate",
"cat_deactivate": "Deactivate",
"cat_activated": "Category activated.",
"cat_deactivated": "Category deactivated.",
"cat_saved": "Category saved.",
"cat_not_found": "This category could not be found.",
"cat_name_fa": "Name (Persian)",
"cat_name_en": "Name (English)",
"cat_description_fa": "Description (Persian)",
"cat_description_en": "Description (English)",
"cat_icon_key": "Icon key (optional)",
"cat_icon_hint": "Matches a name in the icon registry; an unknown or missing key falls back gracefully.",
"cat_sort_order": "Sort order",
"og_section_title": "Pricing options",
"og_empty": "No pricing dimensions yet.",
"og_add": "Add dimension",
"og_edit": "Edit",
"og_cross_category_badge": "All categories",
"og_required_badge": "Required",
"og_optional_badge": "Optional",
"og_scope_all": "Applies to all categories",
"og_scope_all_hint": "A cross-category dimension is offered for every category, not just this one.",
"og_scope_this_hint": "Offered only for this category.",
"og_is_required": "Must be answered before booking",
"og_saved": "Pricing dimension saved.",
"og_value_add": "Add value",
"og_value_edit": "Edit value",
"og_value_saved": "Value saved.",
"og_value_empty": "No values yet.",
"alert_title": "Support alerts",
"alert_subtitle": "Internal triage — never shown to customers or nurses.",
"alert_empty": "No open alerts.",
@@ -1951,24 +2020,69 @@
"draft_banner": "This is placeholder legal copy — it has not yet been reviewed by counsel and must not be relied on before launch.",
"terms_intro": "These draft terms describe how Balinyaar connects families with independent, verified home-nursing professionals. By creating an account you agree to the terms below.",
"terms_sections": [
{ "title": "The service", "body": "Balinyaar is a marketplace: it does not employ nurses. Independent nurses and nursing-company staff list their own services; families search, book, and pay through the platform." },
{ "title": "Bookings and payment", "body": "You pay the full booking price through Balinyaar by card. The amount is held in an internal escrow ledger and is only released to the nurse, weekly, after your visit is confirmed and the dispute window closes." },
{ "title": "Cancellations and refunds", "body": "Cancelling a confirmed booking may incur a fee depending on how close to the visit you cancel, shown to you before you confirm. Approved refunds are returned to your original payment method or provider." },
{ "title": "Nurse verification", "body": "Every nurse on Balinyaar passes an identity check, a professional-competency license check, and other required verification steps before they can be booked. We show what has been verified on their profile." },
{ "title": "Your responsibilities", "body": "Provide accurate information about the person receiving care, communicate through the app's ticket system for anything related to a booking, and treat nurses respectfully." },
{ "title": "Liability", "body": "Balinyaar facilitates bookings between families and independent professionals; it is not itself a healthcare provider. Disputes are handled through our support ticket system." },
{ "title": "Changes to these terms", "body": "We may update these terms as the service evolves. Material changes will be announced in the app before they take effect." },
{ "title": "Contact", "body": "Questions about these terms can be sent through the support ticket system in the app." }
{
"title": "The service",
"body": "Balinyaar is a marketplace: it does not employ nurses. Independent nurses and nursing-company staff list their own services; families search, book, and pay through the platform."
},
{
"title": "Bookings and payment",
"body": "You pay the full booking price through Balinyaar by card. The amount is held in an internal escrow ledger and is only released to the nurse, weekly, after your visit is confirmed and the dispute window closes."
},
{
"title": "Cancellations and refunds",
"body": "Cancelling a confirmed booking may incur a fee depending on how close to the visit you cancel, shown to you before you confirm. Approved refunds are returned to your original payment method or provider."
},
{
"title": "Nurse verification",
"body": "Every nurse on Balinyaar passes an identity check, a professional-competency license check, and other required verification steps before they can be booked. We show what has been verified on their profile."
},
{
"title": "Your responsibilities",
"body": "Provide accurate information about the person receiving care, communicate through the app's ticket system for anything related to a booking, and treat nurses respectfully."
},
{
"title": "Liability",
"body": "Balinyaar facilitates bookings between families and independent professionals; it is not itself a healthcare provider. Disputes are handled through our support ticket system."
},
{
"title": "Changes to these terms",
"body": "We may update these terms as the service evolves. Material changes will be announced in the app before they take effect."
},
{
"title": "Contact",
"body": "Questions about these terms can be sent through the support ticket system in the app."
}
],
"privacy_intro": "This draft policy explains what personal data Balinyaar collects to run the service, and how it is used.",
"privacy_sections": [
{ "title": "Information we collect", "body": "Your mobile number for login; for nurses, national ID and license details for verification; patient care information you or your nurse enter; approximate visit location for check-in/check-out; and payment metadata from our payment provider." },
{ "title": "How we use it", "body": "To create and manage bookings, verify nurse identity and credentials, process payments and weekly nurse payouts, and provide support." },
{ "title": "Who we share it with", "body": "Licensed payment providers, identity-verification vendors, and our licensed home-nursing partner center receive only the information each needs to do their part — never more." },
{ "title": "Data security", "body": "Sensitive fields such as national ID numbers and clinical notes are encrypted. Access to patient care records is limited to the family and the assigned nurse." },
{ "title": "Your rights", "body": "You can review and update most of your information from your profile, and can reach support to ask about, correct, or request deletion of your data." },
{ "title": "Changes to this policy", "body": "We may update this policy as the service evolves. Material changes will be announced in the app before they take effect." },
{ "title": "Contact", "body": "Questions about this policy can be sent through the support ticket system in the app." }
{
"title": "Information we collect",
"body": "Your mobile number for login; for nurses, national ID and license details for verification; patient care information you or your nurse enter; approximate visit location for check-in/check-out; and payment metadata from our payment provider."
},
{
"title": "How we use it",
"body": "To create and manage bookings, verify nurse identity and credentials, process payments and weekly nurse payouts, and provide support."
},
{
"title": "Who we share it with",
"body": "Licensed payment providers, identity-verification vendors, and our licensed home-nursing partner center receive only the information each needs to do their part — never more."
},
{
"title": "Data security",
"body": "Sensitive fields such as national ID numbers and clinical notes are encrypted. Access to patient care records is limited to the family and the assigned nurse."
},
{
"title": "Your rights",
"body": "You can review and update most of your information from your profile, and can reach support to ask about, correct, or request deletion of your data."
},
{
"title": "Changes to this policy",
"body": "We may update this policy as the service evolves. Material changes will be announced in the app before they take effect."
},
{
"title": "Contact",
"body": "Questions about this policy can be sent through the support ticket system in the app."
}
]
},
"welcome": {
@@ -2005,5 +2119,42 @@
"footer_contact_title": "Support",
"footer_contact_body": "For any questions, reach us through the in-app support ticket system after you sign in.",
"footer_copyright": "© {year, number} Balinyaar"
},
"hub": {
"practice_subtitle": "Your profile, services and credentials in one place",
"practice_profile_sub": "Name, photo, specialisations and education",
"practice_services_sub": "The services and prices you offer",
"practice_coverage_sub": "The cities and districts you travel to",
"practice_verification_sub": "Identity, professional credentials and your trust badge",
"practice_status_title": "Your listing status",
"practice_accepting_on": "Accepting bookings",
"practice_accepting_off": "Bookings paused",
"practice_accepting_manage": "Manage",
"finance_subtitle": "Earnings, payouts and your bank account",
"finance_earnings_sub": "Completed visits and your share",
"finance_payouts_sub": "Weekly transfer history",
"finance_bank_sub": "The IBAN payouts are sent to",
"finance_balance_error": "Your balance could not be loaded.",
"more_title": "Support & settings",
"more_support_sub": "Message the Balinyaar team",
"more_notifications_sub": "Recent activity on your account",
"admin_group_empty_title": "No access",
"admin_group_empty_body": "Your current role can't act on any console in this section.",
"admin_trust_subtitle": "Nurse verification and review moderation",
"admin_finance_subtitle": "The weekly nurse payout run",
"admin_support_subtitle": "User tickets and internal alerts",
"admin_system_subtitle": "Platform configuration and your account",
"admin_verification_sub": "Cases waiting for review",
"admin_reviews_sub": "Publish, hide or reject reviews",
"admin_payouts_sub": "Preview and run a payout batch",
"admin_tickets_sub": "The global ticket queue",
"admin_alerts_sub": "The internal alert worklist",
"admin_config_sub": "Config keys and their change history",
"admin_catalog_sub": "Categories and their pricing dimensions",
"admin_holidays_sub": "The bank-holiday calendar",
"admin_audit_sub": "Read-only record of every change",
"admin_partners_sub": "Partner centers and sponsored nurses",
"admin_users_sub": "Search users by name or phone",
"admin_roles_sub": "Grant and revoke roles"
}
}
+179 -28
View File
@@ -23,6 +23,7 @@
"payouts": "تسویه‌ها",
"reviews": "نظرات",
"config": "پیکربندی",
"catalog": "کاتالوگ",
"holidays": "تعطیلات",
"alerts": "هشدارها",
"audit": "گزارش ممیزی",
@@ -62,14 +63,18 @@
"currency_toman": "تومان",
"brand": "بالین‌یار",
"brand_tagline": "مراقبت مطمئن در خانه",
"open_sidebar": "باز کردن منو",
"switch_locale": "تغییر به {locale}",
"page_prev": "قبلی",
"page_next": "بعدی",
"page_indicator": "صفحه {page} از {total}",
"discard_title": "تغییرات نادیده گرفته شود؟",
"discard_body": "تغییراتی که ذخیره نشده از دست می‌رود.",
"discard_confirm": "بله، نادیده بگیر"
"discard_confirm": "بله، نادیده بگیر",
"language": "زبان",
"appearance": "نمایش",
"theme_light": "روشن",
"theme_dark": "تیره",
"theme_system": "سیستم"
},
"shell": {
"customer_app": "اپلیکیشن خانواده",
@@ -185,7 +190,11 @@
"nurseProfile": {
"title": "پروفایل پرستار",
"subtitle": "چیزی که خانواده‌ها هنگام یافتن شما می‌بینند.",
"photo": "عکس پروفایل",
"section_intro_title": "معرفی شما",
"section_intro_description": "عکس و متنی که خانواده‌ها پیش از هر چیز می‌بینند.",
"section_experience_title": "تجربه و تحصیلات",
"section_experience_description": "سابقه و مدرک شما به خانواده‌ها کمک می‌کند با اطمینان انتخاب کنند.",
"section_specializations_description": "حوزه‌هایی که در آن‌ها بیشترین تجربه را دارید.",
"photo_hint": "یک عکس واضح از چهره‌تان.",
"upload": "بارگذاری عکس",
"uploading": "در حال بارگذاری…",
@@ -216,6 +225,7 @@
"education_other": "سایر",
"education_level_other_label": "مقطع تحصیلی خود را وارد کنید",
"education_field_other_label": "رشتهٔ تحصیلی خود را وارد کنید",
"education_other_required": "مورد «سایر» را وارد کنید.",
"specializations_label": "تخصص‌ها",
"preview_cta": "پیش‌نمایش نمایهٔ عمومی من",
"preview_title": "نمایهٔ عمومی من",
@@ -300,6 +310,10 @@
"line_hint": "پلاک، خیابان، واحد — جزئیاتی که پرستار برای یافتن درِ منزل نیاز دارد.",
"line_required": "نشانی کامل را وارد کنید",
"city_required": "شهر را انتخاب کنید",
"recipient_name_label": "نام تحویل‌گیرنده",
"recipient_name_required": "نام فردی که پرستار باید بپرسد را وارد کنید",
"recipient_phone_label": "شماره تماس تحویل‌گیرنده",
"recipient_phone_invalid": "یک شماره موبایل معتبر ایرانی وارد کنید",
"set_primary_toggle": "به‌عنوان آدرس اصلی تنظیم شود",
"map_hint": "برای تعیین محل دقیق بیمار، نشانگر را بکشید یا روی نقشه بزنید.",
"map_required": "روی نقشه یک پین بگذارید",
@@ -378,11 +392,9 @@
"category_locked": "دسته پس از ایجاد خدمت قابل تغییر نیست.",
"options_title": "گزینه‌های خدمت را مشخص کنید",
"options_subtitle": "برای هر مورد الزامی یک گزینه انتخاب کنید.",
"options_none": "این دسته گزینه‌ای برای تنظیم ندارد.",
"options_missing_named": "هنوز انتخاب نشده: {groups}",
"required_badge": "الزامی",
"optional_badge": "اختیاری",
"options_incomplete": "برای ادامه، همهٔ موارد الزامی را انتخاب کنید.",
"price_title": "قیمت و واحد را تعیین کنید",
"price_label": "قیمت (تومان)",
"price_hint": "قیمت را به تومان وارد کنید.",
"price_required": "قیمتی معتبر و بزرگ‌تر از صفر وارد کنید.",
@@ -398,6 +410,10 @@
"preview_untitled": "خدمت جدید",
"summary_options": "گزینه‌ها",
"summary_none": "بدون گزینه",
"section_recap_title": "خدمت انتخابی",
"section_recap_description": "پیش از تعیین قیمت، انتخاب‌های خود را مرور کنید.",
"section_price_title": "قیمت و واحد",
"section_listing_title": "نمایش در جستجو",
"next": "بعدی",
"submit_create": "ثبت خدمت",
"submit_save": "ذخیره تغییرات",
@@ -463,6 +479,7 @@
"unnamed_nurse": "پرستار",
"unnamed_service": "خدمت",
"completed_visits": "{count, number} ویزیت موفق",
"more_services_count": "+{count, plural, one {# خدمت دیگر} other {# خدمت دیگر}}",
"reviews_count": "({count, plural, =0 {بدون نظر} one {# نظر} other {# نظر}})",
"distance_km": "{km} کیلومتر",
"price_from": "از",
@@ -727,8 +744,8 @@
"accept_confirm_cta": "بله، پذیرش درخواست"
},
"dashboard": {
"greeting": "سلام، {name}",
"retry": "تلاش مجدد",
"title": "امروز",
"next_visit_title": "ویزیت بعدی",
"next_visit_empty": "امروز ویزیتی ندارید.",
"next_visit_starts_in": "شروع {relative}",
@@ -762,6 +779,11 @@
"total_payable_label": "مبلغ قابل پرداخت",
"secure_gateway_notice": "پرداخت امن از طریق درگاه بانکی",
"cta_pay": "ادامه پرداخت",
"gateway_title": "در حال انتقال به درگاه پرداخت",
"gateway_hint": "این صفحه نمایشی جایگزین درگاه بانکی است.",
"gateway_reference_label": "کد مرجع",
"gateway_pay_success": "پرداخت (نمایشی)",
"gateway_pay_fail": "انصراف",
"bnpl_option": "یا پرداخت اقساطی",
"state_initiating": "در حال آغاز پرداخت…",
"state_redirecting": "در حال انتقال به درگاه پرداخت…",
@@ -839,12 +861,12 @@
"customer_switch": "خانواده هستید؟ ورود خانواده‌ها",
"rate_limited": "به‌دلیل تلاش زیاد، کمی بعد دوباره تلاش کنید",
"otp_title": "کد تأیید را وارد کنید",
"otp_sent_to": "کد به شماره {phone} ارسال شد",
"otp_sent_to": "کد به شماره <phone></phone> ارسال شد",
"otp_verify_customer": "تأیید و ادامه",
"otp_verify_nurse": "تأیید و ورود",
"otp_invalid": "کد وارد شده نادرست یا منقضی شده است",
"otp_locked": "به‌دلیل تلاش‌های زیاد، ورود موقتاً قفل شد. کد جدید دریافت کنید.",
"resend_in": "ارسال مجدد کد تا {time}",
"resend_in": "ارسال مجدد کد تا <time></time>",
"resend": "ارسال مجدد کد",
"change_number": "تغییر شماره",
"routing_title": "در حال ورود…",
@@ -916,29 +938,34 @@
"group_review": "بررسی",
"payoff_title": "این نشان را خانواده‌ها می‌بینند",
"payoff_subtitle": "با تکمیل هر گروه در پایین، این نشان کامل‌تر می‌شود.",
"identity_title": "تأیید هویت",
"identity_subtitle": "کد ملی، تصویر کارت ملی و یک سلفی زنده.",
"identity_step_number_title": "شمارهٔ ملی شما",
"national_id_label": "کد ملی",
"national_id_hint": "کد ملی ۱۰ رقمی خود را وارد کنید.",
"national_id_invalid": "کد ملی معتبر وارد کنید.",
"card_label": "تصویر کارت ملی",
"card_hint": "کارت ملی را در نور کافی و خوانا عکس بگیرید.",
"card_recommended": "بارگذاری تصویر کارت ملی توصیه می‌شود.",
"card_recommended_short": "توصیه‌شده",
"capture_hint_card": "نور کافی، بدون تاری، چهار گوشهٔ کارت داخل کادر.",
"selfie_label": "سلفی زنده",
"selfie_hint": "برای تشخیص چهره، سلفی بگیرید.",
"capture_hint_selfie": "نور کافی، چهره در مرکز و بدون پوشش.",
"capture_done": "ثبت شد",
"required_badge": "الزامی",
"auto_registry_note": "استعلام خودکار از ثبت احوال انجام می‌شود.",
"error_national_id_mismatch": "کد ملی با ثبت احوال مطابقت نداشت. لطفاً بررسی و دوباره تلاش کنید.",
"error_shared_sim": "به نظر می‌رسد این سیم‌کارت به نام شما نیست. لطفاً با سیم‌کارتی که به نام خودتان است دوباره تلاش کنید.",
"error_shahkar_mismatch": "تطبیق شماره موبایل و کد ملی ناموفق بود. لطفاً بررسی و دوباره تلاش کنید.",
"identity_submit": "ثبت و استعلام",
"identity_submitting": "در حال استعلام…",
"identity_needs_selfie": "برای ادامه، سلفی زنده را ثبت کنید.",
"identity_submitted": "هویت ثبت شد — استعلام آغاز شد",
"back_to_checklist": "بازگشت به فهرست",
"credentials_title": "مدارک حرفه‌ای",
"credentials_subtitle": "مدارک شما پس از بارگذاری، توسط کارشناس بررسی می‌شود.",
"credentials_needs_start": "ابتدا تأیید صلاحیت را از فهرست وضعیت آغاز کنید.",
"credentials_documents_title": "مدارک حرفه‌ای",
"credentials_documents_description": "هر مدرک را بارگذاری کنید؛ کارشناس آن را بررسی می‌کند.",
"credentials_documents_count": "{done} از {total}",
"ino_number_label": "شماره نظام پرستاری",
"ino_number_hint": "شماره عضویت نظام پرستاری خود را وارد کنید.",
"ino_number_required": "شماره نظام پرستاری را وارد کنید.",
@@ -957,10 +984,12 @@
"specialty_wound_care": "زخم و پانسمان",
"specialty_add_placeholder": "تخصص دیگر",
"specialty_add": "افزودن",
"registry_details_label": "جزئیات مدرک (اختیاری)",
"issuing_authority_label": "مرجع صادرکننده",
"issued_at_label": "تاریخ صدور",
"expires_at_label": "تاریخ انقضا",
"registry_details_title": "جزئیات مدرک",
"registry_details_description": "مرجع صادرکننده و تاریخ‌ها بررسی را سریع‌تر می‌کنند.",
"summary_none": "موردی ثبت نشده",
"manual_review_note": "این مدارک به‌صورت دستی توسط کارشناس بررسی می‌شوند؛ تأیید فوری نیست.",
"credentials_submit": "ثبت مدارک",
"credentials_submitting": "در حال ثبت…",
@@ -1423,6 +1452,7 @@
"category_label": "دسته",
"subject_label": "موضوع",
"message_label": "پیام",
"message_required": "متن پیام را بنویسید.",
"submit": "ارسال",
"submitting": "در حال ارسال…",
"cancel": "انصراف",
@@ -1664,6 +1694,45 @@
"hol_bank_hint": "در صورت فعال بودن، تسویه‌های این روز به روز کاری بعد منتقل می‌شوند.",
"hol_saved": "تعطیلی ذخیره شد.",
"hol_year": "سال",
"status_active": "فعال",
"status_inactive": "غیرفعال",
"cat_title": "کاتالوگ خدمات",
"cat_subtitle": "دسته‌بندی‌های خدمات و ابعاد قیمت‌گذاری که پرستاران خدمات خود را بر اساس آن‌ها می‌سازند.",
"cat_empty": "هنوز دسته‌بندی‌ای ثبت نشده است.",
"cat_col_name": "دسته‌بندی",
"cat_col_order": "ترتیب",
"cat_col_status": "وضعیت",
"cat_add": "افزودن دسته‌بندی",
"cat_edit": "ویرایش",
"cat_activate": "فعال‌سازی",
"cat_deactivate": "غیرفعال‌سازی",
"cat_activated": "دسته‌بندی فعال شد.",
"cat_deactivated": "دسته‌بندی غیرفعال شد.",
"cat_saved": "دسته‌بندی ذخیره شد.",
"cat_not_found": "این دسته‌بندی پیدا نشد.",
"cat_name_fa": "نام (فارسی)",
"cat_name_en": "نام (انگلیسی)",
"cat_description_fa": "توضیحات (فارسی)",
"cat_description_en": "توضیحات (انگلیسی)",
"cat_icon_key": "کلید آیکون (اختیاری)",
"cat_icon_hint": "باید با یکی از نام‌های ثبت‌شدهٔ آیکون مطابقت داشته باشد؛ کلید ناشناخته یا خالی به‌صورت خودکار جایگزین می‌شود.",
"cat_sort_order": "ترتیب نمایش",
"og_section_title": "ابعاد قیمت‌گذاری",
"og_empty": "هنوز بُعد قیمت‌گذاری‌ای ثبت نشده است.",
"og_add": "افزودن بُعد",
"og_edit": "ویرایش",
"og_cross_category_badge": "همهٔ دسته‌بندی‌ها",
"og_required_badge": "اجباری",
"og_optional_badge": "اختیاری",
"og_scope_all": "برای همهٔ دسته‌بندی‌ها اعمال شود",
"og_scope_all_hint": "یک بُعد بین‌دسته‌ای برای همهٔ دسته‌بندی‌ها نمایش داده می‌شود، نه فقط همین یکی.",
"og_scope_this_hint": "فقط برای همین دسته‌بندی نمایش داده می‌شود.",
"og_is_required": "پیش از رزرو باید پاسخ داده شود",
"og_saved": "بُعد قیمت‌گذاری ذخیره شد.",
"og_value_add": "افزودن مقدار",
"og_value_edit": "ویرایش مقدار",
"og_value_saved": "مقدار ذخیره شد.",
"og_value_empty": "هنوز مقداری ثبت نشده است.",
"alert_title": "هشدارهای پشتیبانی",
"alert_subtitle": "صف داخلی — هرگز به مشتری یا پرستار نمایش داده نمی‌شود.",
"alert_empty": "هشداری برای رسیدگی نیست.",
@@ -1951,24 +2020,69 @@
"draft_banner": "این متن پیش‌نویس است و هنوز توسط تیم حقوقی بازبینی نشده؛ پیش از انتشار نهایی قابل استناد نیست.",
"terms_intro": "این شرایط پیش‌نویس، نحوه ارتباط بالین‌یار بین خانواده‌ها و پرستاران مستقل و تأییدشده مراقبت در منزل را توضیح می‌دهد. با ساخت حساب کاربری، شرایط زیر را می‌پذیرید.",
"terms_sections": [
{ "title": "ماهیت خدمت", "body": "بالین‌یار یک بازارگاه است و پرستاران را استخدام نمی‌کند. پرستاران مستقل یا شاغل در مراکز پرستاری، خدمات خود را ثبت می‌کنند و خانواده‌ها از طریق پلتفرم جستجو، رزرو و پرداخت انجام می‌دهند." },
{ "title": "رزرو و پرداخت", "body": "مبلغ کامل رزرو را از طریق بالین‌یار و با کارت پرداخت می‌کنید. این مبلغ به‌صورت امانی نزد بالین‌یار نگه‌داری می‌شود و تنها پس از تأیید انجام خدمت و پایان مهلت اعتراض، به‌صورت هفتگی به پرستار پرداخت می‌شود." },
{ "title": "لغو و بازگشت وجه", "body": "لغو یک رزرو تأییدشده، بسته به فاصله زمانی تا زمان مراجعه، ممکن است مشمول کارمزد شود که پیش از تأیید نهایی به شما نمایش داده می‌شود. مبالغ بازگشتی تأییدشده به همان روش پرداخت اصلی یا ارائه‌دهنده مربوطه بازمی‌گردد." },
{ "title": "احراز هویت پرستاران", "body": "هر پرستار پیش از قابل‌رزرو شدن، مراحل احراز هویت، بررسی پروانه صلاحیت حرفه‌ای و سایر مراحل الزامی را می‌گذراند. آنچه تأیید شده در پروفایل او نمایش داده می‌شود." },
{ "title": "مسئولیت‌های شما", "body": "اطلاعات دقیق درباره فرد دریافت‌کننده مراقبت ارائه دهید، برای هر موضوع مرتبط با رزرو از طریق سامانه تیکت پشتیبانی اپلیکیشن ارتباط بگیرید و با پرستاران محترمانه رفتار کنید." },
{ "title": "مسئولیت‌پذیری", "body": "بالین‌یار واسط رزرو بین خانواده‌ها و پرستاران مستقل است و خود ارائه‌دهنده خدمات درمانی نیست. اختلافات از طریق سامانه تیکت پشتیبانی رسیدگی می‌شود." },
{ "title": "تغییر این شرایط", "body": "ممکن است این شرایط با تحول خدمت به‌روزرسانی شود. تغییرات مهم پیش از اعمال، در اپلیکیشن اطلاع‌رسانی می‌شود." },
{ "title": "تماس با ما", "body": "سوالات درباره این شرایط را می‌توانید از طریق سامانه تیکت پشتیبانی در اپلیکیشن ارسال کنید." }
{
"title": "ماهیت خدمت",
"body": "بالین‌یار یک بازارگاه است و پرستاران را استخدام نمی‌کند. پرستاران مستقل یا شاغل در مراکز پرستاری، خدمات خود را ثبت می‌کنند و خانواده‌ها از طریق پلتفرم جستجو، رزرو و پرداخت انجام می‌دهند."
},
{
"title": "رزرو و پرداخت",
"body": "مبلغ کامل رزرو را از طریق بالین‌یار و با کارت پرداخت می‌کنید. این مبلغ به‌صورت امانی نزد بالین‌یار نگه‌داری می‌شود و تنها پس از تأیید انجام خدمت و پایان مهلت اعتراض، به‌صورت هفتگی به پرستار پرداخت می‌شود."
},
{
"title": "لغو و بازگشت وجه",
"body": "لغو یک رزرو تأییدشده، بسته به فاصله زمانی تا زمان مراجعه، ممکن است مشمول کارمزد شود که پیش از تأیید نهایی به شما نمایش داده می‌شود. مبالغ بازگشتی تأییدشده به همان روش پرداخت اصلی یا ارائه‌دهنده مربوطه بازمی‌گردد."
},
{
"title": "احراز هویت پرستاران",
"body": "هر پرستار پیش از قابل‌رزرو شدن، مراحل احراز هویت، بررسی پروانه صلاحیت حرفه‌ای و سایر مراحل الزامی را می‌گذراند. آنچه تأیید شده در پروفایل او نمایش داده می‌شود."
},
{
"title": "مسئولیت‌های شما",
"body": "اطلاعات دقیق درباره فرد دریافت‌کننده مراقبت ارائه دهید، برای هر موضوع مرتبط با رزرو از طریق سامانه تیکت پشتیبانی اپلیکیشن ارتباط بگیرید و با پرستاران محترمانه رفتار کنید."
},
{
"title": "مسئولیت‌پذیری",
"body": "بالین‌یار واسط رزرو بین خانواده‌ها و پرستاران مستقل است و خود ارائه‌دهنده خدمات درمانی نیست. اختلافات از طریق سامانه تیکت پشتیبانی رسیدگی می‌شود."
},
{
"title": "تغییر این شرایط",
"body": "ممکن است این شرایط با تحول خدمت به‌روزرسانی شود. تغییرات مهم پیش از اعمال، در اپلیکیشن اطلاع‌رسانی می‌شود."
},
{
"title": "تماس با ما",
"body": "سوالات درباره این شرایط را می‌توانید از طریق سامانه تیکت پشتیبانی در اپلیکیشن ارسال کنید."
}
],
"privacy_intro": "این پیش‌نویس سیاست حریم خصوصی، اطلاعات شخصی که بالین‌یار برای ارائه خدمت جمع‌آوری می‌کند و نحوه استفاده از آن را توضیح می‌دهد.",
"privacy_sections": [
{ "title": "اطلاعاتی که جمع‌آوری می‌کنیم", "body": "شماره موبایل برای ورود؛ برای پرستاران، کد ملی و اطلاعات پروانه برای احراز هویت؛ اطلاعات مراقبتی بیمار که شما یا پرستار وارد می‌کنید؛ موقعیت تقریبی محل مراجعه برای ورود/خروج پرستار؛ و اطلاعات فراداده پرداخت از ارائه‌دهنده درگاه پرداخت." },
{ "title": "نحوه استفاده", "body": "برای ایجاد و مدیریت رزروها، احراز هویت و اعتبارسنجی پرستاران، پردازش پرداخت‌ها و تسویه هفتگی پرستاران، و ارائه پشتیبانی." },
{ "title": "اشتراک‌گذاری اطلاعات", "body": "ارائه‌دهندگان مجاز پرداخت، سرویس‌های احراز هویت، و مرکز مشاوره و ارائه مراقبت‌های پرستاری در منزل طرف قرارداد ما، تنها به میزان لازم برای انجام وظیفه خود به اطلاعات دسترسی دارند." },
{ "title": "امنیت اطلاعات", "body": "فیلدهای حساس مانند کد ملی و یادداشت‌های بالینی رمزنگاری می‌شوند. دسترسی به پرونده مراقبتی بیمار تنها برای خانواده و پرستار مسئول امکان‌پذیر است." },
{ "title": "حقوق شما", "body": "می‌توانید بیشتر اطلاعات خود را از پروفایل خود مشاهده و ویرایش کنید و برای پرسش، اصلاح یا درخواست حذف اطلاعات با پشتیبانی در تماس باشید." },
{ "title": "تغییر این سیاست", "body": "ممکن است این سیاست با تحول خدمت به‌روزرسانی شود. تغییرات مهم پیش از اعمال، در اپلیکیشن اطلاع‌رسانی می‌شود." },
{ "title": "تماس با ما", "body": "سوالات درباره این سیاست را می‌توانید از طریق سامانه تیکت پشتیبانی در اپلیکیشن ارسال کنید." }
{
"title": "اطلاعاتی که جمع‌آوری می‌کنیم",
"body": "شماره موبایل برای ورود؛ برای پرستاران، کد ملی و اطلاعات پروانه برای احراز هویت؛ اطلاعات مراقبتی بیمار که شما یا پرستار وارد می‌کنید؛ موقعیت تقریبی محل مراجعه برای ورود/خروج پرستار؛ و اطلاعات فراداده پرداخت از ارائه‌دهنده درگاه پرداخت."
},
{
"title": "نحوه استفاده",
"body": "برای ایجاد و مدیریت رزروها، احراز هویت و اعتبارسنجی پرستاران، پردازش پرداخت‌ها و تسویه هفتگی پرستاران، و ارائه پشتیبانی."
},
{
"title": "اشتراک‌گذاری اطلاعات",
"body": "ارائه‌دهندگان مجاز پرداخت، سرویس‌های احراز هویت، و مرکز مشاوره و ارائه مراقبت‌های پرستاری در منزل طرف قرارداد ما، تنها به میزان لازم برای انجام وظیفه خود به اطلاعات دسترسی دارند."
},
{
"title": "امنیت اطلاعات",
"body": "فیلدهای حساس مانند کد ملی و یادداشت‌های بالینی رمزنگاری می‌شوند. دسترسی به پرونده مراقبتی بیمار تنها برای خانواده و پرستار مسئول امکان‌پذیر است."
},
{
"title": "حقوق شما",
"body": "می‌توانید بیشتر اطلاعات خود را از پروفایل خود مشاهده و ویرایش کنید و برای پرسش، اصلاح یا درخواست حذف اطلاعات با پشتیبانی در تماس باشید."
},
{
"title": "تغییر این سیاست",
"body": "ممکن است این سیاست با تحول خدمت به‌روزرسانی شود. تغییرات مهم پیش از اعمال، در اپلیکیشن اطلاع‌رسانی می‌شود."
},
{
"title": "تماس با ما",
"body": "سوالات درباره این سیاست را می‌توانید از طریق سامانه تیکت پشتیبانی در اپلیکیشن ارسال کنید."
}
]
},
"welcome": {
@@ -2005,5 +2119,42 @@
"footer_contact_title": "پشتیبانی",
"footer_contact_body": "برای هر سوالی، پس از ورود از طریق سامانه تیکت پشتیبانی اپلیکیشن با ما در تماس باشید.",
"footer_copyright": "© {year, number} بالین‌یار"
},
"hub": {
"practice_subtitle": "نمایه، خدمات و مدارک شما در یک‌جا",
"practice_profile_sub": "نام، عکس، تخصص‌ها و تحصیلات",
"practice_services_sub": "خدمات و قیمت‌هایی که ارائه می‌دهید",
"practice_coverage_sub": "شهرها و مناطقی که به آن‌ها سر می‌زنید",
"practice_verification_sub": "هویت، مدارک حرفه‌ای و نشان اعتماد",
"practice_status_title": "وضعیت نمایش شما",
"practice_accepting_on": "پذیرش رزرو فعال است",
"practice_accepting_off": "پذیرش رزرو متوقف است",
"practice_accepting_manage": "مدیریت",
"finance_subtitle": "درآمد، تسویه‌ها و حساب بانکی",
"finance_earnings_sub": "ویزیت‌های تکمیل‌شده و سهم شما",
"finance_payouts_sub": "تاریخچهٔ واریزهای هفتگی",
"finance_bank_sub": "شبای مقصد واریز",
"finance_balance_error": "موجودی شما بارگذاری نشد.",
"more_title": "پشتیبانی و تنظیمات",
"more_support_sub": "گفتگو با تیم بالین‌یار",
"more_notifications_sub": "رویدادهای تازهٔ حساب شما",
"admin_group_empty_title": "دسترسی ندارید",
"admin_group_empty_body": "نقش فعلی شما به کنسول‌های این بخش دسترسی ندارد.",
"admin_trust_subtitle": "تأیید صلاحیت پرستاران و بازبینی نظرها",
"admin_finance_subtitle": "تسویهٔ هفتگی پرستاران",
"admin_support_subtitle": "تیکت‌های کاربران و هشدارهای داخلی",
"admin_system_subtitle": "پیکربندی سامانه و حساب شما",
"admin_verification_sub": "صف پرونده‌های در انتظار بررسی",
"admin_reviews_sub": "انتشار، پنهان‌سازی یا رد نظرها",
"admin_payouts_sub": "ساخت و اجرای دستهٔ تسویه",
"admin_tickets_sub": "صف سراسری تیکت‌ها",
"admin_alerts_sub": "کارتابل داخلی هشدارها",
"admin_config_sub": "کلیدهای پیکربندی و تاریخچهٔ تغییرها",
"admin_catalog_sub": "دسته‌بندی‌های خدمات و ابعاد قیمت‌گذاری آن‌ها",
"admin_holidays_sub": "تقویم تعطیلات بانکی",
"admin_audit_sub": "گزارش تغییرها، فقط‌خواندنی",
"admin_partners_sub": "مراکز همکار و پرستاران تحت پوشش",
"admin_users_sub": "جستجوی کاربران بر پایهٔ نام یا شماره",
"admin_roles_sub": "اعطا و لغو نقش‌ها"
}
}
+4 -1
View File
@@ -7,7 +7,10 @@ const nextConfig = {
reactStrictMode: true,
turbopack: {
root: '.'
}
},
// Emits .next/standalone — a self-contained server bundling only the traced runtime dependencies, so
// the Docker image carries no node_modules tree. Harmless for `npm run dev`/`npm run build` locally.
output: 'standalone'
};
export default withNextIntl(nextConfig);
+30 -31
View File
@@ -12,7 +12,6 @@
"@emotion/react": "^11.14.0",
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.1.1",
"@mui/material": "^9.1.1",
"@mui/material-nextjs": "^9.1.1",
"@tanstack/react-query": "^5.101.0",
@@ -23,11 +22,13 @@
"jalaali-js": "^2.0.0",
"js-cookie": "^3.0.8",
"leaflet": "^1.9.4",
"lucide-react": "^1.27.0",
"next": "^16.2.9",
"next-intl": "^4.13.0",
"notistack": "^3.0.2",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-hook-form": "^7.83.0",
"stylis-plugin-rtl": "^2.1.1"
},
"devDependencies": {
@@ -559,9 +560,9 @@
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -1997,38 +1998,11 @@
"url": "https://opencollective.com/mui-org"
}
},
"node_modules/@mui/icons-material": {
"version": "9.1.1",
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-9.1.1.tgz",
"integrity": "sha512-OXhm9DajemStb58AumM06DuPhHTa3XD36TFD4yf6WtJyNRO5DfEZbbnHlBg/US2Y2oOXwM/XurMTBOD6L/YYZw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@mui/material": "^9.1.1",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/material": {
"version": "9.1.1",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-9.1.1.tgz",
"integrity": "sha512-Wv+gInjrpf99l1Q0oHe0eOWGTnlbkzs5nowClX65KCT/2fyPMwcbFEEkUsOHdpcHhB5UAbz/d7jlwt5ajWVvlA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.29.2",
"@mui/core-downloads-tracker": "^9.1.1",
@@ -9228,6 +9202,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.27.0.tgz",
"integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@@ -10290,6 +10273,22 @@
"react": "^19.2.7"
}
},
"node_modules/react-hook-form": {
"version": "7.83.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.83.0.tgz",
"integrity": "sha512-AXt8cMCmx5a7u4uvpb2uRFVrWQhllI4pV+LSykxIac/hjt44TnQkmX9BKuQi2i+LDC62esmiLpilkav+kjVf/A==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+2 -1
View File
@@ -21,7 +21,6 @@
"@emotion/react": "^11.14.0",
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.1.1",
"@mui/material": "^9.1.1",
"@mui/material-nextjs": "^9.1.1",
"@tanstack/react-query": "^5.101.0",
@@ -32,11 +31,13 @@
"jalaali-js": "^2.0.0",
"js-cookie": "^3.0.8",
"leaflet": "^1.9.4",
"lucide-react": "^1.27.0",
"next": "^16.2.9",
"next-intl": "^4.13.0",
"notistack": "^3.0.2",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-hook-form": "^7.83.0",
"stylis-plugin-rtl": "^2.1.1"
},
"devDependencies": {
+4 -1
View File
@@ -1,8 +1,11 @@
#!/usr/bin/env node
/**
* Lints client/messages/fa.json against the banned-orthography-variant rules in
* client/messages/STYLE.md — enforces the phase-12 sweep so it cannot silently regress.
* docs/rules/client/i18n.md §4 — enforces the phase-12 copy sweep so it cannot silently regress.
* Exits non-zero (and prints every offending key) on any match.
*
* The rules below are the machine-checkable subset. The full Persian style guide (glossary, register,
* shell naming, the ZWNJ cases a grep can't express) lives in that doc; keep the two in step.
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
@@ -35,7 +35,7 @@ interface NudgeCardProps {
const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLabel, to, onDismiss, dismissLabel }) => (
<Paper
elevation={0}
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', gap: 2, position: 'relative' }}
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', display: 'flex', gap: 2, position: 'relative' }}
>
<AppIcon icon={icon} size={28} color="var(--bal-primary)" />
<Stack sx={{ gap: 1, flexGrow: 1, minWidth: 0 }}>
@@ -243,7 +243,7 @@ const CategoryGrid: FunctionComponent<{ onSelect: (categoryId: number) => void }
{isLoading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
))}
</Box>
) : isError ? (
@@ -172,6 +172,8 @@ export default function AddressesPage() {
latitude: editing.latitude,
longitude: editing.longitude,
isPrimary: editing.isPrimary,
recipientName: editing.recipientName,
recipientPhone: editing.recipientPhone,
}
: undefined
}
@@ -2,11 +2,13 @@
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Checkbox, FormControlLabel, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { Checkbox, FormControlLabel, MenuItem, Paper, Stack, Typography } from '@mui/material';
import AppButton from '@/components/common/AppButton';
import AppAlert from '@/components/common/AppAlert';
import AppLoading from '@/components/common/AppLoading';
import Money from '@/components/common/Money';
import { RhfControlGroup, RhfTextField } from '@/components/common/form';
import StepperHeader from '@/components/StepperHeader';
import CancellationPolicyDisclosure from '@/components/CancellationPolicyDisclosure';
import { ContactSupportDialog } from '@/components/messaging';
@@ -34,6 +36,12 @@ function cancelErrorKey(error: unknown): string {
return 'err_generic';
}
interface CancelFormValues {
reasonCategory: CancelReasonCategory | '';
reasonNotes: string;
acknowledged: boolean;
}
/**
* Cancellation flow (f10) — the trust-first exit. Step 1 **discloses** the resolved policy tier, the
* refund % + fee %, and the concrete Toman amounts (refunded vs kept) **before** anything is submitted;
@@ -56,11 +64,16 @@ export default function CancelBookingPage() {
const cancel = useCancelBooking();
const [step, setStep] = useState<0 | 1>(0);
const [acknowledged, setAcknowledged] = useState(false);
// Never pre-defaulted (keeps the reason analytics honest) — confirm stays disabled until chosen.
const [reasonCategory, setReasonCategory] = useState<CancelReasonCategory | ''>('');
const [reasonNotes, setReasonNotes] = useState('');
const [supportDialogCategory, setSupportDialogCategory] = useState<TicketCategory | null>(null);
// `reasonCategory` is never pre-defaulted (that would make the reason analytics lie) — the continue
// CTA stays disabled until it and the acknowledgement are both set.
const form = useForm<CancelFormValues>({
mode: 'onTouched',
defaultValues: { reasonCategory: '', reasonNotes: '', acknowledged: false },
});
const { control, getValues } = form;
const reasonCategory = useWatch({ control, name: 'reasonCategory' });
const acknowledged = useWatch({ control, name: 'acknowledged' });
const bookingHref = `/${locale}${ROUTES.BOOKINGS}/${bookingId}`;
@@ -108,19 +121,22 @@ export default function CancelBookingPage() {
);
}
const submit = () =>
const submit = () => {
const values = getValues();
cancel.mutate(
{
bookingId,
sessionIds: preview.refundableSessionIds,
// Guaranteed non-empty: step 1 is only reachable once a reason is chosen (the continue CTA gate).
reasonCategory: reasonCategory as CancelReasonCategory,
reasonNotes: reasonNotes.trim() || undefined,
reasonCategory: values.reasonCategory as CancelReasonCategory,
reasonNotes: values.reasonNotes.trim() || undefined,
},
{ onSuccess: () => router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`) },
);
};
return (
<FormProvider {...form}>
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%', py: 2 }}>
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
{t('cancel_title')}
@@ -132,7 +148,7 @@ export default function CancelBookingPage() {
{/* Off-ramps before the kill switch — exits, not obstacles; the destructive path stays fully
available below. Real rescheduling is DEFERRED (product decision + backend); this opens a
coordination ticket instead. */}
<Stack sx={{ gap: 1, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('offramp_note')}
</Typography>
@@ -158,13 +174,7 @@ export default function CancelBookingPage() {
<CancellationPolicyDisclosure preview={preview} />
<TextField
select
label={t('reason_field_label')}
value={reasonCategory}
onChange={(event) => setReasonCategory(event.target.value as CancelReasonCategory)}
fullWidth
>
<RhfTextField<CancelFormValues> name="reasonCategory" select label={t('reason_field_label')} fullWidth>
<MenuItem value="" disabled>
{t('reason_placeholder')}
</MenuItem>
@@ -173,19 +183,24 @@ export default function CancelBookingPage() {
{t(`reason_cat_${category}`)}
</MenuItem>
))}
</TextField>
<TextField
</RhfTextField>
<RhfTextField<CancelFormValues>
name="reasonNotes"
label={t('reason_notes_label')}
value={reasonNotes}
onChange={(event) => setReasonNotes(event.target.value)}
multiline
minRows={2}
fullWidth
/>
<RhfControlGroup<CancelFormValues> name="acknowledged">
{({ field }) => (
<FormControlLabel
control={<Checkbox checked={acknowledged} onChange={(event) => setAcknowledged(event.target.checked)} />}
control={
<Checkbox checked={Boolean(field.value)} onChange={(event) => field.onChange(event.target.checked)} />
}
label={t('acknowledge_label')}
/>
)}
</RhfControlGroup>
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', flexWrap: 'wrap' }}>
<AppButton variant="text" color="inherit" onClick={() => router.push(bookingHref)}>
@@ -211,7 +226,7 @@ export default function CancelBookingPage() {
</>
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1 }}>
{t('confirm_title')}
</Typography>
@@ -252,5 +267,6 @@ export default function CancelBookingPage() {
</>
)}
</Stack>
</FormProvider>
);
}
@@ -59,7 +59,7 @@ export default function BookingInvoicePage() {
// A malformed id can never load — navigation, not a retry (a manual refetch() bypasses `enabled`).
if (!validId) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon="error" size={44} color="var(--bal-error)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
@@ -89,7 +89,7 @@ export default function BookingInvoicePage() {
if (!invoice) {
const notIssued = error instanceof ApiError && error.status === 404;
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={notIssued ? 'document' : 'error'} size={44} color={notIssued ? 'var(--bal-warning)' : 'var(--bal-error)'} />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
@@ -166,7 +166,7 @@ export default function BookingInvoicePage() {
<Paper
elevation={0}
className={PRINT_AREA_CLASS}
sx={{ p: 3, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
sx={{ p: 3, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
>
<Stack sx={{ gap: 2 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
@@ -1,10 +1,20 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import { Avatar, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, EmptyState, RatingInput, ReviewTagSelector, StatusChip, SurfaceCard } from '@/components';
import { Avatar, Paper, Skeleton, Stack, Typography } from '@mui/material';
import {
AppButton,
AppIcon,
EmptyState,
RatingInput,
ReviewTagSelector,
RhfControlGroup,
RhfTextField,
StatusChip,
SurfaceCard,
} from '@/components';
import type { StatusKind } from '@/components';
import { formatShamsiDate } from '@/utils';
import { useBookingDetail } from '@/services/bookings';
@@ -24,6 +34,12 @@ function variantName(snapshotJson: string): string | null {
const REVIEW_BODY_MAX = 2000;
interface ReviewFormValues {
rating: number;
body: string;
tagCodes: string[];
}
/** moderationStatus → StatusChip kind (published=success, pending=warning, rejected=error, hidden=neutral). */
const STATUS_KIND: Record<ModerationStatus, StatusKind> = {
pending_moderation: 'pending',
@@ -57,19 +73,19 @@ export default function LeaveReviewPage() {
const myReview = useMyReviewForBooking(bookingId, { enabled: reviewable });
const createReview = useCreateReview();
const [rating, setRating] = useState(0);
const [body, setBody] = useState('');
const [tagCodes, setTagCodes] = useState<string[]>([]);
const form = useForm<ReviewFormValues>({ mode: 'onTouched', defaultValues: { rating: 0, body: '', tagCodes: [] } });
const { control, handleSubmit } = form;
const rating = useWatch({ control, name: 'rating' });
const body = useWatch({ control, name: 'body' });
const tagCodes = useWatch({ control, name: 'tagCodes' });
const nurseName = booking?.nurseName?.trim();
const submit = () => {
if (rating < 1) return;
const submit = (values: ReviewFormValues) =>
createReview.mutate(
{ bookingId, body: { rating, body: body.trim() || null, tagCodes } },
{ bookingId, body: { rating: values.rating, body: values.body.trim() || null, tagCodes: values.tagCodes } },
{ onError: () => enqueueSnackbar(t('error_submit'), { variant: 'error' }) },
);
};
// ── Already reviewed → the persistent under-review / published state (never a second form) ───────────────
const existing = myReview.data;
@@ -84,7 +100,7 @@ export default function LeaveReviewPage() {
<Stack sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
<PageHeading title={t('my_review_title')} subtitle={nurseName ? t('for_nurse', { name: nurseName }) : undefined} />
{booking ? <ReviewContextRecap booking={booking} locale={locale} /> : null}
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<StatusChip status={STATUS_KIND[status]} label={t(`status_${status}`)} />
@@ -144,56 +160,59 @@ export default function LeaveReviewPage() {
// ── Eligible → the review form ───────────────────────────────────────────────────────────────────────────
return (
<Stack sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
<FormProvider {...form}>
<Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
<PageHeading title={t('title')} subtitle={nurseName ? t('for_nurse', { name: nurseName }) : t('subtitle')} />
{booking ? <ReviewContextRecap booking={booking} locale={locale} /> : null}
{/* Moderation expectation, up front — not only after submit. */}
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', p: 1.5, borderRadius: 2, bgcolor: 'var(--bal-info-soft)' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', p: 1.5, borderRadius: 'var(--bal-radius-md)', bgcolor: 'var(--bal-info-soft)' }}>
<AppIcon icon="info" size={18} color="var(--bal-info)" />
<Typography variant="body2" sx={{ color: 'var(--bal-info)' }}>
{t('moderation_note')}
</Typography>
</Stack>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('rating_label')}
</Typography>
<RatingInput value={rating} onChange={setRating} ariaLabel={t('rating_label')} />
</Stack>
<RhfControlGroup<ReviewFormValues>
name="rating"
label={t('rating_label')}
rules={{ validate: (value) => Number(value ?? 0) >= 1 }}
>
{({ field }) => (
<RatingInput value={Number(field.value) || 0} onChange={field.onChange} ariaLabel={t('rating_label')} />
)}
</RhfControlGroup>
<TextField
<RhfTextField<ReviewFormValues>
name="body"
label={t('body_label')}
placeholder={t('body_placeholder')}
value={body}
onChange={(e) => setBody(e.target.value.slice(0, REVIEW_BODY_MAX))}
transform={(raw) => raw.slice(0, REVIEW_BODY_MAX)}
multiline
minRows={3}
fullWidth
/>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('tags_label')}
</Typography>
<RhfControlGroup<ReviewFormValues> name="tagCodes" label={t('tags_label')}>
{({ field }) => (
<ReviewTagSelector
codes={REVIEW_TAG_CODES}
selected={tagCodes}
onChange={setTagCodes}
selected={(field.value as string[]) ?? []}
onChange={field.onChange}
labelFor={(code) => (t.has(`tag_${code}`) ? t(`tag_${code}`) : code)}
disabled={createReview.isPending}
/>
</Stack>
)}
</RhfControlGroup>
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
<AppButton variant="text" color="primary" onClick={() => router.back()} disabled={createReview.isPending}>
{tc('cancel')}
</AppButton>
<AppButton
type="submit"
variant="contained"
color="primary"
onClick={submit}
disabled={rating < 1 || createReview.isPending}
startIcon="star"
>
@@ -201,6 +220,7 @@ export default function LeaveReviewPage() {
</AppButton>
</Stack>
</Stack>
</FormProvider>
);
}
@@ -1,13 +1,19 @@
'use client';
import { FunctionComponent, useState } from 'react';
import { FunctionComponent } from 'react';
import { useTranslations } from 'next-intl';
import { Checkbox, CircularProgress, FormControlLabel, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, Money, PhoneNumberField } from '@/components';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { Checkbox, CircularProgress, FormControlLabel, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, Money, PhoneNumberField, RhfControlGroup, RhfTextField } from '@/components';
import { digitsOnly } from '@/utils';
import { useCheckEligibility } from '@/services/bnpl';
import { NATIONAL_ID_LENGTH, NATIONAL_ID_PATTERN } from '@/services/bnpl/constants';
import type { BnplEligibilityResult, ProviderCode } from '@/services/bnpl/types';
interface EligibilityFormValues {
nationalId: string;
consent: boolean;
}
interface EligibilityStepProps {
bookingRequestId: number;
providerCode: ProviderCode;
@@ -36,22 +42,23 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
const t = useTranslations('bnpl');
const tc = useTranslations('common');
const [nationalId, setNationalId] = useState('');
const [consent, setConsent] = useState(false);
const [submitted, setSubmitted] = useState(false);
const form = useForm<EligibilityFormValues>({ mode: 'onTouched', defaultValues: { nationalId: '', consent: false } });
const { control, handleSubmit } = form;
const consent = useWatch({ control, name: 'consent' });
const check = useCheckEligibility();
// A fresh check wins; otherwise re-show a prior approval carried back from D4.
const result = check.data ?? initialResult ?? undefined;
const nationalIdValid = NATIONAL_ID_PATTERN.test(nationalId);
const nationalIdError = submitted && !nationalIdValid;
const providerName = t(`provider_${providerCode}`);
const handleSubmit = () => {
setSubmitted(true);
if (!nationalIdValid || !consent) return;
check.mutate({ bookingRequestId, providerCode, nationalId, mobile: sessionMobile, consent });
};
const submit = (values: EligibilityFormValues) =>
check.mutate({
bookingRequestId,
providerCode,
nationalId: values.nationalId,
mobile: sessionMobile,
consent: values.consent,
});
// Approved — show the ceiling + advance.
if (result?.isEligible) {
@@ -61,7 +68,7 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
elevation={0}
sx={{
p: 2.5,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'var(--bal-success)',
backgroundColor: 'var(--bal-primary-soft)',
@@ -111,25 +118,25 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
body={t('eligibility_error')}
cardLabel={t('pay_with_card')}
onPayWithCard={onPayWithCard}
onRetry={handleSubmit}
onRetry={handleSubmit(submit)}
retryLabel={tc('retry')}
/>
);
}
return (
<Stack sx={{ gap: 2 }}>
<FormProvider {...form}>
<Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('eligibility_title')}
</Typography>
<TextField
<RhfTextField<EligibilityFormValues>
name="nationalId"
label={t('national_id_label')}
placeholder={t('national_id_placeholder')}
value={nationalId}
onChange={(e) => setNationalId(digitsOnly(e.target.value).slice(0, NATIONAL_ID_LENGTH))}
error={nationalIdError}
helperText={nationalIdError ? t('national_id_invalid') : undefined}
transform={(raw) => digitsOnly(raw).slice(0, NATIONAL_ID_LENGTH)}
rules={{ validate: (value) => NATIONAL_ID_PATTERN.test(String(value ?? '')) || t('national_id_invalid') }}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', maxLength: NATIONAL_ID_LENGTH, style: { textAlign: 'start' } } }}
fullWidth
/>
@@ -142,8 +149,16 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
fullWidth
/>
<RhfControlGroup<EligibilityFormValues> name="consent">
{({ field }) => (
<FormControlLabel
control={<Checkbox checked={consent} onChange={(e) => setConsent(e.target.checked)} color="secondary" />}
control={
<Checkbox
checked={Boolean(field.value)}
onChange={(event) => field.onChange(event.target.checked)}
color="secondary"
/>
}
label={
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('consent_label', { provider: providerName })}
@@ -151,14 +166,16 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
}
sx={{ alignItems: 'flex-start', m: 0 }}
/>
)}
</RhfControlGroup>
<Stack sx={{ gap: 1 }}>
<AppButton
type="submit"
color="secondary"
variant="contained"
size="large"
disabled={!consent || check.isPending}
onClick={handleSubmit}
startIcon={check.isPending ? <CircularProgress size={18} color="inherit" /> : undefined}
>
{check.isPending ? t('checking_eligibility') : t('check_eligibility')}
@@ -168,6 +185,7 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
</AppButton>
</Stack>
</Stack>
</FormProvider>
);
};
@@ -190,7 +208,7 @@ function DeclinedPanel({
<Stack sx={{ gap: 2 }}>
<Paper
elevation={0}
sx={{ p: 3, borderRadius: 2, border: '1px solid', borderColor: 'var(--bal-error)', textAlign: 'center' }}
sx={{ p: 3, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'var(--bal-error)', textAlign: 'center' }}
>
<Stack sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="rejected" size={36} color="var(--bal-error)" />
@@ -36,7 +36,7 @@ const MethodStep: FunctionComponent<MethodStepProps> = ({
<Paper
elevation={0}
sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider', textAlign: 'center' }}
sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', textAlign: 'center' }}
>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('payable_amount')}
@@ -51,7 +51,7 @@ const MethodStep: FunctionComponent<MethodStepProps> = ({
display: 'block',
width: '100%',
textAlign: 'start',
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
p: 1.75,
border: '1px solid',
borderColor: 'divider',
@@ -128,7 +128,7 @@ function ProviderOption({
display: 'block',
width: '100%',
textAlign: 'start',
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
p: 1.5,
border: '1px solid',
borderColor: selected ? 'var(--bal-secondary)' : 'divider',
@@ -69,7 +69,7 @@ const PlanStep: FunctionComponent<PlanStepProps> = ({
{shownPlan ? (
<Paper
elevation={0}
sx={{ p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}
>
<Stack sx={{ gap: 0.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
@@ -63,7 +63,7 @@ const ScheduleStep: FunctionComponent<ScheduleStepProps> = ({
// Handoff in progress — the provider redirect is being followed.
if (busy) {
return (
<Paper elevation={0} sx={{ p: 4, borderRadius: 2, border: '1px solid', borderColor: 'divider', textAlign: 'center' }}>
<Paper elevation={0} sx={{ p: 4, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', textAlign: 'center' }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<CircularProgress color="secondary" size="2.5rem" />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
@@ -109,7 +109,7 @@ const ScheduleStep: FunctionComponent<ScheduleStepProps> = ({
elevation={0}
sx={{
p: 1.75,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'var(--bal-secondary)',
backgroundColor: 'var(--bal-secondary-soft)',
@@ -0,0 +1,71 @@
'use client';
import { Suspense } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { Box, Stack, Typography } from '@mui/material';
import { AppButton, AppLoading, PaymentStateCard } from '@/components';
import { ROUTES } from '@/constants';
import {
CHECKOUT_QUERY_OUTCOME,
CHECKOUT_QUERY_REQUEST_ID,
CHECKOUT_QUERY_TRANSACTION_ID,
} from '@/services/payment/constants';
import type { GatewayReturnOutcome } from '@/services/payment/types';
/**
* Mock-gateway harness — a **test harness, not a product feature**, mirroring the BNPL provider-handoff
* harness (`checkout/bnpl/gateway/page.tsx`). It stands in for the PSP so the initiate → redirect → return
* round-trip is exercisable without a real gateway: `MockPaymentProvider.InitPaymentAsync` points its
* `redirectUrl` here, and the pay/cancel buttons drive both outcome branches of the return surface (a real
* PSP redirects back after the cardholder pays or cancels). Deliberately reachable in every build, including
* production — unlike the BNPL harness, this one is NOT env-gated: this deployment is a demo with no real
* gateway wired up (`mvp/blockers.md` §B.5), so the mock stays reachable until a real acquirer is switched
* on, at which point `redirectUrl` becomes the PSP's absolute URL and this page is never reached.
*/
export default function MockGatewayPage() {
return (
<Suspense fallback={<AppLoading />}>
<MockGatewayScreen />
</Suspense>
);
}
function MockGatewayScreen() {
const t = useTranslations('payment');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const requestId = params.get(CHECKOUT_QUERY_REQUEST_ID) ?? '';
const transactionId = params.get(CHECKOUT_QUERY_TRANSACTION_ID) ?? '';
const returnWith = (outcome: GatewayReturnOutcome) => {
const query = new URLSearchParams({
[CHECKOUT_QUERY_REQUEST_ID]: requestId,
[CHECKOUT_QUERY_TRANSACTION_ID]: transactionId,
[CHECKOUT_QUERY_OUTCOME]: outcome,
});
router.replace(`/${locale}${ROUTES.CHECKOUT_RETURN}?${query.toString()}`);
};
return (
<PaymentStateCard icon="payment" tone="var(--bal-secondary)" title={t('gateway_title')} body={t('gateway_hint')}>
{transactionId ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('gateway_reference_label')}:{' '}
<Box component="span" dir="ltr">
#{transactionId}
</Box>
</Typography>
) : null}
<Stack sx={{ gap: 1.5, alignItems: 'center', width: '100%' }}>
<AppButton color="secondary" variant="contained" size="large" onClick={() => returnWith('success')}>
{t('gateway_pay_success')}
</AppButton>
<AppButton variant="text" color="error" onClick={() => returnWith('failure')}>
{t('gateway_pay_fail')}
</AppButton>
</Stack>
</PaymentStateCard>
);
}
@@ -245,7 +245,7 @@ function CheckoutScreen() {
</Stack>
{summary.paymentDeadlineAt ? (
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Paper elevation={0} sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
<CountdownTimer
deadlineIso={summary.paymentDeadlineAt}
label={tb('payment_countdown_label')}
@@ -284,7 +284,7 @@ function CheckoutScreen() {
width: 300,
}}
>
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
{payActions}
</Paper>
</Box>
@@ -310,7 +310,7 @@ function EngagementSummary({ summary, locale }: { summary: CheckoutSummaryDto; l
minute: '2-digit',
});
return (
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<Avatar
src={summary.nurseAvatarUrl ?? undefined}
@@ -148,7 +148,7 @@ function ReturnScreen() {
// Pending-callback (and the brief succeeded → confirmation hand-off): a staged 2-node progress instead
// of a bare spinner+chip+title stack — the flow's calmest, most designed wait state.
return (
<Paper elevation={0} sx={{ p: 4, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 4, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 2.5, alignItems: 'center' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, textAlign: 'center' }}>
{t('state_pending_title')}
@@ -213,7 +213,7 @@ export default function BookingRequestStatusPage() {
elevation={0}
sx={{
p: 2.5,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
@@ -247,7 +247,7 @@ export default function BookingRequestStatusPage() {
</Stack>
</Paper>
) : (
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
<Stack sx={{ gap: 1, alignItems: 'center' }}>
<CountdownTimer
deadlineIso={request.nurseResponseDeadlineAt}
@@ -329,7 +329,7 @@ function TerminalCard({
secondary?: TerminalAction;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: body ? 0.5 : 2 }}>
{title}
@@ -2,6 +2,7 @@
import { Suspense, useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import {
Avatar,
Box,
@@ -21,6 +22,8 @@ import {
EmptyState,
JalaliDateIntentPicker,
PriceDisplay,
RhfControlGroup,
RhfTextField,
StepperHeader,
TrustBadge,
} from '@/components';
@@ -39,6 +42,7 @@ import type {
RequiredCaregiverGender,
} from '@/services/bookingRequests/types';
import type { CustomerAddress } from '@/services/addresses/types';
import type { Patient } from '@/services/patients/types';
const GENDER_OPTIONS: RequiredCaregiverGender[] = ['female', 'male', 'any'];
@@ -54,7 +58,17 @@ const TIME_WINDOWS: TimeWindowOption[] = [
{ key: 'evening', start: '16:00', end: '20:00' },
];
type TouchedField = 'patient' | 'service' | 'address' | 'date' | 'time' | 'gender';
interface RequestFormValues {
patientId: number | '';
variantId: number | '';
addressId: number | '';
gender: RequiredCaregiverGender | '';
date: string;
window: TimeWindowOption['key'] | 'custom' | null;
timeStart: string;
timeEnd: string;
notes: string;
}
/**
* C4 — Booking-request form (فرم درخواست). The destination of the C3 "درخواست رزرو" CTA (it carries the
@@ -72,6 +86,16 @@ export default function BookingRequestFormPage() {
);
}
/**
* Resolves the URL hand-off and waits for every list the form defaults off before mounting it.
*
* The wait is load-bearing rather than cosmetic: the variant and address fields default to "the one
* carried in the URL, else the nurse's first service / the primary address", and those defaults can
* only be computed once the lists exist. Previously the form mounted immediately and re-derived the
* effective value on every render (`variantSel !== '' ? variantSel : firstVariantId`), which meant the
* *stored* value and the *shown* value could disagree, and neither field could carry a plain required
* rule. Mounting once with real `defaultValues` makes the stored value the only value.
*/
function BookingRequestForm() {
const t = useTranslations('booking');
const locale = useLocale();
@@ -90,68 +114,115 @@ function BookingRequestForm() {
const profileQuery = useNurseProfile(hasNurse ? nurseId : undefined);
const patientsQuery = usePatients();
const addressesQuery = useAddresses();
if (!hasNurse) {
return (
<EmptyState
icon="search"
title={t('missing_nurse_title')}
body={t('missing_nurse_body')}
action={
<AppButton
variant="contained"
color="primary"
onClick={() => router.push(`/${locale}${ROUTES.SEARCH}`)}
sx={{ m: 0 }}
>
{t('missing_nurse_cta')}
</AppButton>
}
/>
);
}
if (profileQuery.isLoading || patientsQuery.isLoading || addressesQuery.isLoading) return <FormSkeleton />;
return (
<RequestForm
nurseId={nurseId}
profile={profileQuery.data}
patients={patientsQuery.data?.items ?? []}
addresses={addressesQuery.data?.items ?? []}
carried={{
variantId: variantIdParam,
patientId: patientIdParam,
addressId: addressIdParam,
gender: genderParam === 'male' || genderParam === 'female' ? genderParam : null,
}}
/>
);
}
function RequestForm({
nurseId,
profile,
patients,
addresses,
carried,
}: {
nurseId: number;
profile: NurseProfile | undefined;
patients: Patient[];
addresses: CustomerAddress[];
carried: {
variantId: number | null;
patientId: number | null;
addressId: number | null;
gender: RequiredCaregiverGender | null;
};
}) {
const t = useTranslations('booking');
const locale = useLocale();
const router = useRouter();
const createRequest = useCreateBookingRequest();
const profile = profileQuery.data;
const patients = useMemo(() => patientsQuery.data?.items ?? [], [patientsQuery.data]);
const addresses = useMemo(() => addressesQuery.data?.items ?? [], [addressesQuery.data]);
const services = useMemo(() => profile?.services ?? [], [profile]);
const [patientId, setPatientId] = useState<number | ''>(patientIdParam ?? '');
const [variantSel, setVariantSel] = useState<number | ''>(variantIdParam ?? '');
const [addressSel, setAddressSel] = useState<number | ''>(addressIdParam ?? '');
const [addressEditing, setAddressEditing] = useState(false);
const [gender, setGender] = useState<RequiredCaregiverGender | ''>(
genderParam === 'male' || genderParam === 'female' ? genderParam : '',
);
const [date, setDate] = useState('');
const [windowSel, setWindowSel] = useState<TimeWindowOption['key'] | 'custom' | null>(null);
const [timeStart, setTimeStart] = useState('');
const [timeEnd, setTimeEnd] = useState('');
const [notes, setNotes] = useState('');
const [touched, setTouched] = useState<Record<TouchedField, boolean>>({
patient: false,
service: false,
address: false,
date: false,
time: false,
gender: false,
});
const [pastDateError, setPastDateError] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const markTouched = (field: TouchedField) =>
setTouched((prev) => (prev[field] ? prev : { ...prev, [field]: true }));
// Effective selection = the user's explicit choice, else a sensible default derived from the loaded
// data. Computed during render (no setState-in-effect): the variant defaults to the carried one / the
// first offered, the address to the primary / first.
const firstVariantId: number | '' = services.length > 0 ? services[0].variantId : '';
const variantId = variantSel !== '' ? variantSel : firstVariantId;
const primaryAddressId: number | '' =
addresses.length > 0 ? (addresses.find((address) => address.isPrimary)?.id ?? addresses[0].id) : '';
const addressId = addressSel !== '' ? addressSel : primaryAddressId;
const selectedVariant = useMemo(
() => services.find((service) => service.variantId === variantId),
[services, variantId],
);
const selectedAddress = useMemo(
() => addresses.find((address) => address.id === addressId),
[addresses, addressId],
);
const selectedPatient = useMemo(
() => patients.find((patient) => patient.id === patientId),
[patients, patientId],
);
const form = useForm<RequestFormValues>({
mode: 'onTouched',
defaultValues: {
patientId: carried.patientId ?? '',
variantId: carried.variantId ?? (services.length > 0 ? services[0].variantId : ''),
addressId: carried.addressId ?? primaryAddressId,
gender: carried.gender ?? '',
date: '',
window: null,
timeStart: '',
timeEnd: '',
notes: '',
},
});
const { control, handleSubmit, setValue, getValues } = form;
const values = useWatch({ control });
const patientId = values.patientId ?? '';
const variantId = values.variantId ?? '';
const addressId = values.addressId ?? '';
const gender = values.gender ?? '';
const notes = values.notes ?? '';
const windowSel = values.window ?? null;
const selectedVariant = services.find((service) => service.variantId === variantId);
const selectedAddress = addresses.find((address) => address.id === addressId);
const selectedPatient = patients.find((patient) => patient.id === patientId);
// A concrete gender that contradicts the (single) nurse's gender is a same-gender mismatch (400) —
// block it inline before the round-trip; the server re-validates and is authoritative.
const genderMismatch =
gender !== '' && gender !== 'any' && profile != null && gender !== profile.nurseGender;
const genderMismatch = gender !== '' && gender !== 'any' && profile != null && gender !== profile.nurseGender;
const requiredChosen =
patientId !== '' && variantId !== '' && addressId !== '' && gender !== '' && date !== '' && timeStart !== '' && timeEnd !== '';
const missingFieldLabels: string[] = [];
if (patientId === '') missingFieldLabels.push(t('cta_missing_patient'));
if (variantId === '') missingFieldLabels.push(t('cta_missing_service'));
if (addressId === '') missingFieldLabels.push(t('cta_missing_address'));
if (values.date === '') missingFieldLabels.push(t('cta_missing_date'));
if (values.timeStart === '' || values.timeEnd === '') missingFieldLabels.push(t('cta_missing_time'));
if (gender === '') missingFieldLabels.push(t('cta_missing_gender'));
const requiredChosen = missingFieldLabels.length === 0;
const regionLabel = (address: CustomerAddress): string => {
const city = locale === 'en' ? address.cityNameEn : address.cityNameFa;
@@ -165,31 +236,15 @@ function BookingRequestForm() {
};
const selectWindow = (option: TimeWindowOption) => {
setWindowSel(option.key);
setTimeStart(option.start);
setTimeEnd(option.end);
if (pastDateError) setPastDateError(false);
setValue('window', option.key, { shouldDirty: true });
setValue('timeStart', option.start, { shouldValidate: true });
setValue('timeEnd', option.end, { shouldValidate: true });
// The date's past-guard is a cross-field rule over the start time — re-run it now that one exists.
if (getValues('date')) void form.trigger('date');
};
const missingFieldLabels: string[] = [];
if (patientId === '') missingFieldLabels.push(t('cta_missing_patient'));
if (variantId === '') missingFieldLabels.push(t('cta_missing_service'));
if (addressId === '') missingFieldLabels.push(t('cta_missing_address'));
if (date === '') missingFieldLabels.push(t('cta_missing_date'));
if (timeStart === '' || timeEnd === '') missingFieldLabels.push(t('cta_missing_time'));
if (gender === '') missingFieldLabels.push(t('cta_missing_gender'));
const handleSubmit = () => {
const submit = (formValues: RequestFormValues) => {
setFormError(null);
if (!requiredChosen) return;
if (timeEnd <= timeStart) return;
// Future date+time guard (local wall-clock, matching the wire's date + time fields). Evaluated in the
// handler (not render) so the render path stays pure; the result drives the inline date error.
if (Date.parse(`${date}T${timeStart}`) < Date.now()) {
setPastDateError(true);
return;
}
setPastDateError(false);
if (genderMismatch) return;
const context: BookingRequestDisplayContext | undefined =
@@ -220,53 +275,27 @@ function BookingRequestForm() {
{
payload: {
nurseId,
variantId: variantId as number,
patientId: patientId as number,
customerAddressId: addressId as number,
requestedDate: date,
requestedTimeStart: `${timeStart}:00`,
requestedTimeEnd: `${timeEnd}:00`,
requiredCaregiverGender: gender as RequiredCaregiverGender,
customerNotes: notes.trim() || null,
variantId: formValues.variantId as number,
patientId: formValues.patientId as number,
customerAddressId: formValues.addressId as number,
requestedDate: formValues.date,
requestedTimeStart: `${formValues.timeStart}:00`,
requestedTimeEnd: `${formValues.timeEnd}:00`,
requiredCaregiverGender: formValues.gender as RequiredCaregiverGender,
customerNotes: formValues.notes.trim() || null,
},
context,
},
{
onSuccess: (dto) => {
router.push(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${dto.id}`);
},
onSuccess: (dto) => router.push(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${dto.id}`),
onError: (error) => setFormError(mapCreateError(error, t)),
},
);
};
if (!hasNurse) {
return (
<EmptyState
icon="search"
title={t('missing_nurse_title')}
body={t('missing_nurse_body')}
action={
<AppButton
variant="contained"
color="primary"
onClick={() => router.push(`/${locale}${ROUTES.SEARCH}`)}
sx={{ m: 0 }}
>
{t('missing_nurse_cta')}
</AppButton>
}
/>
);
}
if (profileQuery.isLoading) return <FormSkeleton />;
const timeError = touched.time && timeStart !== '' && timeEnd !== '' && timeEnd <= timeStart;
const pastError = pastDateError;
return (
<Stack sx={{ gap: 3 }}>
<FormProvider {...form}>
<Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 3 }}>
{profile ? <NurseIdentityBar profile={profile} /> : null}
<Box>
@@ -294,14 +323,11 @@ function BookingRequestForm() {
onCta={() => router.push(`/${locale}${ROUTES.PATIENTS}`)}
/>
) : (
<TextField
<RhfTextField<RequestFormValues>
name="patientId"
select
label={t('patient_label')}
value={patientId}
error={touched.patient && patientId === ''}
helperText={touched.patient && patientId === '' ? t('error_patient_required') : undefined}
onChange={(event) => setPatientId(Number(event.target.value))}
onBlur={() => markTouched('patient')}
rules={{ validate: (value) => value !== '' || t('error_patient_required') }}
fullWidth
>
<MenuItem value="" disabled>
@@ -312,7 +338,7 @@ function BookingRequestForm() {
{patient.displayName}
</MenuItem>
))}
</TextField>
</RhfTextField>
)}
{/* Service variant */}
@@ -322,14 +348,11 @@ function BookingRequestForm() {
{t('service_empty')}
</Typography>
) : (
<TextField
<RhfTextField<RequestFormValues>
name="variantId"
select
label={t('service_label')}
value={variantId}
error={touched.service && variantId === ''}
helperText={touched.service && variantId === '' ? t('error_service_required') : undefined}
onChange={(event) => setVariantSel(Number(event.target.value))}
onBlur={() => markTouched('service')}
rules={{ validate: (value) => value !== '' || t('error_service_required') }}
fullWidth
>
<MenuItem value="" disabled>
@@ -340,7 +363,7 @@ function BookingRequestForm() {
{service.displayName}
</MenuItem>
))}
</TextField>
</RhfTextField>
)}
{selectedVariant ? (
<PriceDisplay
@@ -360,17 +383,14 @@ function BookingRequestForm() {
onCta={() => router.push(`/${locale}${ROUTES.ADDRESSES}`)}
/>
) : addressEditing || !selectedAddress ? (
<TextField
<RhfTextField<RequestFormValues>
name="addressId"
select
label={t('address_label')}
value={addressId}
error={touched.address && addressId === ''}
helperText={touched.address && addressId === '' ? t('error_address_required') : undefined}
onChange={(event) => {
setAddressSel(Number(event.target.value));
setAddressEditing(false);
}}
onBlur={() => markTouched('address')}
rules={{ validate: (value) => value !== '' || t('error_address_required') }}
// Collapses back to the compact summary row once the menu closes — picking a different
// address is the normal exit, and dismissing without picking leaves the current one shown.
slotProps={{ select: { onClose: () => setAddressEditing(false) } }}
fullWidth
>
<MenuItem value="" disabled>
@@ -381,7 +401,7 @@ function BookingRequestForm() {
{address.title} · {locale === 'en' ? address.cityNameEn : address.cityNameFa}
</MenuItem>
))}
</TextField>
</RhfTextField>
) : (
<Stack
direction="row"
@@ -411,35 +431,32 @@ function BookingRequestForm() {
</Stack>
)}
{/* Date */}
<Stack sx={{ gap: 1 }} onBlur={() => markTouched('date')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('date_label')}
</Typography>
<JalaliDateIntentPicker
value={date}
onChange={(iso) => {
setDate(iso);
if (pastDateError) setPastDateError(false);
{/* Date — the past-date guard is a cross-field rule against the chosen start time. */}
<RhfControlGroup<RequestFormValues>
name="date"
label={t('date_label')}
rules={{
validate: {
chosen: (value) => value !== '' || t('error_date_required'),
future: (value, all) =>
!all.timeStart || Date.parse(`${value}T${all.timeStart}`) >= Date.now() || t('error_past_date'),
},
}}
>
{({ field }) => (
<JalaliDateIntentPicker
value={(field.value as string) ?? ''}
onChange={field.onChange}
min={todayIso()}
todayLabel={t('date_today')}
tomorrowLabel={t('date_tomorrow')}
pickOtherLabel={t('date_pick_other')}
/>
{pastError ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_past_date')}
</Typography>
) : touched.date && date === '' ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_date_required')}
</Typography>
) : null}
</Stack>
)}
</RhfControlGroup>
{/* Time window — presets kill the end<=start error class; «زمان دلخواه» reveals free time fields. */}
<Stack sx={{ gap: 1 }} onBlur={() => markTouched('time')}>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('time_window_label')}
</Typography>
@@ -458,7 +475,7 @@ function BookingRequestForm() {
<Chip
clickable
label={t('window_custom')}
onClick={() => setWindowSel('custom')}
onClick={() => setValue('window', 'custom', { shouldDirty: true })}
color={windowSel === 'custom' ? 'primary' : undefined}
variant={windowSel === 'custom' ? 'filled' : 'outlined'}
data-window="custom"
@@ -466,51 +483,52 @@ function BookingRequestForm() {
</Stack>
{windowSel === 'custom' ? (
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField
<RhfTextField<RequestFormValues>
name="timeStart"
type="time"
label={t('time_start_label')}
value={timeStart}
onChange={(event) => setTimeStart(event.target.value)}
rules={{ validate: (value) => value !== '' || t('error_time_required') }}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
<RhfTextField<RequestFormValues>
name="timeEnd"
type="time"
label={t('time_end_label')}
value={timeEnd}
error={timeError}
helperText={timeError ? t('error_time_range') : undefined}
onChange={(event) => setTimeEnd(event.target.value)}
rules={{
validate: {
chosen: (value) => value !== '' || t('error_time_required'),
after: (value, all) => !all.timeStart || String(value) > all.timeStart || t('error_time_range'),
},
}}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
</Stack>
) : null}
{touched.time && (timeStart === '' || timeEnd === '') ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_time_required')}
</Typography>
) : null}
</Stack>
{/* Caregiver gender — first-class, three-way, never silently defaulted */}
<Stack sx={{ gap: 1 }} onBlur={() => markTouched('gender')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('gender_label')}
</Typography>
<RhfControlGroup<RequestFormValues>
name="gender"
label={t('gender_label')}
hint={t('gender_hint')}
rules={{ validate: (value) => value !== '' || t('error_gender_required') }}
>
{({ field, hasError }) => (
<ToggleButtonGroup
exclusive
color="primary"
value={gender || null}
value={field.value || null}
onChange={(_event, next: RequiredCaregiverGender | null) => {
if (next) setGender(next);
if (next) field.onChange(next);
}}
sx={{
'& .MuiToggleButton-root': {
flex: 1,
py: 1.25,
fontWeight: 700,
borderColor: touched.gender && gender === '' ? 'var(--bal-error)' : undefined,
borderColor: hasError ? 'var(--bal-error)' : undefined,
},
}}
>
@@ -520,32 +538,26 @@ function BookingRequestForm() {
</ToggleButton>
))}
</ToggleButtonGroup>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('gender_hint')}
</Typography>
{touched.gender && gender === '' ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_gender_required')}
</Typography>
) : null}
)}
</RhfControlGroup>
{genderMismatch ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_gender_mismatch')}
</Typography>
) : null}
</Stack>
{/* Stage-1 notes */}
<Stack sx={{ gap: 0.5 }}>
<TextField
<RhfTextField<RequestFormValues>
name="notes"
label={t('notes_label')}
placeholder={t('notes_placeholder')}
value={notes}
onChange={(event) => setNotes(event.target.value.slice(0, CUSTOMER_NOTES_MAX_LENGTH))}
helperText={t('notes_hint')}
transform={(raw) => raw.slice(0, CUSTOMER_NOTES_MAX_LENGTH)}
multiline
minRows={3}
fullWidth
helperText={t('notes_hint')}
/>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'end' }}>
{t('notes_counter', { count: notes.length, max: CUSTOMER_NOTES_MAX_LENGTH })}
@@ -560,23 +572,24 @@ function BookingRequestForm() {
<Stack sx={{ gap: 1 }}>
<AppButton
type="submit"
color="primary"
variant="contained"
size="large"
startIcon="requests"
disabled={!requiredChosen || genderMismatch || createRequest.isPending}
onClick={handleSubmit}
sx={{ py: 1.5 }}
>
{createRequest.isPending ? t('submitting') : t('submit')}
</AppButton>
{!requiredChosen && missingFieldLabels.length > 0 ? (
{!requiredChosen ? (
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}>
{t('cta_missing_caption', { fields: missingFieldLabels.join(locale === 'fa' ? '، ' : ', ') })}
</Typography>
) : null}
</Stack>
</Stack>
</FormProvider>
);
}
@@ -596,7 +609,9 @@ function NurseIdentityBar({ profile }: { profile: NurseProfile }) {
data-nurse-identity-bar
sx={{
position: 'sticky',
top: 0,
// Sticks just below the shell's pinned header rather than behind it (AppFrame publishes the
// height); `0px` in a chrome-free shell.
top: 'var(--bal-chrome-top, 0px)',
zIndex: 2,
gap: 1.5,
alignItems: 'center',
@@ -3,6 +3,7 @@ import { FunctionComponent, ReactNode, useEffect, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import {
Box,
@@ -23,7 +24,16 @@ import {
useMediaQuery,
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import { AppButton, AppIcon, ConfirmDialog, EmptyState, PatientHeader, VisitNoteCard } from '@/components';
import {
AppButton,
AppIcon,
ConfirmDialog,
EmptyState,
PatientHeader,
RhfControlGroup,
RhfTextField,
VisitNoteCard,
} from '@/components';
import { ROUTES, bookingDetailPath } from '@/constants';
import { formatShamsiDate, formatShamsiMonthYear } from '@/utils';
import { bookingKeys } from '@/services/bookings/keys';
@@ -106,7 +116,7 @@ export default function PatientRecordPage() {
<Paper
elevation={0}
sx={{ p: 2, borderRadius: 2, backgroundColor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', backgroundColor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
>
<AppIcon icon="family" size={22} color="var(--bal-primary)" />
<Typography variant="body2" sx={{ color: 'var(--bal-primary)', fontWeight: 500 }}>
@@ -160,14 +170,19 @@ function EditableTabs({ patientId, tab, canEdit }: { patientId: number; tab: Car
}
const data = record.data;
// The real endpoint fully replaces all three lists from the request (no server-side merge), so every save
// must resend the whole current plan — not just the tab being edited — or the other two tabs get wiped.
const save = (patch: Partial<Pick<FamilyCareRecord, 'medications' | 'routine' | 'tasks'>>, onDone: () => void) => {
update.mutate(patch, {
update.mutate(
{ medications: data.medications, routine: data.routine, tasks: data.tasks, ...patch },
{
onSuccess: () => {
enqueueSnackbar(t('saved'), { variant: 'success' });
onDone();
},
onError: () => enqueueSnackbar(t('save_error'), { variant: 'error' }),
});
},
);
};
if (tab === 'medications') {
@@ -259,15 +274,23 @@ function RecordItemSheet({
);
}
/**
* Client-side id for a row that has never been saved. Module scope on purpose: `Date.now()` is impure
* and the lint rule can't tell that a submit callback only ever runs from an event, so keeping the
* call out of the component body states the same thing structurally.
*/
function newTempId(): string {
return `new-${Date.now()}`;
}
/** Save submits the enclosing `<form>`, so each sheet body owns its submit handler rather than a callback. */
function SheetActions({
onCancel,
onSave,
onDelete,
saving,
canSave,
}: {
onCancel: () => void;
onSave: () => void;
onDelete?: () => void;
saving: boolean;
canSave: boolean;
@@ -285,7 +308,7 @@ function SheetActions({
<AppButton variant="text" onClick={onCancel} disabled={saving}>
{tc('cancel')}
</AppButton>
<AppButton variant="contained" color="primary" onClick={onSave} disabled={saving || !canSave}>
<AppButton type="submit" variant="contained" color="primary" disabled={saving || !canSave}>
{saving ? tc('saving') : tc('save')}
</AppButton>
</Stack>
@@ -296,7 +319,7 @@ function SheetActions({
// A tappable row surface shared by the three editable tabs — mirrors PatientCard's press affordance.
function RowCard({ onOpen, children }: { onOpen?: () => void; children: ReactNode }) {
return (
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}>
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', overflow: 'hidden' }}>
{onOpen ? (
<AppButton
variant="text"
@@ -432,6 +455,16 @@ function MedicationsTab({
);
}
interface MedicationFormValues {
name: string;
doseAmount: string;
doseUnit: DoseUnit | '';
frequencyCode: FrequencyPreset | null;
frequencyText: string;
timeOfDay: TimeOfDayCode[];
timingNote: string;
}
function MedicationSheetBody({
initial,
saving,
@@ -448,62 +481,55 @@ function MedicationSheetBody({
onDirtyChange: (dirty: boolean) => void;
}) {
const t = useTranslations('records');
const [name, setName] = useState(initial?.name ?? '');
const [doseAmount, setDoseAmount] = useState(initial?.doseAmount ?? '');
const [doseUnit, setDoseUnit] = useState<DoseUnit | ''>(initial?.doseUnit ?? '');
const [frequencyCode, setFrequencyCode] = useState<FrequencyPreset | null>(initial?.frequencyCode ?? null);
const [frequencyText, setFrequencyText] = useState(initial?.frequencyText ?? '');
const [timeOfDay, setTimeOfDay] = useState<TimeOfDayCode[]>(initial?.timeOfDay ?? []);
const [timingNote, setTimingNote] = useState(initial?.timingNote ?? '');
const form = useForm<MedicationFormValues>({
mode: 'onTouched',
defaultValues: {
name: initial?.name ?? '',
doseAmount: initial?.doseAmount ?? '',
doseUnit: initial?.doseUnit ?? '',
frequencyCode: initial?.frequencyCode ?? null,
frequencyText: initial?.frequencyText ?? '',
timeOfDay: initial?.timeOfDay ?? [],
timingNote: initial?.timingNote ?? '',
},
});
const { control, formState, handleSubmit, setValue } = form;
const { isDirty } = formState;
const name = useWatch({ control, name: 'name' });
const frequencyCode = useWatch({ control, name: 'frequencyCode' });
useEffect(() => {
const dirty =
name !== (initial?.name ?? '') ||
doseAmount !== (initial?.doseAmount ?? '') ||
doseUnit !== (initial?.doseUnit ?? '') ||
frequencyCode !== (initial?.frequencyCode ?? null) ||
frequencyText !== (initial?.frequencyText ?? '') ||
timingNote !== (initial?.timingNote ?? '') ||
timeOfDay.length !== (initial?.timeOfDay ?? []).length ||
timeOfDay.some((code) => !(initial?.timeOfDay ?? []).includes(code));
onDirtyChange(dirty);
// eslint-disable-next-line react-hooks/exhaustive-deps -- `initial` is a fresh-mount snapshot (the sheet remounts per open), not reactive state.
}, [name, doseAmount, doseUnit, frequencyCode, frequencyText, timeOfDay, timingNote]);
onDirtyChange(isDirty);
}, [isDirty, onDirtyChange]);
const canSave = name.trim().length > 0;
const handleSave = () => {
const submit = (values: MedicationFormValues) => {
onSave({
id: initial?.id ?? `new-${Date.now()}`,
name: name.trim(),
doseAmount: doseAmount.trim() || null,
doseUnit: doseUnit || null,
frequencyCode,
frequencyText: frequencyCode ? null : frequencyText.trim() || null,
timeOfDay,
timingNote: timingNote.trim() || null,
id: initial?.id ?? newTempId(),
name: values.name.trim(),
doseAmount: values.doseAmount.trim() || null,
doseUnit: values.doseUnit || null,
frequencyCode: values.frequencyCode,
frequencyText: values.frequencyCode ? null : values.frequencyText.trim() || null,
timeOfDay: values.timeOfDay,
timingNote: values.timingNote.trim() || null,
});
};
return (
<Stack sx={{ gap: 2 }}>
<TextField label={t('med_name')} value={name} onChange={(e) => setName(e.target.value)} fullWidth required autoFocus />
<FormProvider {...form}>
<Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 2 }}>
<RhfTextField<MedicationFormValues> name="name" label={t('med_name')} fullWidth required autoFocus />
<Stack direction="row" sx={{ gap: 1.5 }}>
<TextField
label={t('med_dose_amount')}
value={doseAmount}
onChange={(e) => setDoseAmount(e.target.value)}
sx={{ flex: 1 }}
/>
<TextField select label={t('med_dose_unit')} value={doseUnit} onChange={(e) => setDoseUnit(e.target.value as DoseUnit)} sx={{ flex: 1 }}>
<RhfTextField<MedicationFormValues> name="doseAmount" label={t('med_dose_amount')} sx={{ flex: 1 }} />
<RhfTextField<MedicationFormValues> name="doseUnit" select label={t('med_dose_unit')} sx={{ flex: 1 }}>
<MenuItem value="">{t('med_dose_unit_none')}</MenuItem>
{DOSE_UNITS.map((unit) => (
<MenuItem key={unit} value={unit}>
{t(`dose_unit_${unit}`)}
</MenuItem>
))}
</TextField>
</RhfTextField>
</Stack>
<Stack sx={{ gap: 1 }}>
@@ -518,8 +544,10 @@ function MedicationSheetBody({
color="primary"
size="small"
onClick={() => {
setFrequencyCode(frequencyCode === preset ? null : preset);
if (frequencyCode !== preset) setFrequencyText('');
const next = frequencyCode === preset ? null : preset;
setValue('frequencyCode', next, { shouldDirty: true });
// A preset and the free-text alternative are mutually exclusive by design.
if (next) setValue('frequencyText', '', { shouldDirty: true });
}}
sx={{ borderRadius: '999px' }}
>
@@ -528,27 +556,24 @@ function MedicationSheetBody({
))}
</Stack>
{!frequencyCode ? (
<TextField
<RhfTextField<MedicationFormValues>
name="frequencyText"
label={t('med_frequency_text')}
value={frequencyText}
onChange={(e) => setFrequencyText(e.target.value)}
fullWidth
size="small"
/>
) : null}
</Stack>
<Stack sx={{ gap: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('time_of_day')}
</Typography>
<TimeOfDayChipRow value={timeOfDay} onChange={setTimeOfDay} />
</Stack>
<RhfControlGroup<MedicationFormValues> name="timeOfDay" hint={t('time_of_day')}>
{({ field }) => <TimeOfDayChipRow value={(field.value as TimeOfDayCode[]) ?? []} onChange={field.onChange} />}
</RhfControlGroup>
<TextField label={t('med_timing')} value={timingNote} onChange={(e) => setTimingNote(e.target.value)} fullWidth size="small" />
<RhfTextField<MedicationFormValues> name="timingNote" label={t('med_timing')} fullWidth size="small" />
<SheetActions onCancel={onCancel} onSave={handleSave} onDelete={onDelete} saving={saving} canSave={canSave} />
<SheetActions onCancel={onCancel} onDelete={onDelete} saving={saving} canSave={name.trim().length > 0} />
</Stack>
</FormProvider>
);
}
@@ -652,42 +677,45 @@ function RoutineSheetBody({
onDirtyChange: (dirty: boolean) => void;
}) {
const t = useTranslations('records');
const [label, setLabel] = useState(initial?.label ?? '');
const [timeOfDay, setTimeOfDay] = useState<TimeOfDayCode[]>(initial?.timeOfDay ?? []);
const [note, setNote] = useState(initial?.note ?? '');
const form = useForm<{ label: string; timeOfDay: TimeOfDayCode[]; note: string }>({
mode: 'onTouched',
defaultValues: {
label: initial?.label ?? '',
timeOfDay: initial?.timeOfDay ?? [],
note: initial?.note ?? '',
},
});
const { control, formState, handleSubmit } = form;
const { isDirty } = formState;
const label = useWatch({ control, name: 'label' });
useEffect(() => {
const dirty =
label !== (initial?.label ?? '') ||
note !== (initial?.note ?? '') ||
timeOfDay.length !== (initial?.timeOfDay ?? []).length ||
timeOfDay.some((code) => !(initial?.timeOfDay ?? []).includes(code));
onDirtyChange(dirty);
// eslint-disable-next-line react-hooks/exhaustive-deps -- `initial` is a fresh-mount snapshot (the sheet remounts per open), not reactive state.
}, [label, note, timeOfDay]);
const canSave = label.trim().length > 0;
const handleSave = () =>
onSave({
id: initial?.id ?? `new-${Date.now()}`,
label: label.trim(),
timeOfDay,
note: note.trim() || null,
});
onDirtyChange(isDirty);
}, [isDirty, onDirtyChange]);
return (
<Stack sx={{ gap: 2 }}>
<TextField label={t('routine_label')} value={label} onChange={(e) => setLabel(e.target.value)} fullWidth required autoFocus />
<Stack sx={{ gap: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('time_of_day')}
</Typography>
<TimeOfDayChipRow value={timeOfDay} onChange={setTimeOfDay} />
</Stack>
<TextField label={t('routine_note')} value={note} onChange={(e) => setNote(e.target.value)} fullWidth size="small" />
<SheetActions onCancel={onCancel} onSave={handleSave} onDelete={onDelete} saving={saving} canSave={canSave} />
<FormProvider {...form}>
<Stack
component="form"
noValidate
onSubmit={handleSubmit((values) =>
onSave({
id: initial?.id ?? newTempId(),
label: values.label.trim(),
timeOfDay: values.timeOfDay,
note: values.note.trim() || null,
}),
)}
sx={{ gap: 2 }}
>
<RhfTextField name="label" label={t('routine_label')} fullWidth required autoFocus />
<RhfControlGroup name="timeOfDay" hint={t('time_of_day')}>
{({ field }) => <TimeOfDayChipRow value={(field.value as TimeOfDayCode[]) ?? []} onChange={field.onChange} />}
</RhfControlGroup>
<RhfTextField name="note" label={t('routine_note')} fullWidth size="small" />
<SheetActions onCancel={onCancel} onDelete={onDelete} saving={saving} canSave={label.trim().length > 0} />
</Stack>
</FormProvider>
);
}
@@ -724,7 +752,7 @@ function TasksTab({
) : (
<Stack sx={{ gap: 1 }}>
{data.map((task) => (
<Paper key={task.id} elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1 }}>
<Paper key={task.id} elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', p: 1 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
<FormControlLabel
control={<Checkbox checked={task.done} onChange={() => (canEdit ? toggleDone(task) : undefined)} disabled={!canEdit || saving} />}
@@ -788,28 +816,42 @@ function TaskSheetBody({
onDirtyChange: (dirty: boolean) => void;
}) {
const t = useTranslations('records');
const [label, setLabel] = useState(initial?.label ?? '');
const [done, setDone] = useState(initial?.done ?? false);
const form = useForm<{ label: string; done: boolean }>({
mode: 'onTouched',
defaultValues: { label: initial?.label ?? '', done: initial?.done ?? false },
});
const { control, formState, handleSubmit } = form;
const { isDirty } = formState;
const label = useWatch({ control, name: 'label' });
useEffect(() => {
onDirtyChange(label !== (initial?.label ?? '') || done !== (initial?.done ?? false));
// eslint-disable-next-line react-hooks/exhaustive-deps -- `initial` is a fresh-mount snapshot (the sheet remounts per open), not reactive state.
}, [label, done]);
const canSave = label.trim().length > 0;
onDirtyChange(isDirty);
}, [isDirty, onDirtyChange]);
return (
<Stack sx={{ gap: 2 }}>
<TextField label={t('task_label')} value={label} onChange={(e) => setLabel(e.target.value)} fullWidth required autoFocus />
<FormControlLabel control={<Checkbox checked={done} onChange={(e) => setDone(e.target.checked)} />} label={t('task_done')} />
<SheetActions
onCancel={onCancel}
onSave={() => onSave({ id: initial?.id ?? `new-${Date.now()}`, label: label.trim(), done })}
onDelete={onDelete}
saving={saving}
canSave={canSave}
<FormProvider {...form}>
<Stack
component="form"
noValidate
onSubmit={handleSubmit((values) =>
onSave({ id: initial?.id ?? newTempId(), label: values.label.trim(), done: values.done }),
)}
sx={{ gap: 2 }}
>
<RhfTextField name="label" label={t('task_label')} fullWidth required autoFocus />
<RhfControlGroup name="done">
{({ field }) => (
<FormControlLabel
control={
<Checkbox checked={Boolean(field.value)} onChange={(event) => field.onChange(event.target.checked)} />
}
label={t('task_done')}
/>
)}
</RhfControlGroup>
<SheetActions onCancel={onCancel} onDelete={onDelete} saving={saving} canSave={label.trim().length > 0} />
</Stack>
</FormProvider>
);
}
@@ -2,8 +2,9 @@
import { FunctionComponent, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import { Box, Divider, MenuItem, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { Box, Divider, MenuItem, Skeleton, Stack, Typography } from '@mui/material';
import {
AppButton,
AppIcon,
@@ -12,8 +13,11 @@ import {
FormDialogShell,
PhoneNumberField,
ProfileSummary,
RhfControlGroup,
RhfTextField,
} from '@/components';
import LocaleSwitcher from '@/components/common/LocaleSwitcher';
import { ThemeModeSetting } from '@/components/settings';
import { isIranianMobile } from '@/components/PhoneNumberField';
import { ROUTES } from '@/constants';
import { digitsOnly } from '@/utils';
@@ -43,6 +47,14 @@ export default function CustomerProfilePage() {
);
}
interface AccountFormValues {
firstName: string;
lastName: string;
language: string;
emergencyName: string;
emergencyPhone: string;
}
const AccountHub: FunctionComponent<{
initial: CustomerProfile | null;
nameFallback: { firstName: string | null; lastName: string | null };
@@ -56,69 +68,63 @@ const AccountHub: FunctionComponent<{
const upsert = useUpsertCustomerProfile();
const logout = useLogout();
const initialFirstName = initial?.firstName ?? nameFallback.firstName ?? '';
const initialLastName = initial?.lastName ?? nameFallback.lastName ?? '';
const initialLanguage = initial?.preferredLanguage ?? 'fa';
const initialEmergencyName = initial?.defaultEmergencyContactName ?? '';
const initialEmergencyPhone = digitsOnly(initial?.defaultEmergencyContactPhone ?? '');
const [firstName, setFirstName] = useState(initialFirstName);
const [lastName, setLastName] = useState(initialLastName);
const [language, setLanguage] = useState(initialLanguage);
const [emergencyName, setEmergencyName] = useState(initialEmergencyName);
const [emergencyPhone, setEmergencyPhone] = useState(initialEmergencyPhone);
const [personalSheetOpen, setPersonalSheetOpen] = useState(false);
const [languageSheetOpen, setLanguageSheetOpen] = useState(false);
const [emergencySheetOpen, setEmergencySheetOpen] = useState(false);
const [signOutOpen, setSignOutOpen] = useState(false);
const [nameError, setNameError] = useState(false);
const [phoneError, setPhoneError] = useState(false);
// ONE form behind all three sheets. Each sheet edits its own slice, but every save writes the whole
// profile (the wire upsert has no PATCH semantics), so the untouched fields have to come from
// somewhere — a single form is that somewhere, and `dirtyFields` then answers per-sheet "is there
// unsaved work here?" without a hand-written comparison per section.
const form = useForm<AccountFormValues>({
mode: 'onTouched',
defaultValues: {
firstName: initial?.firstName ?? nameFallback.firstName ?? '',
lastName: initial?.lastName ?? nameFallback.lastName ?? '',
language: initial?.preferredLanguage ?? 'fa',
emergencyName: initial?.defaultEmergencyContactName ?? '',
emergencyPhone: digitsOnly(initial?.defaultEmergencyContactPhone ?? ''),
},
});
const { control, formState, getValues, reset, trigger } = form;
const { dirtyFields } = formState;
const watched = useWatch({ control });
const displayName = [firstName, lastName].filter(Boolean).join(' ').trim() || phone || '';
const emergencyComplete = Boolean(emergencyName.trim() && emergencyPhone);
const displayName = [watched.firstName, watched.lastName].filter(Boolean).join(' ').trim() || phone || '';
const emergencyComplete = Boolean(watched.emergencyName?.trim() && watched.emergencyPhone);
// Every sheet saves the FULL profile object (the wire upsert has no PATCH semantics) — `patch`
// carries just the fields that sheet owns, the rest come from the shared draft state so editing
// one section never blanks another (the bug the old flat form was one refactor away from).
const save = (
patch: Partial<Record<'firstName' | 'lastName' | 'preferredLanguage', string | null>>,
onDone: () => void,
) => {
const save = (onDone: () => void) => {
const values = getValues();
upsert.mutate(
{
defaultEmergencyContactName: emergencyName.trim(),
defaultEmergencyContactPhone: emergencyPhone,
firstName: firstName.trim() || null,
lastName: lastName.trim() || null,
preferredLanguage: language,
...patch,
defaultEmergencyContactName: values.emergencyName.trim(),
defaultEmergencyContactPhone: values.emergencyPhone,
firstName: values.firstName.trim() || null,
lastName: values.lastName.trim() || null,
preferredLanguage: values.language,
},
{
onSuccess: () => {
enqueueSnackbar(t('saved'), { variant: 'success' });
// Re-baseline so the saved slice stops counting as unsaved work in its sheet's discard guard.
reset(getValues());
onDone();
},
},
);
};
const savePersonal = () =>
save({ firstName: firstName.trim() || null, lastName: lastName.trim() || null }, () => setPersonalSheetOpen(false));
const saveLanguage = () => save({ preferredLanguage: language }, () => setLanguageSheetOpen(false));
const saveEmergency = () => {
const nameInvalid = emergencyName.trim().length === 0;
const phoneInvalid = !isIranianMobile(emergencyPhone);
setNameError(nameInvalid);
setPhoneError(phoneInvalid);
if (nameInvalid || phoneInvalid) return;
save({}, () => setEmergencySheetOpen(false));
const savePersonal = () => save(() => setPersonalSheetOpen(false));
const saveLanguage = () => save(() => setLanguageSheetOpen(false));
const saveEmergency = async () => {
if (!(await trigger(['emergencyName', 'emergencyPhone']))) return;
save(() => setEmergencySheetOpen(false));
};
const personalDirty = firstName !== initialFirstName || lastName !== initialLastName;
const languageDirty = language !== initialLanguage;
const emergencyDirty = emergencyName !== initialEmergencyName || emergencyPhone !== initialEmergencyPhone;
const personalDirty = Boolean(dirtyFields.firstName || dirtyFields.lastName);
const languageDirty = Boolean(dirtyFields.language);
const emergencyDirty = Boolean(dirtyFields.emergencyName || dirtyFields.emergencyPhone);
const goTo = (path: string) => router.push(`/${locale}${path}`);
@@ -130,6 +136,7 @@ const AccountHub: FunctionComponent<{
const cancelLabel = tc('cancel');
return (
<FormProvider {...form}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 520 }}>
<ProfileSummary displayName={displayName} phone={phone} initialsFallback={displayName || undefined} />
@@ -137,12 +144,15 @@ const AccountHub: FunctionComponent<{
<AccountRow icon="account" label={t('row_personal')} onClick={() => setPersonalSheetOpen(true)} />
<EmergencyContactCard
complete={emergencyComplete}
name={emergencyName}
phone={emergencyPhone}
name={watched.emergencyName ?? ''}
phone={watched.emergencyPhone ?? ''}
onEdit={() => setEmergencySheetOpen(true)}
/>
<AccountRow icon="location" label={t('row_addresses')} onClick={() => goTo(ROUTES.ADDRESSES)} />
<AccountRow icon="language" label={t('row_language')} onClick={() => setLanguageSheetOpen(true)} />
{/* The app's appearance control lives here (and in each other actor's settings hub) it
used to occupy a permanent slot in every top bar for a preference set once. */}
<ThemeModeSetting />
<AccountRow icon="notifications" label={t('row_notifications')} onClick={() => goTo(ROUTES.NOTIFICATIONS)} />
<AccountRow icon="support" label={t('row_support')} onClick={() => goTo(ROUTES.SUPPORT_TICKETS)} />
</Stack>
@@ -168,8 +178,8 @@ const AccountHub: FunctionComponent<{
discardCancelLabel={cancelLabel}
>
<Stack sx={{ gap: 2.5 }}>
<TextField label={t('first_name')} value={firstName} onChange={(e) => setFirstName(e.target.value)} fullWidth />
<TextField label={t('last_name')} value={lastName} onChange={(e) => setLastName(e.target.value)} fullWidth />
<RhfTextField<AccountFormValues> name="firstName" label={t('first_name')} fullWidth />
<RhfTextField<AccountFormValues> name="lastName" label={t('last_name')} fullWidth />
<SheetActions onCancel={() => setPersonalSheetOpen(false)} onSave={savePersonal} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
</Stack>
</FormDialogShell>
@@ -197,16 +207,10 @@ const AccountHub: FunctionComponent<{
<LocaleSwitcher />
</Stack>
<Divider />
<TextField
select
label={t('language')}
value={language}
onChange={(e) => setLanguage(e.target.value)}
helperText={t('language_hint')}
>
<RhfTextField<AccountFormValues> name="language" select label={t('language')} helperText={t('language_hint')}>
<MenuItem value="fa">{t('language_fa')}</MenuItem>
<MenuItem value="en">{t('language_en')}</MenuItem>
</TextField>
</RhfTextField>
<SheetActions onCancel={() => setLanguageSheetOpen(false)} onSave={saveLanguage} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
</Stack>
</FormDialogShell>
@@ -227,27 +231,27 @@ const AccountHub: FunctionComponent<{
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('emergency_hint')}
</Typography>
<TextField
<RhfTextField<AccountFormValues>
name="emergencyName"
label={t('emergency_name')}
value={emergencyName}
onChange={(e) => {
setEmergencyName(e.target.value);
if (nameError) setNameError(false);
}}
error={nameError}
rules={{ validate: (value) => String(value ?? '').trim().length > 0 }}
fullWidth
/>
<RhfControlGroup<AccountFormValues>
name="emergencyPhone"
rules={{ validate: (value) => isIranianMobile(String(value ?? '')) }}
>
{({ field, hasError }) => (
<PhoneNumberField
label={t('emergency_phone')}
value={emergencyPhone}
onChange={(v) => {
setEmergencyPhone(v);
if (phoneError) setPhoneError(false);
}}
error={phoneError}
helperText={phoneError ? t('emergency_phone_invalid') : undefined}
value={(field.value as string) ?? ''}
onChange={field.onChange}
error={hasError}
helperText={hasError ? t('emergency_phone_invalid') : undefined}
fullWidth
/>
)}
</RhfControlGroup>
<SheetActions onCancel={() => setEmergencySheetOpen(false)} onSave={saveEmergency} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
</Stack>
</FormDialogShell>
@@ -266,6 +270,7 @@ const AccountHub: FunctionComponent<{
}}
/>
</Box>
</FormProvider>
);
};
@@ -287,7 +292,7 @@ const AccountRow: FunctionComponent<{
alignItems: 'center',
gap: 1.5,
p: 1.5,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
color: tone === 'error' ? 'var(--bal-error)' : 'text.primary',
'&:hover': { bgcolor: 'action.hover' },
}}
@@ -309,7 +314,7 @@ const EmergencyContactCard: FunctionComponent<{
}> = ({ complete, name, phone, onEdit }) => {
const t = useTranslations('profile');
return (
<Box sx={{ p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Box sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1.5 }}>
<AppIcon icon={complete ? 'verified' : 'emergency'} size={22} color={complete ? 'var(--bal-success)' : 'var(--bal-warning)'} />
<Stack sx={{ flexGrow: 1, gap: 0.25, minWidth: 0 }}>
@@ -213,7 +213,7 @@ const CategorySelect: FunctionComponent<{ selectedId: number | null; onSelect: (
{isLoading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
))}
</Box>
) : isError ? (
@@ -140,6 +140,9 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
// Reached by direct URL, not gated by the search-index verified-only invariant — unlike the result card
// (NurseResultCard), an unverified nurse's profile CAN be opened this way, so the badge must reflect
// `profile.isVerified` (already correctly fetched by `getNurseProfile`), never an assumed-verified literal.
return (
<Stack sx={{ gap: 1.5 }}>
@@ -170,7 +173,7 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) {
</Stack>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<TrustBadge state="verified" nurseId={profile.nurseId} />
<TrustBadge state={profile.isVerified ? 'verified' : 'unverified'} nurseId={profile.nurseId} />
{profile.inoMembership ? (
<Chip
icon={<AppIcon icon="license" size={16} color="var(--bal-primary)" />}
@@ -355,7 +358,7 @@ function ReviewCard({ review }: { review: ReviewListItem }) {
const t = useTranslations('reviews');
const locale = useLocale();
return (
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', mb: 0.5, flexWrap: 'wrap' }}>
<RatingInput value={review.rating} readOnly size={16} ariaLabel={t('rating_label')} />
<Typography variant="caption" sx={{ color: 'text.secondary', marginInlineStart: 'auto' }}>
@@ -29,7 +29,7 @@ const WalletInstallments: FunctionComponent = () => {
<Skeleton variant="rounded" height={56} />
</Stack>
) : isError ? (
<Paper elevation={0} sx={{ p: 3, borderRadius: 2, border: '1px solid', borderColor: 'divider', textAlign: 'center' }}>
<Paper elevation={0} sx={{ p: 3, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', textAlign: 'center' }}>
<Stack sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="warning" size={36} color="var(--bal-warning)" />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
@@ -71,7 +71,7 @@ function InstallmentPlanSection({ plan }: { plan: WalletInstallmentPlan }) {
{/* Outstanding-balance card — terracotta financial accent; contrast text is scheme-stable. */}
<Paper
elevation={0}
sx={{ p: 2.25, borderRadius: 3, backgroundColor: 'var(--bal-secondary)', color: 'var(--bal-secondary-contrast)' }}
sx={{ p: 2.25, borderRadius: 'var(--bal-radius-lg)', backgroundColor: 'var(--bal-secondary)', color: 'var(--bal-secondary-contrast)' }}
>
<Typography variant="caption" sx={{ opacity: 0.85 }}>
{t('outstanding_balance')}
@@ -3,12 +3,12 @@ import Stack from '@mui/material/Stack';
import SurfaceCard from '@/components/common/SurfaceCard';
/**
* The shared loading skeleton for the sidebar-shell route groups (`nurse`, `admin`, `partner`) the
* `TopBarAndSideBarLayout` chrome (top bar + sidebar) is already rendered by the enclosing `layout.tsx`
* by the time this shows, so this only needs to shape the content area: a heading line + a short stack of
* generic worklist/detail cards. A private (`_`-prefixed) folder not a route.
* The shared loading skeleton for the nurse/admin/partner route groups. The `MobileShell` chrome
* (top bar + bottom nav) is already rendered by the enclosing `layout.tsx` by the time this shows,
* so this only shapes the content area: a heading line + a short stack of generic worklist cards.
* A private (`_`-prefixed) folder not a route.
*/
export default function SidebarShellSkeleton() {
export default function ShellContentSkeleton() {
return (
<Stack sx={{ gap: 2 }}>
<Skeleton variant="text" width={220} height={32} />
@@ -1,81 +1,64 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Paper, Typography } from '@mui/material';
import { AppIcon, AppLink } from '@/components';
import { AdminPageHeader } from '@/components/admin';
import { useTranslations } from 'next-intl';
import { Stack } from '@mui/material';
import { NavHubList, PageHeader } from '@/components';
import type { NavHubItem } from '@/components';
import { useAdminCapabilities } from '@/hooks';
import { ROUTES } from '@/constants';
interface ConsoleEntry extends NavHubItem {
/** Section this console belongs to — the same four groups the bottom nav carries. */
section: 'trust' | 'finance' | 'support' | 'system';
enabled: boolean;
}
/**
* Admin overview landing (f15) the backoffice home. Renders one **console card** per worklist the current
* principal may act on, derived from `useAdminCapabilities()` (a UI hint; the server still enforces every
* command's role scope). A `support` admin sees verification/tickets/alerts; a `finance` admin sees
* payouts/config; only a `super_admin` sees roles. Each card deep-links into its console.
* Admin overview landing (f15) the backoffice index. Every worklist the current principal may
* act on, grouped by the same four sections as the bottom nav, so the overview and the tabs agree
* on where a console lives. Gating comes from `useAdminCapabilities()` (a UI hint; the server
* still enforces every command's role scope): a `support` admin sees verification/tickets/alerts,
* a `finance` admin sees payouts/config, only a `super_admin` sees roles.
*
* The old 3-column card grid is gone inside a phone-width frame it collapsed to a single column
* of oversized tiles carrying nothing but an icon and one word each.
*/
export default function AdminOverviewScreen() {
const t = useTranslations('admin');
const th = useTranslations('hub');
const tNav = useTranslations('nav');
const locale = useLocale();
const caps = useAdminCapabilities();
// `key` doubles as the `nav` i18n key for the card label.
const consoles: { key: string; route: string; icon: string; enabled: boolean }[] = [
{ key: 'verification', route: ROUTES.ADMIN_VERIFICATION, icon: 'verification', enabled: caps.canVerify },
{ key: 'tickets', route: ROUTES.ADMIN_TICKETS, icon: 'support', enabled: caps.canManageTickets },
{ key: 'payouts', route: ROUTES.ADMIN_PAYOUTS, icon: 'earnings', enabled: caps.canPayout },
{ key: 'reviews', route: ROUTES.ADMIN_REVIEWS, icon: 'moderation', enabled: caps.canModerate },
{ key: 'config', route: ROUTES.ADMIN_CONFIG, icon: 'config', enabled: caps.canConfig },
{ key: 'holidays', route: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', enabled: caps.canConfig },
{ key: 'alerts', route: ROUTES.ADMIN_ALERTS, icon: 'alerts', enabled: caps.canManageAlerts },
{ key: 'audit', route: ROUTES.ADMIN_AUDIT, icon: 'audit', enabled: caps.canViewAudit },
{ key: 'partners', route: ROUTES.ADMIN_PARTNERS, icon: 'partners', enabled: caps.canManagePartners },
{ key: 'roles', route: ROUTES.ADMIN_ROLES, icon: 'roles', enabled: caps.canManageRoles },
].filter((c) => c.enabled);
const consoles: ConsoleEntry[] = [
{ section: 'trust', title: tNav('verification'), subtitle: th('admin_verification_sub'), path: ROUTES.ADMIN_VERIFICATION, icon: 'verification', enabled: caps.canVerify },
{ section: 'trust', title: tNav('reviews'), subtitle: th('admin_reviews_sub'), path: ROUTES.ADMIN_REVIEWS, icon: 'moderation', enabled: caps.canModerate },
{ section: 'finance', title: tNav('payouts'), subtitle: th('admin_payouts_sub'), path: ROUTES.ADMIN_PAYOUTS, icon: 'earnings', enabled: caps.canPayout },
{ section: 'support', title: tNav('tickets'), subtitle: th('admin_tickets_sub'), path: ROUTES.ADMIN_TICKETS, icon: 'support', enabled: caps.canManageTickets },
{ section: 'support', title: tNav('alerts'), subtitle: th('admin_alerts_sub'), path: ROUTES.ADMIN_ALERTS, icon: 'alerts', enabled: caps.canManageAlerts },
{ section: 'system', title: tNav('config'), subtitle: th('admin_config_sub'), path: ROUTES.ADMIN_CONFIG, icon: 'config', enabled: caps.canConfig },
{ section: 'system', title: tNav('catalog'), subtitle: th('admin_catalog_sub'), path: ROUTES.ADMIN_CATALOG, icon: 'category', enabled: caps.canManageCatalog },
{ section: 'system', title: tNav('holidays'), subtitle: th('admin_holidays_sub'), path: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', enabled: caps.canConfig },
{ section: 'system', title: tNav('audit'), subtitle: th('admin_audit_sub'), path: ROUTES.ADMIN_AUDIT, icon: 'audit', enabled: caps.canViewAudit },
{ section: 'system', title: tNav('partners'), subtitle: th('admin_partners_sub'), path: ROUTES.ADMIN_PARTNERS, icon: 'partners', enabled: caps.canManagePartners },
{ section: 'system', title: tNav('users'), subtitle: th('admin_users_sub'), path: ROUTES.ADMIN_USERS, icon: 'users', enabled: caps.canManageRoles },
{ section: 'system', title: tNav('roles'), subtitle: th('admin_roles_sub'), path: ROUTES.ADMIN_ROLES, icon: 'roles', enabled: caps.canManageRoles },
];
const sections: Array<{ key: ConsoleEntry['section']; label: string }> = [
{ key: 'trust', label: tNav('group_trust') },
{ key: 'finance', label: tNav('group_finance') },
{ key: 'support', label: tNav('group_support') },
{ key: 'system', label: tNav('group_system') },
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('overview_title')} subtitle={t('overview_subtitle')} />
<Stack sx={{ gap: 2.5 }}>
<PageHeader title={t('overview_title')} subtitle={t('overview_subtitle')} />
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr' },
gap: 2,
}}
>
{consoles.map((c) => (
<AppLink
key={c.key}
to={`/${locale}${c.route}`}
color="inherit"
underline="none"
sx={{ display: 'block', height: '100%' }}
>
<Paper
elevation={0}
sx={{
p: 3,
height: '100%',
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: 1.5,
cursor: 'pointer',
transition: 'border-color 150ms ease, box-shadow 150ms ease',
'&:hover': { borderColor: 'primary.main', boxShadow: 3 },
}}
>
<AppIcon icon={c.icon} size={32} color="var(--bal-primary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{tNav(c.key)}
</Typography>
</Paper>
</AppLink>
))}
</Box>
</Box>
{sections.map((section) => {
const items = consoles.filter((entry) => entry.section === section.key && entry.enabled);
if (items.length === 0) return null;
return <NavHubList key={section.key} title={section.label} items={items} />;
})}
</Stack>
);
}
@@ -0,0 +1,45 @@
'use client';
import { FunctionComponent, ReactNode } from 'react';
import { Stack } from '@mui/material';
import { useTranslations } from 'next-intl';
import { EmptyState, NavHubList, PageHeader } from '@/components';
import type { NavHubItem } from '@/components';
export interface AdminGroupConsole extends NavHubItem {
/** Capability gate for this console — a hidden row is one the current admin role can't act on. */
enabled: boolean;
}
interface Props {
title: string;
subtitle?: string;
consoles: Array<AdminGroupConsole>;
/** Rendered below the console list (the system group's settings + sign-out). */
children?: ReactNode;
}
/**
* The body every admin group-root page shares: a header, the capability-filtered consoles in that
* group, and an optional tail. Gating stays per-console and is still only a UI hint the server
* authorizes every command regardless of what the nav shows.
* A private (`_`-prefixed) folder, so this is not itself a route.
* @component AdminGroupHub
*/
const AdminGroupHub: FunctionComponent<Props> = ({ title, subtitle, consoles, children }) => {
const t = useTranslations('hub');
const permitted = consoles.filter((console_) => console_.enabled);
return (
<Stack sx={{ gap: 2 }}>
<PageHeader title={title} subtitle={subtitle} />
{permitted.length > 0 ? (
<NavHubList items={permitted} />
) : (
<EmptyState icon="lock" title={t('admin_group_empty_title')} body={t('admin_group_empty_body')} />
)}
{children}
</Stack>
);
};
export default AdminGroupHub;
@@ -71,7 +71,7 @@ function AdminAuditPageInner() {
<Stack
direction="row"
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
>
<TextField
size="small"
@@ -0,0 +1,502 @@
'use client';
import { useState } from 'react';
import { useParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import {
Box,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
FormControlLabel,
Paper,
Skeleton,
Stack,
Switch,
Typography,
} from '@mui/material';
import { AppButton, PageHeader, RhfControlGroup, RhfTextField, StatusChip } from '@/components';
import { AdminEmptyState, AdminErrorState } from '@/components/admin';
import { useAdminCapabilities, useAdminBackToList } from '@/hooks';
import { ROUTES } from '@/constants';
import { ADMIN_CATEGORIES_PAGE_SIZE } from '@/services/catalog/constants';
import type {
CreateOptionValueInput,
OptionGroupInput,
ServiceOptionGroup,
ServiceOptionValue,
UpdateOptionValueInput,
} from '@/services/catalog/types';
import {
useAdminCategories,
useAdminOptionGroups,
useCreateOptionGroup,
useCreateOptionValue,
useSetCategoryActive,
useUpdateOptionGroup,
useUpdateOptionValue,
} from '@/services/catalog';
import { CategoryFormDialog } from '../page';
/**
* A category's pricing-options console (f6 blocker) the option groups (dimensions) and their values
* the nurse builder offers for this category, plus every cross-category group. Option groups have no
* active/inactive toggle in the contract (create/edit only); values do, exposed as a switch in the
* value edit dialog rather than a hard delete, matching the "reference data, never hard-deleted" rule
* that already governs categories and variants.
*/
export default function AdminCatalogCategoryPage() {
const t = useTranslations('admin');
const locale = useLocale();
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const goBack = useAdminBackToList(`/${locale}${ROUTES.ADMIN_CATALOG}`);
const params = useParams<{ categoryId: string }>();
const parsed = Number(params?.categoryId);
const categoryId = Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
// No single-category admin read exists — categories are few, so the admin list (uncached) doubles
// as the detail source, exactly like the public reference-data reads do for the browse side.
const categories = useAdminCategories({ page: 1, pageSize: ADMIN_CATEGORIES_PAGE_SIZE });
const category = categories.data?.items.find((c) => c.id === categoryId) ?? null;
const groups = useAdminOptionGroups(categoryId || null);
const setActive = useSetCategoryActive();
const [editingCategory, setEditingCategory] = useState(false);
const [groupDialog, setGroupDialog] = useState<ServiceOptionGroup | 'new' | null>(null);
const [valueDialog, setValueDialog] = useState<{ groupId: number; value: ServiceOptionValue | 'new' } | null>(null);
const back = <PageHeader title={t('og_section_title')} onBack={goBack} backLabel={t('back')} />;
if (categories.isLoading) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{back}
<Skeleton variant="rounded" height={120} />
<Skeleton variant="rounded" height={220} />
</Box>
);
}
if (categories.isError) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{back}
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => categories.refetch()} />
</Box>
);
}
if (!category) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{back}
<AdminEmptyState icon="category" title={t('cat_not_found')} />
</Box>
);
}
const onToggleActive = () => {
setActive.mutate(
{ id: category.id, isActive: !category.isActive },
{ onSuccess: () => enqueueSnackbar(t(category.isActive ? 'cat_deactivated' : 'cat_activated'), { variant: 'success' }) },
);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<PageHeader
title={category.nameFa}
subtitle={category.nameEn}
onBack={goBack}
backLabel={t('back')}
meta={<StatusChip status={category.isActive ? 'active' : 'neutral'} label={t(category.isActive ? 'status_active' : 'status_inactive')} />}
/>
{category.descriptionFa || category.descriptionEn ? (
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{category.descriptionFa || category.descriptionEn}
</Typography>
</Paper>
) : null}
{caps.canManageCatalog ? (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton variant="outlined" color="inherit" startIcon="edit" onClick={() => setEditingCategory(true)}>
{t('cat_edit')}
</AppButton>
<AppButton
variant="outlined"
color={category.isActive ? 'error' : 'primary'}
onClick={onToggleActive}
disabled={setActive.isPending}
>
{t(category.isActive ? 'cat_deactivate' : 'cat_activate')}
</AppButton>
</Stack>
) : null}
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('og_section_title')}
</Typography>
{caps.canManageCatalog ? (
<AppButton variant="text" color="primary" startIcon="add" onClick={() => setGroupDialog('new')}>
{t('og_add')}
</AppButton>
) : null}
</Stack>
{groups.isLoading ? (
<Skeleton variant="rounded" height={160} />
) : groups.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => groups.refetch()} />
) : (groups.data ?? []).length === 0 ? (
<AdminEmptyState icon="tune" title={t('og_empty')} />
) : (
(groups.data ?? []).map((group) => (
<Paper
key={group.id}
elevation={0}
sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
>
<Stack sx={{ gap: 1.25 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1, flexWrap: 'wrap' }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{group.nameFa} · {group.nameEn}
</Typography>
<Stack direction="row" sx={{ gap: 0.5, mt: 0.5, flexWrap: 'wrap' }}>
{group.serviceCategoryId == null ? <Chip size="small" label={t('og_cross_category_badge')} /> : null}
<Chip size="small" variant="outlined" label={t(group.isRequired ? 'og_required_badge' : 'og_optional_badge')} />
</Stack>
</Box>
{caps.canManageCatalog ? (
<AppButton variant="text" color="primary" startIcon="edit" onClick={() => setGroupDialog(group)}>
{t('cat_edit')}
</AppButton>
) : null}
</Stack>
<Divider />
<Stack sx={{ gap: 1 }}>
{group.values.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('og_value_empty')}
</Typography>
) : (
group.values.map((value) => (
<Stack
key={value.id}
direction="row"
sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="body2">
{value.nameFa} · {value.nameEn}
</Typography>
<StatusChip status={value.isActive ? 'active' : 'neutral'} label={t(value.isActive ? 'status_active' : 'status_inactive')} />
</Stack>
{caps.canManageCatalog ? (
<AppButton variant="text" color="primary" onClick={() => setValueDialog({ groupId: group.id, value })}>
{t('cat_edit')}
</AppButton>
) : null}
</Stack>
))
)}
</Stack>
{caps.canManageCatalog ? (
<AppButton
variant="outlined"
color="primary"
startIcon="add"
onClick={() => setValueDialog({ groupId: group.id, value: 'new' })}
sx={{ alignSelf: 'flex-start' }}
>
{t('og_value_add')}
</AppButton>
) : null}
</Stack>
</Paper>
))
)}
</Stack>
{editingCategory ? <CategoryFormDialog category={category} onClose={() => setEditingCategory(false)} /> : null}
{groupDialog ? (
<OptionGroupFormDialog
categoryId={category.id}
group={groupDialog === 'new' ? null : groupDialog}
onClose={() => setGroupDialog(null)}
/>
) : null}
{valueDialog ? (
<OptionValueFormDialog
groupId={valueDialog.groupId}
value={valueDialog.value === 'new' ? null : valueDialog.value}
onClose={() => setValueDialog(null)}
/>
) : null}
</Box>
);
}
/** `'this'` maps to `serviceCategoryId: categoryId`; `'all'` maps to the cross-category `null`. */
interface GroupFormState {
scope: 'this' | 'all';
nameFa: string;
nameEn: string;
isRequired: boolean;
sortOrder: string;
}
function initialGroupForm(group: ServiceOptionGroup | null): GroupFormState {
return {
scope: group != null && group.serviceCategoryId == null ? 'all' : 'this',
nameFa: group?.nameFa ?? '',
nameEn: group?.nameEn ?? '',
isRequired: group?.isRequired ?? false,
sortOrder: group != null ? String(group.sortOrder) : '0',
};
}
/**
* The create/edit dialog for a pricing dimension (option group). Re-scoping an existing cross-category
* group to "this category only" removes it from every other category that currently sees it the hint
* text under the switch says so, matching what `UpdateServiceOptionGroupCommand` actually does server-side.
*/
function OptionGroupFormDialog({
categoryId,
group,
onClose,
}: {
categoryId: number;
group: ServiceOptionGroup | null;
onClose: () => void;
}) {
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const isEdit = group != null;
const create = useCreateOptionGroup();
const update = useUpdateOptionGroup();
const isPending = create.isPending || update.isPending;
const form = useForm<GroupFormState>({ mode: 'onTouched', defaultValues: initialGroupForm(group) });
const { control, handleSubmit, formState } = form;
const scope = useWatch({ control, name: 'scope' });
const onSave = (values: GroupFormState) => {
const input: OptionGroupInput = {
serviceCategoryId: values.scope === 'all' ? null : categoryId,
nameFa: values.nameFa.trim(),
nameEn: values.nameEn.trim(),
isRequired: values.isRequired,
sortOrder: Number(values.sortOrder),
};
const onSuccess = () => {
enqueueSnackbar(t('og_saved'), { variant: 'success' });
onClose();
};
if (isEdit) update.mutate({ id: group.id, input }, { onSuccess });
else create.mutate(input, { onSuccess });
};
return (
<Dialog open onClose={isPending ? undefined : onClose} fullWidth maxWidth="xs">
<DialogTitle sx={{ fontWeight: 800 }}>{isEdit ? t('og_edit') : t('og_add')}</DialogTitle>
<FormProvider {...form}>
<DialogContent>
{/* A real <form> so Enter submits from any field; the footer button (outside DialogContent)
calls the same handler directly rather than relying on cross-element form association. */}
<Stack component="form" noValidate onSubmit={handleSubmit(onSave)} sx={{ gap: 2, mt: 1 }}>
<RhfTextField<GroupFormState>
name="nameFa"
label={t('cat_name_fa')}
rules={{ validate: (value) => String(value ?? '').trim() !== '' }}
/>
<RhfTextField<GroupFormState>
name="nameEn"
label={t('cat_name_en')}
rules={{ validate: (value) => String(value ?? '').trim() !== '' }}
/>
<Box>
<RhfControlGroup<GroupFormState> name="scope">
{({ field }) => (
<FormControlLabel
control={
<Switch
checked={field.value === 'all'}
onChange={(event) => field.onChange(event.target.checked ? 'all' : 'this')}
/>
}
label={t('og_scope_all')}
/>
)}
</RhfControlGroup>
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
{scope === 'all' ? t('og_scope_all_hint') : t('og_scope_this_hint')}
</Typography>
</Box>
<RhfControlGroup<GroupFormState> name="isRequired">
{({ field }) => (
<FormControlLabel
control={<Switch checked={Boolean(field.value)} onChange={(event) => field.onChange(event.target.checked)} />}
label={t('og_is_required')}
/>
)}
</RhfControlGroup>
<RhfTextField<GroupFormState>
name="sortOrder"
type="number"
label={t('cat_sort_order')}
rules={{ validate: (value) => Number.isFinite(Number(value)) }}
slotProps={{ htmlInput: { step: 1 } }}
/>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={isPending}>
{t('cancel')}
</AppButton>
<AppButton
variant="contained"
color="primary"
onClick={handleSubmit(onSave)}
disabled={!formState.isValid || isPending}
>
{isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</FormProvider>
</Dialog>
);
}
interface ValueFormState {
nameFa: string;
nameEn: string;
sortOrder: string;
isActive: boolean;
}
function initialValueForm(value: ServiceOptionValue | null): ValueFormState {
return {
nameFa: value?.nameFa ?? '',
nameEn: value?.nameEn ?? '',
sortOrder: value != null ? String(value.sortOrder) : '0',
isActive: value?.isActive ?? true,
};
}
/**
* The create/edit dialog for a concrete option value. Create has no active flag (the server always
* creates active); edit exposes it as a switch the only way to deactivate a value, since re-parenting
* to a different group is deliberately not supported (it would silently change variants that already
* answered with this value).
*/
function OptionValueFormDialog({
groupId,
value,
onClose,
}: {
groupId: number;
value: ServiceOptionValue | null;
onClose: () => void;
}) {
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const isEdit = value != null;
const create = useCreateOptionValue();
const update = useUpdateOptionValue();
const isPending = create.isPending || update.isPending;
const form = useForm<ValueFormState>({ mode: 'onTouched', defaultValues: initialValueForm(value) });
const { handleSubmit, formState } = form;
const onSave = (values: ValueFormState) => {
const onSuccess = () => {
enqueueSnackbar(t('og_value_saved'), { variant: 'success' });
onClose();
};
if (isEdit) {
const input: UpdateOptionValueInput = {
nameFa: values.nameFa.trim(),
nameEn: values.nameEn.trim(),
sortOrder: Number(values.sortOrder),
isActive: values.isActive,
};
update.mutate({ id: value.id, input }, { onSuccess });
} else {
const input: CreateOptionValueInput = {
optionGroupId: groupId,
nameFa: values.nameFa.trim(),
nameEn: values.nameEn.trim(),
sortOrder: Number(values.sortOrder),
};
create.mutate(input, { onSuccess });
}
};
return (
<Dialog open onClose={isPending ? undefined : onClose} fullWidth maxWidth="xs">
<DialogTitle sx={{ fontWeight: 800 }}>{isEdit ? t('og_value_edit') : t('og_value_add')}</DialogTitle>
<FormProvider {...form}>
<DialogContent>
<Stack component="form" noValidate onSubmit={handleSubmit(onSave)} sx={{ gap: 2, mt: 1 }}>
<RhfTextField<ValueFormState>
name="nameFa"
label={t('cat_name_fa')}
rules={{ validate: (val) => String(val ?? '').trim() !== '' }}
/>
<RhfTextField<ValueFormState>
name="nameEn"
label={t('cat_name_en')}
rules={{ validate: (val) => String(val ?? '').trim() !== '' }}
/>
<RhfTextField<ValueFormState>
name="sortOrder"
type="number"
label={t('cat_sort_order')}
rules={{ validate: (val) => Number.isFinite(Number(val)) }}
slotProps={{ htmlInput: { step: 1 } }}
/>
{isEdit ? (
<RhfControlGroup<ValueFormState> name="isActive">
{({ field }) => (
<FormControlLabel
control={<Switch checked={Boolean(field.value)} onChange={(event) => field.onChange(event.target.checked)} />}
label={t(field.value ? 'status_active' : 'status_inactive')}
/>
)}
</RhfControlGroup>
) : null}
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={isPending}>
{t('cancel')}
</AppButton>
<AppButton
variant="contained"
color="primary"
onClick={handleSubmit(onSave)}
disabled={!formState.isValid || isPending}
>
{isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</FormProvider>
</Dialog>
);
}
@@ -0,0 +1,244 @@
'use client';
import { Suspense, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { FormProvider, useForm } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import { Box, Dialog, DialogActions, DialogContent, DialogTitle, Skeleton, Stack } from '@mui/material';
import { AppButton, AppLoading, RhfTextField, StatusChip } from '@/components';
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, type AdminTableColumn } from '@/components/admin';
import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { adminCatalogCategoryPath } from '@/constants';
import { ADMIN_CATEGORIES_PAGE_SIZE } from '@/services/catalog/constants';
import type { CategoryInput, ServiceCategory } from '@/services/catalog/types';
import { useAdminCategories, useCreateCategory, useSetCategoryActive, useUpdateCategory } from '@/services/catalog';
/** No list-level filters today (the list is unfiltered) — `useAdminListState` still URL-syncs the page. */
type CategoryListFilters = Record<string, never>;
const EMPTY_FILTERS: CategoryListFilters = {};
/**
* Catalog admin the top-level category list (f6 blocker). Categories are seeded by migration, but
* pricing options are deliberately admin-authored data (never a migration), so outside a developer's
* own dev-seeded box there is nothing here until an admin creates it this console is the fix.
* A row opens the category's option-groups/values console; the create dialog here handles categories
* only. Categories are reference data deactivating (never a hard delete) hides a category from
* public browse and new bookings while leaving existing variants/bookings intact.
*/
export default function AdminCatalogPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminCatalogPageInner />
</Suspense>
);
}
function AdminCatalogPageInner() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const [creating, setCreating] = useState(false);
const setActive = useSetCategoryActive();
const listState = useAdminListState<CategoryListFilters>({
parse: () => EMPTY_FILTERS,
serialize: () => ({}),
empty: EMPTY_FILTERS,
});
const page = listState.page;
const categories = useAdminCategories({ page, pageSize: ADMIN_CATEGORIES_PAGE_SIZE });
const items = categories.data?.items ?? [];
const total = categories.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / ADMIN_CATEGORIES_PAGE_SIZE));
const from = items.length === 0 ? 0 : (page - 1) * ADMIN_CATEGORIES_PAGE_SIZE + 1;
const to = (page - 1) * ADMIN_CATEGORIES_PAGE_SIZE + items.length;
const onToggleActive = (category: ServiceCategory) => {
setActive.mutate(
{ id: category.id, isActive: !category.isActive },
{ onSuccess: () => enqueueSnackbar(t(category.isActive ? 'cat_deactivated' : 'cat_activated'), { variant: 'success' }) },
);
};
const columns: AdminTableColumn<ServiceCategory>[] = [
{ key: 'name', header: t('cat_col_name'), render: (c) => `${c.nameFa} · ${c.nameEn}` },
{ key: 'order', header: t('cat_col_order'), render: (c) => c.sortOrder },
{
key: 'status',
header: t('cat_col_status'),
render: (c) => <StatusChip status={c.isActive ? 'active' : 'neutral'} label={t(c.isActive ? 'status_active' : 'status_inactive')} />,
},
...(caps.canManageCatalog
? [
{
key: 'actions',
header: '',
render: (c: ServiceCategory) => (
<Stack direction="row" sx={{ gap: 0.5, flexWrap: 'wrap' }}>
<AppButton
variant="text"
color={c.isActive ? 'error' : 'primary'}
onClick={(event) => {
event.stopPropagation();
onToggleActive(c);
}}
disabled={setActive.isPending}
>
{t(c.isActive ? 'cat_deactivate' : 'cat_activate')}
</AppButton>
</Stack>
),
} as AdminTableColumn<ServiceCategory>,
]
: []),
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
title={t('cat_title')}
subtitle={t('cat_subtitle')}
actions={
caps.canManageCatalog ? (
<AppButton variant="contained" color="primary" startIcon="add" onClick={() => setCreating(true)}>
{t('cat_add')}
</AppButton>
) : undefined
}
/>
{categories.isLoading ? (
<Skeleton variant="rounded" height={220} />
) : categories.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => categories.refetch()} />
) : items.length === 0 ? (
<AdminEmptyState icon="category" title={t('cat_empty')} />
) : (
<AdminDataTable
columns={columns}
rows={items}
getRowKey={(c) => c.id}
ariaLabel={t('cat_title')}
onRowClick={(c) => router.push(`/${locale}${adminCatalogCategoryPath(c.id)}`)}
footer={total > 0 ? t('showing_range', { from, to, total }) : undefined}
/>
)}
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => listState.goToPage(Math.max(1, page - 1))}
onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', { page, total: pageCount })}
/>
{creating ? <CategoryFormDialog category={null} onClose={() => setCreating(false)} /> : null}
</Box>
);
}
/** The editable slice of `CategoryInput`, kept as strings for controlled text/number inputs. */
interface CategoryFormState {
nameFa: string;
nameEn: string;
descriptionFa: string;
descriptionEn: string;
iconKey: string;
sortOrder: string;
}
function initialForm(category: ServiceCategory | null): CategoryFormState {
return {
nameFa: category?.nameFa ?? '',
nameEn: category?.nameEn ?? '',
descriptionFa: category?.descriptionFa ?? '',
descriptionEn: category?.descriptionEn ?? '',
iconKey: category?.iconKey ?? '',
sortOrder: category != null ? String(category.sortOrder) : '0',
};
}
/**
* The create/edit dialog for a category shared by the list (create, `category=null`) and the detail
* page (edit, `category` prefilled). Both labels are required; description/icon are optional.
*/
export function CategoryFormDialog({ category, onClose }: { category: ServiceCategory | null; onClose: () => void }) {
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const isEdit = category != null;
const create = useCreateCategory();
const update = useUpdateCategory();
const form = useForm<CategoryFormState>({ mode: 'onTouched', defaultValues: initialForm(category) });
const { handleSubmit, formState } = form;
const isPending = create.isPending || update.isPending;
const onSave = (values: CategoryFormState) => {
const input: CategoryInput = {
nameFa: values.nameFa.trim(),
nameEn: values.nameEn.trim(),
descriptionFa: values.descriptionFa.trim() || null,
descriptionEn: values.descriptionEn.trim() || null,
iconKey: values.iconKey.trim() || null,
sortOrder: Number(values.sortOrder),
};
const onSuccess = () => {
enqueueSnackbar(t('cat_saved'), { variant: 'success' });
onClose();
};
if (isEdit) update.mutate({ id: category.id, input }, { onSuccess });
else create.mutate(input, { onSuccess });
};
return (
<Dialog open onClose={isPending ? undefined : onClose} fullWidth maxWidth="xs">
<DialogTitle sx={{ fontWeight: 800 }}>{isEdit ? t('cat_edit') : t('cat_add')}</DialogTitle>
<FormProvider {...form}>
<DialogContent>
{/* A real <form> so Enter submits from any field; the footer button (outside DialogContent)
calls the same handler directly rather than relying on cross-element form association. */}
<Stack component="form" noValidate onSubmit={handleSubmit(onSave)} sx={{ gap: 2, mt: 1 }}>
<RhfTextField<CategoryFormState>
name="nameFa"
label={t('cat_name_fa')}
rules={{ validate: (value) => String(value ?? '').trim() !== '' }}
/>
<RhfTextField<CategoryFormState>
name="nameEn"
label={t('cat_name_en')}
rules={{ validate: (value) => String(value ?? '').trim() !== '' }}
/>
<RhfTextField<CategoryFormState> name="descriptionFa" label={t('cat_description_fa')} multiline minRows={2} />
<RhfTextField<CategoryFormState> name="descriptionEn" label={t('cat_description_en')} multiline minRows={2} />
<RhfTextField<CategoryFormState> name="iconKey" label={t('cat_icon_key')} helperText={t('cat_icon_hint')} slotProps={{ htmlInput: { dir: 'ltr' } }} />
<RhfTextField<CategoryFormState>
name="sortOrder"
type="number"
label={t('cat_sort_order')}
rules={{ validate: (value) => Number.isFinite(Number(value)) }}
slotProps={{ htmlInput: { step: 1 } }}
/>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={isPending}>
{t('cancel')}
</AppButton>
<AppButton
variant="contained"
color="primary"
onClick={handleSubmit(onSave)}
disabled={!formState.isValid || isPending}
>
{isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</FormProvider>
</Dialog>
);
}
@@ -229,7 +229,7 @@ function ConfigHistoryDrawer({ configKey, onClose }: { configKey: string | null;
) : (
<Stack sx={{ gap: 1.5 }}>
{history.data?.items.map((change) => (
<Box key={change.id} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1.5 }}>
<Box key={change.id} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', p: 1.5 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 700 }}>
{t('cfg_history_change_old', { old: change.oldValue ?? '—' })}
@@ -0,0 +1,28 @@
'use client';
import { useTranslations } from 'next-intl';
import { ROUTES } from '@/constants';
import { useAdminCapabilities } from '@/hooks';
import AdminGroupHub from '../_hub/AdminGroupHub';
/** «مالی» group root — the weekly payout dashboard. */
export default function AdminFinancePage() {
const t = useTranslations('hub');
const tn = useTranslations('nav');
const caps = useAdminCapabilities();
return (
<AdminGroupHub
title={tn('group_finance')}
subtitle={t('admin_finance_subtitle')}
consoles={[
{
title: tn('payouts'),
subtitle: t('admin_payouts_sub'),
icon: 'earnings',
path: ROUTES.ADMIN_PAYOUTS,
enabled: caps.canPayout,
},
]}
/>
);
}
@@ -1,6 +1,7 @@
'use client';
import { Suspense, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { FormProvider, useForm } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import {
Box,
@@ -14,9 +15,8 @@ import {
Skeleton,
Stack,
Switch,
TextField,
} from '@mui/material';
import { AppButton, AppLoading, JalaliDateField } from '@/components';
import { AppButton, AppLoading, RhfControlGroup, RhfJalaliDateField, RhfTextField } from '@/components';
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, type AdminTableColumn } from '@/components/admin';
import { formatShamsiDate } from '@/utils';
import { useAdminCapabilities, useAdminListState } from '@/hooks';
@@ -138,19 +138,20 @@ function HolidayDialog({ holiday, onClose }: { holiday: Holiday | null; onClose:
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const upsert = useUpsertHoliday();
const [form, setForm] = useState<HolidayInput>(() => ({
const form = useForm<HolidayInput>({
mode: 'onTouched',
defaultValues: {
holidayDate: holiday?.holidayDate?.slice(0, 10) ?? todayLocalIso(),
nameFa: holiday?.nameFa ?? '',
type: holiday?.type ?? 'official',
isBankClosed: holiday?.isBankClosed ?? true,
}));
},
});
const { handleSubmit, formState } = form;
const valid = form.holidayDate.length > 0 && form.nameFa.trim().length > 0;
const onSave = () => {
if (!valid) return;
const onSave = (values: HolidayInput) =>
upsert.mutate(
{ ...form, nameFa: form.nameFa.trim() },
{ ...values, nameFa: values.nameFa.trim() },
{
onSuccess: () => {
enqueueSnackbar(t('hol_saved'), { variant: 'success' });
@@ -158,50 +159,59 @@ function HolidayDialog({ holiday, onClose }: { holiday: Holiday | null; onClose:
},
},
);
};
return (
<Dialog open onClose={upsert.isPending ? undefined : onClose} fullWidth maxWidth="xs">
<DialogTitle sx={{ fontWeight: 800 }}>{holiday ? t('hol_edit') : t('hol_add')}</DialogTitle>
<FormProvider {...form}>
<DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}>
<JalaliDateField
{/* A real <form> so Enter submits from any field; the footer button (outside DialogContent)
calls the same handler directly rather than relying on cross-element form association. */}
<Stack component="form" noValidate onSubmit={handleSubmit(onSave)} sx={{ gap: 2, mt: 1 }}>
<RhfJalaliDateField<HolidayInput>
name="holidayDate"
label={t('hol_col_date')}
value={form.holidayDate || null}
onChange={(iso) => setForm((f) => ({ ...f, holidayDate: iso }))}
rules={{ validate: (value) => String(value ?? '').length > 0 }}
disabled={!!holiday}
/>
<TextField
<RhfTextField<HolidayInput>
name="nameFa"
label={t('hol_name_fa')}
value={form.nameFa}
onChange={(e) => setForm((f) => ({ ...f, nameFa: e.target.value }))}
rules={{ validate: (value) => String(value ?? '').trim().length > 0 }}
/>
<TextField
select
label={t('hol_col_type')}
value={form.type}
onChange={(e) => setForm((f) => ({ ...f, type: e.target.value as HolidayType }))}
>
<RhfTextField<HolidayInput> name="type" select label={t('hol_col_type')}>
{HOLIDAY_TYPES.map((ty) => (
<MenuItem key={ty} value={ty}>
{t(`htype_${ty}`)}
</MenuItem>
))}
</TextField>
</RhfTextField>
<RhfControlGroup<HolidayInput> name="isBankClosed">
{({ field }) => (
<FormControlLabel
control={<Switch checked={form.isBankClosed} onChange={(e) => setForm((f) => ({ ...f, isBankClosed: e.target.checked }))} />}
control={
<Switch checked={Boolean(field.value)} onChange={(event) => field.onChange(event.target.checked)} />
}
label={t('hol_bank_hint')}
/>
)}
</RhfControlGroup>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={upsert.isPending}>
{t('cancel')}
</AppButton>
<AppButton variant="contained" color="primary" onClick={onSave} disabled={!valid || upsert.isPending}>
<AppButton
variant="contained"
color="primary"
onClick={handleSubmit(onSave)}
disabled={!formState.isValid || upsert.isPending}
>
{upsert.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</FormProvider>
</Dialog>
);
}
@@ -1,5 +1,5 @@
import SidebarShellSkeleton from '../_chrome/SidebarShellSkeleton';
import ShellContentSkeleton from '../_chrome/ShellContentSkeleton';
export default function Loading() {
return <SidebarShellSkeleton />;
return <ShellContentSkeleton />;
}
@@ -123,7 +123,7 @@ export default function AdminPartnerCenterDetailPage() {
meta={<StatusChip status={CENTER_STATE_KIND[data.onboardingState]} label={t(`center_state_${data.onboardingState}`)} />}
/>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack divider={<Divider flexItem />} sx={{ gap: 1.25 }}>
<DetailRow label={t('partner_legal_type')}>{data.legalEntityType || '—'}</DetailRow>
<DetailRow label={t('partner_permit')}>{data.mohEstablishmentPermitNo || '—'}</DetailRow>
@@ -174,7 +174,7 @@ export default function AdminPartnerCenterDetailPage() {
{roster.isLoading ? (
<Skeleton variant="rounded" height={120} />
) : (
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}>
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', overflow: 'hidden' }}>
<Stack divider={<Divider />}>
{(roster.data ?? []).map((nurse) => (
<Stack
@@ -2,6 +2,7 @@
import { Suspense, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import {
Box,
@@ -13,10 +14,9 @@ import {
Skeleton,
Stack,
Switch,
TextField,
Typography,
} from '@mui/material';
import { AppButton, AppLoading, StatusChip } from '@/components';
import { AppButton, AppLoading, RhfControlGroup, RhfTextField, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import {
AdminDataTable,
@@ -183,37 +183,30 @@ export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCe
const create = useCreatePartnerCenter();
const update = useUpdatePartnerCenter(center?.id ?? 0);
const mutation = isEdit ? update : create;
const [form, setForm] = useState<CenterFormState>(() => initialForm(center));
const form = useForm<CenterFormState>({ mode: 'onTouched', defaultValues: initialForm(center) });
const { control, handleSubmit, formState } = form;
const isMerchantOfRecord = useWatch({ control, name: 'isMerchantOfRecord' });
const adminUser = useWatch({ control, name: 'adminUser' });
// Edit mode: the center already has an adminUserId (a plain number) — resolve it to a name so the picker
// opens pre-filled with a person, never a bare id (3.2). Derived in render (never synced into state via an
// effect): once the admin actually picks someone, `form.adminUser` wins over the resolved existing one.
// opens pre-filled with a person, never a bare id (3.2). Derived in render (never synced into form state):
// once the admin actually picks someone, the form value wins over the resolved existing one.
const existingAdminId = center?.adminUserId ?? null;
const existingAdminLookup = useUserLookup(existingAdminId != null ? [existingAdminId] : []);
const resolvedExistingAdmin = existingAdminId != null ? (existingAdminLookup.data?.get(existingAdminId) ?? null) : null;
const displayedAdminUser = form.adminUser !== undefined ? form.adminUser : resolvedExistingAdmin;
const displayedAdminUser = adminUser !== undefined ? adminUser : resolvedExistingAdmin;
const set = <K extends keyof CenterFormState>(key: K, value: CenterFormState[K]) =>
setForm((f) => ({ ...f, [key]: value }));
const commission = Number(form.commissionRate);
const commissionValid = form.commissionRate.trim() !== '' && Number.isFinite(commission) && commission >= 0 && commission < 1;
// On edit a blank IBAN is allowed (it keeps the stored value); on create an MoR center must supply one.
const ibanValid = !form.isMerchantOfRecord || isEdit || form.settlementIban.trim() !== '';
const valid =
form.name.trim() !== '' && form.mohEstablishmentPermitNo.trim() !== '' && commissionValid && ibanValid;
const onSave = () => {
if (!valid) return;
const onSave = (values: CenterFormState) => {
const input: PartnerCenterInput = {
name: form.name.trim(),
legalEntityType: form.legalEntityType.trim(),
mohEstablishmentPermitNo: form.mohEstablishmentPermitNo.trim(),
technicalDirectorLicenseNo: form.technicalDirectorLicenseNo.trim() || null,
enamadCode: form.enamadCode.trim() || null,
settlementIban: form.settlementIban.trim() || null,
isMerchantOfRecord: form.isMerchantOfRecord,
commissionRate: commission,
name: values.name.trim(),
legalEntityType: values.legalEntityType.trim(),
mohEstablishmentPermitNo: values.mohEstablishmentPermitNo.trim(),
technicalDirectorLicenseNo: values.technicalDirectorLicenseNo.trim() || null,
enamadCode: values.enamadCode.trim() || null,
settlementIban: values.settlementIban.trim() || null,
isMerchantOfRecord: values.isMerchantOfRecord,
commissionRate: Number(values.commissionRate),
adminUserId: displayedAdminUser?.id ?? null,
};
mutation.mutate(input, {
@@ -227,67 +220,95 @@ export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCe
return (
<Dialog open onClose={mutation.isPending ? undefined : onClose} fullWidth maxWidth="sm">
<DialogTitle sx={{ fontWeight: 800 }}>{isEdit ? t('partner_edit') : t('partner_create')}</DialogTitle>
<FormProvider {...form}>
<DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}>
<TextField label={t('partner_name')} value={form.name} onChange={(e) => set('name', e.target.value)} />
<TextField
label={t('partner_legal_type')}
value={form.legalEntityType}
onChange={(e) => set('legalEntityType', e.target.value)}
{/* A real <form> so Enter submits from any field; the footer button (outside DialogContent)
calls the same handler directly rather than relying on cross-element form association. */}
<Stack component="form" noValidate onSubmit={handleSubmit(onSave)} sx={{ gap: 2, mt: 1 }}>
<RhfTextField<CenterFormState>
name="name"
label={t('partner_name')}
rules={{ validate: (value) => String(value ?? '').trim() !== '' }}
/>
<TextField
<RhfTextField<CenterFormState> name="legalEntityType" label={t('partner_legal_type')} />
<RhfTextField<CenterFormState>
name="mohEstablishmentPermitNo"
label={t('partner_permit')}
value={form.mohEstablishmentPermitNo}
onChange={(e) => set('mohEstablishmentPermitNo', e.target.value)}
rules={{ validate: (value) => String(value ?? '').trim() !== '' }}
/>
<TextField
<RhfTextField<CenterFormState>
name="technicalDirectorLicenseNo"
label={t('partner_tech_director_license')}
value={form.technicalDirectorLicenseNo}
onChange={(e) => set('technicalDirectorLicenseNo', e.target.value)}
/>
<TextField label={t('partner_enamad')} value={form.enamadCode} onChange={(e) => set('enamadCode', e.target.value)} />
<TextField
<RhfTextField<CenterFormState> name="enamadCode" label={t('partner_enamad')} />
<RhfTextField<CenterFormState>
name="settlementIban"
label={t('partner_iban')}
value={form.settlementIban}
onChange={(e) => set('settlementIban', e.target.value)}
helperText={t('partner_iban_write_hint')}
placeholder={center?.settlementIbanMasked ?? undefined}
// On edit a blank IBAN keeps the stored value; on create an MoR center must supply one.
rules={{ validate: (value) => !isMerchantOfRecord || isEdit || String(value ?? '').trim() !== '' }}
slotProps={{ htmlInput: { dir: 'ltr' } }}
/>
<Box>
<RhfControlGroup<CenterFormState> name="isMerchantOfRecord">
{({ field }) => (
<FormControlLabel
control={<Switch checked={form.isMerchantOfRecord} onChange={(e) => set('isMerchantOfRecord', e.target.checked)} />}
control={
<Switch
checked={Boolean(field.value)}
onChange={(event) => field.onChange(event.target.checked)}
/>
}
label={t('partner_is_mor')}
/>
)}
</RhfControlGroup>
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
{t('partner_is_mor_hint')}
</Typography>
</Box>
<TextField
<RhfTextField<CenterFormState>
name="commissionRate"
type="number"
label={t('partner_commission')}
value={form.commissionRate}
onChange={(e) => set('commissionRate', e.target.value)}
rules={{
validate: (value) => {
const raw = String(value ?? '').trim();
const rate = Number(raw);
return raw !== '' && Number.isFinite(rate) && rate >= 0 && rate < 1;
},
}}
slotProps={{ htmlInput: { min: 0, max: 0.999, step: 0.01 } }}
/>
<RhfControlGroup<CenterFormState> name="adminUser">
{({ field }) => (
<UserPicker
value={displayedAdminUser}
onChange={(u) => set('adminUser', u)}
onChange={field.onChange}
label={t('partner_admin_user')}
placeholder={t('user_picker_search_ph')}
noOptionsText={t('user_picker_no_options')}
loadingText={t('user_picker_loading')}
/>
)}
</RhfControlGroup>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={mutation.isPending}>
{t('cancel')}
</AppButton>
<AppButton variant="contained" color="primary" onClick={onSave} disabled={!valid || mutation.isPending}>
<AppButton
variant="contained"
color="primary"
onClick={handleSubmit(onSave)}
disabled={!formState.isValid || mutation.isPending}
>
{mutation.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</FormProvider>
</Dialog>
);
}
@@ -83,7 +83,7 @@ function AdminPayoutBatchDetailScreen() {
<AdminEmptyState icon="wallet" title={t('payout_empty')} />
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.5 }}>
<StatusChip
status={BATCH_STATUS_KIND[data.batch.status]}
@@ -180,7 +180,7 @@ const PayoutRowCard: FunctionComponent<{ row: AdminPayoutRow; batchId: number; c
};
return (
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.25 }}>
<Stack
direction="row"
@@ -217,7 +217,7 @@ const PayoutRowCard: FunctionComponent<{ row: AdminPayoutRow; batchId: number; c
<Box
sx={{
p: 1.25,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
borderInlineStart: '3px solid',
@@ -283,7 +283,7 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
</Typography>
) : (
<Stack divider={<Divider />} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack divider={<Divider />} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
{result.eligible.map((n) => (
<Stack key={n.nurseId} sx={{ p: 1.5, gap: 0.5 }}>
<Stack
@@ -317,7 +317,7 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
{t('payout_skipped')}
</Typography>
<Stack divider={<Divider />} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack divider={<Divider />} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
{result.skipped.map((n) => (
<Stack
key={n.nurseId}
@@ -166,7 +166,7 @@ function ModerationCard({
return (
<Paper
elevation={0}
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', display: 'flex', flexDirection: 'column', gap: 1.5 }}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<RatingInput value={item.rating} readOnly size={20} ariaLabel={t('mod_col_rating')} />
@@ -0,0 +1,35 @@
'use client';
import { useTranslations } from 'next-intl';
import { ROUTES } from '@/constants';
import { useAdminCapabilities } from '@/hooks';
import AdminGroupHub from '../_hub/AdminGroupHub';
/** «پشتیبانی» group root — the global ticket queue and the internal alert worklist. */
export default function AdminSupportPage() {
const t = useTranslations('hub');
const tn = useTranslations('nav');
const caps = useAdminCapabilities();
return (
<AdminGroupHub
title={tn('group_support')}
subtitle={t('admin_support_subtitle')}
consoles={[
{
title: tn('tickets'),
subtitle: t('admin_tickets_sub'),
icon: 'support',
path: ROUTES.ADMIN_TICKETS,
enabled: caps.canManageTickets,
},
{
title: tn('alerts'),
subtitle: t('admin_alerts_sub'),
icon: 'alerts',
path: ROUTES.ADMIN_ALERTS,
enabled: caps.canManageAlerts,
},
]}
/>
);
}
@@ -0,0 +1,95 @@
'use client';
import { Stack } from '@mui/material';
import { useTranslations } from 'next-intl';
import { ProfileSummary, SurfaceCard } from '@/components';
import { SettingsPanel, SignOutRow } from '@/components/settings';
import { ROUTES } from '@/constants';
import { useAdminCapabilities } from '@/hooks';
import { useMe } from '@/services/auth';
import AdminGroupHub from '../_hub/AdminGroupHub';
/**
* «سیستم» group root platform configuration plus the identity/appearance/sign-out block that
* used to live in the top bar and drawer footer. Always reachable (even for an admin role with no
* system console permitted), because it is the only way out of the app.
*/
export default function AdminSystemPage() {
const t = useTranslations('hub');
const tn = useTranslations('nav');
const ta = useTranslations('admin');
const caps = useAdminCapabilities();
const { data: me } = useMe();
const primaryRoleCode = caps.roles[0];
return (
<AdminGroupHub
title={tn('group_system')}
subtitle={t('admin_system_subtitle')}
consoles={[
{
title: tn('config'),
subtitle: t('admin_config_sub'),
icon: 'config',
path: ROUTES.ADMIN_CONFIG,
enabled: caps.canConfig,
},
{
title: tn('catalog'),
subtitle: t('admin_catalog_sub'),
icon: 'category',
path: ROUTES.ADMIN_CATALOG,
enabled: caps.canManageCatalog,
},
{
title: tn('holidays'),
subtitle: t('admin_holidays_sub'),
icon: 'calendar',
path: ROUTES.ADMIN_HOLIDAYS,
enabled: caps.canConfig,
},
{
title: tn('audit'),
subtitle: t('admin_audit_sub'),
icon: 'audit',
path: ROUTES.ADMIN_AUDIT,
enabled: caps.canViewAudit,
},
{
title: tn('partners'),
subtitle: t('admin_partners_sub'),
icon: 'partners',
path: ROUTES.ADMIN_PARTNERS,
enabled: caps.canManagePartners,
},
{
title: tn('users'),
subtitle: t('admin_users_sub'),
icon: 'users',
path: ROUTES.ADMIN_USERS,
enabled: caps.canManageRoles,
},
{
title: tn('roles'),
subtitle: t('admin_roles_sub'),
icon: 'roles',
path: ROUTES.ADMIN_ROLES,
enabled: caps.canManageRoles,
},
]}
>
<Stack sx={{ gap: 2 }}>
<SurfaceCard>
<ProfileSummary
displayName={me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : ''}
phone={me?.phone}
roleLabel={primaryRoleCode ? ta(`role_${primaryRoleCode}`) : undefined}
loading={!me}
/>
</SurfaceCard>
<SettingsPanel />
<SignOutRow />
</Stack>
</AdminGroupHub>
);
}
@@ -167,7 +167,7 @@ export default function AdminTicketThreadPage() {
<AdminEmptyState icon="support" title={t('ticket_empty')} />
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<PageHeader
title={t('ticket_thread_title', { ref: detail.referenceCode })}
subtitle={detail.subject ?? undefined}
@@ -202,7 +202,7 @@ export default function AdminTicketThreadPage() {
<Paper
id={REFUND_PANEL_ID}
elevation={0}
sx={{ mt: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
sx={{ mt: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
>
<RefundPanel bookingId={detail.bookingId as number} ticketId={detail.id} />
</Paper>
@@ -229,7 +229,7 @@ export default function AdminTicketThreadPage() {
p: 2,
border: '1px solid',
borderColor: isInternal ? 'var(--bal-warning)' : 'divider',
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
bgcolor: isInternal ? 'var(--bal-warning-soft)' : 'background.paper',
}}
>
@@ -112,7 +112,7 @@ function AdminTicketsQueue() {
<Stack
direction="row"
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
>
<TextField
select
@@ -0,0 +1,35 @@
'use client';
import { useTranslations } from 'next-intl';
import { ROUTES } from '@/constants';
import { useAdminCapabilities } from '@/hooks';
import AdminGroupHub from '../_hub/AdminGroupHub';
/** «اعتماد» group root — the verification queue and review moderation. */
export default function AdminTrustPage() {
const t = useTranslations('hub');
const tn = useTranslations('nav');
const caps = useAdminCapabilities();
return (
<AdminGroupHub
title={tn('group_trust')}
subtitle={t('admin_trust_subtitle')}
consoles={[
{
title: tn('verification'),
subtitle: t('admin_verification_sub'),
icon: 'verification',
path: ROUTES.ADMIN_VERIFICATION,
enabled: caps.canVerify,
},
{
title: tn('reviews'),
subtitle: t('admin_reviews_sub'),
icon: 'moderation',
path: ROUTES.ADMIN_REVIEWS,
enabled: caps.canModerate,
},
]}
/>
);
}
@@ -2,6 +2,7 @@
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { FormProvider, useForm } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import {
Box,
@@ -12,10 +13,9 @@ import {
DialogTitle,
Skeleton,
Stack,
TextField,
Typography,
} from '@mui/material';
import { AppButton, AppLoading, JalaliDateField, PageHeader, StatusChip } from '@/components';
import { AppButton, AppLoading, PageHeader, RhfJalaliDateField, RhfTextField, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import { AdminEmptyState, AdminErrorState, ConfirmDialog, DocumentViewer } from '@/components/admin';
import { ROUTES, adminVerificationCasePath } from '@/constants';
@@ -75,7 +75,7 @@ function AdminVerificationCaseScreen() {
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const { data, isLoading, isError, refetch } = useVerificationCase(
const { data, isLoading, isError, isFetching, refetch } = useVerificationCase(
Number.isFinite(nurseVerificationId) ? nurseVerificationId : null,
);
const approve = useApproveVerification();
@@ -182,7 +182,7 @@ function AdminVerificationCaseScreen() {
<AdminEmptyState icon="verified" title={t('ver_empty')} />
) : (
<>
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 2 }}>
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', p: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('ver_identity_name')}
</Typography>
@@ -199,6 +199,8 @@ function AdminVerificationCaseScreen() {
step={step}
nurseVerificationId={nurseVerificationId}
canVerify={caps.canVerify}
onReloadDocuments={refetch}
reloadingDocuments={isFetching}
/>
))}
</Stack>
@@ -207,7 +209,7 @@ function AdminVerificationCaseScreen() {
<Stack sx={{ gap: 1.5 }}>
<Typography variant="h6">{t('ver_credentials_title')}</Typography>
{data.credentials.map((cred) => (
<Box key={cred.id} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1.75 }}>
<Box key={cred.id} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', p: 1.75 }}>
<Stack
direction="row"
sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}
@@ -290,10 +292,14 @@ function StepCard({
step,
nurseVerificationId,
canVerify,
onReloadDocuments,
reloadingDocuments,
}: {
step: AdminVerificationStepDetail;
nurseVerificationId: number;
canVerify: boolean;
onReloadDocuments: () => void;
reloadingDocuments: boolean;
}) {
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
@@ -325,7 +331,7 @@ function StepCard({
};
return (
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 2 }}>
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', p: 2 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, flexGrow: 1 }}>
{t(`step_${step.code}`)}
@@ -339,7 +345,12 @@ function StepCard({
{step.documents.length > 0 ? (
<Stack sx={{ gap: 1.5, mt: 1.5 }}>
{step.documents.map((doc) => (
<DocumentViewer key={doc.id} document={doc} />
<DocumentViewer
key={doc.id}
document={doc}
onReload={onReloadDocuments}
reloading={reloadingDocuments}
/>
))}
</Stack>
) : isManual ? (
@@ -398,6 +409,14 @@ function StepCard({
/** The structured credential form recorded on approving a credential-bearing step. `criminal_record`
* requires an expiry date; `credentialNumber` is accepted as input and never echoed back. */
interface CredentialDecisionValues {
credentialNumber: string;
holderName: string;
issuingAuthority: string;
issuedAt: string;
expiresAt: string;
}
function CredentialDialog({
step,
nurseVerificationId,
@@ -410,28 +429,26 @@ function CredentialDialog({
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const decide = useDecideStep();
const [credentialNumber, setCredentialNumber] = useState('');
const [holderName, setHolderName] = useState('');
const [issuingAuthority, setIssuingAuthority] = useState('');
const [issuedAt, setIssuedAt] = useState('');
const [expiresAt, setExpiresAt] = useState('');
const expiryRequired = step.code === 'criminal_record';
const expiryMissing = expiryRequired && expiresAt.trim().length === 0;
const form = useForm<CredentialDecisionValues>({
mode: 'onTouched',
defaultValues: { credentialNumber: '', holderName: '', issuingAuthority: '', issuedAt: '', expiresAt: '' },
});
const { handleSubmit, formState } = form;
const onSubmit = () => {
if (expiryMissing) return;
const onSubmit = (values: CredentialDecisionValues) =>
decide.mutate(
{
stepId: step.id,
nurseVerificationId,
input: {
approve: true,
credentialNumber: credentialNumber.trim() || undefined,
holderName: holderName.trim() || undefined,
issuingAuthority: issuingAuthority.trim() || undefined,
issuedAt: issuedAt || undefined,
expiresAt: expiresAt || undefined,
credentialNumber: values.credentialNumber.trim() || undefined,
holderName: values.holderName.trim() || undefined,
issuingAuthority: values.issuingAuthority.trim() || undefined,
issuedAt: values.issuedAt || undefined,
expiresAt: values.expiresAt || undefined,
},
},
{
@@ -441,42 +458,41 @@ function CredentialDialog({
},
},
);
};
return (
<Dialog open onClose={decide.isPending ? undefined : onClose} fullWidth maxWidth="sm">
<DialogTitle sx={{ fontWeight: 800 }}>{t('ver_credential_title')}</DialogTitle>
<FormProvider {...form}>
<DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}>
<TextField
{/* A real <form> so Enter submits from any field; the footer button (outside DialogContent)
calls the same handler directly rather than relying on cross-element form association. */}
<Stack component="form" noValidate onSubmit={handleSubmit(onSubmit)} sx={{ gap: 2, mt: 1 }}>
<RhfTextField<CredentialDecisionValues>
name="credentialNumber"
fullWidth
autoFocus
label={t('ver_credential_number')}
value={credentialNumber}
onChange={(e) => setCredentialNumber(e.target.value)}
/>
<TextField
<RhfTextField<CredentialDecisionValues>
name="holderName"
fullWidth
label={t('ver_holder_name')}
helperText={t('ver_holder_hint')}
value={holderName}
onChange={(e) => setHolderName(e.target.value)}
/>
<TextField
<RhfTextField<CredentialDecisionValues>
name="issuingAuthority"
fullWidth
label={t('ver_issuing_authority')}
value={issuingAuthority}
onChange={(e) => setIssuingAuthority(e.target.value)}
/>
<JalaliDateField fullWidth label={t('ver_issued_at')} value={issuedAt || null} onChange={setIssuedAt} />
<JalaliDateField
<RhfJalaliDateField<CredentialDecisionValues> name="issuedAt" fullWidth label={t('ver_issued_at')} />
<RhfJalaliDateField<CredentialDecisionValues>
name="expiresAt"
fullWidth
label={t('ver_expires_at')}
value={expiresAt || null}
onChange={setExpiresAt}
required={expiryRequired}
error={expiryMissing}
helperText={expiryMissing ? t('ver_expiry_required') : undefined}
rules={{
validate: (value) => !expiryRequired || String(value ?? '').trim().length > 0 || t('ver_expiry_required'),
}}
/>
</Stack>
</DialogContent>
@@ -487,12 +503,13 @@ function CredentialDialog({
<AppButton
variant="contained"
color="primary"
onClick={onSubmit}
disabled={decide.isPending || expiryMissing}
onClick={handleSubmit(onSubmit)}
disabled={decide.isPending || !formState.isValid}
>
{decide.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</FormProvider>
</Dialog>
);
}
@@ -174,7 +174,7 @@ function AdminVerificationQueueScreen() {
<Stack
direction="row"
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
>
<TextField
size="small"
@@ -1,96 +1,103 @@
'use client';
import { ReactNode } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Avatar, Box, Skeleton, Stack, Typography } from '@mui/material';
import { Box, Skeleton, Stack, Typography } from '@mui/material';
import {
AccentCard,
AppButton,
AppIcon,
AppLink,
CountdownTimer,
EmptyState,
ErrorState,
Money,
PageHeader,
SurfaceCard,
TrustBadge,
} from '@/components';
import { ROUTES } from '@/constants';
import { formatRelativeTime, formatShamsiDate, localeTag, parseIrr } from '@/utils';
import { useMe } from '@/services/auth';
import { useNurseRequestInbox } from '@/services/bookingRequests';
import { useTodaySessions } from '@/services/bookings';
import { useNurseEarningsBalance } from '@/services/payouts';
import { useUnreadCount } from '@/services/notifications';
import { useVerificationStatus } from '@/services/verification';
import { ownBadgeState } from '@/services/verification/types';
import { coarseResponseLabel } from '@/services/bookingRequests/format';
import DashboardActivationSlot from './DashboardActivationSlot';
const DASHBOARD_MAX_WIDTH = 960;
/** The pill's urgency tiers (ui-phase-7 §3.4): teal >2h · amber <2h · terracotta <30min. */
const URGENT_THRESHOLD_SECONDS = 30 * 60;
const WARN_THRESHOLD_SECONDS = 2 * 60 * 60;
/**
* The nurse "امروز" dashboard (ui-phase-7 §3.1) the operational home replacing the `PlaceholderScreen`.
* Pure assembly: every widget reads an already-cached query. Order matters the pending-requests strip
* is the most time-critical thing a nurse can miss, so it sits above the earnings snapshot.
* The nurse «امروز» home the first bottom-nav destination.
*
* Rebuilt for the phone-width frame. The previous version stacked five same-weight cards, each
* repeating its own icon + bold heading + inline "see all" button; at 480px the buttons wrapped
* mid-word, the countdown collided with the request title, and nothing on the screen looked more
* important than anything else. This version gives the page one visual hierarchy: exactly one hero
* action (the next visit), then sections introduced by a plain label with a text link instead of a
* competing button.
*
* The greeting/identity strip that used to sit above all of it is gone: it spent the most valuable
* row on the screen restating the signed-in name to the person who typed the phone number, and the
* badge beside it duplicated the activation tracker further down. Identity now lives in the shell's
* top bar (`NurseAccountButton`), where it costs no content height and opens the account hub.
*
* Composition only every widget reads a query that is already cached elsewhere in the shell, and
* order encodes urgency: a missed request expires, an unread earnings figure does not.
*/
export default function NurseDashboardScreen() {
const t = useTranslations('dashboard');
const { data: me, isLoading: meLoading } = useMe();
const verification = useVerificationStatus();
const displayName = me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : '';
return (
<Stack sx={{ gap: 3, maxWidth: DASHBOARD_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
{meLoading ? (
<>
<Skeleton variant="circular" width={44} height={44} />
<Skeleton variant="text" width={160} height={32} />
</>
) : (
<>
<Avatar sx={{ width: 44, height: 44, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}>
{(displayName || '؟').charAt(0)}
</Avatar>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="h6" component="h1" sx={{ fontWeight: 700 }}>
{t('greeting', { name: displayName })}
</Typography>
{!verification.isLoading ? <TrustBadge state={ownBadgeState(verification.data)} /> : null}
</Stack>
</>
)}
</Stack>
<Stack sx={{ gap: 2.5 }}>
{/* Load-bearing now that the bottom nav is icon-only: this is the only place the current
section is named, and the page's only h1. */}
<PageHeader title={t('title')} />
<NextVisitCard />
<RequestsStrip />
<EarningsSnapshotCard />
<RequestsSection />
<EarningsSection />
<DashboardActivationSlot />
<NotificationsEntryRow />
</Stack>
);
}
/** First actionable session from `useTodaySessions` + a display-only "time until" line. */
/**
* A section label + an optional text link. Deliberately not a button: on a 480px row a
* `<Button>` labelled «مشاهده همه» wrapped to two lines and outweighed the section it introduced.
*/
function SectionHeader({ title, actionLabel, actionTo }: { title: string; actionLabel?: string; actionTo?: string }) {
const locale = useLocale();
return (
<Stack direction="row" sx={{ alignItems: 'baseline', justifyContent: 'space-between', gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{actionLabel && actionTo ? (
<AppLink to={`/${locale}${actionTo}`} variant="caption" color="primary" sx={{ flexShrink: 0 }}>
{actionLabel}
</AppLink>
) : null}
</Stack>
);
}
/** The page's one hero action: the next actionable session, with a full-width primary CTA. */
function NextVisitCard() {
const t = useTranslations('dashboard');
const locale = useLocale();
const router = useRouter();
const { data, isLoading, isError, refetch } = useTodaySessions();
if (isLoading) return <Skeleton variant="rounded" height={140} />;
if (isLoading) return <Skeleton variant="rounded" height={150} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
if (isError) {
return <ErrorState message={t('next_visit_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
}
const items = data?.items ?? [];
const next = items.find((item) => item.status === 'scheduled' || item.status === 'in_progress');
const next = (data?.items ?? []).find((item) => item.status === 'scheduled' || item.status === 'in_progress');
if (!next) {
return <EmptyState icon="visits" title={t('next_visit_empty')} />;
return (
<Stack sx={{ gap: 1 }}>
<SectionHeader title={t('next_visit_title')} />
<EmptyState icon="visits" title={t('next_visit_empty')} />
</Stack>
);
}
const timeFmt = new Intl.DateTimeFormat(localeTag(locale), { hour: '2-digit', minute: '2-digit' });
@@ -98,48 +105,41 @@ function NextVisitCard() {
const timeUntil = formatRelativeTime(`${next.scheduledDate}T${next.scheduledTimeStart}`, locale, formatShamsiDate);
return (
<SurfaceCard data-widget="next-visit">
<Stack sx={{ gap: 1.25 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="visits" size={20} color="var(--bal-primary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
<AccentCard tone="secondary" data-widget="next-visit">
<Stack sx={{ gap: 1.5 }}>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="overline" sx={{ color: 'text.secondary' }}>
{t('next_visit_title')}
</Typography>
</Stack>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>
{next.patientName}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
<Typography component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>
<MetaLine
items={[
<Box key="range" component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>
{timeRangeLabel}
</Typography>
{timeUntil ? ` · ${t('next_visit_starts_in', { relative: timeUntil })}` : ''}
</Typography>
</Box>,
timeUntil ? t('next_visit_starts_in', { relative: timeUntil }) : null,
]}
/>
</Stack>
<AppButton
variant="contained"
color="secondary"
startIcon="check_in"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_VISITS}`)}
sx={{ alignSelf: 'flex-start' }}
>
<AppButton variant="contained" color="secondary" startIcon="check_in" fullWidth to={`/${locale}${ROUTES.NURSE_VISITS}`}>
{t('next_visit_cta')}
</AppButton>
</Stack>
</SurfaceCard>
</AccentCard>
);
}
/** The most time-critical widget: pending-request count + the most urgent countdown, inline into detail. */
function RequestsStrip() {
/** The most time-critical section: a pending request expires on its own if it isn't answered. */
function RequestsSection() {
const t = useTranslations('dashboard');
const tb = useTranslations('booking');
const locale = useLocale();
const router = useRouter();
const { data, isLoading, isError, refetch } = useNurseRequestInbox();
if (isLoading) return <Skeleton variant="rounded" height={140} />;
if (isLoading) return <Skeleton variant="rounded" height={130} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
if (isError) {
return <ErrorState message={t('requests_strip_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
}
@@ -148,43 +148,38 @@ function RequestsStrip() {
const total = data?.total ?? 0;
if (items.length === 0) {
return <EmptyState icon="requests" title={t('requests_strip_empty')} />;
return (
<Stack sx={{ gap: 1 }}>
<SectionHeader title={t('requests_strip_title', { count: 0 })} />
<EmptyState icon="requests" title={t('requests_strip_empty')} />
</Stack>
);
}
const mostUrgent = items[0];
return (
<SurfaceCard data-widget="requests-strip">
<Stack sx={{ gap: 1.25 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="requests" size={20} color="var(--bal-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('requests_strip_title', { count: total })}
</Typography>
</Stack>
<AppButton
variant="text"
color="primary"
endIcon="requests"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}`)}
>
{t('requests_strip_cta')}
</AppButton>
</Stack>
<Stack sx={{ gap: 1 }} data-widget="requests-strip">
<SectionHeader
title={t('requests_strip_title', { count: total })}
actionLabel={t('requests_strip_cta')}
actionTo={ROUTES.NURSE_REQUESTS}
/>
<Stack
direction="row"
sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}
>
<SurfaceCard>
<Stack sx={{ gap: 1.5 }}>
{/* Name and countdown are siblings on one row, with the pill `flexShrink: 0` the old
layout let the countdown wrap under a long Persian name and collide with the date. */}
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
<Typography variant="body1" noWrap sx={{ fontWeight: 500 }}>
{mostUrgent.counterpartyName}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{formatShamsiDate(mostUrgent.requestedDate, locale)}
</Typography>
</Stack>
<Box sx={{ flexShrink: 0 }}>
<CountdownTimer
deadlineIso={mostUrgent.nurseResponseDeadlineAt}
elapsedText={tb('response_elapsed')}
@@ -193,31 +188,30 @@ function RequestsStrip() {
coarseLabel={(minutes) => coarseResponseLabel(minutes, tb)}
size="sm"
/>
</Box>
</Stack>
<AppButton
variant="outlined"
color="primary"
endIcon="requests"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}/${mostUrgent.id}`)}
sx={{ alignSelf: 'flex-start' }}
fullWidth
to={`/${locale}${ROUTES.NURSE_REQUESTS}/${mostUrgent.id}`}
>
{t('requests_strip_open')}
</AppButton>
</Stack>
</SurfaceCard>
</Stack>
);
}
/** A compact two-stat row (net payable + eligible) — never clamps a negative net balance. */
function EarningsSnapshotCard() {
/** Two stat tiles — the signed net balance is never clamped, an "owed back" reads as an error tone. */
function EarningsSection() {
const t = useTranslations('dashboard');
const tp = useTranslations('payouts');
const locale = useLocale();
const router = useRouter();
const { data, isLoading, isError, refetch } = useNurseEarningsBalance();
if (isLoading) return <Skeleton variant="rounded" height={120} />;
if (isLoading) return <Skeleton variant="rounded" height={110} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
if (isError) {
return <ErrorState message={t('earnings_snapshot_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
}
@@ -228,69 +222,48 @@ function EarningsSnapshotCard() {
const magnitude = isOwed ? -net : net;
return (
<SurfaceCard data-widget="earnings-snapshot">
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="earnings" size={20} color="var(--bal-primary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('earnings_snapshot_title')}
</Typography>
</Stack>
<AppButton
variant="text"
color="primary"
endIcon="earnings"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_EARNINGS}`)}
>
{t('earnings_snapshot_cta')}
</AppButton>
</Stack>
<Stack sx={{ gap: 1 }} data-widget="earnings-snapshot">
<SectionHeader
title={t('earnings_snapshot_title')}
actionLabel={t('earnings_snapshot_cta')}
actionTo={ROUTES.NURSE_EARNINGS}
/>
<Box sx={{ display: 'grid', gap: 1.5, gridTemplateColumns: '1fr 1fr' }}>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{isOwed ? tp('balance_owed_label') : tp('balance_net_label')}
</Typography>
<Money amountIrr={String(magnitude)} size="lg" tone={isOwed ? 'error' : 'emphasis'} sx={{ fontWeight: 800 }} />
</Stack>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{tp('bucket_eligible')}
</Typography>
<Money amountIrr={data.eligibleTotalIrr} size="lg" sx={{ fontWeight: 800 }} />
</Stack>
<StatTile
label={isOwed ? tp('balance_owed_label') : tp('balance_net_label')}
value={<Money amountIrr={String(magnitude)} size="lg" tone={isOwed ? 'error' : 'emphasis'} />}
/>
<StatTile label={tp('bucket_eligible')} value={<Money amountIrr={data.eligibleTotalIrr} size="lg" />} />
</Box>
</Stack>
</SurfaceCard>
);
}
/** The unread-count entry row — the bell in the shell chrome is Phase 2's; this is a dashboard shortcut. */
function NotificationsEntryRow() {
const t = useTranslations('dashboard');
const locale = useLocale();
const unread = useUnreadCount();
function StatTile({ label, value }: { label: string; value: ReactNode }) {
return (
<AppLink to={`/${locale}${ROUTES.NURSE_NOTIFICATIONS}`} color="inherit" underline="none" sx={{ display: 'block' }}>
<SurfaceCard data-widget="notifications-entry">
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="notifications" size={20} color="var(--bal-primary)" />
<Typography variant="body1" sx={{ fontWeight: 500 }}>
{t('notifications_entry_title')}
</Typography>
</Stack>
<Typography
variant="body2"
// --bal-secondary-dark, not --bal-secondary: the plain terracotta fails AA contrast for
// small text on a light surface (frontend-designer skill §2) — this is body copy, not an icon.
sx={{ color: unread > 0 ? 'var(--bal-secondary-dark)' : 'text.secondary', fontWeight: unread > 0 ? 700 : 400 }}
>
{unread > 0 ? t('notifications_entry_unread', { count: unread }) : t('notifications_entry_empty')}
<SurfaceCard padding="sm">
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="caption" noWrap sx={{ color: 'text.secondary' }}>
{label}
</Typography>
{value}
</Stack>
</SurfaceCard>
</AppLink>
);
}
/** Dot-separated secondary facts on one line, skipping the ones that aren't available. */
function MetaLine({ items }: { items: Array<ReactNode> }) {
const present = items.filter(Boolean);
return (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{present.map((item, index) => (
<Box key={index} component="span">
{index > 0 ? ' · ' : null}
{item}
</Box>
))}
</Typography>
);
}
@@ -1,13 +1,19 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { FormProvider, useForm } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import { Box, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppLoading, BankStatusPanel, EmptyState, ErrorState } from '@/components';
import { Box, Stack, Typography } from '@mui/material';
import { AppButton, AppLoading, BankStatusPanel, EmptyState, ErrorState, RhfTextField } from '@/components';
import { useNurseBankAccounts, useAddNurseBankAccount, useSetPrimaryBankAccount } from '@/services/nurse';
import { isValidSheba } from '@/services/nurse/iban';
import { deriveBankStatus } from '@/services/nurse/types';
interface BankFormValues {
iban: string;
holder: string;
}
/**
* Nurse payout bank settings an **accounts section**, not a one-shot form (ui-phase-8 §3.7): submit
* an IBAN (شبا) + account-holder name, then watch the ownership inquiry resolve through its three
@@ -26,28 +32,19 @@ export default function NurseBankPage() {
const addAccount = useAddNurseBankAccount();
const setPrimary = useSetPrimaryBankAccount();
const [iban, setIban] = useState('');
const [holder, setHolder] = useState('');
const [ibanError, setIbanError] = useState(false);
const [holderError, setHolderError] = useState(false);
const [showForm, setShowForm] = useState(false);
const form = useForm<BankFormValues>({ mode: 'onTouched', defaultValues: { iban: '', holder: '' } });
const { handleSubmit, reset } = form;
const accounts = data ?? [];
const showFormNow = !isLoading && !isError && (accounts.length === 0 || showForm);
const submit = () => {
const ibanInvalid = !isValidSheba(iban);
const holderInvalid = holder.trim().length === 0;
setIbanError(ibanInvalid);
setHolderError(holderInvalid);
if (ibanInvalid || holderInvalid) return;
const submit = (values: BankFormValues) => {
addAccount.mutate(
{ iban, accountHolderName: holder.trim() },
{ iban: values.iban, accountHolderName: values.holder.trim() },
{
onSuccess: () => {
setIban('');
setHolder('');
reset();
setShowForm(false);
enqueueSnackbar(t('added'), { variant: 'success' });
},
@@ -131,32 +128,26 @@ export default function NurseBankPage() {
) : null}
{showFormNow ? (
<Stack sx={{ gap: 2 }}>
<TextField
<FormProvider {...form}>
<Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 2 }}>
<RhfTextField<BankFormValues>
name="iban"
label={t('iban_label')}
value={iban}
onChange={(e) => {
setIban(e.target.value.toUpperCase());
if (ibanError) setIbanError(false);
}}
error={ibanError}
helperText={ibanError ? t('iban_invalid') : t('iban_hint')}
helperText={t('iban_hint')}
transform={(raw) => raw.toUpperCase()}
rules={{ validate: (value) => isValidSheba(String(value ?? '')) || t('iban_invalid') }}
slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start', letterSpacing: 1 } } }}
fullWidth
/>
<TextField
<RhfTextField<BankFormValues>
name="holder"
label={t('holder_label')}
value={holder}
onChange={(e) => {
setHolder(e.target.value);
if (holderError) setHolderError(false);
}}
error={holderError}
helperText={holderError ? t('holder_required') : t('holder_hint')}
helperText={t('holder_hint')}
rules={{ validate: (value) => String(value ?? '').trim().length > 0 || t('holder_required') }}
fullWidth
/>
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton color="primary" variant="contained" startIcon="bank" onClick={submit} disabled={addAccount.isPending}>
<AppButton type="submit" color="primary" variant="contained" startIcon="bank" disabled={addAccount.isPending}>
{addAccount.isPending ? t('submitting') : t('submit')}
</AppButton>
{accounts.length > 0 ? (
@@ -164,10 +155,7 @@ export default function NurseBankPage() {
variant="text"
onClick={() => {
setShowForm(false);
setIban('');
setHolder('');
setIbanError(false);
setHolderError(false);
reset();
}}
disabled={addAccount.isPending}
>
@@ -176,6 +164,7 @@ export default function NurseBankPage() {
) : null}
</Stack>
</Stack>
</FormProvider>
) : null}
</Box>
);
@@ -132,7 +132,7 @@ export default function NurseCoveragePage() {
elevation={0}
sx={{
p: 2,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
@@ -153,7 +153,7 @@ export default function NurseCoveragePage() {
</Paper>
)}
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('add_title')}
@@ -157,7 +157,7 @@ function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void
elevation={0}
sx={{
p: 2,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
borderInlineStart: '3px solid',
@@ -168,7 +168,7 @@ function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void
onClick={onToggle}
aria-expanded={open}
aria-controls={EXPLAINER_CONTENT_ID}
sx={{ width: '100%', justifyContent: 'space-between', gap: 1, borderRadius: 1 }}
sx={{ width: '100%', justifyContent: 'space-between', gap: 1, borderRadius: 'var(--bal-radius-sm)' }}
>
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
<AppIcon icon="info" size={18} color="var(--bal-info)" />
@@ -66,14 +66,14 @@ export default function NursePayoutDetailPage() {
<Skeleton variant="rounded" height={200} />
</Stack>
) : isError || !data ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('detail_not_found')}
</Typography>
</Paper>
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
@@ -112,7 +112,7 @@ export default function NursePayoutDetailPage() {
<Box
sx={{
p: 1.75,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
borderInlineStart: '3px solid',
@@ -174,7 +174,7 @@ export default function NursePayoutDetailPage() {
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('detail_bookings_hint')}
</Typography>
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}>
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', overflow: 'hidden' }}>
<Stack divider={<Divider />}>
{data.bookings.map((link) => (
<Stack
@@ -0,0 +1,83 @@
'use client';
import { Skeleton, Stack, Typography } from '@mui/material';
import { useTranslations } from 'next-intl';
import { AccentCard, ErrorState, Money, NavHubList, PageHeader } from '@/components';
import type { NavHubItem } from '@/components';
import { ROUTES } from '@/constants';
import { parseIrr } from '@/utils';
import { useNurseEarningsBalance } from '@/services/payouts';
/**
* «مالی» group root. The one number a nurse opens this tab for the signed net payable balance
* is answered before any navigation, then the three money screens are one tap away. The balance is
* **signed**: an outstanding clawback can exceed accrued earnings, and clamping it to zero would
* quietly tell a nurse they are owed nothing when they in fact owe money back.
*/
export default function NurseFinanceScreen() {
const t = useTranslations('hub');
const tn = useTranslations('nav');
const tp = useTranslations('payouts');
const { data, isLoading, isError, refetch } = useNurseEarningsBalance();
const items: Array<NavHubItem> = [
{
title: tn('earnings'),
subtitle: t('finance_earnings_sub'),
icon: 'earnings',
path: ROUTES.NURSE_EARNINGS,
},
{
title: tn('payouts'),
subtitle: t('finance_payouts_sub'),
icon: 'history',
path: ROUTES.NURSE_EARNINGS_PAYOUTS,
},
{
title: tn('bank'),
subtitle: t('finance_bank_sub'),
icon: 'bank',
path: ROUTES.NURSE_BANK,
},
];
return (
<Stack sx={{ gap: 2 }}>
<PageHeader title={tn('group_finance')} subtitle={t('finance_subtitle')} />
<BalanceSummary data={data} isLoading={isLoading} isError={isError} onRetry={() => refetch()} tp={tp} t={t} />
<NavHubList items={items} />
</Stack>
);
}
interface BalanceSummaryProps {
data: ReturnType<typeof useNurseEarningsBalance>['data'];
isLoading: boolean;
isError: boolean;
onRetry: () => void;
tp: ReturnType<typeof useTranslations<'payouts'>>;
t: ReturnType<typeof useTranslations<'hub'>>;
}
function BalanceSummary({ data, isLoading, isError, onRetry, tp, t }: BalanceSummaryProps) {
if (isLoading) return <Skeleton variant="rounded" height={104} />;
if (isError) return <ErrorState message={t('finance_balance_error')} retryLabel={t('retry')} onRetry={onRetry} />;
if (!data) return null;
const net = parseIrr(data.netPayableBalanceIrr);
const isOwed = net < BigInt(0);
const magnitude = isOwed ? -net : net;
return (
<AccentCard tone={isOwed ? 'error' : 'primary'}>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{isOwed ? tp('balance_owed_label') : tp('balance_net_label')}
</Typography>
<Money amountIrr={String(magnitude)} size="xl" tone={isOwed ? 'error' : 'emphasis'} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{tp('bucket_eligible')}: <Money amountIrr={data.eligibleTotalIrr} size="sm" component="span" />
</Typography>
</Stack>
</AccentCard>
);
}
@@ -0,0 +1,13 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import NurseFinanceScreen from './NurseFinanceScreen';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'nav' });
return { title: t('group_finance') };
}
export default function NurseFinancePage() {
return <NurseFinanceScreen />;
}
@@ -1,5 +1,5 @@
import SidebarShellSkeleton from '../_chrome/SidebarShellSkeleton';
import ShellContentSkeleton from '../_chrome/ShellContentSkeleton';
export default function Loading() {
return <SidebarShellSkeleton />;
return <ShellContentSkeleton />;
}
@@ -0,0 +1,73 @@
'use client';
import { Stack } from '@mui/material';
import { useTranslations } from 'next-intl';
import { NavHubList, PageHeader, ProfileSummary, SurfaceCard } from '@/components';
import type { NavHubItem } from '@/components';
import { SettingsPanel, SignOutRow } from '@/components/settings';
import { ROUTES } from '@/constants';
import { ActorSwitcher } from '@/layout';
import { useMe } from '@/services/auth';
import { useNurseProfile } from '@/services/profiles';
import { useUnreadCount } from '@/services/notifications';
import { useSupportUnreadTotal } from '@/services/tickets';
import { useVerificationStatus } from '@/services/verification';
import { ownBadgeState } from '@/services/verification/types';
/**
* «بیشتر» group root the fourth bottom-nav destination: who you are signed in as, the two
* conversation surfaces (support, notifications), appearance/language, and sign-out. Everything
* here used to live in the sidebar drawer's header and footer, where a preference toggle sat
* permanently next to primary navigation.
*/
export default function NurseMoreScreen() {
const t = useTranslations('hub');
const tn = useTranslations('nav');
const { data: me } = useMe();
const { data: nurseProfile } = useNurseProfile();
const { data: verification } = useVerificationStatus();
const supportUnreadTotal = useSupportUnreadTotal();
const notificationsUnread = useUnreadCount();
const displayName = me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : '';
const items: Array<NavHubItem> = [
{
title: tn('support'),
subtitle: t('more_support_sub'),
icon: 'support',
path: ROUTES.NURSE_SUPPORT_TICKETS,
badgeCount: supportUnreadTotal ?? undefined,
},
{
title: tn('notifications'),
subtitle: t('more_notifications_sub'),
icon: 'notifications',
path: ROUTES.NURSE_NOTIFICATIONS,
badgeCount: notificationsUnread || undefined,
},
];
return (
<Stack sx={{ gap: 2 }}>
<PageHeader title={t('more_title')} />
<SurfaceCard>
<Stack sx={{ gap: 1.5 }}>
<ProfileSummary
displayName={displayName}
phone={me?.phone}
avatarUrl={nurseProfile?.avatarUrl}
trustState={ownBadgeState(verification)}
loading={!me}
/>
<ActorSwitcher target="customer" />
</Stack>
</SurfaceCard>
<NavHubList items={items} />
<SettingsPanel />
<SignOutRow />
</Stack>
);
}
@@ -0,0 +1,13 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import NurseMoreScreen from './NurseMoreScreen';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'hub' });
return { title: t('more_title') };
}
export default function NurseMorePage() {
return <NurseMoreScreen />;
}
@@ -0,0 +1,98 @@
'use client';
import { Stack, Typography } from '@mui/material';
import { useLocale, useTranslations } from 'next-intl';
import { AccentCard, AppLink, NavHubList, PageHeader, StatusChip, TrustBadge } from '@/components';
import type { NavHubItem } from '@/components';
import { ROUTES } from '@/constants';
import { useMyVariants } from '@/services/catalog';
import { useServiceAreas } from '@/services/serviceAreas';
import { useNurseProfile } from '@/services/profiles';
import { useVerificationStatus } from '@/services/verification';
import { ownBadgeState } from '@/services/verification/types';
/**
* «حرفهٔ من» group root the page the bottom-nav tab lands on. It answers the question the
* sidebar section never could ("is my listing actually live, and what's missing?") before handing
* off to the four screens that fix it. Every number here is read off a query that already answers
* it; nothing is derived optimistically, and a count is simply omitted until its query resolves.
*/
export default function NursePracticeScreen() {
const t = useTranslations('hub');
const locale = useLocale();
const tn = useTranslations('nav');
const { data: verification } = useVerificationStatus();
const { data: profile } = useNurseProfile();
const { data: variants } = useMyVariants();
const { data: areas } = useServiceAreas();
const activeVariants = variants?.items.filter((variant) => variant.isActive).length;
const isAccepting = profile?.isAcceptingBookings;
const items: Array<NavHubItem> = [
{
title: tn('profile'),
subtitle: t('practice_profile_sub'),
icon: 'account',
path: ROUTES.NURSE_PROFILE,
},
{
title: tn('services'),
subtitle: t('practice_services_sub'),
icon: 'services',
path: ROUTES.NURSE_SERVICES,
meta: activeVariants === undefined ? undefined : <MetaCount value={activeVariants} />,
},
{
title: tn('coverage'),
subtitle: t('practice_coverage_sub'),
icon: 'coverage',
path: ROUTES.NURSE_COVERAGE,
meta: areas === undefined ? undefined : <MetaCount value={areas.total} />,
},
{
title: tn('verification'),
subtitle: t('practice_verification_sub'),
icon: 'verification',
path: ROUTES.NURSE_VERIFICATION,
},
];
return (
<Stack sx={{ gap: 2 }}>
<PageHeader title={tn('group_profession')} subtitle={t('practice_subtitle')} />
<AccentCard tone={isAccepting ? 'success' : 'warning'}>
<Stack sx={{ gap: 1.25 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('practice_status_title')}
</Typography>
<TrustBadge state={ownBadgeState(verification)} />
</Stack>
{isAccepting === undefined ? null : (
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<StatusChip
status={isAccepting ? 'active' : 'neutral'}
label={isAccepting ? t('practice_accepting_on') : t('practice_accepting_off')}
/>
<AppLink to={`/${locale}${ROUTES.NURSE_SERVICES}`} variant="caption">
{t('practice_accepting_manage')}
</AppLink>
</Stack>
)}
</Stack>
</AccentCard>
<NavHubList items={items} />
</Stack>
);
}
/** A count read straight off a resolved query — never a placeholder while one is in flight. */
function MetaCount({ value }: { value: number }) {
return (
<Typography variant="body2" sx={{ color: 'text.secondary', fontVariantNumeric: 'tabular-nums' }}>
{value}
</Typography>
);
}
@@ -0,0 +1,13 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import NursePracticeScreen from './NursePracticeScreen';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'nav' });
return { title: t('group_profession') };
}
export default function NursePracticePage() {
return <NursePracticeScreen />;
}
@@ -1,20 +1,45 @@
'use client';
import { ChangeEvent, FunctionComponent, useEffect, useRef, useState } from 'react';
import { ChangeEvent, FunctionComponent, useEffect, useRef } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useForm, FormProvider, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import { Avatar, Box, Chip, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, TrustBadge } from '@/components';
import { Avatar, Box, MenuItem, Stack, Typography } from '@mui/material';
import {
AccentCard,
AppButton,
AppIcon,
AppLoading,
FormSection,
PageHeader,
RhfChipSelect,
RhfTextField,
TrustBadge,
} from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants';
import { digitsOnly } from '@/utils';
import { useNurseProfile, useUpsertNurseProfile, useUploadAvatar } from '@/services/profiles';
import type { NurseProfile } from '@/services/profiles/types';
import { useVerificationStatus } from '@/services/verification';
import { ownBadgeState, SPECIALTY_PRESETS } from '@/services/verification/types';
const MAX_YEARS = 80;
const MAX_YEARS_DIGITS = 2;
const OTHER_CODE = '__other';
const EDUCATION_LEVELS = ['diploma', 'associate', 'bachelor', 'master', 'doctorate'] as const;
const EDUCATION_FIELDS = ['nursing', 'midwifery', 'anesthesia', 'operating_room', 'public_health'] as const;
interface ProfileFormValues {
avatarUrl: string | null;
bio: string;
years: string;
educationLevel: string;
educationLevelOther: string;
educationField: string;
educationFieldOther: string;
specializations: string[];
}
function parseSpecializations(json: string): string[] {
try {
const parsed = JSON.parse(json);
@@ -24,13 +49,36 @@ function parseSpecializations(json: string): string[] {
}
}
/** Nurse profile bootstrap (B7 header): avatar + bio + years + qualifications. */
/**
* Splits a stored free-text value into the (select code, "other" free-text) pair the form edits: a
* value the preset list knows becomes the code, anything else becomes «سایر» + the raw text.
*/
function splitPreset(stored: string, presets: readonly string[]): { code: string; other: string } {
if (presets.includes(stored)) return { code: stored, other: '' };
return stored ? { code: OTHER_CODE, other: stored } : { code: '', other: '' };
}
/** Nurse profile bootstrap (B7 header): avatar + bio + experience + qualifications. */
export default function NurseProfilePage() {
const { data: profile, isLoading } = useNurseProfile();
if (isLoading) return <AppLoading />;
return <NurseProfileForm initial={profile ?? null} />;
}
/**
* The nurse's public-facing profile, rebuilt around three named questions instead of one undivided
* column of inputs.
*
* What it replaced: a flat stack of avatar bio years two selects two conditional "other"
* fields a chip row a save button, with no headings and no statement of what any of it was for.
* The nurse could not tell which fields families actually see, which were required, or how much was
* left and every keystroke re-rendered the trust badge, the verification banner and the uploader
* along with the field being typed into, because each input owned a `useState` at page level.
*
* Now: `FormSection` groups the fields into معرفی / تجربه و تحصیلات / تخصصها, each saying who the
* answer is for; react-hook-form owns the values so a keystroke re-renders one field; and validation
* lives on the field it governs rather than in a hand-rolled block at the top of the submit handler.
*/
const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({ initial }) => {
const t = useTranslations('nurseProfile');
const tv = useTranslations('verification');
@@ -43,28 +91,29 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
const badgeState = ownBadgeState(verificationStatus);
const fileInputRef = useRef<HTMLInputElement>(null);
const [avatarUrl, setAvatarUrl] = useState<string | null>(initial?.avatarUrl ?? null);
const [bio, setBio] = useState(initial?.bio ?? '');
const [years, setYears] = useState(initial ? String(initial.yearsOfExperience) : '');
const [yearsError, setYearsError] = useState(false);
const level = splitPreset(initial?.educationLevel ?? '', EDUCATION_LEVELS);
const field = splitPreset(initial?.educationField ?? '', EDUCATION_FIELDS);
const initialLevel = initial?.educationLevel ?? '';
const initialField = initial?.educationField ?? '';
const [educationLevel, setEducationLevel] = useState(
(EDUCATION_LEVELS as readonly string[]).includes(initialLevel) ? initialLevel : initialLevel ? OTHER_CODE : '',
);
const [educationLevelOther, setEducationLevelOther] = useState(
(EDUCATION_LEVELS as readonly string[]).includes(initialLevel) ? '' : initialLevel,
);
const [educationField, setEducationField] = useState(
(EDUCATION_FIELDS as readonly string[]).includes(initialField) ? initialField : initialField ? OTHER_CODE : '',
);
const [educationFieldOther, setEducationFieldOther] = useState(
(EDUCATION_FIELDS as readonly string[]).includes(initialField) ? '' : initialField,
);
const [specializations, setSpecializations] = useState<string[]>(
parseSpecializations(initial?.specializationsJson ?? '[]'),
);
const form = useForm<ProfileFormValues>({
mode: 'onTouched',
defaultValues: {
avatarUrl: initial?.avatarUrl ?? null,
bio: initial?.bio ?? '',
years: initial ? String(initial.yearsOfExperience) : '',
educationLevel: level.code,
educationLevelOther: level.other,
educationField: field.code,
educationFieldOther: field.other,
specializations: parseSpecializations(initial?.specializationsJson ?? '[]'),
},
});
const { control, handleSubmit, setValue, getValues } = form;
// Only these three drive conditional rendering, so they are the only fields worth subscribing the
// page to — the rest re-render nothing but themselves.
const avatarUrl = useWatch({ control, name: 'avatarUrl' });
const educationLevel = useWatch({ control, name: 'educationLevel' });
const educationField = useWatch({ control, name: 'educationField' });
// A staged-but-unsaved avatar must never be silently discarded — warn on reload/tab-close.
const avatarDirty = avatarUrl !== (initial?.avatarUrl ?? null);
@@ -79,100 +128,84 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
return () => window.removeEventListener('beforeunload', handler);
}, [avatarDirty]);
const pickFile = () => fileInputRef.current?.click();
const onFileSelected = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
uploadAvatar.mutate(file, {
onSuccess: (result) => setAvatarUrl(result.url),
onSuccess: (result) => setValue('avatarUrl', result.url, { shouldDirty: true }),
onError: () => enqueueSnackbar(t('avatar_upload_error'), { variant: 'error' }),
});
};
const toggleSpecialty = (value: string) =>
setSpecializations((prev) => (prev.includes(value) ? prev.filter((item) => item !== value) : [...prev, value]));
const handleSave = () => {
const trimmed = years.trim();
const yearsNum = trimmed === '' ? 0 : Number(trimmed);
const yearsInvalid = !Number.isInteger(yearsNum) || yearsNum < 0 || yearsNum > MAX_YEARS;
setYearsError(yearsInvalid);
if (yearsInvalid) return;
const resolvedLevel = educationLevel === OTHER_CODE ? educationLevelOther.trim() : educationLevel;
const resolvedField = educationField === OTHER_CODE ? educationFieldOther.trim() : educationField;
const save = (values: ProfileFormValues) => {
const resolvedLevel =
values.educationLevel === OTHER_CODE ? values.educationLevelOther.trim() : values.educationLevel;
const resolvedField =
values.educationField === OTHER_CODE ? values.educationFieldOther.trim() : values.educationField;
upsert.mutate(
{
bio: bio.trim(),
yearsOfExperience: yearsNum,
bio: values.bio.trim(),
yearsOfExperience: values.years.trim() === '' ? 0 : Number(values.years),
educationLevel: resolvedLevel,
educationField: resolvedField,
specializationsJson: JSON.stringify(specializations),
avatarUrl,
specializationsJson: JSON.stringify(values.specializations),
avatarUrl: values.avatarUrl,
},
{
onSuccess: () => enqueueSnackbar(t('saved'), { variant: 'success' }),
onSuccess: () => {
enqueueSnackbar(t('saved'), { variant: 'success' });
// Re-baseline so the beforeunload guard and `isDirty` stop reporting saved work as unsaved.
form.reset(getValues());
},
onError: () => enqueueSnackbar(t('save_error'), { variant: 'error' }),
},
);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Box>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="h5" component="h1">
{t('title')}
</Typography>
{/* The public trust signal on the nurse's own profile — the same badge f6 reuses in search. */}
<TrustBadge state={badgeState} />
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
{/* Blocked-until-verified banner — shown until the aggregate is approved (incl. the expired state). */}
{badgeState !== 'verified' ? (
<Paper
elevation={0}
sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider', borderInlineStartWidth: 4, borderInlineStartColor: 'var(--bal-warning)' }}
<FormProvider {...form}>
<Box
component="form"
noValidate
onSubmit={handleSubmit(save)}
sx={{ display: 'flex', flexDirection: 'column', gap: 2.5, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}
>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<AppIcon icon="warning" size={24} color="var(--bal-warning)" />
<Stack sx={{ gap: 1, flexGrow: 1 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
<PageHeader title={t('title')} subtitle={t('subtitle')} meta={<TrustBadge state={badgeState} />} />
{/* Blocked-until-verified nudge — shown until the aggregate is approved (incl. the expired state). */}
{badgeState !== 'verified' ? (
<AccentCard tone="warning" padding="sm">
<Stack sx={{ gap: 1 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="warning" size={20} color="var(--bal-warning)" />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('unverified_title')}
</Typography>
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('unverified_body')}
</Typography>
</Box>
<AppButton
color="primary"
variant="outlined"
variant="text"
endIcon="forward"
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
sx={{ alignSelf: 'flex-start' }}
>
{t('unverified_cta')}
</AppButton>
</Stack>
</Stack>
</Paper>
</AccentCard>
) : null}
<FormSection title={t('section_intro_title')} description={t('section_intro_description')} icon="account">
<Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}>
<Avatar src={avatarUrl ?? undefined} sx={{ width: 72, height: 72, bgcolor: 'var(--bal-primary-soft)' }}>
{avatarUrl ? null : <AppIcon icon="account" size={36} color="var(--bal-primary)" />}
<Avatar src={avatarUrl ?? undefined} sx={{ width: 64, height: 64, bgcolor: 'var(--bal-primary-soft)' }}>
{avatarUrl ? null : <AppIcon icon="account" size={32} color="var(--bal-primary)" />}
</Avatar>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('photo')}
</Typography>
<Stack sx={{ gap: 0.5, minWidth: 0 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('photo_hint')}
</Typography>
@@ -180,9 +213,9 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
variant="outlined"
color="primary"
startIcon="camera"
onClick={pickFile}
onClick={() => fileInputRef.current?.click()}
disabled={uploadAvatar.isPending}
sx={{ mt: 0.5, alignSelf: 'flex-start' }}
sx={{ alignSelf: 'flex-start' }}
>
{uploadAvatar.isPending ? t('uploading') : t('upload')}
</AppButton>
@@ -190,113 +223,104 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
</Stack>
</Stack>
<TextField
<RhfTextField<ProfileFormValues>
name="bio"
label={t('bio')}
value={bio}
onChange={(e) => setBio(e.target.value)}
helperText={t('bio_hint')}
multiline
minRows={3}
fullWidth
/>
</FormSection>
<TextField
<FormSection
title={t('section_experience_title')}
description={t('section_experience_description')}
icon="license"
>
<RhfTextField<ProfileFormValues>
name="years"
label={t('years')}
value={years}
onChange={(e) => {
setYears(e.target.value.replace(/\D/g, '').slice(0, 2));
if (yearsError) setYearsError(false);
transform={(raw) => digitsOnly(raw).slice(0, MAX_YEARS_DIGITS)}
rules={{
validate: (value) => {
const trimmed = String(value ?? '').trim();
if (trimmed === '') return true;
const parsed = Number(trimmed);
return (Number.isInteger(parsed) && parsed >= 0 && parsed <= MAX_YEARS) || t('years_invalid');
},
}}
error={yearsError}
helperText={yearsError ? t('years_invalid') : undefined}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }}
sx={{ maxWidth: 200 }}
/>
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField select label={t('education_level_label')} value={educationLevel} onChange={(e) => setEducationLevel(e.target.value)} fullWidth>
<RhfTextField<ProfileFormValues> name="educationLevel" select label={t('education_level_label')} fullWidth>
{EDUCATION_LEVELS.map((code) => (
<MenuItem key={code} value={code}>
{t(`education_level_${code}`)}
</MenuItem>
))}
<MenuItem value={OTHER_CODE}>{t('education_other')}</MenuItem>
</TextField>
<TextField select label={t('education_field_label')} value={educationField} onChange={(e) => setEducationField(e.target.value)} fullWidth>
</RhfTextField>
<RhfTextField<ProfileFormValues> name="educationField" select label={t('education_field_label')} fullWidth>
{EDUCATION_FIELDS.map((code) => (
<MenuItem key={code} value={code}>
{t(`education_field_${code}`)}
</MenuItem>
))}
<MenuItem value={OTHER_CODE}>{t('education_other')}</MenuItem>
</TextField>
</RhfTextField>
</Stack>
{educationLevel === OTHER_CODE ? (
<TextField
<RhfTextField<ProfileFormValues>
name="educationLevelOther"
label={t('education_level_other_label')}
value={educationLevelOther}
onChange={(e) => setEducationLevelOther(e.target.value)}
rules={{ required: t('education_other_required') }}
fullWidth
/>
) : null}
{educationField === OTHER_CODE ? (
<TextField
<RhfTextField<ProfileFormValues>
name="educationFieldOther"
label={t('education_field_other_label')}
value={educationFieldOther}
onChange={(e) => setEducationFieldOther(e.target.value)}
rules={{ required: t('education_other_required') }}
fullWidth
/>
) : null}
</FormSection>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('specializations_label')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{SPECIALTY_PRESETS.map((code) => {
const selected = specializations.includes(code);
return (
<Chip
key={code}
label={tv.has(`specialty_${code}`) ? tv(`specialty_${code}`) : code}
onClick={() => toggleSpecialty(code)}
variant={selected ? 'filled' : 'outlined'}
sx={{
fontWeight: 500,
backgroundColor: selected ? 'var(--bal-primary)' : 'transparent',
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
borderColor: 'var(--bal-primary)',
}}
<FormSection
title={t('specializations_label')}
description={t('section_specializations_description')}
icon="clinical"
optional
optionalLabel={tc('optional')}
>
<RhfChipSelect<ProfileFormValues>
name="specializations"
options={SPECIALTY_PRESETS.map((code) => ({
code,
label: tv.has(`specialty_${code}`) ? tv(`specialty_${code}`) : code,
}))}
allowCustomValues
/>
);
})}
</Stack>
</FormSection>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<AppButton type="submit" color="primary" variant="contained" disabled={upsert.isPending}>
{upsert.isPending ? tc('saving') : t('save')}
</AppButton>
<AppButton variant="text" color="primary" startIcon="account" to={`/${locale}${ROUTES.NURSE_PROFILE_PREVIEW}`}>
{t('preview_cta')}
</AppButton>
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('deferred_services')}
</Typography>
<AppButton
variant="text"
color="primary"
startIcon="account"
to={`/${locale}${ROUTES.NURSE_PROFILE_PREVIEW}`}
sx={{ alignSelf: 'flex-start' }}
>
{t('preview_cta')}
</AppButton>
<AppButton
color="primary"
variant="contained"
onClick={handleSave}
disabled={upsert.isPending}
sx={{ alignSelf: 'flex-start' }}
>
{upsert.isPending ? tc('saving') : t('save')}
</AppButton>
</Box>
</FormProvider>
);
};
@@ -61,7 +61,7 @@ export default function NurseRequestDetailPage() {
if (isError || !request) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto' }}>
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', maxWidth: CONTENT_MAX_WIDTH, mx: 'auto' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}>
{t('not_found_title')}
</Typography>
@@ -131,7 +131,7 @@ export default function NurseRequestDetailPage() {
<StatusChip status={statusKind(request.status)} label={t(`status_${request.status}`)} />
</Stack>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.5 }}>
<DetailRow caption={t('summary_patient')}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
@@ -171,7 +171,7 @@ export default function NurseRequestDetailPage() {
</Paper>
{/* Stage-1 clinical context — ONLY the family's notes, never a clinical/care field. */}
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('inbox_notes_label')}
@@ -225,7 +225,7 @@ export default function NurseRequestDetailPage() {
elevation={0}
sx={{
p: 2,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
@@ -105,7 +105,7 @@ const MyServicesList: FunctionComponent<MyServicesListProps> = ({ onAdd, onEdit
{isLoading ? (
<Stack sx={{ gap: 1.5 }}>
{[0, 1].map((key) => (
<Skeleton key={key} variant="rounded" height={150} sx={{ borderRadius: 2 }} />
<Skeleton key={key} variant="rounded" height={150} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
))}
</Stack>
) : isError ? (
@@ -22,7 +22,7 @@ const PublishGate: FunctionComponent = () => {
const state = useActivationChecklist();
const setAccepting = useSetAcceptingBookings();
if (state.isLoading) return <Skeleton variant="rounded" height={96} sx={{ borderRadius: 2 }} />;
if (state.isLoading) return <Skeleton variant="rounded" height={96} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
if (state.isError) return null; // ActivationChecklist (mounted alongside) already surfaces the error + retry.
const toggle = (accepting: boolean) => {
@@ -1,9 +1,24 @@
'use client';
import { FunctionComponent, useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import { Box, Chip, MenuItem, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AccentCard, AppButton, AppIcon, AppLoading, CategoryTile, ErrorState, StepperHeader, VariantCard } from '@/components';
import { Box, Chip, MenuItem, Skeleton, Stack, TextField, Typography } from '@mui/material';
import {
AccentCard,
AppButton,
AppIcon,
AppLoading,
CategoryTile,
ErrorState,
FormSection,
PageHeader,
RhfTextField,
StepperHeader,
SurfaceCard,
VariantCard,
} from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ApiError } from '@/lib/api/errors';
import { digitsOnly, rialToToman, tomanToRial } from '@/utils';
import {
@@ -23,7 +38,7 @@ import {
} from '@/services/catalog/types';
interface VariantBuilderProps {
/** `null` = create (3-step stepper); a variant = edit (category/options locked, price form only). */
/** `null` = create (stepped flow); a variant = edit (category/options locked, price form only). */
initial: NurseServiceVariant | null;
onDone: () => void;
onCancel: () => void;
@@ -35,15 +50,40 @@ const DEFAULT_UNIT: PriceUnit = 'per_hour';
const MAX_PRICE_DIGITS = 12;
const MAX_DURATION_DIGITS = 4;
type StepKey = 'category' | 'options' | 'price';
interface VariantFormValues {
categoryId: number | null;
/** Option-group id → chosen value id. One field, so a category switch clears the whole answer set. */
options: Record<number, number>;
priceToman: string;
priceUnit: PriceUnit;
duration: string;
/** `null` until the nurse types over the auto-generated name — blank means "let the server name it". */
displayNameOverride: string | null;
}
/**
* The nurse variant builder (`CreateVariant` / `UpdateVariant`).
*
* **Create** is a 3-step stepper: pick category answer required/optional option groups price +
* unit + duration. Every `is_required` group must be answered before advancing; the price is entered
* in **Toman** and converted to an IRR digit-string at the field boundary (`tomanToRial`, integer-safe,
* never a float); the estimated total is shown only from `price` × `sessionCount`, never `price` alone;
* `display_name` auto-generates from the chosen labels and is editable (left blank the server
* generates it). A duplicate identical listing (`409`) shows a friendly inline warning.
* **Create** walks category options price. Two things make the flow deterministic where it
* previously was not:
*
* 1. **A step that has nothing to ask is not shown.** Categories with no option groups used to get a
* middle step whose entire content was "این دسته گزینه‌ای برای تنظیم ندارد" plus a Next button.
* The step list is now derived from the loaded groups, so those categories go straight to pricing.
* 2. **Advancing is gated *before* the tap, not after it.** The old Next button was always enabled and
* surfaced an error only once pressed, from a separate `optionsError` flag. Now the unanswered
* required groups are named under the button while it is disabled, so the blocker is visible
* without probing for it.
*
* The final step is a review as well as a form: the chosen category and options are recapped as chips
* beside the live `VariantCard`, so the listing can be checked without stepping backwards.
*
* Money still crosses the field boundary exactly once the price is entered in **Toman** and
* converted to an IRR digit-string via `tomanToRial` (integer-safe, never a float); the estimated
* total is shown only from `price` × `sessionCount`, never `price` alone. A duplicate identical
* listing (`409`) shows a friendly inline warning that offers to edit the colliding listing instead.
*
* **Edit** locks the category + option-set (changing them would change identity) and edits only
* price/unit/duration/display via `update`.
@@ -61,21 +101,29 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
const updateVariant = useUpdateVariant();
const submitting = createVariant.isPending || updateVariant.isPending;
// --- Create-only state (category → options) ---
const [activeStep, setActiveStep] = useState(0);
const [categoryId, setCategoryId] = useState<number | null>(initial?.serviceCategoryId ?? null);
const [selectedOptions, setSelectedOptions] = useState<Record<number, number>>({});
const [optionsError, setOptionsError] = useState(false);
// --- Shared price state (both create step 3 and edit) ---
// Pre-fill the price field in Toman (the wire carries IRR Rials); the money util does the ÷10.
const [priceToman, setPriceToman] = useState(initial ? String(rialToToman(initial.price)) : '');
const [priceUnit, setPriceUnit] = useState<PriceUnit>(initial?.priceUnit ?? DEFAULT_UNIT);
const [durationStr, setDurationStr] = useState(initial?.sessionCount ? String(initial.sessionCount) : '');
const [displayNameOverride, setDisplayNameOverride] = useState<string | null>(null);
const [priceError, setPriceError] = useState(false);
const [step, setStep] = useState<StepKey>(isEdit ? 'price' : 'category');
const [duplicate, setDuplicate] = useState(false);
const form = useForm<VariantFormValues>({
mode: 'onTouched',
defaultValues: {
categoryId: initial?.serviceCategoryId ?? null,
options: {},
// Pre-fill the price field in Toman (the wire carries IRR Rials); the money util does the ÷10.
priceToman: initial ? String(rialToToman(initial.price)) : '',
priceUnit: initial?.priceUnit ?? DEFAULT_UNIT,
duration: initial?.sessionCount ? String(initial.sessionCount) : '',
displayNameOverride: null,
},
});
const { control, handleSubmit, setValue } = form;
const categoryId = useWatch({ control, name: 'categoryId' });
const selectedOptions = useWatch({ control, name: 'options' });
const priceToman = useWatch({ control, name: 'priceToman' });
const priceUnit = useWatch({ control, name: 'priceUnit' });
const duration = useWatch({ control, name: 'duration' });
const displayNameOverride = useWatch({ control, name: 'displayNameOverride' });
const categoriesQuery = useServiceCategories();
const categories = categoriesQuery.data?.items ?? [];
const optionGroupsQuery = useCategoryOptionGroups(isEdit ? null : categoryId);
@@ -85,6 +133,21 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
const selectedCategory = categories.find((category) => category.id === categoryId) ?? null;
const missingRequiredGroups = groups.filter((group) => group.isRequired && selectedOptions[group.id] == null);
// A category with no option groups has no question to ask, so its step doesn't exist. Until the
// groups for the chosen category have actually loaded the answer is unknown, and the assumption is
// "there is an options step" — that way the step count only ever collapses for a category proven to
// have none, instead of starting at two and growing the moment a category is tapped.
const optionsStepUnknown = categoryId == null || optionGroupsQuery.isLoading || optionGroupsQuery.isFetching;
const hasOptionsStep = !isEdit && (optionsStepUnknown || groups.length > 0);
const effectiveStep: StepKey = step === 'options' && !hasOptionsStep ? 'price' : step;
const visibleSteps: StepKey[] = hasOptionsStep ? ['category', 'options', 'price'] : ['category', 'price'];
const stepLabels: Record<StepKey, string> = {
category: t('step_category'),
options: t('step_options'),
price: t('step_price'),
};
// Reuses the already-cached offerings list (MyServicesList holds the same query) to resolve which
// existing listing a 409 duplicate collided with, so the recovery can offer "edit that one" directly.
const myVariantsQuery = useMyVariants();
@@ -118,53 +181,51 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
const priceValid = priceToman.length > 0 && BigInt(priceToman) > BigInt(0);
const irr = priceValid ? tomanToRial(priceToman) : null;
const durationInt = durationStr ? Number(durationStr) : 0;
const durationInt = duration ? Number(duration) : 0;
const sessionCount = durationInt > 0 ? durationInt : null;
const selectCategory = (id: number) => {
if (id === categoryId) return;
// Switching category invalidates the previous category's option answers + auto-name.
setCategoryId(id);
setSelectedOptions({});
setDisplayNameOverride(null);
setOptionsError(false);
setValue('categoryId', id, { shouldDirty: true });
setValue('options', {});
setValue('displayNameOverride', null);
};
const changeOption = (groupId: number, valueId: number | null) => {
setOptionsError(false);
// A manual displayName override is intentionally left untouched; the auto-name preview tracks
// option changes only while the field hasn't been overridden (displayValue = override ?? autoName).
setSelectedOptions((prev) => {
const next = { ...prev };
const next = { ...selectedOptions };
if (valueId == null) delete next[groupId];
else next[groupId] = valueId;
return next;
});
// A manual displayName override is intentionally left untouched; the auto-name preview tracks
// option changes only while the field hasn't been overridden (displayValue = override ?? autoName).
setValue('options', next, { shouldDirty: true });
};
const goNextFromOptions = () => {
if (missingRequiredGroups.length > 0) {
setOptionsError(true);
const goNext = () => {
if (effectiveStep === 'category') {
setStep(hasOptionsStep ? 'options' : 'price');
return;
}
setActiveStep(2);
setStep('price');
};
const validatePrice = () => {
if (!priceValid) {
setPriceError(true);
return false;
const goBack = () => {
if (effectiveStep === 'price' && !isEdit) {
setStep(hasOptionsStep ? 'options' : 'category');
return;
}
return true;
setStep('category');
};
const submit = () => {
if (!validatePrice() || irr == null) return;
const displayName = displayNameOverride?.trim() ? displayNameOverride.trim() : undefined;
const submit = (values: VariantFormValues) => {
// `handleSubmit` has already enforced the price rule; this is the type narrowing that lets the
// IRR string be passed on, not a second gate.
if (irr == null) return;
const displayName = values.displayNameOverride?.trim() ? values.displayNameOverride.trim() : undefined;
if (isEdit) {
updateVariant.mutate(
{ id: initial.id, input: { price: irr, priceUnit, sessionCount, displayName } },
{ id: initial.id, input: { price: irr, priceUnit: values.priceUnit, sessionCount, displayName } },
{
onSuccess: () => {
enqueueSnackbar(t('saved_toast'), { variant: 'success' });
@@ -176,12 +237,19 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
return;
}
const options: VariantOptionSelection[] = Object.entries(selectedOptions).map(([groupId, valueId]) => ({
const options: VariantOptionSelection[] = Object.entries(values.options).map(([groupId, valueId]) => ({
optionGroupId: Number(groupId),
optionValueId: valueId,
}));
createVariant.mutate(
{ serviceCategoryId: categoryId as number, options, price: irr, priceUnit, sessionCount, displayName },
{
serviceCategoryId: values.categoryId as number,
options,
price: irr,
priceUnit: values.priceUnit,
sessionCount,
displayName,
},
{
onSuccess: () => {
enqueueSnackbar(t('created_toast'), { variant: 'success' });
@@ -196,13 +264,13 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
);
};
// Step 3's live preview — the actual VariantCard, so the nurse sees the listing they're composing,
// not just an abstract price readout.
// The price step's live preview — the actual VariantCard, so the nurse sees the listing they're
// composing, not just an abstract price readout.
const previewVariant: NurseServiceVariant = {
id: 0,
serviceCategoryId: categoryId ?? 0,
categoryNameFa: selectedCategory?.nameFa ?? '',
categoryNameEn: selectedCategory?.nameEn ?? '',
categoryNameFa: selectedCategory?.nameFa ?? initial?.categoryNameFa ?? '',
categoryNameEn: selectedCategory?.nameEn ?? initial?.categoryNameEn ?? '',
price: irr ?? '0',
priceUnit,
sessionCount,
@@ -211,68 +279,119 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
options: [],
};
/** Category + chosen options, so the last step doubles as a review of the first two. */
const recapChips = isEdit
? initial.options.map((option) => ({
key: String(option.optionGroupId),
label: `${pickCatalogName({ nameFa: option.groupNameFa, nameEn: option.groupNameEn }, locale)}: ${pickCatalogName({ nameFa: option.valueNameFa, nameEn: option.valueNameEn }, locale)}`,
}))
: groups.flatMap((group) => {
const value = group.values.find((candidate) => candidate.id === selectedOptions[group.id]);
return value
? [{ key: String(group.id), label: `${pickCatalogName(group, locale)}: ${pickCatalogName(value, locale)}` }]
: [];
});
const priceStep = (
<Stack sx={{ gap: 2.5 }}>
<TextField
<FormSection title={t('section_recap_title')} description={t('section_recap_description')} icon="category">
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{selectedCategory
? pickCatalogName(selectedCategory, locale)
: initial
? pickCatalogName({ nameFa: initial.categoryNameFa, nameEn: initial.categoryNameEn }, locale)
: ''}
</Typography>
{isEdit ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('category_locked')}
</Typography>
) : null}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
{recapChips.length > 0 ? (
recapChips.map((chip) => (
<Chip key={chip.key} size="small" label={chip.label} sx={{ bgcolor: 'var(--bal-primary-soft)' }} />
))
) : (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('summary_none')}
</Typography>
)}
</Box>
</Stack>
</FormSection>
<FormSection title={t('section_price_title')} description={t('price_hint')} icon="earnings">
<RhfTextField<VariantFormValues>
name="priceToman"
label={t('price_label')}
value={priceToman}
onChange={(event) => {
setPriceToman(digitsOnly(event.target.value).slice(0, MAX_PRICE_DIGITS));
if (priceError) setPriceError(false);
if (duplicate) setDuplicate(false);
transform={(raw) => digitsOnly(raw).slice(0, MAX_PRICE_DIGITS)}
rules={{
validate: (value) => {
const raw = String(value ?? '');
return (raw.length > 0 && BigInt(raw) > BigInt(0)) || t('price_required');
},
}}
error={priceError}
helperText={priceError ? t('price_required') : t('price_hint')}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }}
fullWidth
/>
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField
select
label={t('unit_label')}
value={priceUnit}
onChange={(event) => setPriceUnit(event.target.value as PriceUnit)}
fullWidth
>
<RhfTextField<VariantFormValues> name="priceUnit" select label={t('unit_label')} fullWidth>
{PRICE_UNITS.map((unit) => (
<MenuItem key={unit} value={unit}>
{tCatalog(`unit_${unit}`)}
</MenuItem>
))}
</TextField>
</RhfTextField>
<TextField
<RhfTextField<VariantFormValues>
name="duration"
label={t('duration_label')}
value={durationStr}
onChange={(event) => setDurationStr(digitsOnly(event.target.value).slice(0, MAX_DURATION_DIGITS))}
helperText={t('duration_hint')}
transform={(raw) => digitsOnly(raw).slice(0, MAX_DURATION_DIGITS)}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }}
fullWidth
/>
</Stack>
{irr ? (
<Stack sx={{ gap: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t('preview_heading')}
</Typography>
<VariantCard variant={previewVariant} interactive={false} />
{!sessionCount ? (
{!sessionCount && irr ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('rate_note')}
</Typography>
) : null}
</Stack>
) : null}
</FormSection>
<FormSection title={t('section_listing_title')} description={t('display_name_hint')} icon="services">
{/* Not `RhfTextField`: what is *stored* is the override alone (blank the server generates
the name), while what is *shown* falls back to the live auto-generated name. One field,
two values the one case in this form where the display value isn't the form value. */}
<Controller
control={control}
name="displayNameOverride"
render={({ field }) => (
<TextField
label={t('display_name_label')}
value={displayValue}
onChange={(event) => setDisplayNameOverride(event.target.value)}
helperText={t('display_name_hint')}
name={field.name}
inputRef={field.ref}
value={field.value ?? autoName}
onChange={(event) => field.onChange(event.target.value)}
onBlur={field.onBlur}
fullWidth
/>
)}
/>
<Stack sx={{ gap: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t('preview_heading')}
</Typography>
{/* Shown even before a price is entered the preview is what "comprehensive" means here:
the nurse should see the shape of the listing while composing it, not only once it's valid. */}
<VariantCard variant={previewVariant} interactive={false} />
</Stack>
</FormSection>
{duplicate ? (
<AccentCard tone="warning" padding="sm">
@@ -301,92 +420,33 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
</Stack>
);
// --- Edit mode: locked category + options summary, then the price form ---
if (isEdit) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Typography variant="h5" component="h1">
{t('builder_edit_title')}
</Typography>
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{pickCatalogName({ nameFa: initial.categoryNameFa, nameEn: initial.categoryNameEn }, locale)}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('category_locked')}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 0.5 }}>
{initial.options.length > 0 ? (
initial.options.map((option) => (
<Chip
key={option.optionGroupId}
size="small"
label={`${pickCatalogName({ nameFa: option.groupNameFa, nameEn: option.groupNameEn }, locale)}: ${pickCatalogName({ nameFa: option.valueNameFa, nameEn: option.valueNameEn }, locale)}`}
sx={{ bgcolor: 'var(--bal-primary-soft)' }}
/>
))
) : (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('summary_none')}
</Typography>
)}
</Box>
</Stack>
</Paper>
{priceStep}
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
<AppButton variant="text" onClick={onCancel} disabled={submitting}>
{tc('cancel')}
</AppButton>
<AppButton color="primary" variant="contained" onClick={submit} disabled={submitting}>
{submitting ? tc('saving') : t('submit_save')}
</AppButton>
</Stack>
</Box>
);
}
// --- Create mode: the 3-step stepper ---
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: 560 }}>
<Typography variant="h5" component="h1">
{t('builder_add_title')}
</Typography>
<FormProvider {...form}>
<Box
component="form"
noValidate
onSubmit={handleSubmit(submit)}
sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}
>
<PageHeader title={isEdit ? t('builder_edit_title') : t('builder_add_title')} />
{isEdit ? null : (
<StepperHeader
steps={[t('step_category'), t('step_options'), t('step_price')]}
activeStep={activeStep}
steps={visibleSteps.map((key) => stepLabels[key])}
activeStep={visibleSteps.indexOf(effectiveStep)}
/>
)}
{activeStep === 0 ? (
<Stack sx={{ gap: 1.5 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('category_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('category_subtitle')}
</Typography>
</Box>
{effectiveStep === 'category' ? (
<FormSection title={t('category_title')} description={t('category_subtitle')} icon="category">
{categoriesQuery.isLoading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
))}
</Box>
) : categoriesQuery.isError ? (
<Stack sx={{ gap: 1, alignItems: 'flex-start' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('categories_error')}
</Typography>
<AppButton variant="outlined" color="primary" onClick={() => categoriesQuery.refetch()}>
{tc('retry')}
</AppButton>
</Stack>
<ErrorState message={t('categories_error')} retryLabel={tc('retry')} onRetry={() => categoriesQuery.refetch()} />
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}>
{categories.map((category) => (
@@ -400,58 +460,30 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
))}
</Box>
)}
</Stack>
</FormSection>
) : null}
{activeStep === 1 ? (
<Stack sx={{ gap: 2 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('options_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('options_subtitle')}
</Typography>
</Box>
{effectiveStep === 'options' ? (
<FormSection title={t('options_title')} description={t('options_subtitle')} icon="tune">
{optionGroupsQuery.isLoading ? (
<AppLoading />
) : optionGroupsQuery.isError ? (
// A failed fetch must never read as "this category has zero options" — that would let the
// nurse skip required options entirely. Block progression until the retry succeeds.
<ErrorState
message={t('options_error')}
retryLabel={tc('retry')}
onRetry={() => optionGroupsQuery.refetch()}
/>
) : groups.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('options_none')}
</Typography>
<ErrorState message={t('options_error')} retryLabel={tc('retry')} onRetry={() => optionGroupsQuery.refetch()} />
) : (
groups.map((group) => {
const isMissing = optionsError && group.isRequired && selectedOptions[group.id] == null;
return (
groups.map((group) => (
<Stack key={group.id} sx={{ gap: 1 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{pickCatalogName(group, locale)}
</Typography>
{/* The required badge turns red on a blocked advance to point at the unanswered group. */}
<Chip
size="small"
label={group.isRequired ? t('required_badge') : t('optional_badge')}
sx={{
bgcolor: isMissing
? 'var(--bal-error)'
: group.isRequired
? 'var(--bal-primary-soft)'
: 'var(--bal-divider)',
color: isMissing
? 'var(--bal-error-contrast)'
: group.isRequired
? 'var(--bal-primary)'
: 'text.secondary',
bgcolor: group.isRequired ? 'var(--bal-primary-soft)' : 'var(--bal-divider)',
color: group.isRequired ? 'var(--bal-primary)' : 'text.secondary',
fontWeight: 500,
}}
/>
@@ -463,76 +495,64 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
<Chip
key={value.id}
label={pickCatalogName(value, locale)}
onClick={() => changeOption(group.id, selected ? null : value.id)}
aria-pressed={selected}
clickable
color={selected ? 'primary' : 'default'}
variant={selected ? 'filled' : 'outlined'}
sx={{
fontWeight: 500,
backgroundColor: selected ? 'var(--bal-primary)' : 'transparent',
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
borderColor: 'var(--bal-primary)',
}}
onClick={() => changeOption(group.id, selected ? null : value.id)}
/>
);
})}
</Stack>
</Stack>
);
})
))
)}
</FormSection>
) : null}
{optionsError && missingRequiredGroups.length > 0 ? (
<Typography variant="body2" sx={{ color: 'var(--bal-error)', fontWeight: 500 }}>
{t('options_incomplete')}
{effectiveStep === 'price' ? priceStep : null}
{/* Names what is still missing while the button is disabled, instead of revealing it on tap. */}
{effectiveStep === 'options' && missingRequiredGroups.length > 0 ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('options_missing_named', {
groups: missingRequiredGroups.map((group) => pickCatalogName(group, locale)).join('، '),
})}
</Typography>
) : null}
</Stack>
) : null}
{activeStep === 2 ? (
<Stack sx={{ gap: 1.5 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('price_title')}
</Typography>
</Box>
{priceStep}
</Stack>
) : null}
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', mt: 1 }}>
<SurfaceCard padding="sm" sx={{ position: 'sticky', bottom: 'var(--bal-chrome-bottom, 0px)', zIndex: 1 }}>
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between' }}>
<AppButton
variant="text"
onClick={activeStep === 0 ? onCancel : () => setActiveStep((step) => step - 1)}
onClick={effectiveStep === 'category' || isEdit ? onCancel : goBack}
disabled={submitting}
>
{activeStep === 0 ? tc('cancel') : tc('back')}
{effectiveStep === 'category' || isEdit ? tc('cancel') : tc('back')}
</AppButton>
{activeStep === 0 ? (
<AppButton
color="primary"
variant="contained"
onClick={() => setActiveStep(1)}
disabled={categoryId == null}
>
{t('next')}
</AppButton>
) : activeStep === 1 ? (
<AppButton
color="primary"
variant="contained"
onClick={goNextFromOptions}
disabled={optionGroupsQuery.isError}
>
{t('next')}
{effectiveStep === 'price' ? (
<AppButton type="submit" color="primary" variant="contained" disabled={submitting}>
{submitting ? tc('saving') : isEdit ? t('submit_save') : t('submit_create')}
</AppButton>
) : (
<AppButton color="primary" variant="contained" onClick={submit} disabled={submitting}>
{submitting ? tc('saving') : t('submit_create')}
<AppButton
color="primary"
variant="contained"
onClick={goNext}
disabled={
effectiveStep === 'category'
? categoryId == null
: optionGroupsQuery.isError || missingRequiredGroups.length > 0
}
>
{t('next')}
</AppButton>
)}
</Stack>
</SurfaceCard>
</Box>
</FormProvider>
);
};
@@ -1,10 +1,20 @@
'use client';
import { useMemo, useState } from 'react';
import { FunctionComponent, useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import { Box, Chip, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, DocumentUpload, JalaliDateField } from '@/components';
import {
AppButton,
AppIcon,
AppLoading,
DocumentUpload,
FormSection,
RhfChipSelect,
RhfJalaliDateField,
RhfTextField,
} from '@/components';
import type { UploadedDocInfo } from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants';
@@ -15,101 +25,42 @@ import {
useVerificationStatus,
} from '@/services/verification';
import { SPECIALTY_PRESETS } from '@/services/verification/types';
import type { VerificationStep } from '@/services/verification/types';
import type { VerificationStatus, VerificationStep } from '@/services/verification/types';
import { stepDescriptionKey, stepLabelKey } from '../verificationSteps';
import VerificationJourneyHeader from '../VerificationJourneyHeader';
const MANUAL_CREDENTIAL_CODES = ['moh_competency_license', 'ino_membership', 'criminal_record'];
interface CredentialsFormValues {
inoNumber: string;
specialties: string[];
issuingAuthority: string;
issuedAt: string | null;
expiresAt: string | null;
}
/**
* B5 professional credentials. Renders a `DocumentUpload` for **each manual credential step in the
* status** (data-driven a new manual step renders without a code change); each upload moves its step
* to `in_review` (manual admin review copy never claims an automated authority check).
*
* Hydrates from `status.credentialSubmission` (REQ-056, mock-tolerant): once the INO number has been
* recorded, the field locks into a "شمارهٔ نظام ثبت شد" summary never re-prompted as if lost, and
* never re-sent blank (the server's `CredentialDetailsInput.inoNumber` is required; the raw number is
* never read back by design, so re-submitting it isn't possible without the nurse re-entering it via
* "تغییر"). A returning, already-submitted nurse can still fix a **rejected** document directly (each
* upload takes effect immediately, no re-submit needed) and simply returns to the journey no dead
* disabled button, because there is no button to be dead.
* The whole screen used to be one undivided column: a number field, three uploaders, a chip row with
* its own add-a-custom-value sub-form, two date pickers and a submit button, with two independent
* `editingIno ? … : …` branches interleaved through it. Grouping the same content into four named
* sections شماره نظام / مدارک / تخصصها / جزئیات مدرک makes the two genuinely optional groups
* visibly optional and gives the "what still blocks submit?" answer somewhere to live.
*/
export default function CredentialsSubmitPage() {
const t = useTranslations('verification');
const locale = useLocale();
const router = useRouter();
const { enqueueSnackbar } = useSnackbar();
const { data: status, isLoading } = useVerificationStatus();
const uploadDocument = useUploadVerificationDocument();
const submitCredentials = useSubmitCredentials();
const submission = status?.credentialSubmission;
const [inoNumber, setInoNumber] = useState('');
const [inoError, setInoError] = useState(false);
const [editingIno, setEditingIno] = useState(true);
const [specialties, setSpecialties] = useState<string[]>([]);
const [customSpecialty, setCustomSpecialty] = useState('');
const [issuingAuthority, setIssuingAuthority] = useState('');
const [issuedAt, setIssuedAt] = useState<string | null>(null);
const [expiresAt, setExpiresAt] = useState<string | null>(null);
const [uploadedSteps, setUploadedSteps] = useState<Record<number, boolean>>({});
const [hydrated, setHydrated] = useState(false);
// Hydrate once from the server's read-back, adjusted directly during render (never in an effect —
// that would cascade an extra render) — and never overwrite what the nurse is actively editing.
if (!hydrated && submission) {
setHydrated(true);
setEditingIno(!submission.inoNumberSubmitted);
setSpecialties(submission.specialties);
setIssuingAuthority(submission.issuingAuthority ?? '');
setIssuedAt(submission.issuedAt ?? null);
setExpiresAt(submission.expiresAt ?? null);
}
const manualSteps = useMemo(
() => (status?.steps ?? []).filter((step) => MANUAL_CREDENTIAL_CODES.includes(step.code)),
[status],
);
const toggleSpecialty = (value: string) =>
setSpecialties((prev) => (prev.includes(value) ? prev.filter((item) => item !== value) : [...prev, value]));
const addCustomSpecialty = () => {
const value = customSpecialty.trim();
if (value && !specialties.includes(value)) setSpecialties((prev) => [...prev, value]);
setCustomSpecialty('');
};
const uploadToStep = (step: VerificationStep) => async (file: File, onProgress: (percent: number) => void) => {
const doc = await uploadDocument.mutateAsync({ stepId: step.id, file, onProgress });
return { name: doc.originalFileName ?? file.name, sizeBytes: doc.fileSizeBytes } satisfies UploadedDocInfo;
};
const handleSubmit = () => {
const inoValid = inoNumber.trim().length > 0;
setInoError(!inoValid);
if (!inoValid) return;
submitCredentials.mutate(
{
inoNumber: inoNumber.trim(),
specialties,
issuingAuthority: issuingAuthority.trim() || undefined,
issuedAt: issuedAt ?? undefined,
expiresAt: expiresAt ?? undefined,
},
{
onSuccess: () => {
enqueueSnackbar(t('credentials_submitted'), { variant: 'success' });
router.push(`/${locale}${ROUTES.NURSE_VERIFICATION_REVIEW}`);
},
onError: () => enqueueSnackbar(t('credentials_error'), { variant: 'error' }),
},
);
};
if (isLoading) return <AppLoading />;
if (!status || manualSteps.length === 0) {
@@ -126,44 +77,121 @@ export default function CredentialsSubmitPage() {
);
}
return <CredentialsForm status={status} manualSteps={manualSteps} />;
}
/**
* Hydrates from `status.credentialSubmission` (REQ-056, mock-tolerant) as react-hook-form
* `defaultValues` mounted only once the status query has resolved, which is what lets the server
* read-back *be* the initial form state instead of being copied into it by a render-time state
* adjustment. Once the INO number has been recorded the field locks into a "شمارهٔ نظام ثبت شد"
* summary never re-prompted as if lost, and never re-sent blank (the server's
* `CredentialDetailsInput.inoNumber` is required; the raw number is never read back by design, so
* re-submitting it isn't possible without the nurse re-entering it via "تغییر"). A returning,
* already-submitted nurse can still fix a **rejected** document directly (each upload takes effect
* immediately, no re-submit needed) and simply returns to the journey.
*/
const CredentialsForm: FunctionComponent<{ status: VerificationStatus; manualSteps: VerificationStep[] }> = ({
status,
manualSteps,
}) => {
const t = useTranslations('verification');
const tc = useTranslations('common');
const locale = useLocale();
const router = useRouter();
const { enqueueSnackbar } = useSnackbar();
const uploadDocument = useUploadVerificationDocument();
const submitCredentials = useSubmitCredentials();
const submission = status.credentialSubmission;
const [editingIno, setEditingIno] = useState(!submission?.inoNumberSubmitted);
const [customSpecialty, setCustomSpecialty] = useState('');
const [uploadedSteps, setUploadedSteps] = useState<Record<number, boolean>>({});
const form = useForm<CredentialsFormValues>({
mode: 'onTouched',
defaultValues: {
inoNumber: '',
specialties: submission?.specialties ?? [],
issuingAuthority: submission?.issuingAuthority ?? '',
issuedAt: submission?.issuedAt ?? null,
expiresAt: submission?.expiresAt ?? null,
},
});
const { control, handleSubmit, setValue } = form;
const specialties = useWatch({ control, name: 'specialties' });
const issuedAt = useWatch({ control, name: 'issuedAt' });
const expiresAt = useWatch({ control, name: 'expiresAt' });
const issuingAuthority = useWatch({ control, name: 'issuingAuthority' });
const addCustomSpecialty = () => {
const value = customSpecialty.trim();
if (value && !specialties.includes(value)) {
setValue('specialties', [...specialties, value], { shouldDirty: true });
}
setCustomSpecialty('');
};
const uploadToStep = (step: VerificationStep) => async (file: File, onProgress: (percent: number) => void) => {
const doc = await uploadDocument.mutateAsync({ stepId: step.id, file, onProgress });
return { name: doc.originalFileName ?? file.name, sizeBytes: doc.fileSizeBytes } satisfies UploadedDocInfo;
};
// A returning nurse with any manual step already on file (server truth) is never dead-ended: while
// actively (re-)entering the INO number, the gate also counts server-side documents, not only this
// session's uploads.
const hasAnyDocument =
Object.values(uploadedSteps).some(Boolean) ||
manualSteps.some((step) => step.status === 'in_review' || step.status === 'passed');
const canSubmit = hasAnyDocument && !submitCredentials.isPending;
const uploadedCount =
manualSteps.filter((step) => uploadedSteps[step.id] || step.status === 'in_review' || step.status === 'passed')
.length;
const hasAnyDocument = uploadedCount > 0;
const submit = (values: CredentialsFormValues) => {
submitCredentials.mutate(
{
inoNumber: values.inoNumber.trim(),
specialties: values.specialties,
issuingAuthority: values.issuingAuthority.trim() || undefined,
issuedAt: values.issuedAt ?? undefined,
expiresAt: values.expiresAt ?? undefined,
},
{
onSuccess: () => {
enqueueSnackbar(t('credentials_submitted'), { variant: 'success' });
router.push(`/${locale}${ROUTES.NURSE_VERIFICATION_REVIEW}`);
},
onError: () => enqueueSnackbar(t('credentials_error'), { variant: 'error' }),
},
);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<FormProvider {...form}>
<Box
component="form"
noValidate
onSubmit={handleSubmit(submit)}
sx={{ display: 'flex', flexDirection: 'column', gap: 2.5, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}
>
<VerificationJourneyHeader group="credentials" />
<Box>
<Typography variant="h6" component="h2" sx={{ fontWeight: 700 }}>
{t('credentials_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('credentials_subtitle')}
</Typography>
</Box>
<FormSection title={t('ino_number_label')} description={t('ino_number_hint')} icon="license">
{editingIno ? (
<TextField
<RhfTextField<CredentialsFormValues>
name="inoNumber"
label={t('ino_number_label')}
value={inoNumber}
onChange={(event) => {
setInoNumber(event.target.value);
if (inoError) setInoError(false);
}}
error={inoError}
helperText={inoError ? t('ino_number_required') : t('ino_number_hint')}
rules={{ required: t('ino_number_required') }}
slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start' } } }}
fullWidth
/>
) : (
<Stack
direction="row"
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', p: 1.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="verified" size={18} color="var(--bal-success)" />
@@ -174,10 +202,20 @@ export default function CredentialsSubmitPage() {
</AppButton>
</Stack>
)}
</FormSection>
{/* One uploader per manual credential step data-driven from the status. Re-uploading a
rejected document always takes effect immediately, whether or not the INO number is locked. */}
<Stack sx={{ gap: 2 }}>
<FormSection
title={t('credentials_documents_title')}
description={t('credentials_documents_description')}
icon="document"
status={
<Typography variant="caption" sx={{ color: 'text.secondary', flexShrink: 0 }}>
{t('credentials_documents_count', { done: uploadedCount, total: manualSteps.length })}
</Typography>
}
>
{manualSteps.map((step) => (
<DocumentUpload
key={step.code}
@@ -190,51 +228,29 @@ export default function CredentialsSubmitPage() {
existingDoc={step.status === 'in_review' || step.status === 'passed' ? { name: t('doc_uploaded') } : null}
/>
))}
</Stack>
{editingIno ? (
<DocumentUpload label={t('education_label')} hint={t('education_hint')} onUpload={async (file) => ({ name: file.name })} />
) : null}
</FormSection>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('specialties_label')}
</Typography>
<FormSection
title={t('specialties_label')}
description={t('specialties_hint')}
icon="clinical"
optional
optionalLabel={tc('optional')}
>
{editingIno ? (
<>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('specialties_hint')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{SPECIALTY_PRESETS.map((preset) => {
const selected = specialties.includes(preset);
return (
<Chip
key={preset}
label={t.has(`specialty_${preset}`) ? t(`specialty_${preset}`) : preset}
onClick={() => toggleSpecialty(preset)}
icon={selected ? <AppIcon icon="verified" size={16} color="var(--bal-primary-contrast)" /> : undefined}
variant={selected ? 'filled' : 'outlined'}
sx={{
fontWeight: 500,
backgroundColor: selected ? 'var(--bal-primary)' : 'transparent',
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
borderColor: 'var(--bal-primary)',
}}
<RhfChipSelect<CredentialsFormValues>
name="specialties"
options={SPECIALTY_PRESETS.map((preset) => ({
code: preset,
label: t.has(`specialty_${preset}`) ? t(`specialty_${preset}`) : preset,
}))}
allowCustomValues
/>
);
})}
{specialties
.filter((value) => !SPECIALTY_PRESETS.includes(value))
.map((value) => (
<Chip
key={value}
label={value}
onDelete={() => toggleSpecialty(value)}
sx={{ fontWeight: 500, backgroundColor: 'var(--bal-primary)', color: 'var(--bal-primary-contrast)' }}
/>
))}
</Stack>
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<TextField
size="small"
@@ -268,43 +284,37 @@ export default function CredentialsSubmitPage() {
{t('summary_none')}
</Typography>
)}
</Stack>
</FormSection>
{/* Optional registry details the admin cross-checks issue/expiry feed the credential-expiry sweep, so
wrong dates are a correctness risk; a Jalali picker replaces the Gregorian-only native input. */}
{/* Optional registry details the admin cross-checks issue/expiry feed the credential-expiry
sweep, so wrong dates are a correctness risk; a Jalali picker replaces the Gregorian-only
native input. */}
{editingIno ? (
<Stack sx={{ gap: 1.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('registry_details_label')}
</Typography>
<TextField
label={t('issuing_authority_label')}
value={issuingAuthority}
onChange={(event) => setIssuingAuthority(event.target.value)}
fullWidth
/>
<FormSection
title={t('registry_details_title')}
description={t('registry_details_description')}
icon="audit"
optional
optionalLabel={tc('optional')}
>
<RhfTextField<CredentialsFormValues> name="issuingAuthority" label={t('issuing_authority_label')} fullWidth />
<Stack direction="row" sx={{ gap: 1.5, flexWrap: 'wrap' }}>
<JalaliDateField
<RhfJalaliDateField<CredentialsFormValues>
name="issuedAt"
label={t('issued_at_label')}
value={issuedAt}
onChange={setIssuedAt}
max={expiresAt ?? undefined}
sx={{ flex: 1, minWidth: 160 }}
/>
<JalaliDateField
<RhfJalaliDateField<CredentialsFormValues>
name="expiresAt"
label={t('expires_at_label')}
value={expiresAt}
onChange={setExpiresAt}
min={issuedAt ?? undefined}
sx={{ flex: 1, minWidth: 160 }}
/>
</Stack>
</Stack>
</FormSection>
) : issuingAuthority || issuedAt || expiresAt ? (
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('registry_details_label')}
</Typography>
<FormSection title={t('registry_details_title')} icon="audit">
{issuingAuthority ? <Typography variant="body2">{issuingAuthority}</Typography> : null}
{issuedAt || expiresAt ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
@@ -313,12 +323,12 @@ export default function CredentialsSubmitPage() {
{expiresAt ? formatShamsiDate(expiresAt, locale) : ''}
</Typography>
) : null}
</Stack>
</FormSection>
) : null}
<Paper
elevation={0}
sx={{ p: 1.5, borderRadius: 2, bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
>
<AppIcon icon="info" size={18} color="var(--bal-primary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
@@ -334,7 +344,13 @@ export default function CredentialsSubmitPage() {
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton color="primary" variant="contained" startIcon="license" onClick={handleSubmit} disabled={!canSubmit}>
<AppButton
type="submit"
color="primary"
variant="contained"
startIcon="license"
disabled={!hasAnyDocument || submitCredentials.isPending}
>
{submitCredentials.isPending ? t('credentials_submitting') : t('credentials_submit')}
</AppButton>
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}>
@@ -348,5 +364,6 @@ export default function CredentialsSubmitPage() {
</AppButton>
)}
</Box>
</FormProvider>
);
}
};
@@ -2,9 +2,10 @@
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { useForm, FormProvider, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import { Box, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppAlert, AppButton, AppIcon, DocumentUpload } from '@/components';
import { Box, Paper, Stack, Typography } from '@mui/material';
import { AppAlert, AppButton, AppIcon, DocumentUpload, FormSection, RhfTextField } from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants';
import { toEnglishDigits } from '@/utils';
@@ -16,6 +17,12 @@ import VerificationJourneyHeader from '../VerificationJourneyHeader';
type SubmitError = { key: 'national_id_mismatch' | 'shared_sim' | 'shahkar_mismatch' } | null;
interface IdentityFormValues {
nationalId: string;
cardCaptured: boolean;
selfieCaptured: boolean;
}
/**
* B4 identity submission. Collects the national id (10-digit + checksum), a national-ID card image,
* and a liveness selfie, then runs the automated civil-registry KYC + the chained Shahkar match. The
@@ -23,6 +30,12 @@ type SubmitError = { key: 'national_id_mismatch' | 'shared_sim' | 'shahkar_misma
* so `DocumentUpload` runs in local mode here. The auto-query note is honest this check is performed.
* The shared-SIM Shahkar failure surfaces as a clear, non-accusatory message; a national-ID mismatch on
* its own step.
*
* Structured as three named steps rather than one column of controls: the screen asks for a number, a
* card photo and a selfie, and the old layout gave no clue that the selfie was the only *required* one
* of the three the submit button simply stayed dead with the reason buried in a caption near the
* bottom. Each capture's completion is a real form field, so the requirement is declared where it
* applies instead of re-derived in the submit handler.
*/
export default function IdentitySubmitPage() {
const t = useTranslations('verification');
@@ -31,25 +44,23 @@ export default function IdentitySubmitPage() {
const { enqueueSnackbar } = useSnackbar();
const submitIdentity = useSubmitIdentity();
const [nationalId, setNationalId] = useState('');
const [idError, setIdError] = useState(false);
const [cardCaptured, setCardCaptured] = useState(false);
const [selfieCaptured, setSelfieCaptured] = useState(false);
const [submitError, setSubmitError] = useState<SubmitError>(null);
const form = useForm<IdentityFormValues>({
mode: 'onTouched',
defaultValues: { nationalId: '', cardCaptured: false, selfieCaptured: false },
});
const { control, handleSubmit, setValue } = form;
const cardCaptured = useWatch({ control, name: 'cardCaptured' });
const selfieCaptured = useWatch({ control, name: 'selfieCaptured' });
// Local capture: the card/selfie feed the automated KYC (no stored document) — resolve immediately.
const captureLocally = async (file: File) => ({ name: file.name });
const canSubmit = isValidNationalId(nationalId) && selfieCaptured && !submitIdentity.isPending;
const handleSubmit = () => {
const idValid = isValidNationalId(nationalId);
setIdError(!idValid);
const submit = (values: IdentityFormValues) => {
setSubmitError(null);
if (!idValid || !selfieCaptured) return;
submitIdentity.mutate(
{ nationalId, livenessCaptured: selfieCaptured },
{ nationalId: values.nationalId, livenessCaptured: values.selfieCaptured },
{
onSuccess: (result: SubmitIdentityResult) => {
if (result.identity.stepStatus === 'failed') {
@@ -68,58 +79,69 @@ export default function IdentitySubmitPage() {
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<FormProvider {...form}>
<Box
component="form"
noValidate
onSubmit={handleSubmit(submit)}
sx={{ display: 'flex', flexDirection: 'column', gap: 2.5, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}
>
<VerificationJourneyHeader group="identity" />
<Box>
<Typography variant="h6" component="h2" sx={{ fontWeight: 700 }}>
{t('identity_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('identity_subtitle')}
</Typography>
</Box>
<TextField
<FormSection title={t('identity_step_number_title')} description={t('national_id_hint')} icon="identity">
<RhfTextField<IdentityFormValues>
name="nationalId"
label={t('national_id_label')}
value={nationalId}
onChange={(event) => {
setNationalId(toEnglishDigits(event.target.value).replace(/\D/g, '').slice(0, NATIONAL_ID_LENGTH));
if (idError) setIdError(false);
}}
error={idError}
helperText={idError ? t('national_id_invalid') : t('national_id_hint')}
transform={(raw) => toEnglishDigits(raw).replace(/\D/g, '').slice(0, NATIONAL_ID_LENGTH)}
rules={{ validate: (value) => isValidNationalId(String(value ?? '')) || t('national_id_invalid') }}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start', letterSpacing: 2 } } }}
fullWidth
/>
</FormSection>
<Stack sx={{ gap: 1 }}>
<FormSection
title={t('card_label')}
description={t('card_hint')}
icon="camera"
optional
optionalLabel={t('card_recommended_short')}
status={cardCaptured ? <CapturedMark label={t('capture_done')} /> : undefined}
>
<CaptureGuideFrame variant="card" />
<DocumentUpload
label={t('card_label')}
hint={t('card_hint')}
hint={t('capture_hint_card')}
accept={ACCEPTED_IMAGE_TYPES}
capture="environment"
onUpload={captureLocally}
onUploaded={() => setCardCaptured(true)}
onUploaded={() => setValue('cardCaptured', true, { shouldValidate: true })}
/>
</Stack>
</FormSection>
<Stack sx={{ gap: 1 }}>
<FormSection
title={t('selfie_label')}
description={t('selfie_hint')}
icon="account"
status={selfieCaptured ? <CapturedMark label={t('capture_done')} /> : <RequiredMark label={t('required_badge')} />}
>
<CaptureGuideFrame variant="selfie" />
<DocumentUpload
label={t('selfie_label')}
hint={t('selfie_hint')}
hint={t('capture_hint_selfie')}
accept={ACCEPTED_IMAGE_TYPES}
capture="user"
onUpload={captureLocally}
onUploaded={() => setSelfieCaptured(true)}
onUploaded={() => setValue('selfieCaptured', true, { shouldValidate: true })}
/>
</Stack>
</FormSection>
<Paper
elevation={0}
sx={{ p: 1.5, borderRadius: 2, bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
>
<AppIcon icon="info" size={18} color="var(--bal-primary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
@@ -133,19 +155,21 @@ export default function IdentitySubmitPage() {
</AppAlert>
) : null}
{!cardCaptured ? (
{/* The one thing that still gates submit is the selfie, so it says so here rather than leaving
a dead button to be explained by a caption three sections up. */}
{!selfieCaptured ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('card_recommended')}
{t('identity_needs_selfie')}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton
type="submit"
color="primary"
variant="contained"
startIcon="identity"
onClick={handleSubmit}
disabled={!canSubmit}
disabled={!selfieCaptured || submitIdentity.isPending}
>
{submitIdentity.isPending ? t('identity_submitting') : t('identity_submit')}
</AppButton>
@@ -154,6 +178,27 @@ export default function IdentitySubmitPage() {
</AppButton>
</Stack>
</Box>
</FormProvider>
);
}
/** A section's "done" marker — the completion cue a wall of uploaders otherwise never gives. */
function CapturedMark({ label }: { label: string }) {
return (
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center', flexShrink: 0 }}>
<AppIcon icon="verified" size={16} color="var(--bal-success)" />
<Typography variant="caption" sx={{ color: 'var(--bal-success)' }}>
{label}
</Typography>
</Stack>
);
}
function RequiredMark({ label }: { label: string }) {
return (
<Typography variant="caption" sx={{ color: 'var(--bal-warning)', flexShrink: 0 }}>
{label}
</Typography>
);
}
@@ -168,17 +213,18 @@ const CORNER_POSITIONS = [
/**
* A cheap, dependency-free capture guide a dashed frame (viewfinder corners for the card, an oval
* for the selfie) plus a static hint line, shown above the corresponding `DocumentUpload`. There is no
* live camera preview to overlay (the native camera app owns capture via the file input's `capture`
* attribute), so this illustrates *how to frame the shot* rather than tracking the actual photo.
* for the selfie), shown above the corresponding `DocumentUpload`. There is no live camera preview to
* overlay (the native camera app owns capture via the file input's `capture` attribute), so this
* illustrates *how to frame the shot* rather than tracking the actual photo. The hint line that used
* to sit under it now rides on the uploader itself, where the tap target is.
*/
function CaptureGuideFrame({ variant }: { variant: 'card' | 'selfie' }) {
const t = useTranslations('verification');
const isCard = variant === 'card';
return (
<Stack sx={{ alignItems: 'center', gap: 0.75 }}>
<Box
aria-hidden
sx={{
alignSelf: 'center',
position: 'relative',
width: isCard ? 180 : 112,
height: isCard ? 112 : 140,
@@ -195,9 +241,5 @@ function CaptureGuideFrame({ variant }: { variant: 'card' | 'selfie' }) {
))
: null}
</Box>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center', maxWidth: 220 }}>
{t(isCard ? 'capture_hint_card' : 'capture_hint_selfie')}
</Typography>
</Stack>
);
}
@@ -51,9 +51,9 @@ export default function NurseVerificationPage() {
{isLoading ? (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="rounded" height={72} sx={{ borderRadius: 2 }} />
<Skeleton variant="rounded" height={72} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={64} sx={{ borderRadius: 2 }} />
<Skeleton key={key} variant="rounded" height={64} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
))}
</Stack>
) : isError ? (
@@ -127,7 +127,7 @@ function Approved({ onPublish }: { onPublish: () => void }) {
elevation={0}
sx={{
p: 3,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
@@ -1,9 +1,9 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import { Checkbox, FormControlLabel, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, VisitNoteCard } from '@/components';
import { Checkbox, FormControlLabel, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, RhfControlGroup, RhfTextField, VisitNoteCard } from '@/components';
import { formatShamsiDate } from '@/utils';
import { useBookingDetail } from '@/services/bookings';
import { isBookingConfirmedOrBeyond } from '@/services/bookings/types';
@@ -11,6 +11,12 @@ import { useRecordAccess, usePatientCareRecord, usePatientHistory, useCreateVisi
import { VISIT_NOTE_MAX_LENGTH } from '@/services/patientRecords/constants';
import type { TaskResult } from '@/services/patientRecords/types';
interface VisitNoteFormValues {
note: string;
/** Task id → ticked. One field so a whole submitted checklist resets in a single `reset`. */
checked: Record<string, boolean>;
}
/**
* E3 (نمای پرستار) the nurse visit-note authoring, mounted **below** the f8 EVV banner on the nurse booking
* detail. **Append-only:** the nurse ticks today's task checklist and writes a free-text note, then submits
@@ -37,26 +43,22 @@ export default function NurseVisitNotesPanel({ bookingId }: { bookingId: number
const history = usePatientHistory(patientId, 1, { enabled: engaged && patientId > 0 && canView });
const createNote = useCreateVisitNote(patientId);
const [note, setNote] = useState('');
const [checked, setChecked] = useState<Record<string, boolean>>({});
const form = useForm<VisitNoteFormValues>({ mode: 'onTouched', defaultValues: { note: '', checked: {} } });
const { control, handleSubmit, reset } = form;
const note = useWatch({ control, name: 'note' });
if (!booking.data || !engaged) return null;
const tasks = record.data?.tasks ?? [];
const submit = () => {
if (!note.trim()) {
enqueueSnackbar(t('note_required'), { variant: 'error' });
return;
}
const taskResults: TaskResult[] = tasks.map((task) => ({ label: task.label, done: Boolean(checked[task.id]) }));
const submit = (values: VisitNoteFormValues) => {
const taskResults: TaskResult[] = tasks.map((task) => ({ label: task.label, done: Boolean(values.checked[task.id]) }));
createNote.mutate(
{ bookingId, body: note.trim(), taskResults },
{ bookingId, body: values.note.trim(), taskResults },
{
onSuccess: () => {
enqueueSnackbar(t('note_saved'), { variant: 'success' });
setNote('');
setChecked({});
reset({ note: '', checked: {} });
},
onError: () => enqueueSnackbar(t('note_error'), { variant: 'error' }),
},
@@ -70,9 +72,10 @@ export default function NurseVisitNotesPanel({ bookingId }: { bookingId: number
{canAppend ? (
<Paper
elevation={0}
sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider', borderTop: '3px solid var(--bal-secondary)' }}
sx={{ p: 2.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', borderTop: '3px solid var(--bal-secondary)' }}
>
<Stack sx={{ gap: 2 }}>
<FormProvider {...form}>
<Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 2 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
<AppIcon icon="notes" size={20} color="var(--bal-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
@@ -88,43 +91,56 @@ export default function NurseVisitNotesPanel({ bookingId }: { bookingId: number
{record.isLoading ? (
<Skeleton variant="rounded" height={80} />
) : (
tasks.map((task) => (
<RhfControlGroup<VisitNoteFormValues> name="checked">
{({ field }) => {
const ticked = (field.value as Record<string, boolean>) ?? {};
return (
<Stack>
{tasks.map((task) => (
<FormControlLabel
key={task.id}
control={
<Checkbox
checked={Boolean(checked[task.id])}
onChange={(e) => setChecked((c) => ({ ...c, [task.id]: e.target.checked }))}
checked={Boolean(ticked[task.id])}
onChange={(event) =>
field.onChange({ ...ticked, [task.id]: event.target.checked })
}
/>
}
label={task.label}
/>
))
))}
</Stack>
);
}}
</RhfControlGroup>
)}
</Stack>
) : null}
<TextField
<RhfTextField<VisitNoteFormValues>
name="note"
label={t('note_label')}
placeholder={t('note_placeholder')}
value={note}
onChange={(e) => setNote(e.target.value.slice(0, VISIT_NOTE_MAX_LENGTH))}
transform={(raw) => raw.slice(0, VISIT_NOTE_MAX_LENGTH)}
rules={{ validate: (value) => String(value ?? '').trim().length > 0 || t('note_required') }}
multiline
minRows={3}
fullWidth
/>
<AppButton
type="submit"
variant="contained"
color="secondary"
startIcon="notes"
onClick={submit}
disabled={createNote.isPending || !note.trim()}
sx={{ alignSelf: 'flex-end' }}
>
{createNote.isPending ? tc('saving') : t('note_submit')}
</AppButton>
</Stack>
</FormProvider>
</Paper>
) : null}
@@ -78,7 +78,7 @@ function OnboardingBanner({ state }: { state: CenterOnboardingState }) {
const { severity, key } = banner[state];
return (
<Alert severity={severity} sx={{ borderRadius: 2 }}>
<Alert severity={severity} sx={{ borderRadius: 'var(--bal-radius-md)' }}>
{t(key)}
</Alert>
);
@@ -89,7 +89,7 @@ function LicenseBlock({ center }: { center: PartnerCenter }) {
const t = useTranslations('partner');
return (
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 2 }}>
{t('license_title')}
</Typography>
@@ -69,7 +69,7 @@ export default function PartnerBookingDetailPage() {
<AdminEmptyState icon="bookings" title={t('booking_detail_not_found')} />
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
@@ -91,7 +91,7 @@ export default function PartnerBookingDetailPage() {
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('booking_detail_timeline_title')}
</Typography>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<StatusTimeline nodes={timelineNodes} />
</Paper>
</Stack>
@@ -92,7 +92,7 @@ function PartnerBookingsScreen() {
<Stack
direction="row"
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
>
<TextField
select
@@ -1,5 +1,5 @@
import SidebarShellSkeleton from '../_chrome/SidebarShellSkeleton';
import ShellContentSkeleton from '../_chrome/ShellContentSkeleton';
export default function Loading() {
return <SidebarShellSkeleton />;
return <ShellContentSkeleton />;
}
@@ -0,0 +1,38 @@
'use client';
import { Stack } from '@mui/material';
import { useTranslations } from 'next-intl';
import { PageHeader, ProfileSummary, StatusChip, SurfaceCard } from '@/components';
import { SettingsPanel, SignOutRow } from '@/components/settings';
import { useMyPartnerCenter } from '@/services/partnerCenter';
/**
* «بیشتر» group root for the partner portal the center's identity and merchant-of-record status
* (previously squeezed into the top bar, where the MoR indicator was hidden below `sm`), plus
* appearance/language and sign-out.
*/
export default function PartnerMorePage() {
const t = useTranslations('hub');
const tPartner = useTranslations('partner');
const { data: center, isLoading } = useMyPartnerCenter();
return (
<Stack sx={{ gap: 2 }}>
<PageHeader title={t('more_title')} />
<SurfaceCard>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<ProfileSummary displayName={center?.name ?? ''} loading={isLoading} />
{!isLoading && center ? (
<StatusChip
status={center.isMerchantOfRecord ? 'active' : 'neutral'}
label={center.isMerchantOfRecord ? tPartner('is_mor_yes') : tPartner('is_mor_no')}
/>
) : null}
</Stack>
</SurfaceCard>
<SettingsPanel />
<SignOutRow />
</Stack>
);
}
@@ -112,7 +112,7 @@ function PartnerSettlementScreen() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('settlement_title')} />
<Alert severity="info" sx={{ borderRadius: 2 }}>
<Alert severity="info" sx={{ borderRadius: 'var(--bal-radius-md)' }}>
{t('settlement_not_mor')}
</Alert>
</Box>
@@ -149,7 +149,7 @@ function PartnerSettlementScreen() {
}
/>
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Paper elevation={0} sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
<Stack direction="row" sx={{ gap: 2, alignItems: 'baseline', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('settlement_iban')}
@@ -0,0 +1,13 @@
'use client';
import type { ReactNode } from 'react';
import { FocusedLayout } from '@/layout';
/*
* First-use role picker. It sits outside every actor route group on purpose the session has no
* public role yet, so there is no app to show tabs for but it still needs the phone-width frame
* that the actor shells provide, hence the chrome-free `FocusedLayout` (logo strip + content, no
* bottom nav). No `RoleGuard` here: gating on a role is exactly what this page exists to resolve.
*/
export default function SelectRoleLayout({ children }: { children: ReactNode }) {
return <FocusedLayout>{children}</FocusedLayout>;
}
@@ -27,7 +27,7 @@ const ActivationChecklist: FunctionComponent = () => {
const t = useTranslations('activation');
const state = useActivationChecklist();
if (state.isLoading) return <Skeleton variant="rounded" height={220} sx={{ borderRadius: 2 }} />;
if (state.isLoading) return <Skeleton variant="rounded" height={220} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
if (state.isError) {
return <ErrorState message={t('load_error')} retryLabel={t('retry')} onRetry={state.retry} />;
}
@@ -44,7 +44,7 @@ const BnplPlanCard: FunctionComponent<BnplPlanCardProps> = ({ plan, orderAmountI
display: 'block',
width: '100%',
textAlign: 'start',
borderRadius: 2.5,
borderRadius: 'var(--bal-radius-md)',
p: 1.75,
border: '1px solid',
borderColor: selected ? 'var(--bal-secondary)' : 'divider',
@@ -24,7 +24,7 @@ const PREVIEW: CancellationPolicyPreview = {
bookingId: 5003,
cancellable: true,
cancellationPolicyCode: 'free_24h',
refundPercentageApplied: 1,
refundPercentageApplied: 100,
feePercentage: 0,
refundAmountIrr: '17600000',
feeAmountIrr: '0',
@@ -15,9 +15,10 @@ export interface CancellationPolicyDisclosureProps {
preview: CancellationPolicyPreview;
}
/** Percent (integer) from a 01 fraction — a small display number, never money, so JS math is safe. */
function toPercent(fraction: number): number {
return Math.round(fraction * 100);
/** Rounds an already-0100 percent value for display a small display number, never money, so JS math is
* safe. Never re-multiply by 100: the server (and the mock) already serve this scale. */
function toPercent(percent: number): number {
return Math.round(percent);
}
/**
@@ -39,7 +40,7 @@ const CancellationPolicyDisclosure: FunctionComponent<CancellationPolicyDisclosu
return (
<Stack sx={{ gap: 2.5 }} data-testid="cancellation-disclosure" data-policy-code={preview.cancellationPolicyCode}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
@@ -69,7 +70,7 @@ const CancellationPolicyDisclosure: FunctionComponent<CancellationPolicyDisclosu
/>
{isMultiSession && (
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.25 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('sessions_title')}
@@ -55,7 +55,7 @@ const CategoryTile: FunctionComponent<CategoryTileProps> = ({ label, iconKey, on
height: '100%',
minHeight: 116,
p: 2,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: selected ? 'var(--bal-primary)' : 'divider',
bgcolor: selected ? 'var(--bal-primary-soft)' : 'background.paper',
@@ -171,7 +171,7 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
{progress}%
</Typography>
</Stack>
<LinearProgress variant="determinate" value={progress} sx={{ borderRadius: 1, height: 6 }} />
<LinearProgress variant="determinate" value={progress} sx={{ borderRadius: 'var(--bal-radius-sm)', height: 6 }} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('upload_uploading')}
</Typography>
@@ -184,7 +184,7 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
component="img"
src={previewUrl}
alt=""
sx={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 1.5, flexShrink: 0 }}
sx={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 'var(--bal-radius-sm)', flexShrink: 0 }}
/>
) : (
<AppIcon icon="document" size={28} color="var(--bal-primary)" />
@@ -262,7 +262,7 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
}}
sx={{
p: 3,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px dashed',
borderColor: 'divider',
textAlign: 'center',
@@ -54,7 +54,7 @@ const EscrowExplainer: FunctionComponent = () => {
{t('escrow_explainer_toggle')}
</AppButton>
<Collapse in={open} id={EXPLAINER_CONTENT_ID}>
<Stack sx={{ gap: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack
direction="row"
sx={{ gap: { xs: 1.5, sm: 2 }, alignItems: 'flex-start', flexWrap: 'wrap', justifyContent: 'space-between' }}
@@ -25,6 +25,7 @@ const NURSE: NurseSearchResult = {
nurseId: 1,
variantId: 11,
serviceCategoryId: 1,
matchingServiceCount: 1,
nurseName: 'Maryam Rezaei',
avatarUrl: null,
isVerified: true,
@@ -112,6 +113,14 @@ describe('<NurseResultCard/> component', () => {
expect(screen.getByText(/منظم و دقیق/)).toBeInTheDocument();
});
it('discloses "+N more services" only when the nurse matched more than one variant', () => {
renderCard(NURSE);
expect(screen.queryByText(/more_services_count/)).not.toBeInTheDocument();
renderCard({ ...NURSE, matchingServiceCount: 3 });
expect(screen.getByText(/more_services_count:2/)).toBeInTheDocument();
});
it('falls back to a label when the name is missing (b7 join gap)', () => {
renderCard({ ...NURSE, nurseName: '' });
expect(screen.getByText('unnamed_nurse')).toBeInTheDocument();
@@ -65,7 +65,7 @@ const NurseResultCard = ({ nurse, serviceLabel, onSelect }: NurseResultCardProps
alignItems: 'flex-start',
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
cursor: 'pointer',
transition: 'border-color 120ms ease',
'&:hover': { borderColor: 'var(--bal-primary)' },
@@ -84,11 +84,12 @@ const NurseResultCard = ({ nurse, serviceLabel, onSelect }: NurseResultCardProps
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{name}
</Typography>
<TrustBadge state="verified" nurseId={nurse.nurseId} />
<TrustBadge state={nurse.isVerified ? 'verified' : 'unverified'} nurseId={nurse.nurseId} />
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{serviceLabel}
{nurse.matchingServiceCount > 1 ? ` · ${t('more_services_count', { count: nurse.matchingServiceCount - 1 })}` : ''}
</Typography>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap', mt: 0.25 }}>
@@ -145,7 +146,7 @@ const NurseResultCard = ({ nurse, serviceLabel, onSelect }: NurseResultCardProps
/** Matches the real card's anatomy (avatar disc, name+badge row, service-label row, gender+visits meta
* row, rating row, price row) so a loading list doesn't jump when data lands. */
const NurseResultCardSkeleton: FunctionComponent = () => (
<Paper elevation={0} sx={{ p: 2, display: 'flex', gap: 2, alignItems: 'flex-start', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2, display: 'flex', gap: 2, alignItems: 'flex-start', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Skeleton variant="circular" width={56} height={56} />
<Stack sx={{ gap: 0.75, flexGrow: 1 }}>
<Skeleton variant="text" width="55%" height={28} />

Some files were not shown because too many files have changed in this diff Show More