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 # Balinyaar Frontend Designer
Build UI that looks like Balinyaar and behaves correctly in both locales and both 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 color schemes on the first try.
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. **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`) · **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 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 **Beyond color**`tokens.css` also defines non-palette tokens (`colors.ts` never needs
these; they're define-only in CSS): these; they're define-only in CSS):
- **Radius** — `--bal-radius-sm` (4px, controls: buttons/inputs), `--bal-radius-md` - **Radius** — `--bal-radius-sm` (6px, controls: buttons/inputs), `--bal-radius-md`
(10px = `theme.shape.borderRadius`, the house default: cards/paper), `--bal-radius-lg` (8px = `theme.shape.borderRadius`, the house default: cards/paper), `--bal-radius-lg`
(16px: dialogs). Reference the token/constant, never invent a new radius. (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 - **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, 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. 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 emphasis** — `--bal-money-emphasis`, an AA-contrast-safe color for emphasized
money text. `--bal-secondary` (terracotta) fails AA contrast at small sizes on light money text. `--bal-secondary` (terracotta) fails AA contrast at small sizes on light
backgrounds — never use it for money text, use this token instead. 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 ## 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 (= `--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 - **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 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 **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` | | `AppIconButton` | icon-only actions | takes an icon `name`, `title`, `to`/`onClick` |
| `AppIcon` | any icon | `icon="home"` by registered name (§6); `size`, `color` props | | `AppIcon` | any icon | `icon="home"` by registered name (§6); `size`, `color` props |
| `AppLink` | internal/external links | locale-aware Next navigation; default underline `hover` | | `AppLink` | internal/external links | locale-aware Next navigation; default underline `hover` |
| `AppAlert` | inline alerts | default `severity="error"`, `variant="filled"` | | `AppAlert` | inline alerts | defaults to a calm `severity="info"`, `variant="standard"` — pass `severity="error"` explicitly when it really is an error |
| `AppImage` | images | wrapper around next/image conventions |
| `AppLoading` | loading state | default circular, `primary`, `3rem` | | `AppLoading` | loading state | default circular, `primary`, `3rem` |
| `ErrorBoundary` | wrapping fault-prone subtrees | already wraps page content in the shell | | `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 | | `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`, 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/ `APP_ICON_SIZE = 24`, `APP_ICON_STROKE_WIDTH = 1.75`, `APP_BUTTON_ICON_SIZE = 20`,
loading defaults). Change a default there, not per-call-site. `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`, For layout/spacing use MUI primitives directly: `Box`, `Stack`, `Container`, `Grid`,
`Paper`, `Card`. Use the `spacing`/`sx` system (theme spacing unit = 8px) — never inline `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 **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`, `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 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 ## 5. Layout & page shells
- **Four per-actor shells**, each wrapped in `RoleGuard` (don't touch): `CustomerLayout` **There is one layout: a phone.** `AppFrame` (`src/layout/AppFrame.tsx`) renders every
(mobile-first — contextual `TopBar`: brand lockup on the 5 root tabs, title + back screen inside a centered `APP_FRAME_MAX_WIDTH` (480px) column on a `--bal-frame-canvas`
chevron on pushed routes, an inline desktop top-nav at `≥md` replacing the mobile backdrop, at **every viewport**. A wider window gets more canvas, never a wider app —
`BottomBar`), `NurseLayout` / `AdminLayout` / `PartnerLayout` (all three share design one set of states, verify one set of states. Do not add a `≥md` branch that widens
`TopBarAndSideBarLayout`, `src/layout/`). All chrome navigation goes through a shell, restores a sidebar, or lays a screen out in columns.
`@/i18n/navigation` (`Link`/`usePathname`/`useRouter`) — never a raw `next/link` or a
manual `` `/${locale}` `` prefix. - `AppFrame` owns four structural guarantees, and is the only place any of them is
- `TopBarAndSideBarLayout`: a fixed `TopBar` (title from `useRouteTitle()`, the solved: the width cap; the **frame, not the document, owns the scroll** (a single
route→title map in `layout/routeTitle.tsx`) + a `SideBar` rendered as **two Drawers scrolling `<main>` fills the frame, with the bars pinned **`position: absolute`** over
sharing one content tree** — mobile `temporary` and desktop `variant="permanent"` — it — never `fixed`, which would break out of the centered column — and `<main>`
switched purely by `sx` breakpoint, not `useIsMobile()`; the permanent Drawer is a reserving each bar's exact height as padding, so no page needs a top offset);
normal flex sibling of the main column, so desktop reserves its own width with no `overflowX: hidden` + `minWidth: 0`, so an over-wide child clips rather than dragging
manual offset math and no post-hydration layout jump. Optional `identity` (TopBar the app sideways; and, above `sm`, the column **floats** as a rounded shadowed card
chip — admin/partner), `sidebarIdentity` (sidebar card — nurse's `ProfileSummary`), with a gutter all round (edge-to-edge on a phone). Genuinely wide content (a data
and `mobileBottomBar` slots. table) scrolls **inside its own container** — see `AdminDataTable`'s `TableContainer`.
- Sidebar nav items are `{ title, path, icon, group? }` arrays (`@/utils`'s - `AppFrame` also publishes **`--bal-chrome-top` / `--bal-chrome-bottom`** on the scroll
`LinkToPage`) built with `useTranslations('nav')`; a shared `group` string on container (already including `env(safe-area-inset-*)`, and `0px` in a chrome-free
consecutive items renders a `ListSubheader` section (see `NurseLayout`/`AdminLayout`). shell), so any `position: sticky` element can clear the bars without importing a
Selection is computed once via the shared `matchActivePath` (longest-prefix, constant. `StickyActionBar` is the reference consumer — don't recompute an offset.
winner-takes-all) helper — reuse it for any new nav list, never hand-roll - **One authenticated shell**: `MobileShell` = `AppFrame` + a contextual `TopBar` (brand
`pathname.startsWith`. lockup on a tab's own path, back chevron + `useRouteTitle()` on anything deeper) +
- **Public screens** use `PublicLayout` — a minimal corner strip (logo + `BottomBar` + `ErrorBoundary` + `RouteFadeIn`. The four actor layouts (`CustomerLayout`
`LocaleSwitcher` + dark toggle), no sidebar/bottom bar; the step content (`AuthCard`) / `NurseLayout` / `AdminLayout` / `PartnerLayout`, each wrapped in `RoleGuard`) supply
carries its own larger `BrandMark`, so don't duplicate a big lockup in the shell. 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. - Page content is auto-wrapped in `ErrorBoundary` inside every shell.
- Shell dimensions are constants in `src/layout/config.ts` (`SIDE_BAR_WIDTH = 240px`, - Shell dimensions are constants in `src/layout/config.ts` (`APP_FRAME_MAX_WIDTH`,
top-bar `56px` mobile / `64px` desktop). Respect them; don't hard-code. `TOP_BAR_HEIGHT`). Respect them; don't hard-code.
- A page is `src/app/[locale]/(private|public-routes)/…/page.tsx`. Keep page bodies to - A page is `src/app/[locale]/(private|public-routes)/…/page.tsx`. Keep page bodies to
composition + content; push reusable visuals into `src/components/`. composition + content; push reusable visuals into `src/components/`.
- Constrain reading width with `CONTENT_MAX_WIDTH` (800) for text-heavy views; full-bleed - `CONTENT_MAX_WIDTH` mirrors the frame width — a page column can never be wider than the
is fine for dashboards/tables. frame containing it.
- Prefer MUI breakpoints in `sx` (`{ xs: …, md: … }`) for responsive branching over - Prefer MUI breakpoints in `sx` for the little responsive branching that remains over
`useIsMobile()` (`@/hooks`) — the latter is JS/post-hydration and is what caused the `useIsMobile()` (`@/hooks`) — the latter is JS/post-hydration and caused a real SSR
desktop SSR flash `TopBarAndSideBarLayout` now avoids; reach for it only for genuinely flash; reach for it only for genuinely non-structural, JS-only behavior.
non-structural, JS-only behavior.
--- ---
## 6. Icons ## 6. Icons
Icons are a **name registry**, not free imports. `src/components/common/AppIcon/config.ts` 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 maps lowercase names → components. Render with `<AppIcon icon="home" />` or pass the name
the name to `AppButton`/`AppIconButton` (`icon="search"`). to `AppButton`/`AppIconButton` (`icon="search"`).
**One visual family: MUI `*Rounded`.** Every registered icon is the `Rounded` variant of **One visual family: Lucide.** Every registered icon comes from `lucide-react` — a
`@mui/icons-material` (warmer, softer strokes than the old filled/outlined mix — fits contemporary outline family on a 24px grid with round caps/joins, which reads far lighter
"clinical-but-human"). When adding an icon, import the `*Rounded` version; don't mix in a than the filled glyphs this registry used to carry at the small sizes a phone-width app
Filled/Outlined/Sharp/TwoTone icon next to it. ~90 names are registered today, spanning actually uses. `@mui/icons-material` is **no longer a dependency**; never reintroduce it.
navigation, catalog, verification, booking, payments, admin, and messaging — read The house stroke weight is `APP_ICON_STROKE_WIDTH` (1.75 — Lucide ships at 2, which
`AppIcon/config.ts` directly for the full list rather than duplicating it here (it drifts competes with Mikhak's lighter Persian strokes).
too fast for a skill doc to track reliably); the two structural rules below don't.
**`size` actually resizes now.** `AppIcon` drives size via `style.fontSize` (the basis for **The mapping is semantic, not incidental.** A name describes the domain concept
MUI SvgIcon's internal `1em` sizing) instead of `width`/`height` attributes, which MUI's ("verification", "earnings", "coverage") and the glyph depicts *that*, so swapping the
own CSS used to beat. `<AppIcon icon="verified" size={48} />` renders 48px — no more underlying glyph never leaks into call sites. Related concepts share a visual root on
silent 24px flattening. 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 **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` RTL (`back`, `chevron_start`, `chevron_end`, `forward`, `send`) are registered in `AppIcon/config.ts`'s
set. `AppIcon` stamps `data-icon-directional` on those, and one CSS rule `DIRECTIONAL_ICONS` set. `AppIcon` stamps `data-icon-directional` on those, and one CSS
(`app/globals.css`) does `[dir='rtl'] [data-icon-directional] { transform: scaleX(-1); }`. rule (`app/globals.css`) does
Adding a new directional icon is a one-line registry addition — never hand-roll a `[dir='rtl'] [data-icon-directional] { transform: scaleX(-1); }`. Adding a new directional
per-component flip. 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 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 `AppIcon/icons/` and must accept the same `size`/`color`/`strokeWidth` contract
`default` — never pass a raw MUI icon where a name is expected. (`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. switches automatically. Verify on both schemes — never assume a light background.
4. **Tokens, not hexes.** No raw color literals in `sx`/`styled`/components (§2). 4. **Tokens, not hexes.** No raw color literals in `sx`/`styled`/components (§2).
5. **Constants, not magic values.** Cookie names, routes, repeated dimensions, event 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. 6. **Use the wrappers** (§4) and the **icon registry** (§6) before bare MUI.
7. **Shared component ⇒ co-located test** (§4). 7. **Shared component ⇒ co-located test** (§4).
8. **MUI v9 API only.** No v5/v6-era props (e.g. `Stack` `useFlexGap`, `storageWindow`). 8. **MUI v9 API only.** No v5/v6-era props (e.g. `Stack` `useFlexGap`, `storageWindow`).
Avoid deprecated APIs that throw. 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 5. **Verify the four axes:** `/fa` (RTL) and `/en` (LTR) × light and dark. The default
route is `/fa` — start there. route is `/fa` — start there.
6. **Tests** for any new shared component; **never** add a layout above `[locale]` 6. **Tests** for any new shared component; **never** add a layout above `[locale]`
(breaks locale/dir — see CLAUDE.md). (breaks locale/dir — see [structure.md](../../../archive/docs/rules/client/structure.md)).
7. Data/fetch/auth/cookies/toasts → follow CLAUDE.md (`serverFetch`/`clientFetch`, 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. `@/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. - Hard-coded hex/rgb in components → use palette keys or `--bal-*` tokens.
- MUI default success/error colors for feedback → use `--bal-*` semantic 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`. - 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. - New shared component without a `.test.tsx`, or mocking MUI in tests.
- Re-introducing `src/app/layout.tsx` / any layout above `[locale]`. - 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 shells | `client/src/layout/` |
| Layout dimensions | `client/src/layout/config.ts` | | Layout dimensions | `client/src/layout/config.ts` |
| Messages (i18n) | `client/messages/{en,fa}.json` | | 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) - Frontend → [client/CLAUDE.md](client/CLAUDE.md)
- Backend → [server/CLAUDE.md](server/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 `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. just pointers so the convention is discoverable under either name.
+102 -71
View File
@@ -1,114 +1,145 @@
# Balinyaar — Repository Guide (root) # Balinyaar — Repository Guide (root)
This is the **shared, repo-wide** guide for AI coding agents. It is intentionally short. The **shared, repo-wide** guide for AI coding agents. It is intentionally short. Everything specific to one
Everything specific to one side of the stack lives in that project's own `CLAUDE.md`. 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.** > **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 `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. > 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. > Last verified: 2026-08-02 against commit `51e86a1`.
> `CLAUDE.md` is the single source of truth at every level.
--- ---
## What Balinyaar is ## What Balinyaar is
Balinyaar is a **trust-first home-nursing marketplace in Iran**. Independent nurses (and Balinyaar is a **trust-first home-nursing marketplace in Iran**. Independent nurses (and nursing-company
nursing-company employees) list configurable services; families search, book, pay, and review. employees) list configurable services; families search, book, pay, and review. The platform holds funds in an
The platform holds funds in an escrow-style ledger and pays nurses out weekly after a confirmed escrow-style ledger and pays nurses out weekly after a confirmed check-out.
check-out.
Product/domain knowledge — business rules, the database model, payments/BNPL, escrow, the **Start at [`mvp/README.md`](mvp/README.md) for the current state of the product**, in three short,
verification pipeline — is **not** in the code. It lives in [`product/`](product/), organized as a non-technical files: how to manually test any user journey, what's broken and blocking a real launch, and
**structured docs tree** (one topic per file; start at [product/index.md](product/index.md) or its what's missing that isn't clearly scheduled. That folder is the live, load-bearing answer to "what's next."
[README](product/README.md)):
| Folder | What it covers | 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)
| [product/overview/](product/overview/platform-summary.md) | What Balinyaar is, the four cross-cutting ground truths, Persian glossary. **Read first.** | during the 2026-08-02 documentation cleanup. It is **reference material, not required reading**: correct as
| [product/business/](product/business/index.md) | The 14 functional/business requirement areas, one file each | of that date, but not actively maintained going forward. Read it when `mvp/` doesn't answer your question in
| [product/data-model/](product/data-model/index.md) | The ~54-table SQL Server schema across 13 domains + [diagrams](product/data-model/diagrams.md) | enough depth — e.g. designing a new table, or needing the full reasoning behind a business rule.
| [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) |
**Read the relevant `product/` doc before designing any schema, API, or feature.** Don't infer **Never infer business rules from code alone** — the code is young. If `mvp/` and `archive/product/` both go
business rules from code — the code is young and the docs are the source of truth. silent on a money, auth, tenancy, or clinical-data rule, say so rather than guessing.
> **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`.
--- ---
## Repository layout ## Repository layout
This is **two independent projects in one repo**. There is no root-level build, package, or This is **two independent projects in one repo**, plus their documentation. There is no root-level build,
solution — each project is built, linted, and run on its own. 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) | | [`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) | | [`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) | | [`mvp/`](mvp/README.md) | **Current truth** — plain-language test flows, launch blockers, missing MVP features | Markdown | [mvp/README.md](mvp/README.md) |
| [`dev/`](dev/) | Build plan (not app code) | Markdown | [dev/README.md](dev/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 `AGENTS.md` files in this repo are thin pointers to the `CLAUDE.md` in the same folder. **`CLAUDE.md` is the
`NEXT_PUBLIC_API_URL`; the server listens on `https://localhost:5002` by default. 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 two projects communicate over **HTTP/JSON** (optionally gRPC). The client reads the API base URL from
the MVP: a chain of agent-runnable prompt files split into a `backend/` and a `frontend/` track `NEXT_PUBLIC_API_URL`; the server listens on `http://localhost:5002` by default.
([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 **Deployment** is three Docker containers — one `Dockerfile` per project directory, orchestrated by the root
frontend agent run in parallel without touching the same files. It is planning/tooling, **not** a third [`docker-compose.yml`](docker-compose.yml) — behind an existing Caddy reverse proxy on the external `caddy_net`
project — there is nothing to build in it. 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) ## Working agreements (apply to both projects)
1. **Stay within one project per change** unless the task explicitly spans both. 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 2. **Match the surrounding style.** Mirror existing patterns; don't introduce new ones. Each project documents
project documents its conventions in its own `CLAUDE.md`. its conventions in its own `CLAUDE.md`.
3. **Run that project's own checks before declaring work done:** 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. - client: `cd client && npm run check` (type + lint + copy), plus `npm run test:ci` if you touched a tested
- server: `dotnet build Baya.sln` and `dotnet test Baya.sln`. component.
4. **Read the product docs before changing behavior.** Business rules are decisions, not guesses. - server: `cd server && dotnet build Baya.sln` (**zero new warnings**) and `dotnet test Baya.sln`.
5. **Don't reintroduce template/starter scaffolding.** Both projects were derived from open-source - What "done" means in full: [archive/docs/rules/shared/git-and-gates.md](archive/docs/rules/shared/git-and-gates.md).
starters; their branding, demo/showcase pages, and `_TITLE_`/`_DESCRIPTION_` placeholders were 4. **Read [`mvp/`](mvp/README.md) (and `archive/product/` for depth) before changing behavior.** Business rules are decisions, not guesses.
intentionally removed. Don't add them back. 5. **Don't reintroduce template/starter scaffolding.** Both projects were derived from open-source starters;
6. **Never commit secrets.** Use `.env` (client) and `appsettings.*.json` / user-secrets (server). their branding, demo/showcase pages, and `_TITLE_`/`_DESCRIPTION_` placeholders were intentionally removed.
Real connection strings, keys, and tokens never enter git. Don't add them back.
7. **Keep docs honest, and keep the architecture map current.** If you change how something works, 6. **Configuration lives in files, not a secret store.** `dotnet user-secrets` is **not used** — the
update the `CLAUDE.md` that describes it in the same change. Each level documents its architecture `<UserSecretsId>` was removed from `Baya.Web.Api.csproj`, so that store **is not even read**. Any
in one canonical place — **this file's "Repository layout"** (repo), **client/CLAUDE.md "Project instruction anywhere to set a value with it is stale. Server config (including keys) lives in
Structure"** (frontend), **server/CLAUDE.md "Project map"** (backend). When a change alters that `appsettings.*.json`; client config in `.env.development` / `.env.production`; the deployment's
structure — adds, removes, or renames a project, layer, route group, provider, or major folder, or container-specific overrides in `docker-compose.yml`.
changes a cross-project / cross-layer boundary — update the matching architecture section in the This is a deliberate pre-launch trade for a demo deployment — **the repo therefore contains live
same change. Stale instructions are worse than none. 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.** 8. **Write clean, self-documenting code.**
- **No dead code.** Remove unused variables, imports/usings, parameters, and private members — - **No dead code.** Remove unused variables, imports/usings, parameters, and private members — don't leave
don't leave them behind and don't suppress the warning. The client enforces this with ESLint 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 (`@typescript-eslint/no-unused-vars` as an *error*); on the server they are build warnings and the gate is
the gate is zero new warnings. Per-project specifics live in each project's `CLAUDE.md` / zero new warnings.
`CONVENTIONS.md`. - **Comment the *why*, not the *what*.** Don't write verbose comments that restate what the code already
- **Comment the *why*, not the *what*.** Don't write verbose comments that restate what the code says. Add a comment only where a non-obvious decision, constraint, business rule, or trade-off isn't
already says. Add a comment only where a non-obvious decision, constraint, business rule, or evident from the code itself. Prefer a clearer name over a comment.
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 ## Naming
- The **server**'s C# namespaces, projects, and solution all use the `Baya*` prefix - The **server**'s C# namespaces, projects, and solution all use the `Baya*` prefix (`Baya.Web.Api`,
(`Baya.Web.Api`, `Baya.sln`). Keep new server code under the `Baya.*` convention. `Baya.sln`). Keep new server code under the `Baya.*` convention.
- The **client** package is `balinyaar-client`; the `@/*` import alias maps to `client/src/*`. - 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 — The product/brand name is **Balinyaar** — «بالین‌یار» in Persian copy, with a ZWNJ, always. The server's
do not rename it without explicit instruction. `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 cd client && npm install && npm run dev # http://localhost:3000
# Backend # 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 # 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 # 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). # bounded-canvas grid fallback (dev/CI/jsdom all work without a key).
# NEXT_PUBLIC_NESHAN_KEY = your-neshan-web-key # NEXT_PUBLIC_NESHAN_KEY = your-neshan-web-key
+6 -3
View File
@@ -1,10 +1,13 @@
# AGENTS.md — Balinyaar Web Client # AGENTS.md — Balinyaar Web Client
The canonical agent guide for the frontend is **[CLAUDE.md](CLAUDE.md)** (same folder). It is the The canonical agent guide for the frontend is **[CLAUDE.md](CLAUDE.md)** (same folder): stack,
engineering contract: stack, commands, lint/type gates, routing, providers, data fetching, theming, commands, the quality gates, the project structure, and the hard rules every change must follow.
i18n, cookies, and the 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) - 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) - Human setup/run instructions → [README.md](README.md)
- UI/design work → the **frontend-designer** skill - 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", "payouts": "Payouts",
"reviews": "Reviews", "reviews": "Reviews",
"config": "Configuration", "config": "Configuration",
"catalog": "Catalog",
"holidays": "Holidays", "holidays": "Holidays",
"alerts": "Alerts", "alerts": "Alerts",
"audit": "Audit log", "audit": "Audit log",
@@ -62,14 +63,18 @@
"currency_toman": "Toman", "currency_toman": "Toman",
"brand": "Balinyaar", "brand": "Balinyaar",
"brand_tagline": "Home care you can trust", "brand_tagline": "Home care you can trust",
"open_sidebar": "Open menu",
"switch_locale": "Switch to {locale}", "switch_locale": "Switch to {locale}",
"page_prev": "Previous", "page_prev": "Previous",
"page_next": "Next", "page_next": "Next",
"page_indicator": "Page {page} of {total}", "page_indicator": "Page {page} of {total}",
"discard_title": "Discard changes?", "discard_title": "Discard changes?",
"discard_body": "Your unsaved changes will be lost.", "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": { "shell": {
"customer_app": "Family app", "customer_app": "Family app",
@@ -185,7 +190,11 @@
"nurseProfile": { "nurseProfile": {
"title": "Nurse profile", "title": "Nurse profile",
"subtitle": "What families see when they find you.", "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.", "photo_hint": "A clear photo of your face.",
"upload": "Upload photo", "upload": "Upload photo",
"uploading": "Uploading…", "uploading": "Uploading…",
@@ -216,6 +225,7 @@
"education_other": "Other", "education_other": "Other",
"education_level_other_label": "Enter your education level", "education_level_other_label": "Enter your education level",
"education_field_other_label": "Enter your field of study", "education_field_other_label": "Enter your field of study",
"education_other_required": "Fill in the “other” value.",
"specializations_label": "Specialties", "specializations_label": "Specialties",
"preview_cta": "Preview my public profile", "preview_cta": "Preview my public profile",
"preview_title": "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_hint": "Building, street, unit — the detail a nurse needs to find the door.",
"line_required": "Enter the street address", "line_required": "Enter the street address",
"city_required": "Select a city", "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", "set_primary_toggle": "Set as primary address",
"map_hint": "Tap or drag the marker to the patient's exact location.", "map_hint": "Tap or drag the marker to the patient's exact location.",
"map_required": "Drop a pin on the map", "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.", "category_locked": "The category can't be changed after a service is created.",
"options_title": "Set the service options", "options_title": "Set the service options",
"options_subtitle": "Pick one option for each required item.", "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", "required_badge": "Required",
"optional_badge": "Optional", "optional_badge": "Optional",
"options_incomplete": "Answer every required item to continue.",
"price_title": "Set the price and unit",
"price_label": "Price (Toman)", "price_label": "Price (Toman)",
"price_hint": "Enter the price in Toman.", "price_hint": "Enter the price in Toman.",
"price_required": "Enter a valid price greater than zero.", "price_required": "Enter a valid price greater than zero.",
@@ -398,6 +410,10 @@
"preview_untitled": "New service", "preview_untitled": "New service",
"summary_options": "Options", "summary_options": "Options",
"summary_none": "No 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", "next": "Next",
"submit_create": "Add service", "submit_create": "Add service",
"submit_save": "Save changes", "submit_save": "Save changes",
@@ -463,6 +479,7 @@
"unnamed_nurse": "Nurse", "unnamed_nurse": "Nurse",
"unnamed_service": "Service", "unnamed_service": "Service",
"completed_visits": "{count, number} successful visits", "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}})", "reviews_count": "({count, plural, =0 {no reviews} one {# review} other {# reviews}})",
"distance_km": "{km} km", "distance_km": "{km} km",
"price_from": "from", "price_from": "from",
@@ -727,8 +744,8 @@
"accept_confirm_cta": "Yes, accept the request" "accept_confirm_cta": "Yes, accept the request"
}, },
"dashboard": { "dashboard": {
"greeting": "Hi, {name}",
"retry": "Retry", "retry": "Retry",
"title": "Today",
"next_visit_title": "Next visit", "next_visit_title": "Next visit",
"next_visit_empty": "No visits scheduled for today.", "next_visit_empty": "No visits scheduled for today.",
"next_visit_starts_in": "starts {relative}", "next_visit_starts_in": "starts {relative}",
@@ -762,6 +779,11 @@
"total_payable_label": "Total to pay", "total_payable_label": "Total to pay",
"secure_gateway_notice": "Secure payment via bank gateway", "secure_gateway_notice": "Secure payment via bank gateway",
"cta_pay": "Continue to payment", "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", "bnpl_option": "Or pay in installments",
"state_initiating": "Starting payment…", "state_initiating": "Starting payment…",
"state_redirecting": "Redirecting to the payment gateway…", "state_redirecting": "Redirecting to the payment gateway…",
@@ -839,12 +861,12 @@
"customer_switch": "Are you a family? Family sign in", "customer_switch": "Are you a family? Family sign in",
"rate_limited": "Too many attempts — please try again shortly", "rate_limited": "Too many attempts — please try again shortly",
"otp_title": "Enter the verification code", "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_customer": "Verify and continue",
"otp_verify_nurse": "Verify and sign in", "otp_verify_nurse": "Verify and sign in",
"otp_invalid": "The code is incorrect or has expired", "otp_invalid": "The code is incorrect or has expired",
"otp_locked": "Too many attempts. Request a new code to continue.", "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", "resend": "Resend code",
"change_number": "Change number", "change_number": "Change number",
"routing_title": "Signing you in…", "routing_title": "Signing you in…",
@@ -916,29 +938,34 @@
"group_review": "Review", "group_review": "Review",
"payoff_title": "This is the badge families will see", "payoff_title": "This is the badge families will see",
"payoff_subtitle": "It fills in as you complete each group below.", "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_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_label": "National ID",
"national_id_hint": "Enter your 10-digit national ID.", "national_id_hint": "Enter your 10-digit national ID.",
"national_id_invalid": "Enter a valid national ID.", "national_id_invalid": "Enter a valid national ID.",
"card_label": "National-ID card image", "card_label": "National-ID card image",
"card_hint": "Photograph your ID card in good light, clearly readable.", "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.", "capture_hint_card": "Good lighting, no blur, all four corners of the card inside the frame.",
"selfie_label": "Liveness selfie", "selfie_label": "Liveness selfie",
"selfie_hint": "Take a selfie for face verification.", "selfie_hint": "Take a selfie for face verification.",
"capture_hint_selfie": "Good lighting, face centered and unobstructed.", "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.", "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_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_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.", "error_shahkar_mismatch": "The mobile-to-national-ID match failed. Please check and try again.",
"identity_submit": "Submit & verify", "identity_submit": "Submit & verify",
"identity_submitting": "Verifying…", "identity_submitting": "Verifying…",
"identity_needs_selfie": "Capture the liveness selfie to continue.",
"identity_submitted": "Identity submitted — verification started", "identity_submitted": "Identity submitted — verification started",
"back_to_checklist": "Back to checklist", "back_to_checklist": "Back to checklist",
"credentials_title": "Professional credentials",
"credentials_subtitle": "Your documents are reviewed by our team after upload.", "credentials_subtitle": "Your documents are reviewed by our team after upload.",
"credentials_needs_start": "Start verification from the status checklist first.", "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_label": "Nursing-council number",
"ino_number_hint": "Enter your nursing-council membership number.", "ino_number_hint": "Enter your nursing-council membership number.",
"ino_number_required": "Enter your nursing-council number.", "ino_number_required": "Enter your nursing-council number.",
@@ -957,10 +984,12 @@
"specialty_wound_care": "Wound care", "specialty_wound_care": "Wound care",
"specialty_add_placeholder": "Another specialty", "specialty_add_placeholder": "Another specialty",
"specialty_add": "Add", "specialty_add": "Add",
"registry_details_label": "Credential details (optional)",
"issuing_authority_label": "Issuing authority", "issuing_authority_label": "Issuing authority",
"issued_at_label": "Issue date", "issued_at_label": "Issue date",
"expires_at_label": "Expiry 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.", "manual_review_note": "These documents are reviewed manually by our team — not an instant approval.",
"credentials_submit": "Submit credentials", "credentials_submit": "Submit credentials",
"credentials_submitting": "Submitting…", "credentials_submitting": "Submitting…",
@@ -1423,6 +1452,7 @@
"category_label": "Category", "category_label": "Category",
"subject_label": "Subject", "subject_label": "Subject",
"message_label": "Message", "message_label": "Message",
"message_required": "Write your message.",
"submit": "Send", "submit": "Send",
"submitting": "Sending…", "submitting": "Sending…",
"cancel": "Cancel", "cancel": "Cancel",
@@ -1664,6 +1694,45 @@
"hol_bank_hint": "When on, payouts falling on this day shift to the next business day.", "hol_bank_hint": "When on, payouts falling on this day shift to the next business day.",
"hol_saved": "Holiday saved.", "hol_saved": "Holiday saved.",
"hol_year": "Year", "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_title": "Support alerts",
"alert_subtitle": "Internal triage — never shown to customers or nurses.", "alert_subtitle": "Internal triage — never shown to customers or nurses.",
"alert_empty": "No open alerts.", "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.", "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_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": [ "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": "The service",
{ "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." }, "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": "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": "Bookings and payment",
{ "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." }, "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": "Contact", "body": "Questions about these terms can be sent through the support ticket system in the app." } },
{
"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_intro": "This draft policy explains what personal data Balinyaar collects to run the service, and how it is used.",
"privacy_sections": [ "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": "Information we collect",
{ "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." }, "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": "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": "How we use it",
{ "title": "Contact", "body": "Questions about this policy can be sent through the support ticket system in the app." } "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": { "welcome": {
@@ -2005,5 +2119,42 @@
"footer_contact_title": "Support", "footer_contact_title": "Support",
"footer_contact_body": "For any questions, reach us through the in-app support ticket system after you sign in.", "footer_contact_body": "For any questions, reach us through the in-app support ticket system after you sign in.",
"footer_copyright": "© {year, number} Balinyaar" "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": "تسویه‌ها", "payouts": "تسویه‌ها",
"reviews": "نظرات", "reviews": "نظرات",
"config": "پیکربندی", "config": "پیکربندی",
"catalog": "کاتالوگ",
"holidays": "تعطیلات", "holidays": "تعطیلات",
"alerts": "هشدارها", "alerts": "هشدارها",
"audit": "گزارش ممیزی", "audit": "گزارش ممیزی",
@@ -62,14 +63,18 @@
"currency_toman": "تومان", "currency_toman": "تومان",
"brand": "بالین‌یار", "brand": "بالین‌یار",
"brand_tagline": "مراقبت مطمئن در خانه", "brand_tagline": "مراقبت مطمئن در خانه",
"open_sidebar": "باز کردن منو",
"switch_locale": "تغییر به {locale}", "switch_locale": "تغییر به {locale}",
"page_prev": "قبلی", "page_prev": "قبلی",
"page_next": "بعدی", "page_next": "بعدی",
"page_indicator": "صفحه {page} از {total}", "page_indicator": "صفحه {page} از {total}",
"discard_title": "تغییرات نادیده گرفته شود؟", "discard_title": "تغییرات نادیده گرفته شود؟",
"discard_body": "تغییراتی که ذخیره نشده از دست می‌رود.", "discard_body": "تغییراتی که ذخیره نشده از دست می‌رود.",
"discard_confirm": "بله، نادیده بگیر" "discard_confirm": "بله، نادیده بگیر",
"language": "زبان",
"appearance": "نمایش",
"theme_light": "روشن",
"theme_dark": "تیره",
"theme_system": "سیستم"
}, },
"shell": { "shell": {
"customer_app": "اپلیکیشن خانواده", "customer_app": "اپلیکیشن خانواده",
@@ -185,7 +190,11 @@
"nurseProfile": { "nurseProfile": {
"title": "پروفایل پرستار", "title": "پروفایل پرستار",
"subtitle": "چیزی که خانواده‌ها هنگام یافتن شما می‌بینند.", "subtitle": "چیزی که خانواده‌ها هنگام یافتن شما می‌بینند.",
"photo": "عکس پروفایل", "section_intro_title": "معرفی شما",
"section_intro_description": "عکس و متنی که خانواده‌ها پیش از هر چیز می‌بینند.",
"section_experience_title": "تجربه و تحصیلات",
"section_experience_description": "سابقه و مدرک شما به خانواده‌ها کمک می‌کند با اطمینان انتخاب کنند.",
"section_specializations_description": "حوزه‌هایی که در آن‌ها بیشترین تجربه را دارید.",
"photo_hint": "یک عکس واضح از چهره‌تان.", "photo_hint": "یک عکس واضح از چهره‌تان.",
"upload": "بارگذاری عکس", "upload": "بارگذاری عکس",
"uploading": "در حال بارگذاری…", "uploading": "در حال بارگذاری…",
@@ -216,6 +225,7 @@
"education_other": "سایر", "education_other": "سایر",
"education_level_other_label": "مقطع تحصیلی خود را وارد کنید", "education_level_other_label": "مقطع تحصیلی خود را وارد کنید",
"education_field_other_label": "رشتهٔ تحصیلی خود را وارد کنید", "education_field_other_label": "رشتهٔ تحصیلی خود را وارد کنید",
"education_other_required": "مورد «سایر» را وارد کنید.",
"specializations_label": "تخصص‌ها", "specializations_label": "تخصص‌ها",
"preview_cta": "پیش‌نمایش نمایهٔ عمومی من", "preview_cta": "پیش‌نمایش نمایهٔ عمومی من",
"preview_title": "نمایهٔ عمومی من", "preview_title": "نمایهٔ عمومی من",
@@ -300,6 +310,10 @@
"line_hint": "پلاک، خیابان، واحد — جزئیاتی که پرستار برای یافتن درِ منزل نیاز دارد.", "line_hint": "پلاک، خیابان، واحد — جزئیاتی که پرستار برای یافتن درِ منزل نیاز دارد.",
"line_required": "نشانی کامل را وارد کنید", "line_required": "نشانی کامل را وارد کنید",
"city_required": "شهر را انتخاب کنید", "city_required": "شهر را انتخاب کنید",
"recipient_name_label": "نام تحویل‌گیرنده",
"recipient_name_required": "نام فردی که پرستار باید بپرسد را وارد کنید",
"recipient_phone_label": "شماره تماس تحویل‌گیرنده",
"recipient_phone_invalid": "یک شماره موبایل معتبر ایرانی وارد کنید",
"set_primary_toggle": "به‌عنوان آدرس اصلی تنظیم شود", "set_primary_toggle": "به‌عنوان آدرس اصلی تنظیم شود",
"map_hint": "برای تعیین محل دقیق بیمار، نشانگر را بکشید یا روی نقشه بزنید.", "map_hint": "برای تعیین محل دقیق بیمار، نشانگر را بکشید یا روی نقشه بزنید.",
"map_required": "روی نقشه یک پین بگذارید", "map_required": "روی نقشه یک پین بگذارید",
@@ -378,11 +392,9 @@
"category_locked": "دسته پس از ایجاد خدمت قابل تغییر نیست.", "category_locked": "دسته پس از ایجاد خدمت قابل تغییر نیست.",
"options_title": "گزینه‌های خدمت را مشخص کنید", "options_title": "گزینه‌های خدمت را مشخص کنید",
"options_subtitle": "برای هر مورد الزامی یک گزینه انتخاب کنید.", "options_subtitle": "برای هر مورد الزامی یک گزینه انتخاب کنید.",
"options_none": "این دسته گزینه‌ای برای تنظیم ندارد.", "options_missing_named": "هنوز انتخاب نشده: {groups}",
"required_badge": "الزامی", "required_badge": "الزامی",
"optional_badge": "اختیاری", "optional_badge": "اختیاری",
"options_incomplete": "برای ادامه، همهٔ موارد الزامی را انتخاب کنید.",
"price_title": "قیمت و واحد را تعیین کنید",
"price_label": "قیمت (تومان)", "price_label": "قیمت (تومان)",
"price_hint": "قیمت را به تومان وارد کنید.", "price_hint": "قیمت را به تومان وارد کنید.",
"price_required": "قیمتی معتبر و بزرگ‌تر از صفر وارد کنید.", "price_required": "قیمتی معتبر و بزرگ‌تر از صفر وارد کنید.",
@@ -398,6 +410,10 @@
"preview_untitled": "خدمت جدید", "preview_untitled": "خدمت جدید",
"summary_options": "گزینه‌ها", "summary_options": "گزینه‌ها",
"summary_none": "بدون گزینه", "summary_none": "بدون گزینه",
"section_recap_title": "خدمت انتخابی",
"section_recap_description": "پیش از تعیین قیمت، انتخاب‌های خود را مرور کنید.",
"section_price_title": "قیمت و واحد",
"section_listing_title": "نمایش در جستجو",
"next": "بعدی", "next": "بعدی",
"submit_create": "ثبت خدمت", "submit_create": "ثبت خدمت",
"submit_save": "ذخیره تغییرات", "submit_save": "ذخیره تغییرات",
@@ -463,6 +479,7 @@
"unnamed_nurse": "پرستار", "unnamed_nurse": "پرستار",
"unnamed_service": "خدمت", "unnamed_service": "خدمت",
"completed_visits": "{count, number} ویزیت موفق", "completed_visits": "{count, number} ویزیت موفق",
"more_services_count": "+{count, plural, one {# خدمت دیگر} other {# خدمت دیگر}}",
"reviews_count": "({count, plural, =0 {بدون نظر} one {# نظر} other {# نظر}})", "reviews_count": "({count, plural, =0 {بدون نظر} one {# نظر} other {# نظر}})",
"distance_km": "{km} کیلومتر", "distance_km": "{km} کیلومتر",
"price_from": "از", "price_from": "از",
@@ -727,8 +744,8 @@
"accept_confirm_cta": "بله، پذیرش درخواست" "accept_confirm_cta": "بله، پذیرش درخواست"
}, },
"dashboard": { "dashboard": {
"greeting": "سلام، {name}",
"retry": "تلاش مجدد", "retry": "تلاش مجدد",
"title": "امروز",
"next_visit_title": "ویزیت بعدی", "next_visit_title": "ویزیت بعدی",
"next_visit_empty": "امروز ویزیتی ندارید.", "next_visit_empty": "امروز ویزیتی ندارید.",
"next_visit_starts_in": "شروع {relative}", "next_visit_starts_in": "شروع {relative}",
@@ -762,6 +779,11 @@
"total_payable_label": "مبلغ قابل پرداخت", "total_payable_label": "مبلغ قابل پرداخت",
"secure_gateway_notice": "پرداخت امن از طریق درگاه بانکی", "secure_gateway_notice": "پرداخت امن از طریق درگاه بانکی",
"cta_pay": "ادامه پرداخت", "cta_pay": "ادامه پرداخت",
"gateway_title": "در حال انتقال به درگاه پرداخت",
"gateway_hint": "این صفحه نمایشی جایگزین درگاه بانکی است.",
"gateway_reference_label": "کد مرجع",
"gateway_pay_success": "پرداخت (نمایشی)",
"gateway_pay_fail": "انصراف",
"bnpl_option": "یا پرداخت اقساطی", "bnpl_option": "یا پرداخت اقساطی",
"state_initiating": "در حال آغاز پرداخت…", "state_initiating": "در حال آغاز پرداخت…",
"state_redirecting": "در حال انتقال به درگاه پرداخت…", "state_redirecting": "در حال انتقال به درگاه پرداخت…",
@@ -839,12 +861,12 @@
"customer_switch": "خانواده هستید؟ ورود خانواده‌ها", "customer_switch": "خانواده هستید؟ ورود خانواده‌ها",
"rate_limited": "به‌دلیل تلاش زیاد، کمی بعد دوباره تلاش کنید", "rate_limited": "به‌دلیل تلاش زیاد، کمی بعد دوباره تلاش کنید",
"otp_title": "کد تأیید را وارد کنید", "otp_title": "کد تأیید را وارد کنید",
"otp_sent_to": "کد به شماره {phone} ارسال شد", "otp_sent_to": "کد به شماره <phone></phone> ارسال شد",
"otp_verify_customer": "تأیید و ادامه", "otp_verify_customer": "تأیید و ادامه",
"otp_verify_nurse": "تأیید و ورود", "otp_verify_nurse": "تأیید و ورود",
"otp_invalid": "کد وارد شده نادرست یا منقضی شده است", "otp_invalid": "کد وارد شده نادرست یا منقضی شده است",
"otp_locked": "به‌دلیل تلاش‌های زیاد، ورود موقتاً قفل شد. کد جدید دریافت کنید.", "otp_locked": "به‌دلیل تلاش‌های زیاد، ورود موقتاً قفل شد. کد جدید دریافت کنید.",
"resend_in": "ارسال مجدد کد تا {time}", "resend_in": "ارسال مجدد کد تا <time></time>",
"resend": "ارسال مجدد کد", "resend": "ارسال مجدد کد",
"change_number": "تغییر شماره", "change_number": "تغییر شماره",
"routing_title": "در حال ورود…", "routing_title": "در حال ورود…",
@@ -916,29 +938,34 @@
"group_review": "بررسی", "group_review": "بررسی",
"payoff_title": "این نشان را خانواده‌ها می‌بینند", "payoff_title": "این نشان را خانواده‌ها می‌بینند",
"payoff_subtitle": "با تکمیل هر گروه در پایین، این نشان کامل‌تر می‌شود.", "payoff_subtitle": "با تکمیل هر گروه در پایین، این نشان کامل‌تر می‌شود.",
"identity_title": "تأیید هویت",
"identity_subtitle": "کد ملی، تصویر کارت ملی و یک سلفی زنده.", "identity_subtitle": "کد ملی، تصویر کارت ملی و یک سلفی زنده.",
"identity_step_number_title": "شمارهٔ ملی شما",
"national_id_label": "کد ملی", "national_id_label": "کد ملی",
"national_id_hint": "کد ملی ۱۰ رقمی خود را وارد کنید.", "national_id_hint": "کد ملی ۱۰ رقمی خود را وارد کنید.",
"national_id_invalid": "کد ملی معتبر وارد کنید.", "national_id_invalid": "کد ملی معتبر وارد کنید.",
"card_label": "تصویر کارت ملی", "card_label": "تصویر کارت ملی",
"card_hint": "کارت ملی را در نور کافی و خوانا عکس بگیرید.", "card_hint": "کارت ملی را در نور کافی و خوانا عکس بگیرید.",
"card_recommended": "بارگذاری تصویر کارت ملی توصیه می‌شود.", "card_recommended_short": "توصیه‌شده",
"capture_hint_card": "نور کافی، بدون تاری، چهار گوشهٔ کارت داخل کادر.", "capture_hint_card": "نور کافی، بدون تاری، چهار گوشهٔ کارت داخل کادر.",
"selfie_label": "سلفی زنده", "selfie_label": "سلفی زنده",
"selfie_hint": "برای تشخیص چهره، سلفی بگیرید.", "selfie_hint": "برای تشخیص چهره، سلفی بگیرید.",
"capture_hint_selfie": "نور کافی، چهره در مرکز و بدون پوشش.", "capture_hint_selfie": "نور کافی، چهره در مرکز و بدون پوشش.",
"capture_done": "ثبت شد",
"required_badge": "الزامی",
"auto_registry_note": "استعلام خودکار از ثبت احوال انجام می‌شود.", "auto_registry_note": "استعلام خودکار از ثبت احوال انجام می‌شود.",
"error_national_id_mismatch": "کد ملی با ثبت احوال مطابقت نداشت. لطفاً بررسی و دوباره تلاش کنید.", "error_national_id_mismatch": "کد ملی با ثبت احوال مطابقت نداشت. لطفاً بررسی و دوباره تلاش کنید.",
"error_shared_sim": "به نظر می‌رسد این سیم‌کارت به نام شما نیست. لطفاً با سیم‌کارتی که به نام خودتان است دوباره تلاش کنید.", "error_shared_sim": "به نظر می‌رسد این سیم‌کارت به نام شما نیست. لطفاً با سیم‌کارتی که به نام خودتان است دوباره تلاش کنید.",
"error_shahkar_mismatch": "تطبیق شماره موبایل و کد ملی ناموفق بود. لطفاً بررسی و دوباره تلاش کنید.", "error_shahkar_mismatch": "تطبیق شماره موبایل و کد ملی ناموفق بود. لطفاً بررسی و دوباره تلاش کنید.",
"identity_submit": "ثبت و استعلام", "identity_submit": "ثبت و استعلام",
"identity_submitting": "در حال استعلام…", "identity_submitting": "در حال استعلام…",
"identity_needs_selfie": "برای ادامه، سلفی زنده را ثبت کنید.",
"identity_submitted": "هویت ثبت شد — استعلام آغاز شد", "identity_submitted": "هویت ثبت شد — استعلام آغاز شد",
"back_to_checklist": "بازگشت به فهرست", "back_to_checklist": "بازگشت به فهرست",
"credentials_title": "مدارک حرفه‌ای",
"credentials_subtitle": "مدارک شما پس از بارگذاری، توسط کارشناس بررسی می‌شود.", "credentials_subtitle": "مدارک شما پس از بارگذاری، توسط کارشناس بررسی می‌شود.",
"credentials_needs_start": "ابتدا تأیید صلاحیت را از فهرست وضعیت آغاز کنید.", "credentials_needs_start": "ابتدا تأیید صلاحیت را از فهرست وضعیت آغاز کنید.",
"credentials_documents_title": "مدارک حرفه‌ای",
"credentials_documents_description": "هر مدرک را بارگذاری کنید؛ کارشناس آن را بررسی می‌کند.",
"credentials_documents_count": "{done} از {total}",
"ino_number_label": "شماره نظام پرستاری", "ino_number_label": "شماره نظام پرستاری",
"ino_number_hint": "شماره عضویت نظام پرستاری خود را وارد کنید.", "ino_number_hint": "شماره عضویت نظام پرستاری خود را وارد کنید.",
"ino_number_required": "شماره نظام پرستاری را وارد کنید.", "ino_number_required": "شماره نظام پرستاری را وارد کنید.",
@@ -957,10 +984,12 @@
"specialty_wound_care": "زخم و پانسمان", "specialty_wound_care": "زخم و پانسمان",
"specialty_add_placeholder": "تخصص دیگر", "specialty_add_placeholder": "تخصص دیگر",
"specialty_add": "افزودن", "specialty_add": "افزودن",
"registry_details_label": "جزئیات مدرک (اختیاری)",
"issuing_authority_label": "مرجع صادرکننده", "issuing_authority_label": "مرجع صادرکننده",
"issued_at_label": "تاریخ صدور", "issued_at_label": "تاریخ صدور",
"expires_at_label": "تاریخ انقضا", "expires_at_label": "تاریخ انقضا",
"registry_details_title": "جزئیات مدرک",
"registry_details_description": "مرجع صادرکننده و تاریخ‌ها بررسی را سریع‌تر می‌کنند.",
"summary_none": "موردی ثبت نشده",
"manual_review_note": "این مدارک به‌صورت دستی توسط کارشناس بررسی می‌شوند؛ تأیید فوری نیست.", "manual_review_note": "این مدارک به‌صورت دستی توسط کارشناس بررسی می‌شوند؛ تأیید فوری نیست.",
"credentials_submit": "ثبت مدارک", "credentials_submit": "ثبت مدارک",
"credentials_submitting": "در حال ثبت…", "credentials_submitting": "در حال ثبت…",
@@ -1423,6 +1452,7 @@
"category_label": "دسته", "category_label": "دسته",
"subject_label": "موضوع", "subject_label": "موضوع",
"message_label": "پیام", "message_label": "پیام",
"message_required": "متن پیام را بنویسید.",
"submit": "ارسال", "submit": "ارسال",
"submitting": "در حال ارسال…", "submitting": "در حال ارسال…",
"cancel": "انصراف", "cancel": "انصراف",
@@ -1664,6 +1694,45 @@
"hol_bank_hint": "در صورت فعال بودن، تسویه‌های این روز به روز کاری بعد منتقل می‌شوند.", "hol_bank_hint": "در صورت فعال بودن، تسویه‌های این روز به روز کاری بعد منتقل می‌شوند.",
"hol_saved": "تعطیلی ذخیره شد.", "hol_saved": "تعطیلی ذخیره شد.",
"hol_year": "سال", "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_title": "هشدارهای پشتیبانی",
"alert_subtitle": "صف داخلی — هرگز به مشتری یا پرستار نمایش داده نمی‌شود.", "alert_subtitle": "صف داخلی — هرگز به مشتری یا پرستار نمایش داده نمی‌شود.",
"alert_empty": "هشداری برای رسیدگی نیست.", "alert_empty": "هشداری برای رسیدگی نیست.",
@@ -1951,24 +2020,69 @@
"draft_banner": "این متن پیش‌نویس است و هنوز توسط تیم حقوقی بازبینی نشده؛ پیش از انتشار نهایی قابل استناد نیست.", "draft_banner": "این متن پیش‌نویس است و هنوز توسط تیم حقوقی بازبینی نشده؛ پیش از انتشار نهایی قابل استناد نیست.",
"terms_intro": "این شرایط پیش‌نویس، نحوه ارتباط بالین‌یار بین خانواده‌ها و پرستاران مستقل و تأییدشده مراقبت در منزل را توضیح می‌دهد. با ساخت حساب کاربری، شرایط زیر را می‌پذیرید.", "terms_intro": "این شرایط پیش‌نویس، نحوه ارتباط بالین‌یار بین خانواده‌ها و پرستاران مستقل و تأییدشده مراقبت در منزل را توضیح می‌دهد. با ساخت حساب کاربری، شرایط زیر را می‌پذیرید.",
"terms_sections": [ "terms_sections": [
{ "title": "ماهیت خدمت", "body": "بالین‌یار یک بازارگاه است و پرستاران را استخدام نمی‌کند. پرستاران مستقل یا شاغل در مراکز پرستاری، خدمات خود را ثبت می‌کنند و خانواده‌ها از طریق پلتفرم جستجو، رزرو و پرداخت انجام می‌دهند." }, {
{ "title": "رزرو و پرداخت", "body": "مبلغ کامل رزرو را از طریق بالین‌یار و با کارت پرداخت می‌کنید. این مبلغ به‌صورت امانی نزد بالین‌یار نگه‌داری می‌شود و تنها پس از تأیید انجام خدمت و پایان مهلت اعتراض، به‌صورت هفتگی به پرستار پرداخت می‌شود." }, "title": "ماهیت خدمت",
{ "title": "لغو و بازگشت وجه", "body": "لغو یک رزرو تأییدشده، بسته به فاصله زمانی تا زمان مراجعه، ممکن است مشمول کارمزد شود که پیش از تأیید نهایی به شما نمایش داده می‌شود. مبالغ بازگشتی تأییدشده به همان روش پرداخت اصلی یا ارائه‌دهنده مربوطه بازمی‌گردد." }, "body": "بالین‌یار یک بازارگاه است و پرستاران را استخدام نمی‌کند. پرستاران مستقل یا شاغل در مراکز پرستاری، خدمات خود را ثبت می‌کنند و خانواده‌ها از طریق پلتفرم جستجو، رزرو و پرداخت انجام می‌دهند."
{ "title": "احراز هویت پرستاران", "body": "هر پرستار پیش از قابل‌رزرو شدن، مراحل احراز هویت، بررسی پروانه صلاحیت حرفه‌ای و سایر مراحل الزامی را می‌گذراند. آنچه تأیید شده در پروفایل او نمایش داده می‌شود." }, },
{ "title": "مسئولیت‌های شما", "body": "اطلاعات دقیق درباره فرد دریافت‌کننده مراقبت ارائه دهید، برای هر موضوع مرتبط با رزرو از طریق سامانه تیکت پشتیبانی اپلیکیشن ارتباط بگیرید و با پرستاران محترمانه رفتار کنید." }, {
{ "title": "مسئولیت‌پذیری", "body": "بالین‌یار واسط رزرو بین خانواده‌ها و پرستاران مستقل است و خود ارائه‌دهنده خدمات درمانی نیست. اختلافات از طریق سامانه تیکت پشتیبانی رسیدگی می‌شود." }, "title": "رزرو و پرداخت",
{ "title": "تغییر این شرایط", "body": "ممکن است این شرایط با تحول خدمت به‌روزرسانی شود. تغییرات مهم پیش از اعمال، در اپلیکیشن اطلاع‌رسانی می‌شود." }, "body": "مبلغ کامل رزرو را از طریق بالین‌یار و با کارت پرداخت می‌کنید. این مبلغ به‌صورت امانی نزد بالین‌یار نگه‌داری می‌شود و تنها پس از تأیید انجام خدمت و پایان مهلت اعتراض، به‌صورت هفتگی به پرستار پرداخت می‌شود."
{ "title": "تماس با ما", "body": "سوالات درباره این شرایط را می‌توانید از طریق سامانه تیکت پشتیبانی در اپلیکیشن ارسال کنید." } },
{
"title": "لغو و بازگشت وجه",
"body": "لغو یک رزرو تأییدشده، بسته به فاصله زمانی تا زمان مراجعه، ممکن است مشمول کارمزد شود که پیش از تأیید نهایی به شما نمایش داده می‌شود. مبالغ بازگشتی تأییدشده به همان روش پرداخت اصلی یا ارائه‌دهنده مربوطه بازمی‌گردد."
},
{
"title": "احراز هویت پرستاران",
"body": "هر پرستار پیش از قابل‌رزرو شدن، مراحل احراز هویت، بررسی پروانه صلاحیت حرفه‌ای و سایر مراحل الزامی را می‌گذراند. آنچه تأیید شده در پروفایل او نمایش داده می‌شود."
},
{
"title": "مسئولیت‌های شما",
"body": "اطلاعات دقیق درباره فرد دریافت‌کننده مراقبت ارائه دهید، برای هر موضوع مرتبط با رزرو از طریق سامانه تیکت پشتیبانی اپلیکیشن ارتباط بگیرید و با پرستاران محترمانه رفتار کنید."
},
{
"title": "مسئولیت‌پذیری",
"body": "بالین‌یار واسط رزرو بین خانواده‌ها و پرستاران مستقل است و خود ارائه‌دهنده خدمات درمانی نیست. اختلافات از طریق سامانه تیکت پشتیبانی رسیدگی می‌شود."
},
{
"title": "تغییر این شرایط",
"body": "ممکن است این شرایط با تحول خدمت به‌روزرسانی شود. تغییرات مهم پیش از اعمال، در اپلیکیشن اطلاع‌رسانی می‌شود."
},
{
"title": "تماس با ما",
"body": "سوالات درباره این شرایط را می‌توانید از طریق سامانه تیکت پشتیبانی در اپلیکیشن ارسال کنید."
}
], ],
"privacy_intro": "این پیش‌نویس سیاست حریم خصوصی، اطلاعات شخصی که بالین‌یار برای ارائه خدمت جمع‌آوری می‌کند و نحوه استفاده از آن را توضیح می‌دهد.", "privacy_intro": "این پیش‌نویس سیاست حریم خصوصی، اطلاعات شخصی که بالین‌یار برای ارائه خدمت جمع‌آوری می‌کند و نحوه استفاده از آن را توضیح می‌دهد.",
"privacy_sections": [ "privacy_sections": [
{ "title": "اطلاعاتی که جمع‌آوری می‌کنیم", "body": "شماره موبایل برای ورود؛ برای پرستاران، کد ملی و اطلاعات پروانه برای احراز هویت؛ اطلاعات مراقبتی بیمار که شما یا پرستار وارد می‌کنید؛ موقعیت تقریبی محل مراجعه برای ورود/خروج پرستار؛ و اطلاعات فراداده پرداخت از ارائه‌دهنده درگاه پرداخت." }, {
{ "title": "نحوه استفاده", "body": "برای ایجاد و مدیریت رزروها، احراز هویت و اعتبارسنجی پرستاران، پردازش پرداخت‌ها و تسویه هفتگی پرستاران، و ارائه پشتیبانی." }, "title": "اطلاعاتی که جمع‌آوری می‌کنیم",
{ "title": "اشتراک‌گذاری اطلاعات", "body": "ارائه‌دهندگان مجاز پرداخت، سرویس‌های احراز هویت، و مرکز مشاوره و ارائه مراقبت‌های پرستاری در منزل طرف قرارداد ما، تنها به میزان لازم برای انجام وظیفه خود به اطلاعات دسترسی دارند." }, "body": "شماره موبایل برای ورود؛ برای پرستاران، کد ملی و اطلاعات پروانه برای احراز هویت؛ اطلاعات مراقبتی بیمار که شما یا پرستار وارد می‌کنید؛ موقعیت تقریبی محل مراجعه برای ورود/خروج پرستار؛ و اطلاعات فراداده پرداخت از ارائه‌دهنده درگاه پرداخت."
{ "title": "امنیت اطلاعات", "body": "فیلدهای حساس مانند کد ملی و یادداشت‌های بالینی رمزنگاری می‌شوند. دسترسی به پرونده مراقبتی بیمار تنها برای خانواده و پرستار مسئول امکان‌پذیر است." }, },
{ "title": "حقوق شما", "body": "می‌توانید بیشتر اطلاعات خود را از پروفایل خود مشاهده و ویرایش کنید و برای پرسش، اصلاح یا درخواست حذف اطلاعات با پشتیبانی در تماس باشید." }, {
{ "title": "تغییر این سیاست", "body": "ممکن است این سیاست با تحول خدمت به‌روزرسانی شود. تغییرات مهم پیش از اعمال، در اپلیکیشن اطلاع‌رسانی می‌شود." }, "title": "نحوه استفاده",
{ "title": "تماس با ما", "body": "سوالات درباره این سیاست را می‌توانید از طریق سامانه تیکت پشتیبانی در اپلیکیشن ارسال کنید." } "body": "برای ایجاد و مدیریت رزروها، احراز هویت و اعتبارسنجی پرستاران، پردازش پرداخت‌ها و تسویه هفتگی پرستاران، و ارائه پشتیبانی."
},
{
"title": "اشتراک‌گذاری اطلاعات",
"body": "ارائه‌دهندگان مجاز پرداخت، سرویس‌های احراز هویت، و مرکز مشاوره و ارائه مراقبت‌های پرستاری در منزل طرف قرارداد ما، تنها به میزان لازم برای انجام وظیفه خود به اطلاعات دسترسی دارند."
},
{
"title": "امنیت اطلاعات",
"body": "فیلدهای حساس مانند کد ملی و یادداشت‌های بالینی رمزنگاری می‌شوند. دسترسی به پرونده مراقبتی بیمار تنها برای خانواده و پرستار مسئول امکان‌پذیر است."
},
{
"title": "حقوق شما",
"body": "می‌توانید بیشتر اطلاعات خود را از پروفایل خود مشاهده و ویرایش کنید و برای پرسش، اصلاح یا درخواست حذف اطلاعات با پشتیبانی در تماس باشید."
},
{
"title": "تغییر این سیاست",
"body": "ممکن است این سیاست با تحول خدمت به‌روزرسانی شود. تغییرات مهم پیش از اعمال، در اپلیکیشن اطلاع‌رسانی می‌شود."
},
{
"title": "تماس با ما",
"body": "سوالات درباره این سیاست را می‌توانید از طریق سامانه تیکت پشتیبانی در اپلیکیشن ارسال کنید."
}
] ]
}, },
"welcome": { "welcome": {
@@ -2005,5 +2119,42 @@
"footer_contact_title": "پشتیبانی", "footer_contact_title": "پشتیبانی",
"footer_contact_body": "برای هر سوالی، پس از ورود از طریق سامانه تیکت پشتیبانی اپلیکیشن با ما در تماس باشید.", "footer_contact_body": "برای هر سوالی، پس از ورود از طریق سامانه تیکت پشتیبانی اپلیکیشن با ما در تماس باشید.",
"footer_copyright": "© {year, number} بالین‌یار" "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, reactStrictMode: true,
turbopack: { turbopack: {
root: '.' 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); export default withNextIntl(nextConfig);
+30 -31
View File
@@ -12,7 +12,6 @@
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/server": "^11.11.0", "@emotion/server": "^11.11.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.1.1",
"@mui/material": "^9.1.1", "@mui/material": "^9.1.1",
"@mui/material-nextjs": "^9.1.1", "@mui/material-nextjs": "^9.1.1",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
@@ -23,11 +22,13 @@
"jalaali-js": "^2.0.0", "jalaali-js": "^2.0.0",
"js-cookie": "^3.0.8", "js-cookie": "^3.0.8",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^1.27.0",
"next": "^16.2.9", "next": "^16.2.9",
"next-intl": "^4.13.0", "next-intl": "^4.13.0",
"notistack": "^3.0.2", "notistack": "^3.0.2",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-hook-form": "^7.83.0",
"stylis-plugin-rtl": "^2.1.1" "stylis-plugin-rtl": "^2.1.1"
}, },
"devDependencies": { "devDependencies": {
@@ -559,9 +560,9 @@
} }
}, },
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.2", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -1997,38 +1998,11 @@
"url": "https://opencollective.com/mui-org" "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": { "node_modules/@mui/material": {
"version": "9.1.1", "version": "9.1.1",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-9.1.1.tgz", "resolved": "https://registry.npmjs.org/@mui/material/-/material-9.1.1.tgz",
"integrity": "sha512-Wv+gInjrpf99l1Q0oHe0eOWGTnlbkzs5nowClX65KCT/2fyPMwcbFEEkUsOHdpcHhB5UAbz/d7jlwt5ajWVvlA==", "integrity": "sha512-Wv+gInjrpf99l1Q0oHe0eOWGTnlbkzs5nowClX65KCT/2fyPMwcbFEEkUsOHdpcHhB5UAbz/d7jlwt5ajWVvlA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.29.2", "@babel/runtime": "^7.29.2",
"@mui/core-downloads-tracker": "^9.1.1", "@mui/core-downloads-tracker": "^9.1.1",
@@ -9228,6 +9202,15 @@
"yallist": "^3.0.2" "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": { "node_modules/lz-string": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@@ -10290,6 +10273,22 @@
"react": "^19.2.7" "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": { "node_modules/react-is": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "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/react": "^11.14.0",
"@emotion/server": "^11.11.0", "@emotion/server": "^11.11.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.1.1",
"@mui/material": "^9.1.1", "@mui/material": "^9.1.1",
"@mui/material-nextjs": "^9.1.1", "@mui/material-nextjs": "^9.1.1",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
@@ -32,11 +31,13 @@
"jalaali-js": "^2.0.0", "jalaali-js": "^2.0.0",
"js-cookie": "^3.0.8", "js-cookie": "^3.0.8",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^1.27.0",
"next": "^16.2.9", "next": "^16.2.9",
"next-intl": "^4.13.0", "next-intl": "^4.13.0",
"notistack": "^3.0.2", "notistack": "^3.0.2",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-hook-form": "^7.83.0",
"stylis-plugin-rtl": "^2.1.1" "stylis-plugin-rtl": "^2.1.1"
}, },
"devDependencies": { "devDependencies": {
+4 -1
View File
@@ -1,8 +1,11 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Lints client/messages/fa.json against the banned-orthography-variant rules in * 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. * 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 { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -35,7 +35,7 @@ interface NudgeCardProps {
const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLabel, to, onDismiss, dismissLabel }) => ( const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLabel, to, onDismiss, dismissLabel }) => (
<Paper <Paper
elevation={0} 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)" /> <AppIcon icon={icon} size={28} color="var(--bal-primary)" />
<Stack sx={{ gap: 1, flexGrow: 1, minWidth: 0 }}> <Stack sx={{ gap: 1, flexGrow: 1, minWidth: 0 }}>
@@ -243,7 +243,7 @@ const CategoryGrid: FunctionComponent<{ onSelect: (categoryId: number) => void }
{isLoading ? ( {isLoading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}> <Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => ( {[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> </Box>
) : isError ? ( ) : isError ? (
@@ -172,6 +172,8 @@ export default function AddressesPage() {
latitude: editing.latitude, latitude: editing.latitude,
longitude: editing.longitude, longitude: editing.longitude,
isPrimary: editing.isPrimary, isPrimary: editing.isPrimary,
recipientName: editing.recipientName,
recipientPhone: editing.recipientPhone,
} }
: undefined : undefined
} }
@@ -2,11 +2,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl'; 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 AppButton from '@/components/common/AppButton';
import AppAlert from '@/components/common/AppAlert'; import AppAlert from '@/components/common/AppAlert';
import AppLoading from '@/components/common/AppLoading'; import AppLoading from '@/components/common/AppLoading';
import Money from '@/components/common/Money'; import Money from '@/components/common/Money';
import { RhfControlGroup, RhfTextField } from '@/components/common/form';
import StepperHeader from '@/components/StepperHeader'; import StepperHeader from '@/components/StepperHeader';
import CancellationPolicyDisclosure from '@/components/CancellationPolicyDisclosure'; import CancellationPolicyDisclosure from '@/components/CancellationPolicyDisclosure';
import { ContactSupportDialog } from '@/components/messaging'; import { ContactSupportDialog } from '@/components/messaging';
@@ -34,6 +36,12 @@ function cancelErrorKey(error: unknown): string {
return 'err_generic'; 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 * 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; * 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 cancel = useCancelBooking();
const [step, setStep] = useState<0 | 1>(0); 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); 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}`; const bookingHref = `/${locale}${ROUTES.BOOKINGS}/${bookingId}`;
@@ -108,19 +121,22 @@ export default function CancelBookingPage() {
); );
} }
const submit = () => const submit = () => {
const values = getValues();
cancel.mutate( cancel.mutate(
{ {
bookingId, bookingId,
sessionIds: preview.refundableSessionIds, sessionIds: preview.refundableSessionIds,
// Guaranteed non-empty: step 1 is only reachable once a reason is chosen (the continue CTA gate). // Guaranteed non-empty: step 1 is only reachable once a reason is chosen (the continue CTA gate).
reasonCategory: reasonCategory as CancelReasonCategory, reasonCategory: values.reasonCategory as CancelReasonCategory,
reasonNotes: reasonNotes.trim() || undefined, reasonNotes: values.reasonNotes.trim() || undefined,
}, },
{ onSuccess: () => router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`) }, { onSuccess: () => router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`) },
); );
};
return ( return (
<FormProvider {...form}>
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%', py: 2 }}> <Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%', py: 2 }}>
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}> <Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
{t('cancel_title')} {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 {/* 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 available below. Real rescheduling is DEFERRED (product decision + backend); this opens a
coordination ticket instead. */} 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' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('offramp_note')} {t('offramp_note')}
</Typography> </Typography>
@@ -158,13 +174,7 @@ export default function CancelBookingPage() {
<CancellationPolicyDisclosure preview={preview} /> <CancellationPolicyDisclosure preview={preview} />
<TextField <RhfTextField<CancelFormValues> name="reasonCategory" select label={t('reason_field_label')} fullWidth>
select
label={t('reason_field_label')}
value={reasonCategory}
onChange={(event) => setReasonCategory(event.target.value as CancelReasonCategory)}
fullWidth
>
<MenuItem value="" disabled> <MenuItem value="" disabled>
{t('reason_placeholder')} {t('reason_placeholder')}
</MenuItem> </MenuItem>
@@ -173,19 +183,24 @@ export default function CancelBookingPage() {
{t(`reason_cat_${category}`)} {t(`reason_cat_${category}`)}
</MenuItem> </MenuItem>
))} ))}
</TextField> </RhfTextField>
<TextField <RhfTextField<CancelFormValues>
name="reasonNotes"
label={t('reason_notes_label')} label={t('reason_notes_label')}
value={reasonNotes}
onChange={(event) => setReasonNotes(event.target.value)}
multiline multiline
minRows={2} minRows={2}
fullWidth fullWidth
/> />
<RhfControlGroup<CancelFormValues> name="acknowledged">
{({ field }) => (
<FormControlLabel <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')} label={t('acknowledge_label')}
/> />
)}
</RhfControlGroup>
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', flexWrap: 'wrap' }}> <Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', flexWrap: 'wrap' }}>
<AppButton variant="text" color="inherit" onClick={() => router.push(bookingHref)}> <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 }}> <Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1 }}>
{t('confirm_title')} {t('confirm_title')}
</Typography> </Typography>
@@ -252,5 +267,6 @@ export default function CancelBookingPage() {
</> </>
)} )}
</Stack> </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`). // A malformed id can never load — navigation, not a retry (a manual refetch() bypasses `enabled`).
if (!validId) { if (!validId) {
return ( 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' }}> <Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon="error" size={44} color="var(--bal-error)" /> <AppIcon icon="error" size={44} color="var(--bal-error)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}> <Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
@@ -89,7 +89,7 @@ export default function BookingInvoicePage() {
if (!invoice) { if (!invoice) {
const notIssued = error instanceof ApiError && error.status === 404; const notIssued = error instanceof ApiError && error.status === 404;
return ( 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' }}> <Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={notIssued ? 'document' : 'error'} size={44} color={notIssued ? 'var(--bal-warning)' : 'var(--bal-error)'} /> <AppIcon icon={notIssued ? 'document' : 'error'} size={44} color={notIssued ? 'var(--bal-warning)' : 'var(--bal-error)'} />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}> <Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
@@ -166,7 +166,7 @@ export default function BookingInvoicePage() {
<Paper <Paper
elevation={0} elevation={0}
className={PRINT_AREA_CLASS} 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 sx={{ gap: 2 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}> <Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
@@ -1,10 +1,20 @@
'use client'; 'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { Avatar, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material'; import { Avatar, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, EmptyState, RatingInput, ReviewTagSelector, StatusChip, SurfaceCard } from '@/components'; import {
AppButton,
AppIcon,
EmptyState,
RatingInput,
ReviewTagSelector,
RhfControlGroup,
RhfTextField,
StatusChip,
SurfaceCard,
} from '@/components';
import type { StatusKind } from '@/components'; import type { StatusKind } from '@/components';
import { formatShamsiDate } from '@/utils'; import { formatShamsiDate } from '@/utils';
import { useBookingDetail } from '@/services/bookings'; import { useBookingDetail } from '@/services/bookings';
@@ -24,6 +34,12 @@ function variantName(snapshotJson: string): string | null {
const REVIEW_BODY_MAX = 2000; const REVIEW_BODY_MAX = 2000;
interface ReviewFormValues {
rating: number;
body: string;
tagCodes: string[];
}
/** moderationStatus → StatusChip kind (published=success, pending=warning, rejected=error, hidden=neutral). */ /** moderationStatus → StatusChip kind (published=success, pending=warning, rejected=error, hidden=neutral). */
const STATUS_KIND: Record<ModerationStatus, StatusKind> = { const STATUS_KIND: Record<ModerationStatus, StatusKind> = {
pending_moderation: 'pending', pending_moderation: 'pending',
@@ -57,19 +73,19 @@ export default function LeaveReviewPage() {
const myReview = useMyReviewForBooking(bookingId, { enabled: reviewable }); const myReview = useMyReviewForBooking(bookingId, { enabled: reviewable });
const createReview = useCreateReview(); const createReview = useCreateReview();
const [rating, setRating] = useState(0); const form = useForm<ReviewFormValues>({ mode: 'onTouched', defaultValues: { rating: 0, body: '', tagCodes: [] } });
const [body, setBody] = useState(''); const { control, handleSubmit } = form;
const [tagCodes, setTagCodes] = useState<string[]>([]); const rating = useWatch({ control, name: 'rating' });
const body = useWatch({ control, name: 'body' });
const tagCodes = useWatch({ control, name: 'tagCodes' });
const nurseName = booking?.nurseName?.trim(); const nurseName = booking?.nurseName?.trim();
const submit = () => { const submit = (values: ReviewFormValues) =>
if (rating < 1) return;
createReview.mutate( 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' }) }, { onError: () => enqueueSnackbar(t('error_submit'), { variant: 'error' }) },
); );
};
// ── Already reviewed → the persistent under-review / published state (never a second form) ─────────────── // ── Already reviewed → the persistent under-review / published state (never a second form) ───────────────
const existing = myReview.data; const existing = myReview.data;
@@ -84,7 +100,7 @@ export default function LeaveReviewPage() {
<Stack sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}> <Stack sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
<PageHeading title={t('my_review_title')} subtitle={nurseName ? t('for_nurse', { name: nurseName }) : undefined} /> <PageHeading title={t('my_review_title')} subtitle={nurseName ? t('for_nurse', { name: nurseName }) : undefined} />
{booking ? <ReviewContextRecap booking={booking} locale={locale} /> : null} {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 sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}> <Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<StatusChip status={STATUS_KIND[status]} label={t(`status_${status}`)} /> <StatusChip status={STATUS_KIND[status]} label={t(`status_${status}`)} />
@@ -144,56 +160,59 @@ export default function LeaveReviewPage() {
// ── Eligible → the review form ─────────────────────────────────────────────────────────────────────────── // ── Eligible → the review form ───────────────────────────────────────────────────────────────────────────
return ( 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')} /> <PageHeading title={t('title')} subtitle={nurseName ? t('for_nurse', { name: nurseName }) : t('subtitle')} />
{booking ? <ReviewContextRecap booking={booking} locale={locale} /> : null} {booking ? <ReviewContextRecap booking={booking} locale={locale} /> : null}
{/* Moderation expectation, up front — not only after submit. */} {/* 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)" /> <AppIcon icon="info" size={18} color="var(--bal-info)" />
<Typography variant="body2" sx={{ color: 'var(--bal-info)' }}> <Typography variant="body2" sx={{ color: 'var(--bal-info)' }}>
{t('moderation_note')} {t('moderation_note')}
</Typography> </Typography>
</Stack> </Stack>
<Stack sx={{ gap: 1 }}> <RhfControlGroup<ReviewFormValues>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}> name="rating"
{t('rating_label')} label={t('rating_label')}
</Typography> rules={{ validate: (value) => Number(value ?? 0) >= 1 }}
<RatingInput value={rating} onChange={setRating} ariaLabel={t('rating_label')} /> >
</Stack> {({ field }) => (
<RatingInput value={Number(field.value) || 0} onChange={field.onChange} ariaLabel={t('rating_label')} />
)}
</RhfControlGroup>
<TextField <RhfTextField<ReviewFormValues>
name="body"
label={t('body_label')} label={t('body_label')}
placeholder={t('body_placeholder')} placeholder={t('body_placeholder')}
value={body} transform={(raw) => raw.slice(0, REVIEW_BODY_MAX)}
onChange={(e) => setBody(e.target.value.slice(0, REVIEW_BODY_MAX))}
multiline multiline
minRows={3} minRows={3}
fullWidth fullWidth
/> />
<Stack sx={{ gap: 1 }}> <RhfControlGroup<ReviewFormValues> name="tagCodes" label={t('tags_label')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}> {({ field }) => (
{t('tags_label')}
</Typography>
<ReviewTagSelector <ReviewTagSelector
codes={REVIEW_TAG_CODES} codes={REVIEW_TAG_CODES}
selected={tagCodes} selected={(field.value as string[]) ?? []}
onChange={setTagCodes} onChange={field.onChange}
labelFor={(code) => (t.has(`tag_${code}`) ? t(`tag_${code}`) : code)} labelFor={(code) => (t.has(`tag_${code}`) ? t(`tag_${code}`) : code)}
disabled={createReview.isPending} disabled={createReview.isPending}
/> />
</Stack> )}
</RhfControlGroup>
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}> <Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
<AppButton variant="text" color="primary" onClick={() => router.back()} disabled={createReview.isPending}> <AppButton variant="text" color="primary" onClick={() => router.back()} disabled={createReview.isPending}>
{tc('cancel')} {tc('cancel')}
</AppButton> </AppButton>
<AppButton <AppButton
type="submit"
variant="contained" variant="contained"
color="primary" color="primary"
onClick={submit}
disabled={rating < 1 || createReview.isPending} disabled={rating < 1 || createReview.isPending}
startIcon="star" startIcon="star"
> >
@@ -201,6 +220,7 @@ export default function LeaveReviewPage() {
</AppButton> </AppButton>
</Stack> </Stack>
</Stack> </Stack>
</FormProvider>
); );
} }
@@ -1,13 +1,19 @@
'use client'; 'use client';
import { FunctionComponent, useState } from 'react'; import { FunctionComponent } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Checkbox, CircularProgress, FormControlLabel, Paper, Stack, TextField, Typography } from '@mui/material'; import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { AppButton, AppIcon, Money, PhoneNumberField } from '@/components'; import { Checkbox, CircularProgress, FormControlLabel, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, Money, PhoneNumberField, RhfControlGroup, RhfTextField } from '@/components';
import { digitsOnly } from '@/utils'; import { digitsOnly } from '@/utils';
import { useCheckEligibility } from '@/services/bnpl'; import { useCheckEligibility } from '@/services/bnpl';
import { NATIONAL_ID_LENGTH, NATIONAL_ID_PATTERN } from '@/services/bnpl/constants'; import { NATIONAL_ID_LENGTH, NATIONAL_ID_PATTERN } from '@/services/bnpl/constants';
import type { BnplEligibilityResult, ProviderCode } from '@/services/bnpl/types'; import type { BnplEligibilityResult, ProviderCode } from '@/services/bnpl/types';
interface EligibilityFormValues {
nationalId: string;
consent: boolean;
}
interface EligibilityStepProps { interface EligibilityStepProps {
bookingRequestId: number; bookingRequestId: number;
providerCode: ProviderCode; providerCode: ProviderCode;
@@ -36,22 +42,23 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
const t = useTranslations('bnpl'); const t = useTranslations('bnpl');
const tc = useTranslations('common'); const tc = useTranslations('common');
const [nationalId, setNationalId] = useState(''); const form = useForm<EligibilityFormValues>({ mode: 'onTouched', defaultValues: { nationalId: '', consent: false } });
const [consent, setConsent] = useState(false); const { control, handleSubmit } = form;
const [submitted, setSubmitted] = useState(false); const consent = useWatch({ control, name: 'consent' });
const check = useCheckEligibility(); const check = useCheckEligibility();
// A fresh check wins; otherwise re-show a prior approval carried back from D4. // A fresh check wins; otherwise re-show a prior approval carried back from D4.
const result = check.data ?? initialResult ?? undefined; const result = check.data ?? initialResult ?? undefined;
const nationalIdValid = NATIONAL_ID_PATTERN.test(nationalId);
const nationalIdError = submitted && !nationalIdValid;
const providerName = t(`provider_${providerCode}`); const providerName = t(`provider_${providerCode}`);
const handleSubmit = () => { const submit = (values: EligibilityFormValues) =>
setSubmitted(true); check.mutate({
if (!nationalIdValid || !consent) return; bookingRequestId,
check.mutate({ bookingRequestId, providerCode, nationalId, mobile: sessionMobile, consent }); providerCode,
}; nationalId: values.nationalId,
mobile: sessionMobile,
consent: values.consent,
});
// Approved — show the ceiling + advance. // Approved — show the ceiling + advance.
if (result?.isEligible) { if (result?.isEligible) {
@@ -61,7 +68,7 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
elevation={0} elevation={0}
sx={{ sx={{
p: 2.5, p: 2.5,
borderRadius: 2, borderRadius: 'var(--bal-radius-md)',
border: '1px solid', border: '1px solid',
borderColor: 'var(--bal-success)', borderColor: 'var(--bal-success)',
backgroundColor: 'var(--bal-primary-soft)', backgroundColor: 'var(--bal-primary-soft)',
@@ -111,25 +118,25 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
body={t('eligibility_error')} body={t('eligibility_error')}
cardLabel={t('pay_with_card')} cardLabel={t('pay_with_card')}
onPayWithCard={onPayWithCard} onPayWithCard={onPayWithCard}
onRetry={handleSubmit} onRetry={handleSubmit(submit)}
retryLabel={tc('retry')} retryLabel={tc('retry')}
/> />
); );
} }
return ( return (
<Stack sx={{ gap: 2 }}> <FormProvider {...form}>
<Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}> <Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('eligibility_title')} {t('eligibility_title')}
</Typography> </Typography>
<TextField <RhfTextField<EligibilityFormValues>
name="nationalId"
label={t('national_id_label')} label={t('national_id_label')}
placeholder={t('national_id_placeholder')} placeholder={t('national_id_placeholder')}
value={nationalId} transform={(raw) => digitsOnly(raw).slice(0, NATIONAL_ID_LENGTH)}
onChange={(e) => setNationalId(digitsOnly(e.target.value).slice(0, NATIONAL_ID_LENGTH))} rules={{ validate: (value) => NATIONAL_ID_PATTERN.test(String(value ?? '')) || t('national_id_invalid') }}
error={nationalIdError}
helperText={nationalIdError ? t('national_id_invalid') : undefined}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', maxLength: NATIONAL_ID_LENGTH, style: { textAlign: 'start' } } }} slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', maxLength: NATIONAL_ID_LENGTH, style: { textAlign: 'start' } } }}
fullWidth fullWidth
/> />
@@ -142,8 +149,16 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
fullWidth fullWidth
/> />
<RhfControlGroup<EligibilityFormValues> name="consent">
{({ field }) => (
<FormControlLabel <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={ label={
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('consent_label', { provider: providerName })} {t('consent_label', { provider: providerName })}
@@ -151,14 +166,16 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
} }
sx={{ alignItems: 'flex-start', m: 0 }} sx={{ alignItems: 'flex-start', m: 0 }}
/> />
)}
</RhfControlGroup>
<Stack sx={{ gap: 1 }}> <Stack sx={{ gap: 1 }}>
<AppButton <AppButton
type="submit"
color="secondary" color="secondary"
variant="contained" variant="contained"
size="large" size="large"
disabled={!consent || check.isPending} disabled={!consent || check.isPending}
onClick={handleSubmit}
startIcon={check.isPending ? <CircularProgress size={18} color="inherit" /> : undefined} startIcon={check.isPending ? <CircularProgress size={18} color="inherit" /> : undefined}
> >
{check.isPending ? t('checking_eligibility') : t('check_eligibility')} {check.isPending ? t('checking_eligibility') : t('check_eligibility')}
@@ -168,6 +185,7 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
</AppButton> </AppButton>
</Stack> </Stack>
</Stack> </Stack>
</FormProvider>
); );
}; };
@@ -190,7 +208,7 @@ function DeclinedPanel({
<Stack sx={{ gap: 2 }}> <Stack sx={{ gap: 2 }}>
<Paper <Paper
elevation={0} 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' }}> <Stack sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="rejected" size={36} color="var(--bal-error)" /> <AppIcon icon="rejected" size={36} color="var(--bal-error)" />
@@ -36,7 +36,7 @@ const MethodStep: FunctionComponent<MethodStepProps> = ({
<Paper <Paper
elevation={0} 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' }}> <Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('payable_amount')} {t('payable_amount')}
@@ -51,7 +51,7 @@ const MethodStep: FunctionComponent<MethodStepProps> = ({
display: 'block', display: 'block',
width: '100%', width: '100%',
textAlign: 'start', textAlign: 'start',
borderRadius: 2, borderRadius: 'var(--bal-radius-md)',
p: 1.75, p: 1.75,
border: '1px solid', border: '1px solid',
borderColor: 'divider', borderColor: 'divider',
@@ -128,7 +128,7 @@ function ProviderOption({
display: 'block', display: 'block',
width: '100%', width: '100%',
textAlign: 'start', textAlign: 'start',
borderRadius: 2, borderRadius: 'var(--bal-radius-md)',
p: 1.5, p: 1.5,
border: '1px solid', border: '1px solid',
borderColor: selected ? 'var(--bal-secondary)' : 'divider', borderColor: selected ? 'var(--bal-secondary)' : 'divider',
@@ -69,7 +69,7 @@ const PlanStep: FunctionComponent<PlanStepProps> = ({
{shownPlan ? ( {shownPlan ? (
<Paper <Paper
elevation={0} 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 sx={{ gap: 0.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}> <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. // Handoff in progress — the provider redirect is being followed.
if (busy) { if (busy) {
return ( 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' }}> <Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<CircularProgress color="secondary" size="2.5rem" /> <CircularProgress color="secondary" size="2.5rem" />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}> <Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
@@ -109,7 +109,7 @@ const ScheduleStep: FunctionComponent<ScheduleStepProps> = ({
elevation={0} elevation={0}
sx={{ sx={{
p: 1.75, p: 1.75,
borderRadius: 2, borderRadius: 'var(--bal-radius-md)',
border: '1px solid', border: '1px solid',
borderColor: 'var(--bal-secondary)', borderColor: 'var(--bal-secondary)',
backgroundColor: 'var(--bal-secondary-soft)', 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> </Stack>
{summary.paymentDeadlineAt ? ( {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 <CountdownTimer
deadlineIso={summary.paymentDeadlineAt} deadlineIso={summary.paymentDeadlineAt}
label={tb('payment_countdown_label')} label={tb('payment_countdown_label')}
@@ -284,7 +284,7 @@ function CheckoutScreen() {
width: 300, 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} {payActions}
</Paper> </Paper>
</Box> </Box>
@@ -310,7 +310,7 @@ function EngagementSummary({ summary, locale }: { summary: CheckoutSummaryDto; l
minute: '2-digit', minute: '2-digit',
}); });
return ( 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' }}> <Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<Avatar <Avatar
src={summary.nurseAvatarUrl ?? undefined} 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 // 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. // of a bare spinner+chip+title stack — the flow's calmest, most designed wait state.
return ( 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' }}> <Stack sx={{ gap: 2.5, alignItems: 'center' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, textAlign: 'center' }}> <Typography variant="subtitle1" sx={{ fontWeight: 700, textAlign: 'center' }}>
{t('state_pending_title')} {t('state_pending_title')}
@@ -213,7 +213,7 @@ export default function BookingRequestStatusPage() {
elevation={0} elevation={0}
sx={{ sx={{
p: 2.5, p: 2.5,
borderRadius: 2, borderRadius: 'var(--bal-radius-md)',
border: '1px solid', border: '1px solid',
borderColor: 'divider', borderColor: 'divider',
borderInlineStartWidth: 4, borderInlineStartWidth: 4,
@@ -247,7 +247,7 @@ export default function BookingRequestStatusPage() {
</Stack> </Stack>
</Paper> </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' }}> <Stack sx={{ gap: 1, alignItems: 'center' }}>
<CountdownTimer <CountdownTimer
deadlineIso={request.nurseResponseDeadlineAt} deadlineIso={request.nurseResponseDeadlineAt}
@@ -329,7 +329,7 @@ function TerminalCard({
secondary?: TerminalAction; secondary?: TerminalAction;
}) { }) {
return ( 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} /> <AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: body ? 0.5 : 2 }}> <Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: body ? 0.5 : 2 }}>
{title} {title}
@@ -2,6 +2,7 @@
import { Suspense, useMemo, useState } from 'react'; import { Suspense, useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { import {
Avatar, Avatar,
Box, Box,
@@ -21,6 +22,8 @@ import {
EmptyState, EmptyState,
JalaliDateIntentPicker, JalaliDateIntentPicker,
PriceDisplay, PriceDisplay,
RhfControlGroup,
RhfTextField,
StepperHeader, StepperHeader,
TrustBadge, TrustBadge,
} from '@/components'; } from '@/components';
@@ -39,6 +42,7 @@ import type {
RequiredCaregiverGender, RequiredCaregiverGender,
} from '@/services/bookingRequests/types'; } from '@/services/bookingRequests/types';
import type { CustomerAddress } from '@/services/addresses/types'; import type { CustomerAddress } from '@/services/addresses/types';
import type { Patient } from '@/services/patients/types';
const GENDER_OPTIONS: RequiredCaregiverGender[] = ['female', 'male', 'any']; const GENDER_OPTIONS: RequiredCaregiverGender[] = ['female', 'male', 'any'];
@@ -54,7 +58,17 @@ const TIME_WINDOWS: TimeWindowOption[] = [
{ key: 'evening', start: '16:00', end: '20:00' }, { 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 * 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() { function BookingRequestForm() {
const t = useTranslations('booking'); const t = useTranslations('booking');
const locale = useLocale(); const locale = useLocale();
@@ -90,68 +114,115 @@ function BookingRequestForm() {
const profileQuery = useNurseProfile(hasNurse ? nurseId : undefined); const profileQuery = useNurseProfile(hasNurse ? nurseId : undefined);
const patientsQuery = usePatients(); const patientsQuery = usePatients();
const addressesQuery = useAddresses(); 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 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 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 [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 [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 | '' = const primaryAddressId: number | '' =
addresses.length > 0 ? (addresses.find((address) => address.isPrimary)?.id ?? addresses[0].id) : ''; addresses.length > 0 ? (addresses.find((address) => address.isPrimary)?.id ?? addresses[0].id) : '';
const addressId = addressSel !== '' ? addressSel : primaryAddressId;
const selectedVariant = useMemo( const form = useForm<RequestFormValues>({
() => services.find((service) => service.variantId === variantId), mode: 'onTouched',
[services, variantId], defaultValues: {
); patientId: carried.patientId ?? '',
const selectedAddress = useMemo( variantId: carried.variantId ?? (services.length > 0 ? services[0].variantId : ''),
() => addresses.find((address) => address.id === addressId), addressId: carried.addressId ?? primaryAddressId,
[addresses, addressId], gender: carried.gender ?? '',
); date: '',
const selectedPatient = useMemo( window: null,
() => patients.find((patient) => patient.id === patientId), timeStart: '',
[patients, patientId], 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) — // 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. // block it inline before the round-trip; the server re-validates and is authoritative.
const genderMismatch = const genderMismatch = gender !== '' && gender !== 'any' && profile != null && gender !== profile.nurseGender;
gender !== '' && gender !== 'any' && profile != null && gender !== profile.nurseGender;
const requiredChosen = const missingFieldLabels: string[] = [];
patientId !== '' && variantId !== '' && addressId !== '' && gender !== '' && date !== '' && timeStart !== '' && timeEnd !== ''; 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 regionLabel = (address: CustomerAddress): string => {
const city = locale === 'en' ? address.cityNameEn : address.cityNameFa; const city = locale === 'en' ? address.cityNameEn : address.cityNameFa;
@@ -165,31 +236,15 @@ function BookingRequestForm() {
}; };
const selectWindow = (option: TimeWindowOption) => { const selectWindow = (option: TimeWindowOption) => {
setWindowSel(option.key); setValue('window', option.key, { shouldDirty: true });
setTimeStart(option.start); setValue('timeStart', option.start, { shouldValidate: true });
setTimeEnd(option.end); setValue('timeEnd', option.end, { shouldValidate: true });
if (pastDateError) setPastDateError(false); // 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[] = []; const submit = (formValues: RequestFormValues) => {
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 = () => {
setFormError(null); 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; if (genderMismatch) return;
const context: BookingRequestDisplayContext | undefined = const context: BookingRequestDisplayContext | undefined =
@@ -220,53 +275,27 @@ function BookingRequestForm() {
{ {
payload: { payload: {
nurseId, nurseId,
variantId: variantId as number, variantId: formValues.variantId as number,
patientId: patientId as number, patientId: formValues.patientId as number,
customerAddressId: addressId as number, customerAddressId: formValues.addressId as number,
requestedDate: date, requestedDate: formValues.date,
requestedTimeStart: `${timeStart}:00`, requestedTimeStart: `${formValues.timeStart}:00`,
requestedTimeEnd: `${timeEnd}:00`, requestedTimeEnd: `${formValues.timeEnd}:00`,
requiredCaregiverGender: gender as RequiredCaregiverGender, requiredCaregiverGender: formValues.gender as RequiredCaregiverGender,
customerNotes: notes.trim() || null, customerNotes: formValues.notes.trim() || null,
}, },
context, context,
}, },
{ {
onSuccess: (dto) => { onSuccess: (dto) => router.push(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${dto.id}`),
router.push(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${dto.id}`);
},
onError: (error) => setFormError(mapCreateError(error, t)), onError: (error) => setFormError(mapCreateError(error, t)),
}, },
); );
}; };
if (!hasNurse) {
return ( return (
<EmptyState <FormProvider {...form}>
icon="search" <Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 3 }}>
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 }}>
{profile ? <NurseIdentityBar profile={profile} /> : null} {profile ? <NurseIdentityBar profile={profile} /> : null}
<Box> <Box>
@@ -294,14 +323,11 @@ function BookingRequestForm() {
onCta={() => router.push(`/${locale}${ROUTES.PATIENTS}`)} onCta={() => router.push(`/${locale}${ROUTES.PATIENTS}`)}
/> />
) : ( ) : (
<TextField <RhfTextField<RequestFormValues>
name="patientId"
select select
label={t('patient_label')} label={t('patient_label')}
value={patientId} rules={{ validate: (value) => value !== '' || t('error_patient_required') }}
error={touched.patient && patientId === ''}
helperText={touched.patient && patientId === '' ? t('error_patient_required') : undefined}
onChange={(event) => setPatientId(Number(event.target.value))}
onBlur={() => markTouched('patient')}
fullWidth fullWidth
> >
<MenuItem value="" disabled> <MenuItem value="" disabled>
@@ -312,7 +338,7 @@ function BookingRequestForm() {
{patient.displayName} {patient.displayName}
</MenuItem> </MenuItem>
))} ))}
</TextField> </RhfTextField>
)} )}
{/* Service variant */} {/* Service variant */}
@@ -322,14 +348,11 @@ function BookingRequestForm() {
{t('service_empty')} {t('service_empty')}
</Typography> </Typography>
) : ( ) : (
<TextField <RhfTextField<RequestFormValues>
name="variantId"
select select
label={t('service_label')} label={t('service_label')}
value={variantId} rules={{ validate: (value) => value !== '' || t('error_service_required') }}
error={touched.service && variantId === ''}
helperText={touched.service && variantId === '' ? t('error_service_required') : undefined}
onChange={(event) => setVariantSel(Number(event.target.value))}
onBlur={() => markTouched('service')}
fullWidth fullWidth
> >
<MenuItem value="" disabled> <MenuItem value="" disabled>
@@ -340,7 +363,7 @@ function BookingRequestForm() {
{service.displayName} {service.displayName}
</MenuItem> </MenuItem>
))} ))}
</TextField> </RhfTextField>
)} )}
{selectedVariant ? ( {selectedVariant ? (
<PriceDisplay <PriceDisplay
@@ -360,17 +383,14 @@ function BookingRequestForm() {
onCta={() => router.push(`/${locale}${ROUTES.ADDRESSES}`)} onCta={() => router.push(`/${locale}${ROUTES.ADDRESSES}`)}
/> />
) : addressEditing || !selectedAddress ? ( ) : addressEditing || !selectedAddress ? (
<TextField <RhfTextField<RequestFormValues>
name="addressId"
select select
label={t('address_label')} label={t('address_label')}
value={addressId} rules={{ validate: (value) => value !== '' || t('error_address_required') }}
error={touched.address && addressId === ''} // Collapses back to the compact summary row once the menu closes — picking a different
helperText={touched.address && addressId === '' ? t('error_address_required') : undefined} // address is the normal exit, and dismissing without picking leaves the current one shown.
onChange={(event) => { slotProps={{ select: { onClose: () => setAddressEditing(false) } }}
setAddressSel(Number(event.target.value));
setAddressEditing(false);
}}
onBlur={() => markTouched('address')}
fullWidth fullWidth
> >
<MenuItem value="" disabled> <MenuItem value="" disabled>
@@ -381,7 +401,7 @@ function BookingRequestForm() {
{address.title} · {locale === 'en' ? address.cityNameEn : address.cityNameFa} {address.title} · {locale === 'en' ? address.cityNameEn : address.cityNameFa}
</MenuItem> </MenuItem>
))} ))}
</TextField> </RhfTextField>
) : ( ) : (
<Stack <Stack
direction="row" direction="row"
@@ -411,35 +431,32 @@ function BookingRequestForm() {
</Stack> </Stack>
)} )}
{/* Date */} {/* Date — the past-date guard is a cross-field rule against the chosen start time. */}
<Stack sx={{ gap: 1 }} onBlur={() => markTouched('date')}> <RhfControlGroup<RequestFormValues>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}> name="date"
{t('date_label')} label={t('date_label')}
</Typography> rules={{
<JalaliDateIntentPicker validate: {
value={date} chosen: (value) => value !== '' || t('error_date_required'),
onChange={(iso) => { future: (value, all) =>
setDate(iso); !all.timeStart || Date.parse(`${value}T${all.timeStart}`) >= Date.now() || t('error_past_date'),
if (pastDateError) setPastDateError(false); },
}} }}
>
{({ field }) => (
<JalaliDateIntentPicker
value={(field.value as string) ?? ''}
onChange={field.onChange}
min={todayIso()} min={todayIso()}
todayLabel={t('date_today')} todayLabel={t('date_today')}
tomorrowLabel={t('date_tomorrow')} tomorrowLabel={t('date_tomorrow')}
pickOtherLabel={t('date_pick_other')} pickOtherLabel={t('date_pick_other')}
/> />
{pastError ? ( )}
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}> </RhfControlGroup>
{t('error_past_date')}
</Typography>
) : touched.date && date === '' ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_date_required')}
</Typography>
) : null}
</Stack>
{/* Time window — presets kill the end<=start error class; «زمان دلخواه» reveals free time fields. */} {/* 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 }}> <Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('time_window_label')} {t('time_window_label')}
</Typography> </Typography>
@@ -458,7 +475,7 @@ function BookingRequestForm() {
<Chip <Chip
clickable clickable
label={t('window_custom')} label={t('window_custom')}
onClick={() => setWindowSel('custom')} onClick={() => setValue('window', 'custom', { shouldDirty: true })}
color={windowSel === 'custom' ? 'primary' : undefined} color={windowSel === 'custom' ? 'primary' : undefined}
variant={windowSel === 'custom' ? 'filled' : 'outlined'} variant={windowSel === 'custom' ? 'filled' : 'outlined'}
data-window="custom" data-window="custom"
@@ -466,51 +483,52 @@ function BookingRequestForm() {
</Stack> </Stack>
{windowSel === 'custom' ? ( {windowSel === 'custom' ? (
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}> <Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField <RhfTextField<RequestFormValues>
name="timeStart"
type="time" type="time"
label={t('time_start_label')} label={t('time_start_label')}
value={timeStart} rules={{ validate: (value) => value !== '' || t('error_time_required') }}
onChange={(event) => setTimeStart(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }} slotProps={{ inputLabel: { shrink: true } }}
fullWidth fullWidth
/> />
<TextField <RhfTextField<RequestFormValues>
name="timeEnd"
type="time" type="time"
label={t('time_end_label')} label={t('time_end_label')}
value={timeEnd} rules={{
error={timeError} validate: {
helperText={timeError ? t('error_time_range') : undefined} chosen: (value) => value !== '' || t('error_time_required'),
onChange={(event) => setTimeEnd(event.target.value)} after: (value, all) => !all.timeStart || String(value) > all.timeStart || t('error_time_range'),
},
}}
slotProps={{ inputLabel: { shrink: true } }} slotProps={{ inputLabel: { shrink: true } }}
fullWidth fullWidth
/> />
</Stack> </Stack>
) : null} ) : null}
{touched.time && (timeStart === '' || timeEnd === '') ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_time_required')}
</Typography>
) : null}
</Stack> </Stack>
{/* Caregiver gender — first-class, three-way, never silently defaulted */} {/* Caregiver gender — first-class, three-way, never silently defaulted */}
<Stack sx={{ gap: 1 }} onBlur={() => markTouched('gender')}> <RhfControlGroup<RequestFormValues>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}> name="gender"
{t('gender_label')} label={t('gender_label')}
</Typography> hint={t('gender_hint')}
rules={{ validate: (value) => value !== '' || t('error_gender_required') }}
>
{({ field, hasError }) => (
<ToggleButtonGroup <ToggleButtonGroup
exclusive exclusive
color="primary" color="primary"
value={gender || null} value={field.value || null}
onChange={(_event, next: RequiredCaregiverGender | null) => { onChange={(_event, next: RequiredCaregiverGender | null) => {
if (next) setGender(next); if (next) field.onChange(next);
}} }}
sx={{ sx={{
'& .MuiToggleButton-root': { '& .MuiToggleButton-root': {
flex: 1, flex: 1,
py: 1.25, py: 1.25,
fontWeight: 700, fontWeight: 700,
borderColor: touched.gender && gender === '' ? 'var(--bal-error)' : undefined, borderColor: hasError ? 'var(--bal-error)' : undefined,
}, },
}} }}
> >
@@ -520,32 +538,26 @@ function BookingRequestForm() {
</ToggleButton> </ToggleButton>
))} ))}
</ToggleButtonGroup> </ToggleButtonGroup>
<Typography variant="caption" sx={{ color: 'text.secondary' }}> )}
{t('gender_hint')} </RhfControlGroup>
</Typography>
{touched.gender && gender === '' ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_gender_required')}
</Typography>
) : null}
{genderMismatch ? ( {genderMismatch ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}> <Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_gender_mismatch')} {t('error_gender_mismatch')}
</Typography> </Typography>
) : null} ) : null}
</Stack>
{/* Stage-1 notes */} {/* Stage-1 notes */}
<Stack sx={{ gap: 0.5 }}> <Stack sx={{ gap: 0.5 }}>
<TextField <RhfTextField<RequestFormValues>
name="notes"
label={t('notes_label')} label={t('notes_label')}
placeholder={t('notes_placeholder')} placeholder={t('notes_placeholder')}
value={notes} helperText={t('notes_hint')}
onChange={(event) => setNotes(event.target.value.slice(0, CUSTOMER_NOTES_MAX_LENGTH))} transform={(raw) => raw.slice(0, CUSTOMER_NOTES_MAX_LENGTH)}
multiline multiline
minRows={3} minRows={3}
fullWidth fullWidth
helperText={t('notes_hint')}
/> />
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'end' }}> <Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'end' }}>
{t('notes_counter', { count: notes.length, max: CUSTOMER_NOTES_MAX_LENGTH })} {t('notes_counter', { count: notes.length, max: CUSTOMER_NOTES_MAX_LENGTH })}
@@ -560,23 +572,24 @@ function BookingRequestForm() {
<Stack sx={{ gap: 1 }}> <Stack sx={{ gap: 1 }}>
<AppButton <AppButton
type="submit"
color="primary" color="primary"
variant="contained" variant="contained"
size="large" size="large"
startIcon="requests" startIcon="requests"
disabled={!requiredChosen || genderMismatch || createRequest.isPending} disabled={!requiredChosen || genderMismatch || createRequest.isPending}
onClick={handleSubmit}
sx={{ py: 1.5 }} sx={{ py: 1.5 }}
> >
{createRequest.isPending ? t('submitting') : t('submit')} {createRequest.isPending ? t('submitting') : t('submit')}
</AppButton> </AppButton>
{!requiredChosen && missingFieldLabels.length > 0 ? ( {!requiredChosen ? (
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}> <Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}>
{t('cta_missing_caption', { fields: missingFieldLabels.join(locale === 'fa' ? '، ' : ', ') })} {t('cta_missing_caption', { fields: missingFieldLabels.join(locale === 'fa' ? '، ' : ', ') })}
</Typography> </Typography>
) : null} ) : null}
</Stack> </Stack>
</Stack> </Stack>
</FormProvider>
); );
} }
@@ -596,7 +609,9 @@ function NurseIdentityBar({ profile }: { profile: NurseProfile }) {
data-nurse-identity-bar data-nurse-identity-bar
sx={{ sx={{
position: 'sticky', 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, zIndex: 2,
gap: 1.5, gap: 1.5,
alignItems: 'center', alignItems: 'center',
@@ -3,6 +3,7 @@ import { FunctionComponent, ReactNode, useEffect, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { import {
Box, Box,
@@ -23,7 +24,16 @@ import {
useMediaQuery, useMediaQuery,
} from '@mui/material'; } from '@mui/material';
import { useTheme } from '@mui/material/styles'; 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 { ROUTES, bookingDetailPath } from '@/constants';
import { formatShamsiDate, formatShamsiMonthYear } from '@/utils'; import { formatShamsiDate, formatShamsiMonthYear } from '@/utils';
import { bookingKeys } from '@/services/bookings/keys'; import { bookingKeys } from '@/services/bookings/keys';
@@ -106,7 +116,7 @@ export default function PatientRecordPage() {
<Paper <Paper
elevation={0} 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)" /> <AppIcon icon="family" size={22} color="var(--bal-primary)" />
<Typography variant="body2" sx={{ color: 'var(--bal-primary)', fontWeight: 500 }}> <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; 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) => { 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: () => { onSuccess: () => {
enqueueSnackbar(t('saved'), { variant: 'success' }); enqueueSnackbar(t('saved'), { variant: 'success' });
onDone(); onDone();
}, },
onError: () => enqueueSnackbar(t('save_error'), { variant: 'error' }), onError: () => enqueueSnackbar(t('save_error'), { variant: 'error' }),
}); },
);
}; };
if (tab === 'medications') { 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({ function SheetActions({
onCancel, onCancel,
onSave,
onDelete, onDelete,
saving, saving,
canSave, canSave,
}: { }: {
onCancel: () => void; onCancel: () => void;
onSave: () => void;
onDelete?: () => void; onDelete?: () => void;
saving: boolean; saving: boolean;
canSave: boolean; canSave: boolean;
@@ -285,7 +308,7 @@ function SheetActions({
<AppButton variant="text" onClick={onCancel} disabled={saving}> <AppButton variant="text" onClick={onCancel} disabled={saving}>
{tc('cancel')} {tc('cancel')}
</AppButton> </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')} {saving ? tc('saving') : tc('save')}
</AppButton> </AppButton>
</Stack> </Stack>
@@ -296,7 +319,7 @@ function SheetActions({
// A tappable row surface shared by the three editable tabs — mirrors PatientCard's press affordance. // A tappable row surface shared by the three editable tabs — mirrors PatientCard's press affordance.
function RowCard({ onOpen, children }: { onOpen?: () => void; children: ReactNode }) { function RowCard({ onOpen, children }: { onOpen?: () => void; children: ReactNode }) {
return ( 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 ? ( {onOpen ? (
<AppButton <AppButton
variant="text" 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({ function MedicationSheetBody({
initial, initial,
saving, saving,
@@ -448,62 +481,55 @@ function MedicationSheetBody({
onDirtyChange: (dirty: boolean) => void; onDirtyChange: (dirty: boolean) => void;
}) { }) {
const t = useTranslations('records'); const t = useTranslations('records');
const [name, setName] = useState(initial?.name ?? ''); const form = useForm<MedicationFormValues>({
const [doseAmount, setDoseAmount] = useState(initial?.doseAmount ?? ''); mode: 'onTouched',
const [doseUnit, setDoseUnit] = useState<DoseUnit | ''>(initial?.doseUnit ?? ''); defaultValues: {
const [frequencyCode, setFrequencyCode] = useState<FrequencyPreset | null>(initial?.frequencyCode ?? null); name: initial?.name ?? '',
const [frequencyText, setFrequencyText] = useState(initial?.frequencyText ?? ''); doseAmount: initial?.doseAmount ?? '',
const [timeOfDay, setTimeOfDay] = useState<TimeOfDayCode[]>(initial?.timeOfDay ?? []); doseUnit: initial?.doseUnit ?? '',
const [timingNote, setTimingNote] = useState(initial?.timingNote ?? ''); 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(() => { useEffect(() => {
const dirty = onDirtyChange(isDirty);
name !== (initial?.name ?? '') || }, [isDirty, onDirtyChange]);
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]);
const canSave = name.trim().length > 0; const submit = (values: MedicationFormValues) => {
const handleSave = () => {
onSave({ onSave({
id: initial?.id ?? `new-${Date.now()}`, id: initial?.id ?? newTempId(),
name: name.trim(), name: values.name.trim(),
doseAmount: doseAmount.trim() || null, doseAmount: values.doseAmount.trim() || null,
doseUnit: doseUnit || null, doseUnit: values.doseUnit || null,
frequencyCode, frequencyCode: values.frequencyCode,
frequencyText: frequencyCode ? null : frequencyText.trim() || null, frequencyText: values.frequencyCode ? null : values.frequencyText.trim() || null,
timeOfDay, timeOfDay: values.timeOfDay,
timingNote: timingNote.trim() || null, timingNote: values.timingNote.trim() || null,
}); });
}; };
return ( return (
<Stack sx={{ gap: 2 }}> <FormProvider {...form}>
<TextField label={t('med_name')} value={name} onChange={(e) => setName(e.target.value)} fullWidth required autoFocus /> <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 }}> <Stack direction="row" sx={{ gap: 1.5 }}>
<TextField <RhfTextField<MedicationFormValues> name="doseAmount" label={t('med_dose_amount')} sx={{ flex: 1 }} />
label={t('med_dose_amount')} <RhfTextField<MedicationFormValues> name="doseUnit" select label={t('med_dose_unit')} sx={{ flex: 1 }}>
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 }}>
<MenuItem value="">{t('med_dose_unit_none')}</MenuItem> <MenuItem value="">{t('med_dose_unit_none')}</MenuItem>
{DOSE_UNITS.map((unit) => ( {DOSE_UNITS.map((unit) => (
<MenuItem key={unit} value={unit}> <MenuItem key={unit} value={unit}>
{t(`dose_unit_${unit}`)} {t(`dose_unit_${unit}`)}
</MenuItem> </MenuItem>
))} ))}
</TextField> </RhfTextField>
</Stack> </Stack>
<Stack sx={{ gap: 1 }}> <Stack sx={{ gap: 1 }}>
@@ -518,8 +544,10 @@ function MedicationSheetBody({
color="primary" color="primary"
size="small" size="small"
onClick={() => { onClick={() => {
setFrequencyCode(frequencyCode === preset ? null : preset); const next = frequencyCode === preset ? null : preset;
if (frequencyCode !== preset) setFrequencyText(''); 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' }} sx={{ borderRadius: '999px' }}
> >
@@ -528,27 +556,24 @@ function MedicationSheetBody({
))} ))}
</Stack> </Stack>
{!frequencyCode ? ( {!frequencyCode ? (
<TextField <RhfTextField<MedicationFormValues>
name="frequencyText"
label={t('med_frequency_text')} label={t('med_frequency_text')}
value={frequencyText}
onChange={(e) => setFrequencyText(e.target.value)}
fullWidth fullWidth
size="small" size="small"
/> />
) : null} ) : null}
</Stack> </Stack>
<Stack sx={{ gap: 1 }}> <RhfControlGroup<MedicationFormValues> name="timeOfDay" hint={t('time_of_day')}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}> {({ field }) => <TimeOfDayChipRow value={(field.value as TimeOfDayCode[]) ?? []} onChange={field.onChange} />}
{t('time_of_day')} </RhfControlGroup>
</Typography>
<TimeOfDayChipRow value={timeOfDay} onChange={setTimeOfDay} />
</Stack>
<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> </Stack>
</FormProvider>
); );
} }
@@ -652,42 +677,45 @@ function RoutineSheetBody({
onDirtyChange: (dirty: boolean) => void; onDirtyChange: (dirty: boolean) => void;
}) { }) {
const t = useTranslations('records'); const t = useTranslations('records');
const [label, setLabel] = useState(initial?.label ?? ''); const form = useForm<{ label: string; timeOfDay: TimeOfDayCode[]; note: string }>({
const [timeOfDay, setTimeOfDay] = useState<TimeOfDayCode[]>(initial?.timeOfDay ?? []); mode: 'onTouched',
const [note, setNote] = useState(initial?.note ?? ''); 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(() => { useEffect(() => {
const dirty = onDirtyChange(isDirty);
label !== (initial?.label ?? '') || }, [isDirty, onDirtyChange]);
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,
});
return ( return (
<Stack sx={{ gap: 2 }}> <FormProvider {...form}>
<TextField label={t('routine_label')} value={label} onChange={(e) => setLabel(e.target.value)} fullWidth required autoFocus /> <Stack
<Stack sx={{ gap: 1 }}> component="form"
<Typography variant="caption" sx={{ color: 'text.secondary' }}> noValidate
{t('time_of_day')} onSubmit={handleSubmit((values) =>
</Typography> onSave({
<TimeOfDayChipRow value={timeOfDay} onChange={setTimeOfDay} /> id: initial?.id ?? newTempId(),
</Stack> label: values.label.trim(),
<TextField label={t('routine_note')} value={note} onChange={(e) => setNote(e.target.value)} fullWidth size="small" /> timeOfDay: values.timeOfDay,
<SheetActions onCancel={onCancel} onSave={handleSave} onDelete={onDelete} saving={saving} canSave={canSave} /> 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> </Stack>
</FormProvider>
); );
} }
@@ -724,7 +752,7 @@ function TasksTab({
) : ( ) : (
<Stack sx={{ gap: 1 }}> <Stack sx={{ gap: 1 }}>
{data.map((task) => ( {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 }}> <Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
<FormControlLabel <FormControlLabel
control={<Checkbox checked={task.done} onChange={() => (canEdit ? toggleDone(task) : undefined)} disabled={!canEdit || saving} />} control={<Checkbox checked={task.done} onChange={() => (canEdit ? toggleDone(task) : undefined)} disabled={!canEdit || saving} />}
@@ -788,28 +816,42 @@ function TaskSheetBody({
onDirtyChange: (dirty: boolean) => void; onDirtyChange: (dirty: boolean) => void;
}) { }) {
const t = useTranslations('records'); const t = useTranslations('records');
const [label, setLabel] = useState(initial?.label ?? ''); const form = useForm<{ label: string; done: boolean }>({
const [done, setDone] = useState(initial?.done ?? false); 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(() => { useEffect(() => {
onDirtyChange(label !== (initial?.label ?? '') || done !== (initial?.done ?? false)); onDirtyChange(isDirty);
// eslint-disable-next-line react-hooks/exhaustive-deps -- `initial` is a fresh-mount snapshot (the sheet remounts per open), not reactive state. }, [isDirty, onDirtyChange]);
}, [label, done]);
const canSave = label.trim().length > 0;
return ( return (
<Stack sx={{ gap: 2 }}> <FormProvider {...form}>
<TextField label={t('task_label')} value={label} onChange={(e) => setLabel(e.target.value)} fullWidth required autoFocus /> <Stack
<FormControlLabel control={<Checkbox checked={done} onChange={(e) => setDone(e.target.checked)} />} label={t('task_done')} /> component="form"
<SheetActions noValidate
onCancel={onCancel} onSubmit={handleSubmit((values) =>
onSave={() => onSave({ id: initial?.id ?? `new-${Date.now()}`, label: label.trim(), done })} onSave({ id: initial?.id ?? newTempId(), label: values.label.trim(), done: values.done }),
onDelete={onDelete} )}
saving={saving} sx={{ gap: 2 }}
canSave={canSave} >
<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> </Stack>
</FormProvider>
); );
} }
@@ -2,8 +2,9 @@
import { FunctionComponent, useState } from 'react'; import { FunctionComponent, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack'; 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 { import {
AppButton, AppButton,
AppIcon, AppIcon,
@@ -12,8 +13,11 @@ import {
FormDialogShell, FormDialogShell,
PhoneNumberField, PhoneNumberField,
ProfileSummary, ProfileSummary,
RhfControlGroup,
RhfTextField,
} from '@/components'; } from '@/components';
import LocaleSwitcher from '@/components/common/LocaleSwitcher'; import LocaleSwitcher from '@/components/common/LocaleSwitcher';
import { ThemeModeSetting } from '@/components/settings';
import { isIranianMobile } from '@/components/PhoneNumberField'; import { isIranianMobile } from '@/components/PhoneNumberField';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { digitsOnly } from '@/utils'; 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<{ const AccountHub: FunctionComponent<{
initial: CustomerProfile | null; initial: CustomerProfile | null;
nameFallback: { firstName: string | null; lastName: string | null }; nameFallback: { firstName: string | null; lastName: string | null };
@@ -56,69 +68,63 @@ const AccountHub: FunctionComponent<{
const upsert = useUpsertCustomerProfile(); const upsert = useUpsertCustomerProfile();
const logout = useLogout(); 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 [personalSheetOpen, setPersonalSheetOpen] = useState(false);
const [languageSheetOpen, setLanguageSheetOpen] = useState(false); const [languageSheetOpen, setLanguageSheetOpen] = useState(false);
const [emergencySheetOpen, setEmergencySheetOpen] = useState(false); const [emergencySheetOpen, setEmergencySheetOpen] = useState(false);
const [signOutOpen, setSignOutOpen] = useState(false); const [signOutOpen, setSignOutOpen] = useState(false);
const [nameError, setNameError] = useState(false); // ONE form behind all three sheets. Each sheet edits its own slice, but every save writes the whole
const [phoneError, setPhoneError] = useState(false); // 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 displayName = [watched.firstName, watched.lastName].filter(Boolean).join(' ').trim() || phone || '';
const emergencyComplete = Boolean(emergencyName.trim() && emergencyPhone); const emergencyComplete = Boolean(watched.emergencyName?.trim() && watched.emergencyPhone);
// Every sheet saves the FULL profile object (the wire upsert has no PATCH semantics) — `patch` const save = (onDone: () => void) => {
// carries just the fields that sheet owns, the rest come from the shared draft state so editing const values = getValues();
// 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,
) => {
upsert.mutate( upsert.mutate(
{ {
defaultEmergencyContactName: emergencyName.trim(), defaultEmergencyContactName: values.emergencyName.trim(),
defaultEmergencyContactPhone: emergencyPhone, defaultEmergencyContactPhone: values.emergencyPhone,
firstName: firstName.trim() || null, firstName: values.firstName.trim() || null,
lastName: lastName.trim() || null, lastName: values.lastName.trim() || null,
preferredLanguage: language, preferredLanguage: values.language,
...patch,
}, },
{ {
onSuccess: () => { onSuccess: () => {
enqueueSnackbar(t('saved'), { variant: 'success' }); 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(); onDone();
}, },
}, },
); );
}; };
const savePersonal = () => const savePersonal = () => save(() => setPersonalSheetOpen(false));
save({ firstName: firstName.trim() || null, lastName: lastName.trim() || null }, () => setPersonalSheetOpen(false)); const saveLanguage = () => save(() => setLanguageSheetOpen(false));
const saveLanguage = () => save({ preferredLanguage: language }, () => setLanguageSheetOpen(false)); const saveEmergency = async () => {
const saveEmergency = () => { if (!(await trigger(['emergencyName', 'emergencyPhone']))) return;
const nameInvalid = emergencyName.trim().length === 0; save(() => setEmergencySheetOpen(false));
const phoneInvalid = !isIranianMobile(emergencyPhone);
setNameError(nameInvalid);
setPhoneError(phoneInvalid);
if (nameInvalid || phoneInvalid) return;
save({}, () => setEmergencySheetOpen(false));
}; };
const personalDirty = firstName !== initialFirstName || lastName !== initialLastName; const personalDirty = Boolean(dirtyFields.firstName || dirtyFields.lastName);
const languageDirty = language !== initialLanguage; const languageDirty = Boolean(dirtyFields.language);
const emergencyDirty = emergencyName !== initialEmergencyName || emergencyPhone !== initialEmergencyPhone; const emergencyDirty = Boolean(dirtyFields.emergencyName || dirtyFields.emergencyPhone);
const goTo = (path: string) => router.push(`/${locale}${path}`); const goTo = (path: string) => router.push(`/${locale}${path}`);
@@ -130,6 +136,7 @@ const AccountHub: FunctionComponent<{
const cancelLabel = tc('cancel'); const cancelLabel = tc('cancel');
return ( return (
<FormProvider {...form}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 520 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 520 }}>
<ProfileSummary displayName={displayName} phone={phone} initialsFallback={displayName || undefined} /> <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)} /> <AccountRow icon="account" label={t('row_personal')} onClick={() => setPersonalSheetOpen(true)} />
<EmergencyContactCard <EmergencyContactCard
complete={emergencyComplete} complete={emergencyComplete}
name={emergencyName} name={watched.emergencyName ?? ''}
phone={emergencyPhone} phone={watched.emergencyPhone ?? ''}
onEdit={() => setEmergencySheetOpen(true)} onEdit={() => setEmergencySheetOpen(true)}
/> />
<AccountRow icon="location" label={t('row_addresses')} onClick={() => goTo(ROUTES.ADDRESSES)} /> <AccountRow icon="location" label={t('row_addresses')} onClick={() => goTo(ROUTES.ADDRESSES)} />
<AccountRow icon="language" label={t('row_language')} onClick={() => setLanguageSheetOpen(true)} /> <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="notifications" label={t('row_notifications')} onClick={() => goTo(ROUTES.NOTIFICATIONS)} />
<AccountRow icon="support" label={t('row_support')} onClick={() => goTo(ROUTES.SUPPORT_TICKETS)} /> <AccountRow icon="support" label={t('row_support')} onClick={() => goTo(ROUTES.SUPPORT_TICKETS)} />
</Stack> </Stack>
@@ -168,8 +178,8 @@ const AccountHub: FunctionComponent<{
discardCancelLabel={cancelLabel} discardCancelLabel={cancelLabel}
> >
<Stack sx={{ gap: 2.5 }}> <Stack sx={{ gap: 2.5 }}>
<TextField label={t('first_name')} value={firstName} onChange={(e) => setFirstName(e.target.value)} fullWidth /> <RhfTextField<AccountFormValues> name="firstName" label={t('first_name')} fullWidth />
<TextField label={t('last_name')} value={lastName} onChange={(e) => setLastName(e.target.value)} fullWidth /> <RhfTextField<AccountFormValues> name="lastName" label={t('last_name')} fullWidth />
<SheetActions onCancel={() => setPersonalSheetOpen(false)} onSave={savePersonal} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} /> <SheetActions onCancel={() => setPersonalSheetOpen(false)} onSave={savePersonal} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
</Stack> </Stack>
</FormDialogShell> </FormDialogShell>
@@ -197,16 +207,10 @@ const AccountHub: FunctionComponent<{
<LocaleSwitcher /> <LocaleSwitcher />
</Stack> </Stack>
<Divider /> <Divider />
<TextField <RhfTextField<AccountFormValues> name="language" select label={t('language')} helperText={t('language_hint')}>
select
label={t('language')}
value={language}
onChange={(e) => setLanguage(e.target.value)}
helperText={t('language_hint')}
>
<MenuItem value="fa">{t('language_fa')}</MenuItem> <MenuItem value="fa">{t('language_fa')}</MenuItem>
<MenuItem value="en">{t('language_en')}</MenuItem> <MenuItem value="en">{t('language_en')}</MenuItem>
</TextField> </RhfTextField>
<SheetActions onCancel={() => setLanguageSheetOpen(false)} onSave={saveLanguage} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} /> <SheetActions onCancel={() => setLanguageSheetOpen(false)} onSave={saveLanguage} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
</Stack> </Stack>
</FormDialogShell> </FormDialogShell>
@@ -227,27 +231,27 @@ const AccountHub: FunctionComponent<{
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('emergency_hint')} {t('emergency_hint')}
</Typography> </Typography>
<TextField <RhfTextField<AccountFormValues>
name="emergencyName"
label={t('emergency_name')} label={t('emergency_name')}
value={emergencyName} rules={{ validate: (value) => String(value ?? '').trim().length > 0 }}
onChange={(e) => {
setEmergencyName(e.target.value);
if (nameError) setNameError(false);
}}
error={nameError}
fullWidth fullWidth
/> />
<RhfControlGroup<AccountFormValues>
name="emergencyPhone"
rules={{ validate: (value) => isIranianMobile(String(value ?? '')) }}
>
{({ field, hasError }) => (
<PhoneNumberField <PhoneNumberField
label={t('emergency_phone')} label={t('emergency_phone')}
value={emergencyPhone} value={(field.value as string) ?? ''}
onChange={(v) => { onChange={field.onChange}
setEmergencyPhone(v); error={hasError}
if (phoneError) setPhoneError(false); helperText={hasError ? t('emergency_phone_invalid') : undefined}
}}
error={phoneError}
helperText={phoneError ? t('emergency_phone_invalid') : undefined}
fullWidth fullWidth
/> />
)}
</RhfControlGroup>
<SheetActions onCancel={() => setEmergencySheetOpen(false)} onSave={saveEmergency} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} /> <SheetActions onCancel={() => setEmergencySheetOpen(false)} onSave={saveEmergency} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
</Stack> </Stack>
</FormDialogShell> </FormDialogShell>
@@ -266,6 +270,7 @@ const AccountHub: FunctionComponent<{
}} }}
/> />
</Box> </Box>
</FormProvider>
); );
}; };
@@ -287,7 +292,7 @@ const AccountRow: FunctionComponent<{
alignItems: 'center', alignItems: 'center',
gap: 1.5, gap: 1.5,
p: 1.5, p: 1.5,
borderRadius: 2, borderRadius: 'var(--bal-radius-md)',
color: tone === 'error' ? 'var(--bal-error)' : 'text.primary', color: tone === 'error' ? 'var(--bal-error)' : 'text.primary',
'&:hover': { bgcolor: 'action.hover' }, '&:hover': { bgcolor: 'action.hover' },
}} }}
@@ -309,7 +314,7 @@ const EmergencyContactCard: FunctionComponent<{
}> = ({ complete, name, phone, onEdit }) => { }> = ({ complete, name, phone, onEdit }) => {
const t = useTranslations('profile'); const t = useTranslations('profile');
return ( 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 }}> <Stack direction="row" sx={{ alignItems: 'center', gap: 1.5 }}>
<AppIcon icon={complete ? 'verified' : 'emergency'} size={22} color={complete ? 'var(--bal-success)' : 'var(--bal-warning)'} /> <AppIcon icon={complete ? 'verified' : 'emergency'} size={22} color={complete ? 'var(--bal-success)' : 'var(--bal-warning)'} />
<Stack sx={{ flexGrow: 1, gap: 0.25, minWidth: 0 }}> <Stack sx={{ flexGrow: 1, gap: 0.25, minWidth: 0 }}>
@@ -213,7 +213,7 @@ const CategorySelect: FunctionComponent<{ selectedId: number | null; onSelect: (
{isLoading ? ( {isLoading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}> <Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => ( {[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> </Box>
) : isError ? ( ) : isError ? (
@@ -140,6 +140,9 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) {
minimumFractionDigits: 1, minimumFractionDigits: 1,
maximumFractionDigits: 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 ( return (
<Stack sx={{ gap: 1.5 }}> <Stack sx={{ gap: 1.5 }}>
@@ -170,7 +173,7 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) {
</Stack> </Stack>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}> <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 ? ( {profile.inoMembership ? (
<Chip <Chip
icon={<AppIcon icon="license" size={16} color="var(--bal-primary)" />} icon={<AppIcon icon="license" size={16} color="var(--bal-primary)" />}
@@ -355,7 +358,7 @@ function ReviewCard({ review }: { review: ReviewListItem }) {
const t = useTranslations('reviews'); const t = useTranslations('reviews');
const locale = useLocale(); const locale = useLocale();
return ( 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' }}> <Stack direction="row" sx={{ gap: 1, alignItems: 'center', mb: 0.5, flexWrap: 'wrap' }}>
<RatingInput value={review.rating} readOnly size={16} ariaLabel={t('rating_label')} /> <RatingInput value={review.rating} readOnly size={16} ariaLabel={t('rating_label')} />
<Typography variant="caption" sx={{ color: 'text.secondary', marginInlineStart: 'auto' }}> <Typography variant="caption" sx={{ color: 'text.secondary', marginInlineStart: 'auto' }}>
@@ -29,7 +29,7 @@ const WalletInstallments: FunctionComponent = () => {
<Skeleton variant="rounded" height={56} /> <Skeleton variant="rounded" height={56} />
</Stack> </Stack>
) : isError ? ( ) : 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' }}> <Stack sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="warning" size={36} color="var(--bal-warning)" /> <AppIcon icon="warning" size={36} color="var(--bal-warning)" />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}> <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. */} {/* Outstanding-balance card — terracotta financial accent; contrast text is scheme-stable. */}
<Paper <Paper
elevation={0} 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 }}> <Typography variant="caption" sx={{ opacity: 0.85 }}>
{t('outstanding_balance')} {t('outstanding_balance')}
@@ -3,12 +3,12 @@ import Stack from '@mui/material/Stack';
import SurfaceCard from '@/components/common/SurfaceCard'; import SurfaceCard from '@/components/common/SurfaceCard';
/** /**
* The shared loading skeleton for the sidebar-shell route groups (`nurse`, `admin`, `partner`) the * The shared loading skeleton for the nurse/admin/partner route groups. The `MobileShell` chrome
* `TopBarAndSideBarLayout` chrome (top bar + sidebar) is already rendered by the enclosing `layout.tsx` * (top bar + bottom nav) is already rendered by the enclosing `layout.tsx` by the time this shows,
* by the time this shows, so this only needs to shape the content area: a heading line + a short stack of * so this only shapes the content area: a heading line + a short stack of generic worklist cards.
* generic worklist/detail cards. A private (`_`-prefixed) folder not a route. * A private (`_`-prefixed) folder not a route.
*/ */
export default function SidebarShellSkeleton() { export default function ShellContentSkeleton() {
return ( return (
<Stack sx={{ gap: 2 }}> <Stack sx={{ gap: 2 }}>
<Skeleton variant="text" width={220} height={32} /> <Skeleton variant="text" width={220} height={32} />
@@ -1,81 +1,64 @@
'use client'; 'use client';
import { useLocale, useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Box, Paper, Typography } from '@mui/material'; import { Stack } from '@mui/material';
import { AppIcon, AppLink } from '@/components'; import { NavHubList, PageHeader } from '@/components';
import { AdminPageHeader } from '@/components/admin'; import type { NavHubItem } from '@/components';
import { useAdminCapabilities } from '@/hooks'; import { useAdminCapabilities } from '@/hooks';
import { ROUTES } from '@/constants'; 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 * Admin overview landing (f15) the backoffice index. Every worklist the current principal may
* principal may act on, derived from `useAdminCapabilities()` (a UI hint; the server still enforces every * act on, grouped by the same four sections as the bottom nav, so the overview and the tabs agree
* command's role scope). A `support` admin sees verification/tickets/alerts; a `finance` admin sees * on where a console lives. Gating comes from `useAdminCapabilities()` (a UI hint; the server
* payouts/config; only a `super_admin` sees roles. Each card deep-links into its console. * 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() { export default function AdminOverviewScreen() {
const t = useTranslations('admin'); const t = useTranslations('admin');
const th = useTranslations('hub');
const tNav = useTranslations('nav'); const tNav = useTranslations('nav');
const locale = useLocale();
const caps = useAdminCapabilities(); const caps = useAdminCapabilities();
// `key` doubles as the `nav` i18n key for the card label. const consoles: ConsoleEntry[] = [
const consoles: { key: string; route: string; icon: string; enabled: boolean }[] = [ { section: 'trust', title: tNav('verification'), subtitle: th('admin_verification_sub'), path: ROUTES.ADMIN_VERIFICATION, icon: 'verification', enabled: caps.canVerify },
{ key: 'verification', route: 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 },
{ key: 'tickets', route: ROUTES.ADMIN_TICKETS, icon: 'support', enabled: caps.canManageTickets }, { section: 'finance', title: tNav('payouts'), subtitle: th('admin_payouts_sub'), path: ROUTES.ADMIN_PAYOUTS, icon: 'earnings', enabled: caps.canPayout },
{ key: 'payouts', route: 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 },
{ key: 'reviews', route: ROUTES.ADMIN_REVIEWS, icon: 'moderation', enabled: caps.canModerate }, { section: 'support', title: tNav('alerts'), subtitle: th('admin_alerts_sub'), path: ROUTES.ADMIN_ALERTS, icon: 'alerts', enabled: caps.canManageAlerts },
{ key: 'config', route: ROUTES.ADMIN_CONFIG, icon: 'config', enabled: caps.canConfig }, { section: 'system', title: tNav('config'), subtitle: th('admin_config_sub'), path: ROUTES.ADMIN_CONFIG, icon: 'config', enabled: caps.canConfig },
{ key: 'holidays', route: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', enabled: caps.canConfig }, { section: 'system', title: tNav('catalog'), subtitle: th('admin_catalog_sub'), path: ROUTES.ADMIN_CATALOG, icon: 'category', enabled: caps.canManageCatalog },
{ key: 'alerts', route: ROUTES.ADMIN_ALERTS, icon: 'alerts', enabled: caps.canManageAlerts }, { section: 'system', title: tNav('holidays'), subtitle: th('admin_holidays_sub'), path: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', enabled: caps.canConfig },
{ key: 'audit', route: ROUTES.ADMIN_AUDIT, icon: 'audit', enabled: caps.canViewAudit }, { section: 'system', title: tNav('audit'), subtitle: th('admin_audit_sub'), path: ROUTES.ADMIN_AUDIT, icon: 'audit', enabled: caps.canViewAudit },
{ key: 'partners', route: ROUTES.ADMIN_PARTNERS, icon: 'partners', enabled: caps.canManagePartners }, { section: 'system', title: tNav('partners'), subtitle: th('admin_partners_sub'), path: ROUTES.ADMIN_PARTNERS, icon: 'partners', enabled: caps.canManagePartners },
{ key: 'roles', route: ROUTES.ADMIN_ROLES, icon: 'roles', enabled: caps.canManageRoles }, { section: 'system', title: tNav('users'), subtitle: th('admin_users_sub'), path: ROUTES.ADMIN_USERS, icon: 'users', enabled: caps.canManageRoles },
].filter((c) => c.enabled); { 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 ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Stack sx={{ gap: 2.5 }}>
<AdminPageHeader title={t('overview_title')} subtitle={t('overview_subtitle')} /> <PageHeader title={t('overview_title')} subtitle={t('overview_subtitle')} />
<Box {sections.map((section) => {
sx={{ const items = consoles.filter((entry) => entry.section === section.key && entry.enabled);
display: 'grid', if (items.length === 0) return null;
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr' }, return <NavHubList key={section.key} title={section.label} items={items} />;
gap: 2, })}
}} </Stack>
>
{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>
); );
} }
@@ -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 <Stack
direction="row" 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 <TextField
size="small" 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 }}> <Stack sx={{ gap: 1.5 }}>
{history.data?.items.map((change) => ( {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' }}> <Stack direction="row" sx={{ alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 700 }}> <Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 700 }}>
{t('cfg_history_change_old', { old: change.oldValue ?? '—' })} {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'; 'use client';
import { Suspense, useState } from 'react'; import { Suspense, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { FormProvider, useForm } from 'react-hook-form';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { import {
Box, Box,
@@ -14,9 +15,8 @@ import {
Skeleton, Skeleton,
Stack, Stack,
Switch, Switch,
TextField,
} from '@mui/material'; } 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 { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, type AdminTableColumn } from '@/components/admin';
import { formatShamsiDate } from '@/utils'; import { formatShamsiDate } from '@/utils';
import { useAdminCapabilities, useAdminListState } from '@/hooks'; import { useAdminCapabilities, useAdminListState } from '@/hooks';
@@ -138,19 +138,20 @@ function HolidayDialog({ holiday, onClose }: { holiday: Holiday | null; onClose:
const t = useTranslations('admin'); const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const upsert = useUpsertHoliday(); const upsert = useUpsertHoliday();
const [form, setForm] = useState<HolidayInput>(() => ({ const form = useForm<HolidayInput>({
mode: 'onTouched',
defaultValues: {
holidayDate: holiday?.holidayDate?.slice(0, 10) ?? todayLocalIso(), holidayDate: holiday?.holidayDate?.slice(0, 10) ?? todayLocalIso(),
nameFa: holiday?.nameFa ?? '', nameFa: holiday?.nameFa ?? '',
type: holiday?.type ?? 'official', type: holiday?.type ?? 'official',
isBankClosed: holiday?.isBankClosed ?? true, isBankClosed: holiday?.isBankClosed ?? true,
})); },
});
const { handleSubmit, formState } = form;
const valid = form.holidayDate.length > 0 && form.nameFa.trim().length > 0; const onSave = (values: HolidayInput) =>
const onSave = () => {
if (!valid) return;
upsert.mutate( upsert.mutate(
{ ...form, nameFa: form.nameFa.trim() }, { ...values, nameFa: values.nameFa.trim() },
{ {
onSuccess: () => { onSuccess: () => {
enqueueSnackbar(t('hol_saved'), { variant: 'success' }); enqueueSnackbar(t('hol_saved'), { variant: 'success' });
@@ -158,50 +159,59 @@ function HolidayDialog({ holiday, onClose }: { holiday: Holiday | null; onClose:
}, },
}, },
); );
};
return ( return (
<Dialog open onClose={upsert.isPending ? undefined : onClose} fullWidth maxWidth="xs"> <Dialog open onClose={upsert.isPending ? undefined : onClose} fullWidth maxWidth="xs">
<DialogTitle sx={{ fontWeight: 800 }}>{holiday ? t('hol_edit') : t('hol_add')}</DialogTitle> <DialogTitle sx={{ fontWeight: 800 }}>{holiday ? t('hol_edit') : t('hol_add')}</DialogTitle>
<FormProvider {...form}>
<DialogContent> <DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}> {/* A real <form> so Enter submits from any field; the footer button (outside DialogContent)
<JalaliDateField 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')} label={t('hol_col_date')}
value={form.holidayDate || null} rules={{ validate: (value) => String(value ?? '').length > 0 }}
onChange={(iso) => setForm((f) => ({ ...f, holidayDate: iso }))}
disabled={!!holiday} disabled={!!holiday}
/> />
<TextField <RhfTextField<HolidayInput>
name="nameFa"
label={t('hol_name_fa')} label={t('hol_name_fa')}
value={form.nameFa} rules={{ validate: (value) => String(value ?? '').trim().length > 0 }}
onChange={(e) => setForm((f) => ({ ...f, nameFa: e.target.value }))}
/> />
<TextField <RhfTextField<HolidayInput> name="type" select label={t('hol_col_type')}>
select
label={t('hol_col_type')}
value={form.type}
onChange={(e) => setForm((f) => ({ ...f, type: e.target.value as HolidayType }))}
>
{HOLIDAY_TYPES.map((ty) => ( {HOLIDAY_TYPES.map((ty) => (
<MenuItem key={ty} value={ty}> <MenuItem key={ty} value={ty}>
{t(`htype_${ty}`)} {t(`htype_${ty}`)}
</MenuItem> </MenuItem>
))} ))}
</TextField> </RhfTextField>
<RhfControlGroup<HolidayInput> name="isBankClosed">
{({ field }) => (
<FormControlLabel <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')} label={t('hol_bank_hint')}
/> />
)}
</RhfControlGroup>
</Stack> </Stack>
</DialogContent> </DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}> <DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={upsert.isPending}> <AppButton variant="text" color="inherit" onClick={onClose} disabled={upsert.isPending}>
{t('cancel')} {t('cancel')}
</AppButton> </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')} {upsert.isPending ? t('saving') : t('save')}
</AppButton> </AppButton>
</DialogActions> </DialogActions>
</FormProvider>
</Dialog> </Dialog>
); );
} }
@@ -1,5 +1,5 @@
import SidebarShellSkeleton from '../_chrome/SidebarShellSkeleton'; import ShellContentSkeleton from '../_chrome/ShellContentSkeleton';
export default function Loading() { 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}`)} />} 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 }}> <Stack divider={<Divider flexItem />} sx={{ gap: 1.25 }}>
<DetailRow label={t('partner_legal_type')}>{data.legalEntityType || '—'}</DetailRow> <DetailRow label={t('partner_legal_type')}>{data.legalEntityType || '—'}</DetailRow>
<DetailRow label={t('partner_permit')}>{data.mohEstablishmentPermitNo || '—'}</DetailRow> <DetailRow label={t('partner_permit')}>{data.mohEstablishmentPermitNo || '—'}</DetailRow>
@@ -174,7 +174,7 @@ export default function AdminPartnerCenterDetailPage() {
{roster.isLoading ? ( {roster.isLoading ? (
<Skeleton variant="rounded" height={120} /> <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 />}> <Stack divider={<Divider />}>
{(roster.data ?? []).map((nurse) => ( {(roster.data ?? []).map((nurse) => (
<Stack <Stack
@@ -2,6 +2,7 @@
import { Suspense, useState } from 'react'; import { Suspense, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { import {
Box, Box,
@@ -13,10 +14,9 @@ import {
Skeleton, Skeleton,
Stack, Stack,
Switch, Switch,
TextField,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import { AppButton, AppLoading, StatusChip } from '@/components'; import { AppButton, AppLoading, RhfControlGroup, RhfTextField, StatusChip } from '@/components';
import type { StatusKind } from '@/components'; import type { StatusKind } from '@/components';
import { import {
AdminDataTable, AdminDataTable,
@@ -183,37 +183,30 @@ export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCe
const create = useCreatePartnerCenter(); const create = useCreatePartnerCenter();
const update = useUpdatePartnerCenter(center?.id ?? 0); const update = useUpdatePartnerCenter(center?.id ?? 0);
const mutation = isEdit ? update : create; 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 // 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 // opens pre-filled with a person, never a bare id (3.2). Derived in render (never synced into form state):
// effect): once the admin actually picks someone, `form.adminUser` wins over the resolved existing one. // once the admin actually picks someone, the form value wins over the resolved existing one.
const existingAdminId = center?.adminUserId ?? null; const existingAdminId = center?.adminUserId ?? null;
const existingAdminLookup = useUserLookup(existingAdminId != null ? [existingAdminId] : []); const existingAdminLookup = useUserLookup(existingAdminId != null ? [existingAdminId] : []);
const resolvedExistingAdmin = existingAdminId != null ? (existingAdminLookup.data?.get(existingAdminId) ?? null) : null; 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]) => const onSave = (values: CenterFormState) => {
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 input: PartnerCenterInput = { const input: PartnerCenterInput = {
name: form.name.trim(), name: values.name.trim(),
legalEntityType: form.legalEntityType.trim(), legalEntityType: values.legalEntityType.trim(),
mohEstablishmentPermitNo: form.mohEstablishmentPermitNo.trim(), mohEstablishmentPermitNo: values.mohEstablishmentPermitNo.trim(),
technicalDirectorLicenseNo: form.technicalDirectorLicenseNo.trim() || null, technicalDirectorLicenseNo: values.technicalDirectorLicenseNo.trim() || null,
enamadCode: form.enamadCode.trim() || null, enamadCode: values.enamadCode.trim() || null,
settlementIban: form.settlementIban.trim() || null, settlementIban: values.settlementIban.trim() || null,
isMerchantOfRecord: form.isMerchantOfRecord, isMerchantOfRecord: values.isMerchantOfRecord,
commissionRate: commission, commissionRate: Number(values.commissionRate),
adminUserId: displayedAdminUser?.id ?? null, adminUserId: displayedAdminUser?.id ?? null,
}; };
mutation.mutate(input, { mutation.mutate(input, {
@@ -227,67 +220,95 @@ export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCe
return ( return (
<Dialog open onClose={mutation.isPending ? undefined : onClose} fullWidth maxWidth="sm"> <Dialog open onClose={mutation.isPending ? undefined : onClose} fullWidth maxWidth="sm">
<DialogTitle sx={{ fontWeight: 800 }}>{isEdit ? t('partner_edit') : t('partner_create')}</DialogTitle> <DialogTitle sx={{ fontWeight: 800 }}>{isEdit ? t('partner_edit') : t('partner_create')}</DialogTitle>
<FormProvider {...form}>
<DialogContent> <DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}> {/* A real <form> so Enter submits from any field; the footer button (outside DialogContent)
<TextField label={t('partner_name')} value={form.name} onChange={(e) => set('name', e.target.value)} /> calls the same handler directly rather than relying on cross-element form association. */}
<TextField <Stack component="form" noValidate onSubmit={handleSubmit(onSave)} sx={{ gap: 2, mt: 1 }}>
label={t('partner_legal_type')} <RhfTextField<CenterFormState>
value={form.legalEntityType} name="name"
onChange={(e) => set('legalEntityType', e.target.value)} 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')} label={t('partner_permit')}
value={form.mohEstablishmentPermitNo} rules={{ validate: (value) => String(value ?? '').trim() !== '' }}
onChange={(e) => set('mohEstablishmentPermitNo', e.target.value)}
/> />
<TextField <RhfTextField<CenterFormState>
name="technicalDirectorLicenseNo"
label={t('partner_tech_director_license')} 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)} /> <RhfTextField<CenterFormState> name="enamadCode" label={t('partner_enamad')} />
<TextField <RhfTextField<CenterFormState>
name="settlementIban"
label={t('partner_iban')} label={t('partner_iban')}
value={form.settlementIban}
onChange={(e) => set('settlementIban', e.target.value)}
helperText={t('partner_iban_write_hint')} helperText={t('partner_iban_write_hint')}
placeholder={center?.settlementIbanMasked ?? undefined} 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' } }} slotProps={{ htmlInput: { dir: 'ltr' } }}
/> />
<Box> <Box>
<RhfControlGroup<CenterFormState> name="isMerchantOfRecord">
{({ field }) => (
<FormControlLabel <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')} label={t('partner_is_mor')}
/> />
)}
</RhfControlGroup>
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}> <Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
{t('partner_is_mor_hint')} {t('partner_is_mor_hint')}
</Typography> </Typography>
</Box> </Box>
<TextField <RhfTextField<CenterFormState>
name="commissionRate"
type="number" type="number"
label={t('partner_commission')} label={t('partner_commission')}
value={form.commissionRate} rules={{
onChange={(e) => set('commissionRate', e.target.value)} 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 } }} slotProps={{ htmlInput: { min: 0, max: 0.999, step: 0.01 } }}
/> />
<RhfControlGroup<CenterFormState> name="adminUser">
{({ field }) => (
<UserPicker <UserPicker
value={displayedAdminUser} value={displayedAdminUser}
onChange={(u) => set('adminUser', u)} onChange={field.onChange}
label={t('partner_admin_user')} label={t('partner_admin_user')}
placeholder={t('user_picker_search_ph')} placeholder={t('user_picker_search_ph')}
noOptionsText={t('user_picker_no_options')} noOptionsText={t('user_picker_no_options')}
loadingText={t('user_picker_loading')} loadingText={t('user_picker_loading')}
/> />
)}
</RhfControlGroup>
</Stack> </Stack>
</DialogContent> </DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}> <DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={mutation.isPending}> <AppButton variant="text" color="inherit" onClick={onClose} disabled={mutation.isPending}>
{t('cancel')} {t('cancel')}
</AppButton> </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')} {mutation.isPending ? t('saving') : t('save')}
</AppButton> </AppButton>
</DialogActions> </DialogActions>
</FormProvider>
</Dialog> </Dialog>
); );
} }
@@ -83,7 +83,7 @@ function AdminPayoutBatchDetailScreen() {
<AdminEmptyState icon="wallet" title={t('payout_empty')} /> <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 }}> <Stack sx={{ gap: 1.5 }}>
<StatusChip <StatusChip
status={BATCH_STATUS_KIND[data.batch.status]} status={BATCH_STATUS_KIND[data.batch.status]}
@@ -180,7 +180,7 @@ const PayoutRowCard: FunctionComponent<{ row: AdminPayoutRow; batchId: number; c
}; };
return ( 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 sx={{ gap: 1.25 }}>
<Stack <Stack
direction="row" direction="row"
@@ -217,7 +217,7 @@ const PayoutRowCard: FunctionComponent<{ row: AdminPayoutRow; batchId: number; c
<Box <Box
sx={{ sx={{
p: 1.25, p: 1.25,
borderRadius: 2, borderRadius: 'var(--bal-radius-md)',
border: '1px solid', border: '1px solid',
borderColor: 'divider', borderColor: 'divider',
borderInlineStart: '3px solid', borderInlineStart: '3px solid',
@@ -283,7 +283,7 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
</Typography> </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) => ( {result.eligible.map((n) => (
<Stack key={n.nurseId} sx={{ p: 1.5, gap: 0.5 }}> <Stack key={n.nurseId} sx={{ p: 1.5, gap: 0.5 }}>
<Stack <Stack
@@ -317,7 +317,7 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}> <Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
{t('payout_skipped')} {t('payout_skipped')}
</Typography> </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) => ( {result.skipped.map((n) => (
<Stack <Stack
key={n.nurseId} key={n.nurseId}
@@ -166,7 +166,7 @@ function ModerationCard({
return ( return (
<Paper <Paper
elevation={0} 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' }}> <Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<RatingInput value={item.rating} readOnly size={20} ariaLabel={t('mod_col_rating')} /> <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')} /> <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 <PageHeader
title={t('ticket_thread_title', { ref: detail.referenceCode })} title={t('ticket_thread_title', { ref: detail.referenceCode })}
subtitle={detail.subject ?? undefined} subtitle={detail.subject ?? undefined}
@@ -202,7 +202,7 @@ export default function AdminTicketThreadPage() {
<Paper <Paper
id={REFUND_PANEL_ID} id={REFUND_PANEL_ID}
elevation={0} 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} /> <RefundPanel bookingId={detail.bookingId as number} ticketId={detail.id} />
</Paper> </Paper>
@@ -229,7 +229,7 @@ export default function AdminTicketThreadPage() {
p: 2, p: 2,
border: '1px solid', border: '1px solid',
borderColor: isInternal ? 'var(--bal-warning)' : 'divider', borderColor: isInternal ? 'var(--bal-warning)' : 'divider',
borderRadius: 2, borderRadius: 'var(--bal-radius-md)',
bgcolor: isInternal ? 'var(--bal-warning-soft)' : 'background.paper', bgcolor: isInternal ? 'var(--bal-warning-soft)' : 'background.paper',
}} }}
> >
@@ -112,7 +112,7 @@ function AdminTicketsQueue() {
<Stack <Stack
direction="row" 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 <TextField
select 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 { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { FormProvider, useForm } from 'react-hook-form';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { import {
Box, Box,
@@ -12,10 +13,9 @@ import {
DialogTitle, DialogTitle,
Skeleton, Skeleton,
Stack, Stack,
TextField,
Typography, Typography,
} from '@mui/material'; } 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 type { StatusKind } from '@/components';
import { AdminEmptyState, AdminErrorState, ConfirmDialog, DocumentViewer } from '@/components/admin'; import { AdminEmptyState, AdminErrorState, ConfirmDialog, DocumentViewer } from '@/components/admin';
import { ROUTES, adminVerificationCasePath } from '@/constants'; import { ROUTES, adminVerificationCasePath } from '@/constants';
@@ -75,7 +75,7 @@ function AdminVerificationCaseScreen() {
const caps = useAdminCapabilities(); const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const { data, isLoading, isError, refetch } = useVerificationCase( const { data, isLoading, isError, isFetching, refetch } = useVerificationCase(
Number.isFinite(nurseVerificationId) ? nurseVerificationId : null, Number.isFinite(nurseVerificationId) ? nurseVerificationId : null,
); );
const approve = useApproveVerification(); const approve = useApproveVerification();
@@ -182,7 +182,7 @@ function AdminVerificationCaseScreen() {
<AdminEmptyState icon="verified" title={t('ver_empty')} /> <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' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('ver_identity_name')} {t('ver_identity_name')}
</Typography> </Typography>
@@ -199,6 +199,8 @@ function AdminVerificationCaseScreen() {
step={step} step={step}
nurseVerificationId={nurseVerificationId} nurseVerificationId={nurseVerificationId}
canVerify={caps.canVerify} canVerify={caps.canVerify}
onReloadDocuments={refetch}
reloadingDocuments={isFetching}
/> />
))} ))}
</Stack> </Stack>
@@ -207,7 +209,7 @@ function AdminVerificationCaseScreen() {
<Stack sx={{ gap: 1.5 }}> <Stack sx={{ gap: 1.5 }}>
<Typography variant="h6">{t('ver_credentials_title')}</Typography> <Typography variant="h6">{t('ver_credentials_title')}</Typography>
{data.credentials.map((cred) => ( {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 <Stack
direction="row" direction="row"
sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }} sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}
@@ -290,10 +292,14 @@ function StepCard({
step, step,
nurseVerificationId, nurseVerificationId,
canVerify, canVerify,
onReloadDocuments,
reloadingDocuments,
}: { }: {
step: AdminVerificationStepDetail; step: AdminVerificationStepDetail;
nurseVerificationId: number; nurseVerificationId: number;
canVerify: boolean; canVerify: boolean;
onReloadDocuments: () => void;
reloadingDocuments: boolean;
}) { }) {
const t = useTranslations('admin'); const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
@@ -325,7 +331,7 @@ function StepCard({
}; };
return ( 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' }}> <Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, flexGrow: 1 }}> <Typography variant="subtitle1" sx={{ fontWeight: 700, flexGrow: 1 }}>
{t(`step_${step.code}`)} {t(`step_${step.code}`)}
@@ -339,7 +345,12 @@ function StepCard({
{step.documents.length > 0 ? ( {step.documents.length > 0 ? (
<Stack sx={{ gap: 1.5, mt: 1.5 }}> <Stack sx={{ gap: 1.5, mt: 1.5 }}>
{step.documents.map((doc) => ( {step.documents.map((doc) => (
<DocumentViewer key={doc.id} document={doc} /> <DocumentViewer
key={doc.id}
document={doc}
onReload={onReloadDocuments}
reloading={reloadingDocuments}
/>
))} ))}
</Stack> </Stack>
) : isManual ? ( ) : isManual ? (
@@ -398,6 +409,14 @@ function StepCard({
/** The structured credential form recorded on approving a credential-bearing step. `criminal_record` /** 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. */ * 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({ function CredentialDialog({
step, step,
nurseVerificationId, nurseVerificationId,
@@ -410,28 +429,26 @@ function CredentialDialog({
const t = useTranslations('admin'); const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const decide = useDecideStep(); 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 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 = () => { const onSubmit = (values: CredentialDecisionValues) =>
if (expiryMissing) return;
decide.mutate( decide.mutate(
{ {
stepId: step.id, stepId: step.id,
nurseVerificationId, nurseVerificationId,
input: { input: {
approve: true, approve: true,
credentialNumber: credentialNumber.trim() || undefined, credentialNumber: values.credentialNumber.trim() || undefined,
holderName: holderName.trim() || undefined, holderName: values.holderName.trim() || undefined,
issuingAuthority: issuingAuthority.trim() || undefined, issuingAuthority: values.issuingAuthority.trim() || undefined,
issuedAt: issuedAt || undefined, issuedAt: values.issuedAt || undefined,
expiresAt: expiresAt || undefined, expiresAt: values.expiresAt || undefined,
}, },
}, },
{ {
@@ -441,42 +458,41 @@ function CredentialDialog({
}, },
}, },
); );
};
return ( return (
<Dialog open onClose={decide.isPending ? undefined : onClose} fullWidth maxWidth="sm"> <Dialog open onClose={decide.isPending ? undefined : onClose} fullWidth maxWidth="sm">
<DialogTitle sx={{ fontWeight: 800 }}>{t('ver_credential_title')}</DialogTitle> <DialogTitle sx={{ fontWeight: 800 }}>{t('ver_credential_title')}</DialogTitle>
<FormProvider {...form}>
<DialogContent> <DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}> {/* A real <form> so Enter submits from any field; the footer button (outside DialogContent)
<TextField 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 fullWidth
autoFocus autoFocus
label={t('ver_credential_number')} label={t('ver_credential_number')}
value={credentialNumber}
onChange={(e) => setCredentialNumber(e.target.value)}
/> />
<TextField <RhfTextField<CredentialDecisionValues>
name="holderName"
fullWidth fullWidth
label={t('ver_holder_name')} label={t('ver_holder_name')}
helperText={t('ver_holder_hint')} helperText={t('ver_holder_hint')}
value={holderName}
onChange={(e) => setHolderName(e.target.value)}
/> />
<TextField <RhfTextField<CredentialDecisionValues>
name="issuingAuthority"
fullWidth fullWidth
label={t('ver_issuing_authority')} 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} /> <RhfJalaliDateField<CredentialDecisionValues> name="issuedAt" fullWidth label={t('ver_issued_at')} />
<JalaliDateField <RhfJalaliDateField<CredentialDecisionValues>
name="expiresAt"
fullWidth fullWidth
label={t('ver_expires_at')} label={t('ver_expires_at')}
value={expiresAt || null}
onChange={setExpiresAt}
required={expiryRequired} required={expiryRequired}
error={expiryMissing} rules={{
helperText={expiryMissing ? t('ver_expiry_required') : undefined} validate: (value) => !expiryRequired || String(value ?? '').trim().length > 0 || t('ver_expiry_required'),
}}
/> />
</Stack> </Stack>
</DialogContent> </DialogContent>
@@ -487,12 +503,13 @@ function CredentialDialog({
<AppButton <AppButton
variant="contained" variant="contained"
color="primary" color="primary"
onClick={onSubmit} onClick={handleSubmit(onSubmit)}
disabled={decide.isPending || expiryMissing} disabled={decide.isPending || !formState.isValid}
> >
{decide.isPending ? t('saving') : t('save')} {decide.isPending ? t('saving') : t('save')}
</AppButton> </AppButton>
</DialogActions> </DialogActions>
</FormProvider>
</Dialog> </Dialog>
); );
} }
@@ -174,7 +174,7 @@ function AdminVerificationQueueScreen() {
<Stack <Stack
direction="row" 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 <TextField
size="small" size="small"
@@ -1,96 +1,103 @@
'use client'; 'use client';
import { ReactNode } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation'; import { Box, Skeleton, Stack, Typography } from '@mui/material';
import { Avatar, Box, Skeleton, Stack, Typography } from '@mui/material';
import { import {
AccentCard,
AppButton, AppButton,
AppIcon,
AppLink, AppLink,
CountdownTimer, CountdownTimer,
EmptyState, EmptyState,
ErrorState, ErrorState,
Money, Money,
PageHeader,
SurfaceCard, SurfaceCard,
TrustBadge,
} from '@/components'; } from '@/components';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { formatRelativeTime, formatShamsiDate, localeTag, parseIrr } from '@/utils'; import { formatRelativeTime, formatShamsiDate, localeTag, parseIrr } from '@/utils';
import { useMe } from '@/services/auth';
import { useNurseRequestInbox } from '@/services/bookingRequests'; import { useNurseRequestInbox } from '@/services/bookingRequests';
import { useTodaySessions } from '@/services/bookings'; import { useTodaySessions } from '@/services/bookings';
import { useNurseEarningsBalance } from '@/services/payouts'; 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 { coarseResponseLabel } from '@/services/bookingRequests/format';
import DashboardActivationSlot from './DashboardActivationSlot'; 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. */ /** The pill's urgency tiers (ui-phase-7 §3.4): teal >2h · amber <2h · terracotta <30min. */
const URGENT_THRESHOLD_SECONDS = 30 * 60; const URGENT_THRESHOLD_SECONDS = 30 * 60;
const WARN_THRESHOLD_SECONDS = 2 * 60 * 60; const WARN_THRESHOLD_SECONDS = 2 * 60 * 60;
/** /**
* The nurse "امروز" dashboard (ui-phase-7 §3.1) the operational home replacing the `PlaceholderScreen`. * The nurse «امروز» home the first bottom-nav destination.
* 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. * 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() { export default function NurseDashboardScreen() {
const t = useTranslations('dashboard'); 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 ( return (
<Stack sx={{ gap: 3, maxWidth: DASHBOARD_MAX_WIDTH, mx: 'auto', width: '100%' }}> <Stack sx={{ gap: 2.5 }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}> {/* Load-bearing now that the bottom nav is icon-only: this is the only place the current
{meLoading ? ( section is named, and the page's only h1. */}
<> <PageHeader title={t('title')} />
<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>
<NextVisitCard /> <NextVisitCard />
<RequestsStrip /> <RequestsSection />
<EarningsSnapshotCard /> <EarningsSection />
<DashboardActivationSlot /> <DashboardActivationSlot />
<NotificationsEntryRow />
</Stack> </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() { function NextVisitCard() {
const t = useTranslations('dashboard'); const t = useTranslations('dashboard');
const locale = useLocale(); const locale = useLocale();
const router = useRouter();
const { data, isLoading, isError, refetch } = useTodaySessions(); 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) { if (isError) {
return <ErrorState message={t('next_visit_error')} retryLabel={t('retry')} onRetry={() => refetch()} />; return <ErrorState message={t('next_visit_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
} }
const items = data?.items ?? []; const next = (data?.items ?? []).find((item) => item.status === 'scheduled' || item.status === 'in_progress');
const next = items.find((item) => item.status === 'scheduled' || item.status === 'in_progress');
if (!next) { 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' }); 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); const timeUntil = formatRelativeTime(`${next.scheduledDate}T${next.scheduledTimeStart}`, locale, formatShamsiDate);
return ( return (
<SurfaceCard data-widget="next-visit"> <AccentCard tone="secondary" data-widget="next-visit">
<Stack sx={{ gap: 1.25 }}> <Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}> <Stack sx={{ gap: 0.25, minWidth: 0 }}>
<AppIcon icon="visits" size={20} color="var(--bal-primary)" /> <Typography variant="overline" sx={{ color: 'text.secondary' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('next_visit_title')} {t('next_visit_title')}
</Typography> </Typography>
</Stack> <Typography variant="h6" sx={{ fontWeight: 700 }}>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
{next.patientName} {next.patientName}
</Typography> </Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <MetaLine
<Typography component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}> items={[
<Box key="range" component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>
{timeRangeLabel} {timeRangeLabel}
</Typography> </Box>,
{timeUntil ? ` · ${t('next_visit_starts_in', { relative: timeUntil })}` : ''} timeUntil ? t('next_visit_starts_in', { relative: timeUntil }) : null,
</Typography> ]}
/>
</Stack> </Stack>
<AppButton
variant="contained" <AppButton variant="contained" color="secondary" startIcon="check_in" fullWidth to={`/${locale}${ROUTES.NURSE_VISITS}`}>
color="secondary"
startIcon="check_in"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_VISITS}`)}
sx={{ alignSelf: 'flex-start' }}
>
{t('next_visit_cta')} {t('next_visit_cta')}
</AppButton> </AppButton>
</Stack> </Stack>
</SurfaceCard> </AccentCard>
); );
} }
/** The most time-critical widget: pending-request count + the most urgent countdown, inline into detail. */ /** The most time-critical section: a pending request expires on its own if it isn't answered. */
function RequestsStrip() { function RequestsSection() {
const t = useTranslations('dashboard'); const t = useTranslations('dashboard');
const tb = useTranslations('booking'); const tb = useTranslations('booking');
const locale = useLocale(); const locale = useLocale();
const router = useRouter();
const { data, isLoading, isError, refetch } = useNurseRequestInbox(); 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) { if (isError) {
return <ErrorState message={t('requests_strip_error')} retryLabel={t('retry')} onRetry={() => refetch()} />; return <ErrorState message={t('requests_strip_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
} }
@@ -148,43 +148,38 @@ function RequestsStrip() {
const total = data?.total ?? 0; const total = data?.total ?? 0;
if (items.length === 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]; const mostUrgent = items[0];
return ( return (
<SurfaceCard data-widget="requests-strip"> <Stack sx={{ gap: 1 }} data-widget="requests-strip">
<Stack sx={{ gap: 1.25 }}> <SectionHeader
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}> title={t('requests_strip_title', { count: total })}
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}> actionLabel={t('requests_strip_cta')}
<AppIcon icon="requests" size={20} color="var(--bal-secondary)" /> actionTo={ROUTES.NURSE_REQUESTS}
<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 <SurfaceCard>
direction="row" <Stack sx={{ gap: 1.5 }}>
sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }} {/* 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 }}> <Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="body1" sx={{ fontWeight: 500 }}> <Typography variant="body1" noWrap sx={{ fontWeight: 500 }}>
{mostUrgent.counterpartyName} {mostUrgent.counterpartyName}
</Typography> </Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}> <Typography variant="caption" sx={{ color: 'text.secondary' }}>
{formatShamsiDate(mostUrgent.requestedDate, locale)} {formatShamsiDate(mostUrgent.requestedDate, locale)}
</Typography> </Typography>
</Stack> </Stack>
<Box sx={{ flexShrink: 0 }}>
<CountdownTimer <CountdownTimer
deadlineIso={mostUrgent.nurseResponseDeadlineAt} deadlineIso={mostUrgent.nurseResponseDeadlineAt}
elapsedText={tb('response_elapsed')} elapsedText={tb('response_elapsed')}
@@ -193,31 +188,30 @@ function RequestsStrip() {
coarseLabel={(minutes) => coarseResponseLabel(minutes, tb)} coarseLabel={(minutes) => coarseResponseLabel(minutes, tb)}
size="sm" size="sm"
/> />
</Box>
</Stack> </Stack>
<AppButton <AppButton
variant="outlined" variant="outlined"
color="primary" color="primary"
endIcon="requests" fullWidth
onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}/${mostUrgent.id}`)} to={`/${locale}${ROUTES.NURSE_REQUESTS}/${mostUrgent.id}`}
sx={{ alignSelf: 'flex-start' }}
> >
{t('requests_strip_open')} {t('requests_strip_open')}
</AppButton> </AppButton>
</Stack> </Stack>
</SurfaceCard> </SurfaceCard>
</Stack>
); );
} }
/** A compact two-stat row (net payable + eligible) — never clamps a negative net balance. */ /** Two stat tiles — the signed net balance is never clamped, an "owed back" reads as an error tone. */
function EarningsSnapshotCard() { function EarningsSection() {
const t = useTranslations('dashboard'); const t = useTranslations('dashboard');
const tp = useTranslations('payouts'); const tp = useTranslations('payouts');
const locale = useLocale();
const router = useRouter();
const { data, isLoading, isError, refetch } = useNurseEarningsBalance(); 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) { if (isError) {
return <ErrorState message={t('earnings_snapshot_error')} retryLabel={t('retry')} onRetry={() => refetch()} />; return <ErrorState message={t('earnings_snapshot_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
} }
@@ -228,69 +222,48 @@ function EarningsSnapshotCard() {
const magnitude = isOwed ? -net : net; const magnitude = isOwed ? -net : net;
return ( return (
<SurfaceCard data-widget="earnings-snapshot"> <Stack sx={{ gap: 1 }} data-widget="earnings-snapshot">
<Stack sx={{ gap: 1.5 }}> <SectionHeader
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}> title={t('earnings_snapshot_title')}
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}> actionLabel={t('earnings_snapshot_cta')}
<AppIcon icon="earnings" size={20} color="var(--bal-primary)" /> actionTo={ROUTES.NURSE_EARNINGS}
<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>
<Box sx={{ display: 'grid', gap: 1.5, gridTemplateColumns: '1fr 1fr' }}> <Box sx={{ display: 'grid', gap: 1.5, gridTemplateColumns: '1fr 1fr' }}>
<Stack sx={{ gap: 0.25 }}> <StatTile
<Typography variant="caption" sx={{ color: 'text.secondary' }}> label={isOwed ? tp('balance_owed_label') : tp('balance_net_label')}
{isOwed ? tp('balance_owed_label') : tp('balance_net_label')} value={<Money amountIrr={String(magnitude)} size="lg" tone={isOwed ? 'error' : 'emphasis'} />}
</Typography> />
<Money amountIrr={String(magnitude)} size="lg" tone={isOwed ? 'error' : 'emphasis'} sx={{ fontWeight: 800 }} /> <StatTile label={tp('bucket_eligible')} value={<Money amountIrr={data.eligibleTotalIrr} size="lg" />} />
</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>
</Box> </Box>
</Stack> </Stack>
</SurfaceCard>
); );
} }
/** The unread-count entry row — the bell in the shell chrome is Phase 2's; this is a dashboard shortcut. */ function StatTile({ label, value }: { label: string; value: ReactNode }) {
function NotificationsEntryRow() {
const t = useTranslations('dashboard');
const locale = useLocale();
const unread = useUnreadCount();
return ( return (
<AppLink to={`/${locale}${ROUTES.NURSE_NOTIFICATIONS}`} color="inherit" underline="none" sx={{ display: 'block' }}> <SurfaceCard padding="sm">
<SurfaceCard data-widget="notifications-entry"> <Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between' }}> <Typography variant="caption" noWrap sx={{ color: 'text.secondary' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}> {label}
<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')}
</Typography> </Typography>
{value}
</Stack> </Stack>
</SurfaceCard> </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'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { FormProvider, useForm } from 'react-hook-form';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { Box, Stack, TextField, Typography } from '@mui/material'; import { Box, Stack, Typography } from '@mui/material';
import { AppButton, AppLoading, BankStatusPanel, EmptyState, ErrorState } from '@/components'; import { AppButton, AppLoading, BankStatusPanel, EmptyState, ErrorState, RhfTextField } from '@/components';
import { useNurseBankAccounts, useAddNurseBankAccount, useSetPrimaryBankAccount } from '@/services/nurse'; import { useNurseBankAccounts, useAddNurseBankAccount, useSetPrimaryBankAccount } from '@/services/nurse';
import { isValidSheba } from '@/services/nurse/iban'; import { isValidSheba } from '@/services/nurse/iban';
import { deriveBankStatus } from '@/services/nurse/types'; 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 * 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 * 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 addAccount = useAddNurseBankAccount();
const setPrimary = useSetPrimaryBankAccount(); 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 [showForm, setShowForm] = useState(false);
const form = useForm<BankFormValues>({ mode: 'onTouched', defaultValues: { iban: '', holder: '' } });
const { handleSubmit, reset } = form;
const accounts = data ?? []; const accounts = data ?? [];
const showFormNow = !isLoading && !isError && (accounts.length === 0 || showForm); const showFormNow = !isLoading && !isError && (accounts.length === 0 || showForm);
const submit = () => { const submit = (values: BankFormValues) => {
const ibanInvalid = !isValidSheba(iban);
const holderInvalid = holder.trim().length === 0;
setIbanError(ibanInvalid);
setHolderError(holderInvalid);
if (ibanInvalid || holderInvalid) return;
addAccount.mutate( addAccount.mutate(
{ iban, accountHolderName: holder.trim() }, { iban: values.iban, accountHolderName: values.holder.trim() },
{ {
onSuccess: () => { onSuccess: () => {
setIban(''); reset();
setHolder('');
setShowForm(false); setShowForm(false);
enqueueSnackbar(t('added'), { variant: 'success' }); enqueueSnackbar(t('added'), { variant: 'success' });
}, },
@@ -131,32 +128,26 @@ export default function NurseBankPage() {
) : null} ) : null}
{showFormNow ? ( {showFormNow ? (
<Stack sx={{ gap: 2 }}> <FormProvider {...form}>
<TextField <Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 2 }}>
<RhfTextField<BankFormValues>
name="iban"
label={t('iban_label')} label={t('iban_label')}
value={iban} helperText={t('iban_hint')}
onChange={(e) => { transform={(raw) => raw.toUpperCase()}
setIban(e.target.value.toUpperCase()); rules={{ validate: (value) => isValidSheba(String(value ?? '')) || t('iban_invalid') }}
if (ibanError) setIbanError(false);
}}
error={ibanError}
helperText={ibanError ? t('iban_invalid') : t('iban_hint')}
slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start', letterSpacing: 1 } } }} slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start', letterSpacing: 1 } } }}
fullWidth fullWidth
/> />
<TextField <RhfTextField<BankFormValues>
name="holder"
label={t('holder_label')} label={t('holder_label')}
value={holder} helperText={t('holder_hint')}
onChange={(e) => { rules={{ validate: (value) => String(value ?? '').trim().length > 0 || t('holder_required') }}
setHolder(e.target.value);
if (holderError) setHolderError(false);
}}
error={holderError}
helperText={holderError ? t('holder_required') : t('holder_hint')}
fullWidth fullWidth
/> />
<Stack direction="row" sx={{ gap: 1 }}> <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')} {addAccount.isPending ? t('submitting') : t('submit')}
</AppButton> </AppButton>
{accounts.length > 0 ? ( {accounts.length > 0 ? (
@@ -164,10 +155,7 @@ export default function NurseBankPage() {
variant="text" variant="text"
onClick={() => { onClick={() => {
setShowForm(false); setShowForm(false);
setIban(''); reset();
setHolder('');
setIbanError(false);
setHolderError(false);
}} }}
disabled={addAccount.isPending} disabled={addAccount.isPending}
> >
@@ -176,6 +164,7 @@ export default function NurseBankPage() {
) : null} ) : null}
</Stack> </Stack>
</Stack> </Stack>
</FormProvider>
) : null} ) : null}
</Box> </Box>
); );
@@ -132,7 +132,7 @@ export default function NurseCoveragePage() {
elevation={0} elevation={0}
sx={{ sx={{
p: 2, p: 2,
borderRadius: 2, borderRadius: 'var(--bal-radius-md)',
border: '1px solid', border: '1px solid',
borderColor: 'divider', borderColor: 'divider',
borderInlineStartWidth: 4, borderInlineStartWidth: 4,
@@ -153,7 +153,7 @@ export default function NurseCoveragePage() {
</Paper> </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 }}> <Stack sx={{ gap: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}> <Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('add_title')} {t('add_title')}
@@ -157,7 +157,7 @@ function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void
elevation={0} elevation={0}
sx={{ sx={{
p: 2, p: 2,
borderRadius: 2, borderRadius: 'var(--bal-radius-md)',
border: '1px solid', border: '1px solid',
borderColor: 'divider', borderColor: 'divider',
borderInlineStart: '3px solid', borderInlineStart: '3px solid',
@@ -168,7 +168,7 @@ function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void
onClick={onToggle} onClick={onToggle}
aria-expanded={open} aria-expanded={open}
aria-controls={EXPLAINER_CONTENT_ID} 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' }}> <Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
<AppIcon icon="info" size={18} color="var(--bal-info)" /> <AppIcon icon="info" size={18} color="var(--bal-info)" />
@@ -66,14 +66,14 @@ export default function NursePayoutDetailPage() {
<Skeleton variant="rounded" height={200} /> <Skeleton variant="rounded" height={200} />
</Stack> </Stack>
) : isError || !data ? ( ) : 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' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('detail_not_found')} {t('detail_not_found')}
</Typography> </Typography>
</Paper> </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 sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}> <Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}> <Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
@@ -112,7 +112,7 @@ export default function NursePayoutDetailPage() {
<Box <Box
sx={{ sx={{
p: 1.75, p: 1.75,
borderRadius: 2, borderRadius: 'var(--bal-radius-md)',
border: '1px solid', border: '1px solid',
borderColor: 'divider', borderColor: 'divider',
borderInlineStart: '3px solid', borderInlineStart: '3px solid',
@@ -174,7 +174,7 @@ export default function NursePayoutDetailPage() {
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('detail_bookings_hint')} {t('detail_bookings_hint')}
</Typography> </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 />}> <Stack divider={<Divider />}>
{data.bookings.map((link) => ( {data.bookings.map((link) => (
<Stack <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() { 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'; 'use client';
import { ChangeEvent, FunctionComponent, useEffect, useRef, useState } from 'react'; import { ChangeEvent, FunctionComponent, useEffect, useRef } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useForm, FormProvider, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { Avatar, Box, Chip, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material'; import { Avatar, Box, MenuItem, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, TrustBadge } from '@/components'; import {
AccentCard,
AppButton,
AppIcon,
AppLoading,
FormSection,
PageHeader,
RhfChipSelect,
RhfTextField,
TrustBadge,
} from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { digitsOnly } from '@/utils';
import { useNurseProfile, useUpsertNurseProfile, useUploadAvatar } from '@/services/profiles'; import { useNurseProfile, useUpsertNurseProfile, useUploadAvatar } from '@/services/profiles';
import type { NurseProfile } from '@/services/profiles/types'; import type { NurseProfile } from '@/services/profiles/types';
import { useVerificationStatus } from '@/services/verification'; import { useVerificationStatus } from '@/services/verification';
import { ownBadgeState, SPECIALTY_PRESETS } from '@/services/verification/types'; import { ownBadgeState, SPECIALTY_PRESETS } from '@/services/verification/types';
const MAX_YEARS = 80; const MAX_YEARS = 80;
const MAX_YEARS_DIGITS = 2;
const OTHER_CODE = '__other'; const OTHER_CODE = '__other';
const EDUCATION_LEVELS = ['diploma', 'associate', 'bachelor', 'master', 'doctorate'] as const; const EDUCATION_LEVELS = ['diploma', 'associate', 'bachelor', 'master', 'doctorate'] as const;
const EDUCATION_FIELDS = ['nursing', 'midwifery', 'anesthesia', 'operating_room', 'public_health'] 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[] { function parseSpecializations(json: string): string[] {
try { try {
const parsed = JSON.parse(json); 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() { export default function NurseProfilePage() {
const { data: profile, isLoading } = useNurseProfile(); const { data: profile, isLoading } = useNurseProfile();
if (isLoading) return <AppLoading />; if (isLoading) return <AppLoading />;
return <NurseProfileForm initial={profile ?? null} />; 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 NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({ initial }) => {
const t = useTranslations('nurseProfile'); const t = useTranslations('nurseProfile');
const tv = useTranslations('verification'); const tv = useTranslations('verification');
@@ -43,28 +91,29 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
const badgeState = ownBadgeState(verificationStatus); const badgeState = ownBadgeState(verificationStatus);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [avatarUrl, setAvatarUrl] = useState<string | null>(initial?.avatarUrl ?? null); const level = splitPreset(initial?.educationLevel ?? '', EDUCATION_LEVELS);
const [bio, setBio] = useState(initial?.bio ?? ''); const field = splitPreset(initial?.educationField ?? '', EDUCATION_FIELDS);
const [years, setYears] = useState(initial ? String(initial.yearsOfExperience) : '');
const [yearsError, setYearsError] = useState(false);
const initialLevel = initial?.educationLevel ?? ''; const form = useForm<ProfileFormValues>({
const initialField = initial?.educationField ?? ''; mode: 'onTouched',
const [educationLevel, setEducationLevel] = useState( defaultValues: {
(EDUCATION_LEVELS as readonly string[]).includes(initialLevel) ? initialLevel : initialLevel ? OTHER_CODE : '', avatarUrl: initial?.avatarUrl ?? null,
); bio: initial?.bio ?? '',
const [educationLevelOther, setEducationLevelOther] = useState( years: initial ? String(initial.yearsOfExperience) : '',
(EDUCATION_LEVELS as readonly string[]).includes(initialLevel) ? '' : initialLevel, educationLevel: level.code,
); educationLevelOther: level.other,
const [educationField, setEducationField] = useState( educationField: field.code,
(EDUCATION_FIELDS as readonly string[]).includes(initialField) ? initialField : initialField ? OTHER_CODE : '', educationFieldOther: field.other,
); specializations: parseSpecializations(initial?.specializationsJson ?? '[]'),
const [educationFieldOther, setEducationFieldOther] = useState( },
(EDUCATION_FIELDS as readonly string[]).includes(initialField) ? '' : initialField, });
); const { control, handleSubmit, setValue, getValues } = form;
const [specializations, setSpecializations] = useState<string[]>(
parseSpecializations(initial?.specializationsJson ?? '[]'), // 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. // A staged-but-unsaved avatar must never be silently discarded — warn on reload/tab-close.
const avatarDirty = avatarUrl !== (initial?.avatarUrl ?? null); const avatarDirty = avatarUrl !== (initial?.avatarUrl ?? null);
@@ -79,100 +128,84 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
return () => window.removeEventListener('beforeunload', handler); return () => window.removeEventListener('beforeunload', handler);
}, [avatarDirty]); }, [avatarDirty]);
const pickFile = () => fileInputRef.current?.click();
const onFileSelected = (event: ChangeEvent<HTMLInputElement>) => { const onFileSelected = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
event.target.value = ''; event.target.value = '';
if (!file) return; if (!file) return;
uploadAvatar.mutate(file, { uploadAvatar.mutate(file, {
onSuccess: (result) => setAvatarUrl(result.url), onSuccess: (result) => setValue('avatarUrl', result.url, { shouldDirty: true }),
onError: () => enqueueSnackbar(t('avatar_upload_error'), { variant: 'error' }), onError: () => enqueueSnackbar(t('avatar_upload_error'), { variant: 'error' }),
}); });
}; };
const toggleSpecialty = (value: string) => const save = (values: ProfileFormValues) => {
setSpecializations((prev) => (prev.includes(value) ? prev.filter((item) => item !== value) : [...prev, value])); const resolvedLevel =
values.educationLevel === OTHER_CODE ? values.educationLevelOther.trim() : values.educationLevel;
const handleSave = () => { const resolvedField =
const trimmed = years.trim(); values.educationField === OTHER_CODE ? values.educationFieldOther.trim() : values.educationField;
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;
upsert.mutate( upsert.mutate(
{ {
bio: bio.trim(), bio: values.bio.trim(),
yearsOfExperience: yearsNum, yearsOfExperience: values.years.trim() === '' ? 0 : Number(values.years),
educationLevel: resolvedLevel, educationLevel: resolvedLevel,
educationField: resolvedField, educationField: resolvedField,
specializationsJson: JSON.stringify(specializations), specializationsJson: JSON.stringify(values.specializations),
avatarUrl, 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' }), onError: () => enqueueSnackbar(t('save_error'), { variant: 'error' }),
}, },
); );
}; };
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}> <FormProvider {...form}>
<Box> <Box
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}> component="form"
<Typography variant="h5" component="h1"> noValidate
{t('title')} onSubmit={handleSubmit(save)}
</Typography> sx={{ display: 'flex', flexDirection: 'column', gap: 2.5, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}
{/* 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)' }}
> >
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}> <PageHeader title={t('title')} subtitle={t('subtitle')} meta={<TrustBadge state={badgeState} />} />
<AppIcon icon="warning" size={24} color="var(--bal-warning)" />
<Stack sx={{ gap: 1, flexGrow: 1 }}> {/* Blocked-until-verified nudge — shown until the aggregate is approved (incl. the expired state). */}
<Box> {badgeState !== 'verified' ? (
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}> <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')} {t('unverified_title')}
</Typography> </Typography>
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('unverified_body')} {t('unverified_body')}
</Typography> </Typography>
</Box>
<AppButton <AppButton
color="primary" color="primary"
variant="outlined" variant="text"
endIcon="forward"
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`} to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
sx={{ alignSelf: 'flex-start' }} sx={{ alignSelf: 'flex-start' }}
> >
{t('unverified_cta')} {t('unverified_cta')}
</AppButton> </AppButton>
</Stack> </Stack>
</Stack> </AccentCard>
</Paper>
) : null} ) : null}
<FormSection title={t('section_intro_title')} description={t('section_intro_description')} icon="account">
<Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}> <Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}>
<Avatar src={avatarUrl ?? undefined} sx={{ width: 72, height: 72, bgcolor: 'var(--bal-primary-soft)' }}> <Avatar src={avatarUrl ?? undefined} sx={{ width: 64, height: 64, bgcolor: 'var(--bal-primary-soft)' }}>
{avatarUrl ? null : <AppIcon icon="account" size={36} color="var(--bal-primary)" />} {avatarUrl ? null : <AppIcon icon="account" size={32} color="var(--bal-primary)" />}
</Avatar> </Avatar>
<Stack sx={{ gap: 0.5 }}> <Stack sx={{ gap: 0.5, minWidth: 0 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('photo')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}> <Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('photo_hint')} {t('photo_hint')}
</Typography> </Typography>
@@ -180,9 +213,9 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
variant="outlined" variant="outlined"
color="primary" color="primary"
startIcon="camera" startIcon="camera"
onClick={pickFile} onClick={() => fileInputRef.current?.click()}
disabled={uploadAvatar.isPending} disabled={uploadAvatar.isPending}
sx={{ mt: 0.5, alignSelf: 'flex-start' }} sx={{ alignSelf: 'flex-start' }}
> >
{uploadAvatar.isPending ? t('uploading') : t('upload')} {uploadAvatar.isPending ? t('uploading') : t('upload')}
</AppButton> </AppButton>
@@ -190,113 +223,104 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
</Stack> </Stack>
</Stack> </Stack>
<TextField <RhfTextField<ProfileFormValues>
name="bio"
label={t('bio')} label={t('bio')}
value={bio}
onChange={(e) => setBio(e.target.value)}
helperText={t('bio_hint')} helperText={t('bio_hint')}
multiline multiline
minRows={3} minRows={3}
fullWidth fullWidth
/> />
</FormSection>
<TextField <FormSection
title={t('section_experience_title')}
description={t('section_experience_description')}
icon="license"
>
<RhfTextField<ProfileFormValues>
name="years"
label={t('years')} label={t('years')}
value={years} transform={(raw) => digitsOnly(raw).slice(0, MAX_YEARS_DIGITS)}
onChange={(e) => { rules={{
setYears(e.target.value.replace(/\D/g, '').slice(0, 2)); validate: (value) => {
if (yearsError) setYearsError(false); 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' } } }} slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }}
sx={{ maxWidth: 200 }} sx={{ maxWidth: 200 }}
/> />
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}> <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) => ( {EDUCATION_LEVELS.map((code) => (
<MenuItem key={code} value={code}> <MenuItem key={code} value={code}>
{t(`education_level_${code}`)} {t(`education_level_${code}`)}
</MenuItem> </MenuItem>
))} ))}
<MenuItem value={OTHER_CODE}>{t('education_other')}</MenuItem> <MenuItem value={OTHER_CODE}>{t('education_other')}</MenuItem>
</TextField> </RhfTextField>
<TextField select label={t('education_field_label')} value={educationField} onChange={(e) => setEducationField(e.target.value)} fullWidth> <RhfTextField<ProfileFormValues> name="educationField" select label={t('education_field_label')} fullWidth>
{EDUCATION_FIELDS.map((code) => ( {EDUCATION_FIELDS.map((code) => (
<MenuItem key={code} value={code}> <MenuItem key={code} value={code}>
{t(`education_field_${code}`)} {t(`education_field_${code}`)}
</MenuItem> </MenuItem>
))} ))}
<MenuItem value={OTHER_CODE}>{t('education_other')}</MenuItem> <MenuItem value={OTHER_CODE}>{t('education_other')}</MenuItem>
</TextField> </RhfTextField>
</Stack> </Stack>
{educationLevel === OTHER_CODE ? ( {educationLevel === OTHER_CODE ? (
<TextField <RhfTextField<ProfileFormValues>
name="educationLevelOther"
label={t('education_level_other_label')} label={t('education_level_other_label')}
value={educationLevelOther} rules={{ required: t('education_other_required') }}
onChange={(e) => setEducationLevelOther(e.target.value)}
fullWidth fullWidth
/> />
) : null} ) : null}
{educationField === OTHER_CODE ? ( {educationField === OTHER_CODE ? (
<TextField <RhfTextField<ProfileFormValues>
name="educationFieldOther"
label={t('education_field_other_label')} label={t('education_field_other_label')}
value={educationFieldOther} rules={{ required: t('education_other_required') }}
onChange={(e) => setEducationFieldOther(e.target.value)}
fullWidth fullWidth
/> />
) : null} ) : null}
</FormSection>
<Stack sx={{ gap: 1 }}> <FormSection
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}> title={t('specializations_label')}
{t('specializations_label')} description={t('section_specializations_description')}
</Typography> icon="clinical"
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}> optional
{SPECIALTY_PRESETS.map((code) => { optionalLabel={tc('optional')}
const selected = specializations.includes(code); >
return ( <RhfChipSelect<ProfileFormValues>
<Chip name="specializations"
key={code} options={SPECIALTY_PRESETS.map((code) => ({
label={tv.has(`specialty_${code}`) ? tv(`specialty_${code}`) : code} code,
onClick={() => toggleSpecialty(code)} label: tv.has(`specialty_${code}`) ? tv(`specialty_${code}`) : code,
variant={selected ? 'filled' : 'outlined'} }))}
sx={{ allowCustomValues
fontWeight: 500,
backgroundColor: selected ? 'var(--bal-primary)' : 'transparent',
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
borderColor: 'var(--bal-primary)',
}}
/> />
); </FormSection>
})}
</Stack> <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> </Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}> <Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('deferred_services')} {t('deferred_services')}
</Typography> </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> </Box>
</FormProvider>
); );
}; };
@@ -61,7 +61,7 @@ export default function NurseRequestDetailPage() {
if (isError || !request) { if (isError || !request) {
return ( 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 }}> <Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}>
{t('not_found_title')} {t('not_found_title')}
</Typography> </Typography>
@@ -131,7 +131,7 @@ export default function NurseRequestDetailPage() {
<StatusChip status={statusKind(request.status)} label={t(`status_${request.status}`)} /> <StatusChip status={statusKind(request.status)} label={t(`status_${request.status}`)} />
</Stack> </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 }}> <Stack sx={{ gap: 1.5 }}>
<DetailRow caption={t('summary_patient')}> <DetailRow caption={t('summary_patient')}>
<Typography variant="body2" sx={{ fontWeight: 500 }}> <Typography variant="body2" sx={{ fontWeight: 500 }}>
@@ -171,7 +171,7 @@ export default function NurseRequestDetailPage() {
</Paper> </Paper>
{/* Stage-1 clinical context — ONLY the family's notes, never a clinical/care field. */} {/* 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 }}> <Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}> <Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('inbox_notes_label')} {t('inbox_notes_label')}
@@ -225,7 +225,7 @@ export default function NurseRequestDetailPage() {
elevation={0} elevation={0}
sx={{ sx={{
p: 2, p: 2,
borderRadius: 2, borderRadius: 'var(--bal-radius-md)',
border: '1px solid', border: '1px solid',
borderColor: 'divider', borderColor: 'divider',
borderInlineStartWidth: 4, borderInlineStartWidth: 4,
@@ -105,7 +105,7 @@ const MyServicesList: FunctionComponent<MyServicesListProps> = ({ onAdd, onEdit
{isLoading ? ( {isLoading ? (
<Stack sx={{ gap: 1.5 }}> <Stack sx={{ gap: 1.5 }}>
{[0, 1].map((key) => ( {[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> </Stack>
) : isError ? ( ) : isError ? (
@@ -22,7 +22,7 @@ const PublishGate: FunctionComponent = () => {
const state = useActivationChecklist(); const state = useActivationChecklist();
const setAccepting = useSetAcceptingBookings(); 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. if (state.isError) return null; // ActivationChecklist (mounted alongside) already surfaces the error + retry.
const toggle = (accepting: boolean) => { const toggle = (accepting: boolean) => {
@@ -1,9 +1,24 @@
'use client'; 'use client';
import { FunctionComponent, useMemo, useState } from 'react'; import { FunctionComponent, useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { Box, Chip, MenuItem, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material'; import { Box, Chip, MenuItem, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AccentCard, AppButton, AppIcon, AppLoading, CategoryTile, ErrorState, StepperHeader, VariantCard } from '@/components'; 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 { ApiError } from '@/lib/api/errors';
import { digitsOnly, rialToToman, tomanToRial } from '@/utils'; import { digitsOnly, rialToToman, tomanToRial } from '@/utils';
import { import {
@@ -23,7 +38,7 @@ import {
} from '@/services/catalog/types'; } from '@/services/catalog/types';
interface VariantBuilderProps { 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; initial: NurseServiceVariant | null;
onDone: () => void; onDone: () => void;
onCancel: () => void; onCancel: () => void;
@@ -35,15 +50,40 @@ const DEFAULT_UNIT: PriceUnit = 'per_hour';
const MAX_PRICE_DIGITS = 12; const MAX_PRICE_DIGITS = 12;
const MAX_DURATION_DIGITS = 4; 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`). * The nurse variant builder (`CreateVariant` / `UpdateVariant`).
* *
* **Create** is a 3-step stepper: pick category answer required/optional option groups price + * **Create** walks category options price. Two things make the flow deterministic where it
* unit + duration. Every `is_required` group must be answered before advancing; the price is entered * previously was not:
* 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; * 1. **A step that has nothing to ask is not shown.** Categories with no option groups used to get a
* `display_name` auto-generates from the chosen labels and is editable (left blank the server * middle step whose entire content was "این دسته گزینه‌ای برای تنظیم ندارد" plus a Next button.
* generates it). A duplicate identical listing (`409`) shows a friendly inline warning. * 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 * **Edit** locks the category + option-set (changing them would change identity) and edits only
* price/unit/duration/display via `update`. * price/unit/duration/display via `update`.
@@ -61,21 +101,29 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
const updateVariant = useUpdateVariant(); const updateVariant = useUpdateVariant();
const submitting = createVariant.isPending || updateVariant.isPending; const submitting = createVariant.isPending || updateVariant.isPending;
// --- Create-only state (category → options) --- const [step, setStep] = useState<StepKey>(isEdit ? 'price' : 'category');
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 [duplicate, setDuplicate] = useState(false); 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 categoriesQuery = useServiceCategories();
const categories = categoriesQuery.data?.items ?? []; const categories = categoriesQuery.data?.items ?? [];
const optionGroupsQuery = useCategoryOptionGroups(isEdit ? null : categoryId); 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 selectedCategory = categories.find((category) => category.id === categoryId) ?? null;
const missingRequiredGroups = groups.filter((group) => group.isRequired && selectedOptions[group.id] == 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 // 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. // existing listing a 409 duplicate collided with, so the recovery can offer "edit that one" directly.
const myVariantsQuery = useMyVariants(); const myVariantsQuery = useMyVariants();
@@ -118,53 +181,51 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
const priceValid = priceToman.length > 0 && BigInt(priceToman) > BigInt(0); const priceValid = priceToman.length > 0 && BigInt(priceToman) > BigInt(0);
const irr = priceValid ? tomanToRial(priceToman) : null; 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 sessionCount = durationInt > 0 ? durationInt : null;
const selectCategory = (id: number) => { const selectCategory = (id: number) => {
if (id === categoryId) return; if (id === categoryId) return;
// Switching category invalidates the previous category's option answers + auto-name. // Switching category invalidates the previous category's option answers + auto-name.
setCategoryId(id); setValue('categoryId', id, { shouldDirty: true });
setSelectedOptions({}); setValue('options', {});
setDisplayNameOverride(null); setValue('displayNameOverride', null);
setOptionsError(false);
}; };
const changeOption = (groupId: number, valueId: number | null) => { const changeOption = (groupId: number, valueId: number | null) => {
setOptionsError(false); const next = { ...selectedOptions };
// 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 };
if (valueId == null) delete next[groupId]; if (valueId == null) delete next[groupId];
else next[groupId] = valueId; 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 = () => { const goNext = () => {
if (missingRequiredGroups.length > 0) { if (effectiveStep === 'category') {
setOptionsError(true); setStep(hasOptionsStep ? 'options' : 'price');
return; return;
} }
setActiveStep(2); setStep('price');
}; };
const validatePrice = () => { const goBack = () => {
if (!priceValid) { if (effectiveStep === 'price' && !isEdit) {
setPriceError(true); setStep(hasOptionsStep ? 'options' : 'category');
return false; return;
} }
return true; setStep('category');
}; };
const submit = () => { const submit = (values: VariantFormValues) => {
if (!validatePrice() || irr == null) return; // `handleSubmit` has already enforced the price rule; this is the type narrowing that lets the
const displayName = displayNameOverride?.trim() ? displayNameOverride.trim() : undefined; // IRR string be passed on, not a second gate.
if (irr == null) return;
const displayName = values.displayNameOverride?.trim() ? values.displayNameOverride.trim() : undefined;
if (isEdit) { if (isEdit) {
updateVariant.mutate( updateVariant.mutate(
{ id: initial.id, input: { price: irr, priceUnit, sessionCount, displayName } }, { id: initial.id, input: { price: irr, priceUnit: values.priceUnit, sessionCount, displayName } },
{ {
onSuccess: () => { onSuccess: () => {
enqueueSnackbar(t('saved_toast'), { variant: 'success' }); enqueueSnackbar(t('saved_toast'), { variant: 'success' });
@@ -176,12 +237,19 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
return; return;
} }
const options: VariantOptionSelection[] = Object.entries(selectedOptions).map(([groupId, valueId]) => ({ const options: VariantOptionSelection[] = Object.entries(values.options).map(([groupId, valueId]) => ({
optionGroupId: Number(groupId), optionGroupId: Number(groupId),
optionValueId: valueId, optionValueId: valueId,
})); }));
createVariant.mutate( 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: () => { onSuccess: () => {
enqueueSnackbar(t('created_toast'), { variant: 'success' }); 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, // The price step's live preview — the actual VariantCard, so the nurse sees the listing they're
// not just an abstract price readout. // composing, not just an abstract price readout.
const previewVariant: NurseServiceVariant = { const previewVariant: NurseServiceVariant = {
id: 0, id: 0,
serviceCategoryId: categoryId ?? 0, serviceCategoryId: categoryId ?? 0,
categoryNameFa: selectedCategory?.nameFa ?? '', categoryNameFa: selectedCategory?.nameFa ?? initial?.categoryNameFa ?? '',
categoryNameEn: selectedCategory?.nameEn ?? '', categoryNameEn: selectedCategory?.nameEn ?? initial?.categoryNameEn ?? '',
price: irr ?? '0', price: irr ?? '0',
priceUnit, priceUnit,
sessionCount, sessionCount,
@@ -211,68 +279,119 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
options: [], 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 = ( const priceStep = (
<Stack sx={{ gap: 2.5 }}> <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')} label={t('price_label')}
value={priceToman} transform={(raw) => digitsOnly(raw).slice(0, MAX_PRICE_DIGITS)}
onChange={(event) => { rules={{
setPriceToman(digitsOnly(event.target.value).slice(0, MAX_PRICE_DIGITS)); validate: (value) => {
if (priceError) setPriceError(false); const raw = String(value ?? '');
if (duplicate) setDuplicate(false); 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' } } }} slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }}
fullWidth fullWidth
/> />
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}> <Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField <RhfTextField<VariantFormValues> name="priceUnit" select label={t('unit_label')} fullWidth>
select
label={t('unit_label')}
value={priceUnit}
onChange={(event) => setPriceUnit(event.target.value as PriceUnit)}
fullWidth
>
{PRICE_UNITS.map((unit) => ( {PRICE_UNITS.map((unit) => (
<MenuItem key={unit} value={unit}> <MenuItem key={unit} value={unit}>
{tCatalog(`unit_${unit}`)} {tCatalog(`unit_${unit}`)}
</MenuItem> </MenuItem>
))} ))}
</TextField> </RhfTextField>
<TextField <RhfTextField<VariantFormValues>
name="duration"
label={t('duration_label')} label={t('duration_label')}
value={durationStr}
onChange={(event) => setDurationStr(digitsOnly(event.target.value).slice(0, MAX_DURATION_DIGITS))}
helperText={t('duration_hint')} helperText={t('duration_hint')}
transform={(raw) => digitsOnly(raw).slice(0, MAX_DURATION_DIGITS)}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }} slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }}
fullWidth fullWidth
/> />
</Stack> </Stack>
{irr ? ( {!sessionCount && irr ? (
<Stack sx={{ gap: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t('preview_heading')}
</Typography>
<VariantCard variant={previewVariant} interactive={false} />
{!sessionCount ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}> <Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('rate_note')} {t('rate_note')}
</Typography> </Typography>
) : null} ) : null}
</Stack> </FormSection>
) : null}
<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 <TextField
label={t('display_name_label')} label={t('display_name_label')}
value={displayValue} name={field.name}
onChange={(event) => setDisplayNameOverride(event.target.value)} inputRef={field.ref}
helperText={t('display_name_hint')} value={field.value ?? autoName}
onChange={(event) => field.onChange(event.target.value)}
onBlur={field.onBlur}
fullWidth 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 ? ( {duplicate ? (
<AccentCard tone="warning" padding="sm"> <AccentCard tone="warning" padding="sm">
@@ -301,92 +420,33 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
</Stack> </Stack>
); );
// --- Edit mode: locked category + options summary, then the price form ---
if (isEdit) {
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}> <FormProvider {...form}>
<Typography variant="h5" component="h1"> <Box
{t('builder_edit_title')} component="form"
</Typography> noValidate
onSubmit={handleSubmit(submit)}
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}> sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}
<Stack sx={{ gap: 1 }}> >
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}> <PageHeader title={isEdit ? t('builder_edit_title') : t('builder_add_title')} />
{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>
{isEdit ? null : (
<StepperHeader <StepperHeader
steps={[t('step_category'), t('step_options'), t('step_price')]} steps={visibleSteps.map((key) => stepLabels[key])}
activeStep={activeStep} activeStep={visibleSteps.indexOf(effectiveStep)}
/> />
)}
{activeStep === 0 ? ( {effectiveStep === 'category' ? (
<Stack sx={{ gap: 1.5 }}> <FormSection title={t('category_title')} description={t('category_subtitle')} icon="category">
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('category_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('category_subtitle')}
</Typography>
</Box>
{categoriesQuery.isLoading ? ( {categoriesQuery.isLoading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}> <Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => ( {[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> </Box>
) : categoriesQuery.isError ? ( ) : categoriesQuery.isError ? (
<Stack sx={{ gap: 1, alignItems: 'flex-start' }}> <ErrorState message={t('categories_error')} retryLabel={tc('retry')} onRetry={() => categoriesQuery.refetch()} />
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('categories_error')}
</Typography>
<AppButton variant="outlined" color="primary" onClick={() => categoriesQuery.refetch()}>
{tc('retry')}
</AppButton>
</Stack>
) : ( ) : (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}> <Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}>
{categories.map((category) => ( {categories.map((category) => (
@@ -400,58 +460,30 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
))} ))}
</Box> </Box>
)} )}
</Stack> </FormSection>
) : null} ) : null}
{activeStep === 1 ? ( {effectiveStep === 'options' ? (
<Stack sx={{ gap: 2 }}> <FormSection title={t('options_title')} description={t('options_subtitle')} icon="tune">
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('options_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('options_subtitle')}
</Typography>
</Box>
{optionGroupsQuery.isLoading ? ( {optionGroupsQuery.isLoading ? (
<AppLoading /> <AppLoading />
) : optionGroupsQuery.isError ? ( ) : optionGroupsQuery.isError ? (
// A failed fetch must never read as "this category has zero options" — that would let the // 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. // nurse skip required options entirely. Block progression until the retry succeeds.
<ErrorState <ErrorState message={t('options_error')} retryLabel={tc('retry')} onRetry={() => optionGroupsQuery.refetch()} />
message={t('options_error')}
retryLabel={tc('retry')}
onRetry={() => optionGroupsQuery.refetch()}
/>
) : groups.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('options_none')}
</Typography>
) : ( ) : (
groups.map((group) => { groups.map((group) => (
const isMissing = optionsError && group.isRequired && selectedOptions[group.id] == null;
return (
<Stack key={group.id} sx={{ gap: 1 }}> <Stack key={group.id} sx={{ gap: 1 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}> <Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}> <Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{pickCatalogName(group, locale)} {pickCatalogName(group, locale)}
</Typography> </Typography>
{/* The required badge turns red on a blocked advance to point at the unanswered group. */}
<Chip <Chip
size="small" size="small"
label={group.isRequired ? t('required_badge') : t('optional_badge')} label={group.isRequired ? t('required_badge') : t('optional_badge')}
sx={{ sx={{
bgcolor: isMissing bgcolor: group.isRequired ? 'var(--bal-primary-soft)' : 'var(--bal-divider)',
? 'var(--bal-error)' color: group.isRequired ? 'var(--bal-primary)' : 'text.secondary',
: group.isRequired
? 'var(--bal-primary-soft)'
: 'var(--bal-divider)',
color: isMissing
? 'var(--bal-error-contrast)'
: group.isRequired
? 'var(--bal-primary)'
: 'text.secondary',
fontWeight: 500, fontWeight: 500,
}} }}
/> />
@@ -463,76 +495,64 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
<Chip <Chip
key={value.id} key={value.id}
label={pickCatalogName(value, locale)} label={pickCatalogName(value, locale)}
onClick={() => changeOption(group.id, selected ? null : value.id)} aria-pressed={selected}
clickable
color={selected ? 'primary' : 'default'}
variant={selected ? 'filled' : 'outlined'} variant={selected ? 'filled' : 'outlined'}
sx={{ onClick={() => changeOption(group.id, selected ? null : value.id)}
fontWeight: 500,
backgroundColor: selected ? 'var(--bal-primary)' : 'transparent',
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
borderColor: 'var(--bal-primary)',
}}
/> />
); );
})} })}
</Stack> </Stack>
</Stack> </Stack>
); ))
})
)} )}
</FormSection>
) : null}
{optionsError && missingRequiredGroups.length > 0 ? ( {effectiveStep === 'price' ? priceStep : null}
<Typography variant="body2" sx={{ color: 'var(--bal-error)', fontWeight: 500 }}>
{t('options_incomplete')} {/* 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> </Typography>
) : null} ) : null}
</Stack>
) : null}
{activeStep === 2 ? ( <SurfaceCard padding="sm" sx={{ position: 'sticky', bottom: 'var(--bal-chrome-bottom, 0px)', zIndex: 1 }}>
<Stack sx={{ gap: 1.5 }}> <Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between' }}>
<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 }}>
<AppButton <AppButton
variant="text" variant="text"
onClick={activeStep === 0 ? onCancel : () => setActiveStep((step) => step - 1)} onClick={effectiveStep === 'category' || isEdit ? onCancel : goBack}
disabled={submitting} disabled={submitting}
> >
{activeStep === 0 ? tc('cancel') : tc('back')} {effectiveStep === 'category' || isEdit ? tc('cancel') : tc('back')}
</AppButton> </AppButton>
{activeStep === 0 ? ( {effectiveStep === 'price' ? (
<AppButton <AppButton type="submit" color="primary" variant="contained" disabled={submitting}>
color="primary" {submitting ? tc('saving') : isEdit ? t('submit_save') : t('submit_create')}
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')}
</AppButton> </AppButton>
) : ( ) : (
<AppButton color="primary" variant="contained" onClick={submit} disabled={submitting}> <AppButton
{submitting ? tc('saving') : t('submit_create')} color="primary"
variant="contained"
onClick={goNext}
disabled={
effectiveStep === 'category'
? categoryId == null
: optionGroupsQuery.isError || missingRequiredGroups.length > 0
}
>
{t('next')}
</AppButton> </AppButton>
)} )}
</Stack> </Stack>
</SurfaceCard>
</Box> </Box>
</FormProvider>
); );
}; };
@@ -1,10 +1,20 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { FunctionComponent, useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { Box, Chip, Paper, Stack, TextField, Typography } from '@mui/material'; 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 type { UploadedDocInfo } from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config'; import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
@@ -15,101 +25,42 @@ import {
useVerificationStatus, useVerificationStatus,
} from '@/services/verification'; } from '@/services/verification';
import { SPECIALTY_PRESETS } from '@/services/verification/types'; 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 { stepDescriptionKey, stepLabelKey } from '../verificationSteps';
import VerificationJourneyHeader from '../VerificationJourneyHeader'; import VerificationJourneyHeader from '../VerificationJourneyHeader';
const MANUAL_CREDENTIAL_CODES = ['moh_competency_license', 'ino_membership', 'criminal_record']; 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 * 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 * 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). * 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 * The whole screen used to be one undivided column: a number field, three uploaders, a chip row with
* recorded, the field locks into a "شمارهٔ نظام ثبت شد" summary never re-prompted as if lost, and * its own add-a-custom-value sub-form, two date pickers and a submit button, with two independent
* never re-sent blank (the server's `CredentialDetailsInput.inoNumber` is required; the raw number is * `editingIno ? … : …` branches interleaved through it. Grouping the same content into four named
* never read back by design, so re-submitting it isn't possible without the nurse re-entering it via * sections شماره نظام / مدارک / تخصصها / جزئیات مدرک makes the two genuinely optional groups
* "تغییر"). A returning, already-submitted nurse can still fix a **rejected** document directly (each * visibly optional and gives the "what still blocks submit?" answer somewhere to live.
* 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.
*/ */
export default function CredentialsSubmitPage() { export default function CredentialsSubmitPage() {
const t = useTranslations('verification'); const t = useTranslations('verification');
const locale = useLocale(); const locale = useLocale();
const router = useRouter();
const { enqueueSnackbar } = useSnackbar();
const { data: status, isLoading } = useVerificationStatus(); 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( const manualSteps = useMemo(
() => (status?.steps ?? []).filter((step) => MANUAL_CREDENTIAL_CODES.includes(step.code)), () => (status?.steps ?? []).filter((step) => MANUAL_CREDENTIAL_CODES.includes(step.code)),
[status], [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 (isLoading) return <AppLoading />;
if (!status || manualSteps.length === 0) { 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 // 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 // actively (re-)entering the INO number, the gate also counts server-side documents, not only this
// session's uploads. // session's uploads.
const hasAnyDocument = const uploadedCount =
Object.values(uploadedSteps).some(Boolean) || manualSteps.filter((step) => uploadedSteps[step.id] || step.status === 'in_review' || step.status === 'passed')
manualSteps.some((step) => step.status === 'in_review' || step.status === 'passed'); .length;
const canSubmit = hasAnyDocument && !submitCredentials.isPending; 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 ( 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" /> <VerificationJourneyHeader group="credentials" />
<Box>
<Typography variant="h6" component="h2" sx={{ fontWeight: 700 }}>
{t('credentials_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('credentials_subtitle')} {t('credentials_subtitle')}
</Typography> </Typography>
</Box>
<FormSection title={t('ino_number_label')} description={t('ino_number_hint')} icon="license">
{editingIno ? ( {editingIno ? (
<TextField <RhfTextField<CredentialsFormValues>
name="inoNumber"
label={t('ino_number_label')} label={t('ino_number_label')}
value={inoNumber} rules={{ required: t('ino_number_required') }}
onChange={(event) => {
setInoNumber(event.target.value);
if (inoError) setInoError(false);
}}
error={inoError}
helperText={inoError ? t('ino_number_required') : t('ino_number_hint')}
slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start' } } }} slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start' } } }}
fullWidth fullWidth
/> />
) : ( ) : (
<Stack <Stack
direction="row" 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' }}> <Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="verified" size={18} color="var(--bal-success)" /> <AppIcon icon="verified" size={18} color="var(--bal-success)" />
@@ -174,10 +202,20 @@ export default function CredentialsSubmitPage() {
</AppButton> </AppButton>
</Stack> </Stack>
)} )}
</FormSection>
{/* One uploader per manual credential step data-driven from the status. Re-uploading a {/* 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. */} 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) => ( {manualSteps.map((step) => (
<DocumentUpload <DocumentUpload
key={step.code} key={step.code}
@@ -190,51 +228,29 @@ export default function CredentialsSubmitPage() {
existingDoc={step.status === 'in_review' || step.status === 'passed' ? { name: t('doc_uploaded') } : null} existingDoc={step.status === 'in_review' || step.status === 'passed' ? { name: t('doc_uploaded') } : null}
/> />
))} ))}
</Stack>
{editingIno ? ( {editingIno ? (
<DocumentUpload label={t('education_label')} hint={t('education_hint')} onUpload={async (file) => ({ name: file.name })} /> <DocumentUpload label={t('education_label')} hint={t('education_hint')} onUpload={async (file) => ({ name: file.name })} />
) : null} ) : null}
</FormSection>
<Stack sx={{ gap: 1 }}> <FormSection
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}> title={t('specialties_label')}
{t('specialties_label')} description={t('specialties_hint')}
</Typography> icon="clinical"
optional
optionalLabel={tc('optional')}
>
{editingIno ? ( {editingIno ? (
<> <>
<Typography variant="caption" sx={{ color: 'text.secondary' }}> <RhfChipSelect<CredentialsFormValues>
{t('specialties_hint')} name="specialties"
</Typography> options={SPECIALTY_PRESETS.map((preset) => ({
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}> code: preset,
{SPECIALTY_PRESETS.map((preset) => { label: t.has(`specialty_${preset}`) ? t(`specialty_${preset}`) : preset,
const selected = specialties.includes(preset); }))}
return ( allowCustomValues
<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)',
}}
/> />
);
})}
{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' }}> <Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<TextField <TextField
size="small" size="small"
@@ -268,43 +284,37 @@ export default function CredentialsSubmitPage() {
{t('summary_none')} {t('summary_none')}
</Typography> </Typography>
)} )}
</Stack> </FormSection>
{/* Optional registry details the admin cross-checks issue/expiry feed the credential-expiry sweep, so {/* Optional registry details the admin cross-checks issue/expiry feed the credential-expiry
wrong dates are a correctness risk; a Jalali picker replaces the Gregorian-only native input. */} sweep, so wrong dates are a correctness risk; a Jalali picker replaces the Gregorian-only
native input. */}
{editingIno ? ( {editingIno ? (
<Stack sx={{ gap: 1.5 }}> <FormSection
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}> title={t('registry_details_title')}
{t('registry_details_label')} description={t('registry_details_description')}
</Typography> icon="audit"
<TextField optional
label={t('issuing_authority_label')} optionalLabel={tc('optional')}
value={issuingAuthority} >
onChange={(event) => setIssuingAuthority(event.target.value)} <RhfTextField<CredentialsFormValues> name="issuingAuthority" label={t('issuing_authority_label')} fullWidth />
fullWidth
/>
<Stack direction="row" sx={{ gap: 1.5, flexWrap: 'wrap' }}> <Stack direction="row" sx={{ gap: 1.5, flexWrap: 'wrap' }}>
<JalaliDateField <RhfJalaliDateField<CredentialsFormValues>
name="issuedAt"
label={t('issued_at_label')} label={t('issued_at_label')}
value={issuedAt}
onChange={setIssuedAt}
max={expiresAt ?? undefined} max={expiresAt ?? undefined}
sx={{ flex: 1, minWidth: 160 }} sx={{ flex: 1, minWidth: 160 }}
/> />
<JalaliDateField <RhfJalaliDateField<CredentialsFormValues>
name="expiresAt"
label={t('expires_at_label')} label={t('expires_at_label')}
value={expiresAt}
onChange={setExpiresAt}
min={issuedAt ?? undefined} min={issuedAt ?? undefined}
sx={{ flex: 1, minWidth: 160 }} sx={{ flex: 1, minWidth: 160 }}
/> />
</Stack> </Stack>
</Stack> </FormSection>
) : issuingAuthority || issuedAt || expiresAt ? ( ) : issuingAuthority || issuedAt || expiresAt ? (
<Stack sx={{ gap: 0.5 }}> <FormSection title={t('registry_details_title')} icon="audit">
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('registry_details_label')}
</Typography>
{issuingAuthority ? <Typography variant="body2">{issuingAuthority}</Typography> : null} {issuingAuthority ? <Typography variant="body2">{issuingAuthority}</Typography> : null}
{issuedAt || expiresAt ? ( {issuedAt || expiresAt ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}> <Typography variant="caption" sx={{ color: 'text.secondary' }}>
@@ -313,12 +323,12 @@ export default function CredentialsSubmitPage() {
{expiresAt ? formatShamsiDate(expiresAt, locale) : ''} {expiresAt ? formatShamsiDate(expiresAt, locale) : ''}
</Typography> </Typography>
) : null} ) : null}
</Stack> </FormSection>
) : null} ) : null}
<Paper <Paper
elevation={0} 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)" /> <AppIcon icon="info" size={18} color="var(--bal-primary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}> <Typography variant="caption" sx={{ color: 'text.secondary' }}>
@@ -334,7 +344,13 @@ export default function CredentialsSubmitPage() {
</Typography> </Typography>
) : null} ) : null}
<Stack direction="row" sx={{ gap: 1 }}> <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')} {submitCredentials.isPending ? t('credentials_submitting') : t('credentials_submit')}
</AppButton> </AppButton>
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}> <AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}>
@@ -348,5 +364,6 @@ export default function CredentialsSubmitPage() {
</AppButton> </AppButton>
)} )}
</Box> </Box>
</FormProvider>
); );
} };
@@ -2,9 +2,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useForm, FormProvider, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { Box, Paper, Stack, TextField, Typography } from '@mui/material'; import { Box, Paper, Stack, Typography } from '@mui/material';
import { AppAlert, AppButton, AppIcon, DocumentUpload } from '@/components'; import { AppAlert, AppButton, AppIcon, DocumentUpload, FormSection, RhfTextField } from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config'; import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { toEnglishDigits } from '@/utils'; import { toEnglishDigits } from '@/utils';
@@ -16,6 +17,12 @@ import VerificationJourneyHeader from '../VerificationJourneyHeader';
type SubmitError = { key: 'national_id_mismatch' | 'shared_sim' | 'shahkar_mismatch' } | null; 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, * 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 * 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. * 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 * The shared-SIM Shahkar failure surfaces as a clear, non-accusatory message; a national-ID mismatch on
* its own step. * 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() { export default function IdentitySubmitPage() {
const t = useTranslations('verification'); const t = useTranslations('verification');
@@ -31,25 +44,23 @@ export default function IdentitySubmitPage() {
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const submitIdentity = useSubmitIdentity(); 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 [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. // Local capture: the card/selfie feed the automated KYC (no stored document) — resolve immediately.
const captureLocally = async (file: File) => ({ name: file.name }); const captureLocally = async (file: File) => ({ name: file.name });
const canSubmit = isValidNationalId(nationalId) && selfieCaptured && !submitIdentity.isPending; const submit = (values: IdentityFormValues) => {
const handleSubmit = () => {
const idValid = isValidNationalId(nationalId);
setIdError(!idValid);
setSubmitError(null); setSubmitError(null);
if (!idValid || !selfieCaptured) return;
submitIdentity.mutate( submitIdentity.mutate(
{ nationalId, livenessCaptured: selfieCaptured }, { nationalId: values.nationalId, livenessCaptured: values.selfieCaptured },
{ {
onSuccess: (result: SubmitIdentityResult) => { onSuccess: (result: SubmitIdentityResult) => {
if (result.identity.stepStatus === 'failed') { if (result.identity.stepStatus === 'failed') {
@@ -68,58 +79,69 @@ export default function IdentitySubmitPage() {
}; };
return ( 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" /> <VerificationJourneyHeader group="identity" />
<Box>
<Typography variant="h6" component="h2" sx={{ fontWeight: 700 }}>
{t('identity_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('identity_subtitle')} {t('identity_subtitle')}
</Typography> </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')} label={t('national_id_label')}
value={nationalId} transform={(raw) => toEnglishDigits(raw).replace(/\D/g, '').slice(0, NATIONAL_ID_LENGTH)}
onChange={(event) => { rules={{ validate: (value) => isValidNationalId(String(value ?? '')) || t('national_id_invalid') }}
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')}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start', letterSpacing: 2 } } }} slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start', letterSpacing: 2 } } }}
fullWidth 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" /> <CaptureGuideFrame variant="card" />
<DocumentUpload <DocumentUpload
label={t('card_label')} label={t('card_label')}
hint={t('card_hint')} hint={t('capture_hint_card')}
accept={ACCEPTED_IMAGE_TYPES} accept={ACCEPTED_IMAGE_TYPES}
capture="environment" capture="environment"
onUpload={captureLocally} 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" /> <CaptureGuideFrame variant="selfie" />
<DocumentUpload <DocumentUpload
label={t('selfie_label')} label={t('selfie_label')}
hint={t('selfie_hint')} hint={t('capture_hint_selfie')}
accept={ACCEPTED_IMAGE_TYPES} accept={ACCEPTED_IMAGE_TYPES}
capture="user" capture="user"
onUpload={captureLocally} onUpload={captureLocally}
onUploaded={() => setSelfieCaptured(true)} onUploaded={() => setValue('selfieCaptured', true, { shouldValidate: true })}
/> />
</Stack> </FormSection>
<Paper <Paper
elevation={0} 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)" /> <AppIcon icon="info" size={18} color="var(--bal-primary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}> <Typography variant="caption" sx={{ color: 'text.secondary' }}>
@@ -133,19 +155,21 @@ export default function IdentitySubmitPage() {
</AppAlert> </AppAlert>
) : null} ) : 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' }}> <Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('card_recommended')} {t('identity_needs_selfie')}
</Typography> </Typography>
) : null} ) : null}
<Stack direction="row" sx={{ gap: 1 }}> <Stack direction="row" sx={{ gap: 1 }}>
<AppButton <AppButton
type="submit"
color="primary" color="primary"
variant="contained" variant="contained"
startIcon="identity" startIcon="identity"
onClick={handleSubmit} disabled={!selfieCaptured || submitIdentity.isPending}
disabled={!canSubmit}
> >
{submitIdentity.isPending ? t('identity_submitting') : t('identity_submit')} {submitIdentity.isPending ? t('identity_submitting') : t('identity_submit')}
</AppButton> </AppButton>
@@ -154,6 +178,27 @@ export default function IdentitySubmitPage() {
</AppButton> </AppButton>
</Stack> </Stack>
</Box> </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 * 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 * for the selfie), shown above the corresponding `DocumentUpload`. There is no live camera preview to
* live camera preview to overlay (the native camera app owns capture via the file input's `capture` * overlay (the native camera app owns capture via the file input's `capture` attribute), so this
* attribute), so this illustrates *how to frame the shot* rather than tracking the actual photo. * 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' }) { function CaptureGuideFrame({ variant }: { variant: 'card' | 'selfie' }) {
const t = useTranslations('verification');
const isCard = variant === 'card'; const isCard = variant === 'card';
return ( return (
<Stack sx={{ alignItems: 'center', gap: 0.75 }}>
<Box <Box
aria-hidden
sx={{ sx={{
alignSelf: 'center',
position: 'relative', position: 'relative',
width: isCard ? 180 : 112, width: isCard ? 180 : 112,
height: isCard ? 112 : 140, height: isCard ? 112 : 140,
@@ -195,9 +241,5 @@ function CaptureGuideFrame({ variant }: { variant: 'card' | 'selfie' }) {
)) ))
: null} : null}
</Box> </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 ? ( {isLoading ? (
<Stack sx={{ gap: 1.5 }}> <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) => ( {[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> </Stack>
) : isError ? ( ) : isError ? (
@@ -127,7 +127,7 @@ function Approved({ onPublish }: { onPublish: () => void }) {
elevation={0} elevation={0}
sx={{ sx={{
p: 3, p: 3,
borderRadius: 2, borderRadius: 'var(--bal-radius-md)',
border: '1px solid', border: '1px solid',
borderColor: 'divider', borderColor: 'divider',
borderInlineStartWidth: 4, borderInlineStartWidth: 4,
@@ -1,9 +1,9 @@
'use client'; 'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { Checkbox, FormControlLabel, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material'; import { Checkbox, FormControlLabel, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, VisitNoteCard } from '@/components'; import { AppButton, AppIcon, RhfControlGroup, RhfTextField, VisitNoteCard } from '@/components';
import { formatShamsiDate } from '@/utils'; import { formatShamsiDate } from '@/utils';
import { useBookingDetail } from '@/services/bookings'; import { useBookingDetail } from '@/services/bookings';
import { isBookingConfirmedOrBeyond } from '@/services/bookings/types'; 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 { VISIT_NOTE_MAX_LENGTH } from '@/services/patientRecords/constants';
import type { TaskResult } from '@/services/patientRecords/types'; 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 * 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 * 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 history = usePatientHistory(patientId, 1, { enabled: engaged && patientId > 0 && canView });
const createNote = useCreateVisitNote(patientId); const createNote = useCreateVisitNote(patientId);
const [note, setNote] = useState(''); const form = useForm<VisitNoteFormValues>({ mode: 'onTouched', defaultValues: { note: '', checked: {} } });
const [checked, setChecked] = useState<Record<string, boolean>>({}); const { control, handleSubmit, reset } = form;
const note = useWatch({ control, name: 'note' });
if (!booking.data || !engaged) return null; if (!booking.data || !engaged) return null;
const tasks = record.data?.tasks ?? []; const tasks = record.data?.tasks ?? [];
const submit = () => { const submit = (values: VisitNoteFormValues) => {
if (!note.trim()) { const taskResults: TaskResult[] = tasks.map((task) => ({ label: task.label, done: Boolean(values.checked[task.id]) }));
enqueueSnackbar(t('note_required'), { variant: 'error' });
return;
}
const taskResults: TaskResult[] = tasks.map((task) => ({ label: task.label, done: Boolean(checked[task.id]) }));
createNote.mutate( createNote.mutate(
{ bookingId, body: note.trim(), taskResults }, { bookingId, body: values.note.trim(), taskResults },
{ {
onSuccess: () => { onSuccess: () => {
enqueueSnackbar(t('note_saved'), { variant: 'success' }); enqueueSnackbar(t('note_saved'), { variant: 'success' });
setNote(''); reset({ note: '', checked: {} });
setChecked({});
}, },
onError: () => enqueueSnackbar(t('note_error'), { variant: 'error' }), onError: () => enqueueSnackbar(t('note_error'), { variant: 'error' }),
}, },
@@ -70,9 +72,10 @@ export default function NurseVisitNotesPanel({ bookingId }: { bookingId: number
{canAppend ? ( {canAppend ? (
<Paper <Paper
elevation={0} 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 }}> <Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
<AppIcon icon="notes" size={20} color="var(--bal-secondary)" /> <AppIcon icon="notes" size={20} color="var(--bal-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}> <Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
@@ -88,43 +91,56 @@ export default function NurseVisitNotesPanel({ bookingId }: { bookingId: number
{record.isLoading ? ( {record.isLoading ? (
<Skeleton variant="rounded" height={80} /> <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 <FormControlLabel
key={task.id} key={task.id}
control={ control={
<Checkbox <Checkbox
checked={Boolean(checked[task.id])} checked={Boolean(ticked[task.id])}
onChange={(e) => setChecked((c) => ({ ...c, [task.id]: e.target.checked }))} onChange={(event) =>
field.onChange({ ...ticked, [task.id]: event.target.checked })
}
/> />
} }
label={task.label} label={task.label}
/> />
)) ))}
</Stack>
);
}}
</RhfControlGroup>
)} )}
</Stack> </Stack>
) : null} ) : null}
<TextField <RhfTextField<VisitNoteFormValues>
name="note"
label={t('note_label')} label={t('note_label')}
placeholder={t('note_placeholder')} placeholder={t('note_placeholder')}
value={note} transform={(raw) => raw.slice(0, VISIT_NOTE_MAX_LENGTH)}
onChange={(e) => setNote(e.target.value.slice(0, VISIT_NOTE_MAX_LENGTH))} rules={{ validate: (value) => String(value ?? '').trim().length > 0 || t('note_required') }}
multiline multiline
minRows={3} minRows={3}
fullWidth fullWidth
/> />
<AppButton <AppButton
type="submit"
variant="contained" variant="contained"
color="secondary" color="secondary"
startIcon="notes" startIcon="notes"
onClick={submit}
disabled={createNote.isPending || !note.trim()} disabled={createNote.isPending || !note.trim()}
sx={{ alignSelf: 'flex-end' }} sx={{ alignSelf: 'flex-end' }}
> >
{createNote.isPending ? tc('saving') : t('note_submit')} {createNote.isPending ? tc('saving') : t('note_submit')}
</AppButton> </AppButton>
</Stack> </Stack>
</FormProvider>
</Paper> </Paper>
) : null} ) : null}
@@ -78,7 +78,7 @@ function OnboardingBanner({ state }: { state: CenterOnboardingState }) {
const { severity, key } = banner[state]; const { severity, key } = banner[state];
return ( return (
<Alert severity={severity} sx={{ borderRadius: 2 }}> <Alert severity={severity} sx={{ borderRadius: 'var(--bal-radius-md)' }}>
{t(key)} {t(key)}
</Alert> </Alert>
); );
@@ -89,7 +89,7 @@ function LicenseBlock({ center }: { center: PartnerCenter }) {
const t = useTranslations('partner'); const t = useTranslations('partner');
return ( 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 }}> <Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 2 }}>
{t('license_title')} {t('license_title')}
</Typography> </Typography>
@@ -69,7 +69,7 @@ export default function PartnerBookingDetailPage() {
<AdminEmptyState icon="bookings" title={t('booking_detail_not_found')} /> <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 sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}> <Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}> <Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
@@ -91,7 +91,7 @@ export default function PartnerBookingDetailPage() {
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}> <Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('booking_detail_timeline_title')} {t('booking_detail_timeline_title')}
</Typography> </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} /> <StatusTimeline nodes={timelineNodes} />
</Paper> </Paper>
</Stack> </Stack>
@@ -92,7 +92,7 @@ function PartnerBookingsScreen() {
<Stack <Stack
direction="row" 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 <TextField
select select
@@ -1,5 +1,5 @@
import SidebarShellSkeleton from '../_chrome/SidebarShellSkeleton'; import ShellContentSkeleton from '../_chrome/ShellContentSkeleton';
export default function Loading() { 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 ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('settlement_title')} /> <AdminPageHeader title={t('settlement_title')} />
<Alert severity="info" sx={{ borderRadius: 2 }}> <Alert severity="info" sx={{ borderRadius: 'var(--bal-radius-md)' }}>
{t('settlement_not_mor')} {t('settlement_not_mor')}
</Alert> </Alert>
</Box> </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' }}> <Stack direction="row" sx={{ gap: 2, alignItems: 'baseline', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('settlement_iban')} {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 t = useTranslations('activation');
const state = useActivationChecklist(); 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) { if (state.isError) {
return <ErrorState message={t('load_error')} retryLabel={t('retry')} onRetry={state.retry} />; return <ErrorState message={t('load_error')} retryLabel={t('retry')} onRetry={state.retry} />;
} }
@@ -44,7 +44,7 @@ const BnplPlanCard: FunctionComponent<BnplPlanCardProps> = ({ plan, orderAmountI
display: 'block', display: 'block',
width: '100%', width: '100%',
textAlign: 'start', textAlign: 'start',
borderRadius: 2.5, borderRadius: 'var(--bal-radius-md)',
p: 1.75, p: 1.75,
border: '1px solid', border: '1px solid',
borderColor: selected ? 'var(--bal-secondary)' : 'divider', borderColor: selected ? 'var(--bal-secondary)' : 'divider',
@@ -24,7 +24,7 @@ const PREVIEW: CancellationPolicyPreview = {
bookingId: 5003, bookingId: 5003,
cancellable: true, cancellable: true,
cancellationPolicyCode: 'free_24h', cancellationPolicyCode: 'free_24h',
refundPercentageApplied: 1, refundPercentageApplied: 100,
feePercentage: 0, feePercentage: 0,
refundAmountIrr: '17600000', refundAmountIrr: '17600000',
feeAmountIrr: '0', feeAmountIrr: '0',
@@ -15,9 +15,10 @@ export interface CancellationPolicyDisclosureProps {
preview: CancellationPolicyPreview; preview: CancellationPolicyPreview;
} }
/** Percent (integer) from a 01 fraction — a small display number, never money, so JS math is safe. */ /** Rounds an already-0100 percent value for display a small display number, never money, so JS math is
function toPercent(fraction: number): number { * safe. Never re-multiply by 100: the server (and the mock) already serve this scale. */
return Math.round(fraction * 100); function toPercent(percent: number): number {
return Math.round(percent);
} }
/** /**
@@ -39,7 +40,7 @@ const CancellationPolicyDisclosure: FunctionComponent<CancellationPolicyDisclosu
return ( return (
<Stack sx={{ gap: 2.5 }} data-testid="cancellation-disclosure" data-policy-code={preview.cancellationPolicyCode}> <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 sx={{ gap: 1 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}> <Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}> <Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
@@ -69,7 +70,7 @@ const CancellationPolicyDisclosure: FunctionComponent<CancellationPolicyDisclosu
/> />
{isMultiSession && ( {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 }}> <Stack sx={{ gap: 1.25 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}> <Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('sessions_title')} {t('sessions_title')}
@@ -55,7 +55,7 @@ const CategoryTile: FunctionComponent<CategoryTileProps> = ({ label, iconKey, on
height: '100%', height: '100%',
minHeight: 116, minHeight: 116,
p: 2, p: 2,
borderRadius: 2, borderRadius: 'var(--bal-radius-md)',
border: '1px solid', border: '1px solid',
borderColor: selected ? 'var(--bal-primary)' : 'divider', borderColor: selected ? 'var(--bal-primary)' : 'divider',
bgcolor: selected ? 'var(--bal-primary-soft)' : 'background.paper', bgcolor: selected ? 'var(--bal-primary-soft)' : 'background.paper',
@@ -171,7 +171,7 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
{progress}% {progress}%
</Typography> </Typography>
</Stack> </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' }}> <Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('upload_uploading')} {t('upload_uploading')}
</Typography> </Typography>
@@ -184,7 +184,7 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
component="img" component="img"
src={previewUrl} src={previewUrl}
alt="" 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)" /> <AppIcon icon="document" size={28} color="var(--bal-primary)" />
@@ -262,7 +262,7 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
}} }}
sx={{ sx={{
p: 3, p: 3,
borderRadius: 2, borderRadius: 'var(--bal-radius-md)',
border: '1px dashed', border: '1px dashed',
borderColor: 'divider', borderColor: 'divider',
textAlign: 'center', textAlign: 'center',
@@ -54,7 +54,7 @@ const EscrowExplainer: FunctionComponent = () => {
{t('escrow_explainer_toggle')} {t('escrow_explainer_toggle')}
</AppButton> </AppButton>
<Collapse in={open} id={EXPLAINER_CONTENT_ID}> <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 <Stack
direction="row" direction="row"
sx={{ gap: { xs: 1.5, sm: 2 }, alignItems: 'flex-start', flexWrap: 'wrap', justifyContent: 'space-between' }} sx={{ gap: { xs: 1.5, sm: 2 }, alignItems: 'flex-start', flexWrap: 'wrap', justifyContent: 'space-between' }}
@@ -25,6 +25,7 @@ const NURSE: NurseSearchResult = {
nurseId: 1, nurseId: 1,
variantId: 11, variantId: 11,
serviceCategoryId: 1, serviceCategoryId: 1,
matchingServiceCount: 1,
nurseName: 'Maryam Rezaei', nurseName: 'Maryam Rezaei',
avatarUrl: null, avatarUrl: null,
isVerified: true, isVerified: true,
@@ -112,6 +113,14 @@ describe('<NurseResultCard/> component', () => {
expect(screen.getByText(/منظم و دقیق/)).toBeInTheDocument(); 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)', () => { it('falls back to a label when the name is missing (b7 join gap)', () => {
renderCard({ ...NURSE, nurseName: '' }); renderCard({ ...NURSE, nurseName: '' });
expect(screen.getByText('unnamed_nurse')).toBeInTheDocument(); expect(screen.getByText('unnamed_nurse')).toBeInTheDocument();
@@ -65,7 +65,7 @@ const NurseResultCard = ({ nurse, serviceLabel, onSelect }: NurseResultCardProps
alignItems: 'flex-start', alignItems: 'flex-start',
border: '1px solid', border: '1px solid',
borderColor: 'divider', borderColor: 'divider',
borderRadius: 2, borderRadius: 'var(--bal-radius-md)',
cursor: 'pointer', cursor: 'pointer',
transition: 'border-color 120ms ease', transition: 'border-color 120ms ease',
'&:hover': { borderColor: 'var(--bal-primary)' }, '&:hover': { borderColor: 'var(--bal-primary)' },
@@ -84,11 +84,12 @@ const NurseResultCard = ({ nurse, serviceLabel, onSelect }: NurseResultCardProps
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}> <Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{name} {name}
</Typography> </Typography>
<TrustBadge state="verified" nurseId={nurse.nurseId} /> <TrustBadge state={nurse.isVerified ? 'verified' : 'unverified'} nurseId={nurse.nurseId} />
</Stack> </Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
{serviceLabel} {serviceLabel}
{nurse.matchingServiceCount > 1 ? ` · ${t('more_services_count', { count: nurse.matchingServiceCount - 1 })}` : ''}
</Typography> </Typography>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap', mt: 0.25 }}> <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 /** 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. */ * row, rating row, price row) so a loading list doesn't jump when data lands. */
const NurseResultCardSkeleton: FunctionComponent = () => ( 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} /> <Skeleton variant="circular" width={56} height={56} />
<Stack sx={{ gap: 0.75, flexGrow: 1 }}> <Stack sx={{ gap: 0.75, flexGrow: 1 }}>
<Skeleton variant="text" width="55%" height={28} /> <Skeleton variant="text" width="55%" height={28} />

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