Compare commits

..

40 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
hamid 12ce7fa7de ui phase 13 2026-07-20 01:35:15 +03:30
hamid d33568bf31 ui phase 12 2026-07-19 21:31:59 +03:30
hamid 87fa4cd497 ui phase 11 2026-07-19 19:19:44 +03:30
hamid b4b8c9ea79 ui phase 10 2026-07-19 17:13:32 +03:30
hamid b638e25a0e ui phase 9 2026-07-19 15:14:44 +03:30
hamid 1ef4feb911 ui phase 8 2026-07-19 13:57:11 +03:30
hamid edc38543fd ui phase 7 2026-07-19 11:56:59 +03:30
hamid a438edeeaa ui phase 6 2026-07-19 09:49:25 +03:30
hamid 4c70d8e424 ui phase 5 2026-07-18 09:51:03 +03:30
hamid 53b4e1b0a4 ui phase 3 2026-07-18 01:56:17 +03:30
hamid 222856d600 ui phase 2 2026-07-17 19:05:17 +03:30
838 changed files with 28374 additions and 71998 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.
+178 -51
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 |
| `UserInfo` | user avatar/identity block | feature component | | `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,58 +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
- **Private (authenticated) screens** render inside `PrivateLayout` **There is one layout: a phone.** `AppFrame` (`src/layout/AppFrame.tsx`) renders every
`TopBarAndSideBarLayout` (`src/layout/`): a `TopBar` + a `SideBar` (variant screen inside a centered `APP_FRAME_MAX_WIDTH` (480px) column on a `--bal-frame-canvas`
`sidebarPersistentOnDesktop`: persistent ≥desktop, temporary drawer on mobile) + backdrop, at **every viewport**. A wider window gets more canvas, never a wider app —
a dark-mode toggle. Sidebar nav items are `{ title, path, icon }` arrays built with design one set of states, verify one set of states. Do not add a `≥md` branch that widens
`useTranslations('nav')`. Page content is auto-wrapped in `ErrorBoundary`. a shell, restores a sidebar, or lays a screen out in columns.
- **Public screens** use `PublicLayout`.
- Shell dimensions are constants in `src/layout/config.ts` (`SIDE_BAR_WIDTH = 240px`, - `AppFrame` owns four structural guarantees, and is the only place any of them is
top-bar `56px` mobile / `64px` desktop, anchors). Respect them; don't hard-code. solved: the width cap; the **frame, not the document, owns the scroll** (a single
scrolling `<main>` fills the frame, with the bars pinned **`position: absolute`** over
it — never `fixed`, which would break out of the centered column — and `<main>`
reserving each bar's exact height as padding, so no page needs a top offset);
`overflowX: hidden` + `minWidth: 0`, so an over-wide child clips rather than dragging
the app sideways; and, above `sm`, the column **floats** as a rounded shadowed card
with a gutter all round (edge-to-edge on a phone). Genuinely wide content (a data
table) scrolls **inside its own container** — see `AdminDataTable`'s `TableContainer`.
- `AppFrame` also publishes **`--bal-chrome-top` / `--bal-chrome-bottom`** on the scroll
container (already including `env(safe-area-inset-*)`, and `0px` in a chrome-free
shell), so any `position: sticky` element can clear the bars without importing a
constant. `StickyActionBar` is the reference consumer — don't recompute an offset.
- **One authenticated shell**: `MobileShell` = `AppFrame` + a contextual `TopBar` (brand
lockup on a tab's own path, back chevron + `useRouteTitle()` on anything deeper) +
`BottomBar` + `ErrorBoundary` + `RouteFadeIn`. The four actor layouts (`CustomerLayout`
/ `NurseLayout` / `AdminLayout` / `PartnerLayout`, each wrapped in `RoleGuard`) supply
only `tabs` and `headerActions`. Add a destination by adding a tab or a hub row — never
by forking the shell.
- **The chrome is light, not structural.** The top bar is *not* an `AppBar` — no filled
surface, no rule, no elevation of its own; `AppFrame` wraps both bars in the shared
`FLOATING_BAR_SX`, so the header is the bottom bar mirrored: inset from the frame edges,
fully rounded (`--bal-radius-pill`), elevated. Neither should read as a slab sealing off
an edge of a 480px screen. The bottom bar is **icon-only** (at five tabs the caption was
the widest thing in it and cost a whole line — the label survives as `aria-label`/
`title`), each tab a fixed 44px circle laid out `space-around`.
- **A stateful card carries its state in its content, not a stripe.** `AccentCard`'s
colored edge stripe was removed — a column of them read as a row of loose vertical rules
down the RTL edge of the screen. `tone` survives as the semantic label (reaching the DOM
as `data-accent-tone`); the `StatusChip`, icon and copy inside carry the state.
**Do not reintroduce the stripe.**
- **Navigation is the bottom bar. There is no drawer.** Tabs are `LinkToPage` arrays
(`@/utils`) built with `useTranslations('nav')`, 35 of them, and by convention the last
is a settings/«بیشتر» hub. Active state comes from the shared `matchActivePath`
(longest-prefix, winner-takes-all) over each tab's own path **plus its `matchPaths`
claims — use `matchPaths` when a tab owns a destination outside its own URL subtree
(`/nurse/finance` owning `/nurse/earnings`). Never hand-roll `pathname.startsWith`.
- **A nav group's root is a real page**, not a drawer section: a short summary of that
domain (read only off queries that already answer it — never a fabricated figure) over a
`NavHubList` of its destinations. See `/nurse/practice`, `/nurse/finance`,
`/admin/trust`, `/admin/system`.
- **Chrome carries no preferences.** Language and appearance live in `SettingsPanel`
(`@/components/settings`), mounted in each actor's settings hub and nowhere else. The
top bar is for identity, the page title, and at most a notification bell. Appearance is a
three-way segmented control (light/dark/**system**) — never a boolean switch, which cannot
express the app's own default.
- **Public screens** use `PublicLayout` — the frame and nothing else, **no top bar**; the
step content (`AuthCard`) carries the only brand mark on screen. `FocusedLayout` is the
framed chrome-free shell for can't-tab-away flows (onboarding, `/select-role`).
- All chrome navigation goes through `@/i18n/navigation` (`Link`/`usePathname`/
`useRouter`) — never a raw `next/link` or a manual `` `/${locale}` `` prefix. (Inside a
*page*, `AppLink`/`AppButton`'s `to` is a plain `next/link` and still needs the prefix.)
- Page content is auto-wrapped in `ErrorBoundary` inside every shell.
- Shell dimensions are constants in `src/layout/config.ts` (`APP_FRAME_MAX_WIDTH`,
`TOP_BAR_HEIGHT`). Respect them; don't hard-code.
- A page is `src/app/[locale]/(private|public-routes)/…/page.tsx`. Keep page bodies to - 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.
- Use `useIsMobile()` (`@/hooks`) for responsive branching, or MUI breakpoints in `sx`. - Prefer MUI breakpoints in `sx` for the little responsive branching that remains over
`useIsMobile()` (`@/hooks`) — the latter is JS/post-hydration and caused a real SSR
flash; reach for it only for genuinely non-structural, JS-only behavior.
--- ---
## 6. Icons ## 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.
--- ---
@@ -231,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.
--- ---
@@ -251,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.
@@ -268,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`.
--- ---
@@ -298,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
+6
View File
@@ -20,3 +20,9 @@ NEXT_PUBLIC_PUBLIC_URL = http://localhost:3000
NEXT_PUBLIC_API_URL = https://localhost:5002 NEXT_PUBLIC_API_URL = https://localhost:5002
# NEXT_PUBLIC_API_URL = https://dev-api.domain.com # NEXT_PUBLIC_API_URL = https://dev-api.domain.com
# NEXT_PUBLIC_API_URL = https://api.domain.com # NEXT_PUBLIC_API_URL = https://api.domain.com
# Neshan **web** key (client-embeddable maps/search/reverse-geocode) — get one from
# https://platform.neshan.org (a separate key from the server's NeshanGeocoder key, which lives in
# server appsettings, never here). Leave unset to keep the address map-pin picker's
# bounded-canvas grid fallback (dev/CI/jsdom all work without a key).
# NEXT_PUBLIC_NESHAN_KEY = your-neshan-web-key
+6 -3
View File
@@ -1,10 +1,13 @@
# AGENTS.md — Balinyaar Web Client # 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 -911
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"]
+662 -100
View File
File diff suppressed because it is too large Load Diff
+724 -162
View File
File diff suppressed because it is too large Load Diff
+40 -16
View File
@@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server';
import { routing } from './src/i18n/routing'; import { routing } from './src/i18n/routing';
import { COOKIE_NAMES } from './src/lib/cookies'; import { COOKIE_NAMES } from './src/lib/cookies';
import { isTokenAlive } from './src/lib/auth/token'; import { isTokenAlive } from './src/lib/auth/token';
import { HEADER_NAMES, PUBLIC_PATHS, ROUTES } from './src/constants'; import { HEADER_NAMES, PUBLIC_PATHS, RETURN_URL_PARAM, ROUTES } from './src/constants';
const intlMiddleware = createMiddleware(routing); const intlMiddleware = createMiddleware(routing);
@@ -17,24 +17,43 @@ export default function middleware(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
// Strip the locale segment to get the actual path (e.g. /fa/login → /login)
const pathWithoutLocale = '/' + pathname.split('/').slice(2).join('/');
const isPublic = PUBLIC_PATHS.some((p) => pathWithoutLocale.startsWith(p));
if (!isPublic) {
const token = request.cookies.get(COOKIE_NAMES.ACCESS_TOKEN)?.value;
if (!isTokenAlive(token)) {
const locale = request.cookies.get('NEXT_LOCALE')?.value ?? routing.defaultLocale;
return NextResponse.redirect(new URL(`/${locale}${ROUTES.LOGIN}`, request.url));
}
}
// Detect locale from the normalized URL (next-intl always puts it at position 1) // Detect locale from the normalized URL (next-intl always puts it at position 1)
const locale = routing.locales.find( const locale = routing.locales.find(
(l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`), (l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`),
) ?? routing.defaultLocale; ) ?? routing.defaultLocale;
// Strip the locale segment to get the actual path (e.g. /fa/login → /login)
const pathWithoutLocale = '/' + pathname.split('/').slice(2).join('/');
const isPublic = PUBLIC_PATHS.some((p) => pathWithoutLocale.startsWith(p));
const isAuthenticated = isTokenAlive(request.cookies.get(COOKIE_NAMES.ACCESS_TOKEN)?.value);
// Guest front door (ui-phase-13): an unauthenticated hit on the exact root is REWRITTEN — never
// redirected — to the public landing, so the URL/SEO canonical stays '/'. Exact match only:
// never add ROUTES.HOME ('/') to PUBLIC_PATHS itself, whose `startsWith` check below would
// otherwise silently un-gate every route (§ the routes.ts comment on PUBLIC_PATHS).
if (!isAuthenticated && pathWithoutLocale === ROUTES.HOME) {
return NextResponse.rewrite(new URL(`/${locale}${ROUTES.WELCOME}`, request.url));
}
// A signed-in visitor landing on the marketing page directly gets their real home instead.
if (isAuthenticated && pathWithoutLocale === ROUTES.WELCOME) {
return NextResponse.redirect(new URL(`/${locale}${ROUTES.HOME}`, request.url));
}
if (!isPublic && !isAuthenticated) {
const loginUrl = new URL(`/${locale}${ROUTES.LOGIN}`, request.url);
// Carry the attempted (locale-stripped) destination so a deep link — an SMS booking link, a
// shared nurse profile — survives the round trip through login instead of dumping the user
// on their role home. Validated same-origin + role-permitting on the way back out
// (resolvePostLoginDestination in services/auth/routing.ts); '/' is the default anyway.
const next = pathWithoutLocale + request.nextUrl.search;
if (next && next !== '/') {
loginUrl.searchParams.set(RETURN_URL_PARAM, next);
}
return NextResponse.redirect(loginUrl);
}
const requestHeaders = new Headers(request.headers); const requestHeaders = new Headers(request.headers);
requestHeaders.set(HEADER_NAMES.LOCALE, locale); requestHeaders.set(HEADER_NAMES.LOCALE, locale);
@@ -49,6 +68,11 @@ export default function middleware(request: NextRequest) {
} }
export const config = { export const config = {
// Match all pathnames except internal Next.js paths, API routes, and static files // Match all pathnames except internal Next.js paths, API routes, and static files. The bare
matcher: ['/((?!_next|_vercel|api|.*\\..*).*)'], // root '/' is listed explicitly alongside the catch-all regex — Next's matcher does not
// reliably invoke middleware for the literal root path through the negative-lookahead pattern
// alone (verified empirically in this Next 16/Turbopack build: '/' skipped middleware entirely
// and 404'd, while every other path matched fine). This is load-bearing for ui-phase-13's
// guest-front-door rewrite, which only fires on an exact '/' match.
matcher: ['/', '/((?!_next|_vercel|api|.*\\..*).*)'],
}; };
+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);
+55 -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",
@@ -22,11 +21,14 @@
"copy-to-clipboard": "latest", "copy-to-clipboard": "latest",
"jalaali-js": "^2.0.0", "jalaali-js": "^2.0.0",
"js-cookie": "^3.0.8", "js-cookie": "^3.0.8",
"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": {
@@ -36,6 +38,7 @@
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/leaflet": "^1.9.21",
"@types/node": "^25.9.3", "@types/node": "^25.9.3",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@@ -557,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,
@@ -1995,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",
@@ -3361,6 +3337,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/graceful-fs": { "node_modules/@types/graceful-fs": {
"version": "4.1.9", "version": "4.1.9",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
@@ -3657,6 +3640,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "25.9.3", "version": "25.9.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
@@ -9128,6 +9121,12 @@
"node": ">=0.10" "node": ">=0.10"
} }
}, },
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/leven": { "node_modules/leven": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -9203,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",
@@ -10265,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",
+6 -2
View File
@@ -9,18 +9,18 @@
"format": "prettier ./ --write", "format": "prettier ./ --write",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"lint:copy": "node scripts/check-copy.mjs",
"start": "next start", "start": "next start",
"test": "jest --watch", "test": "jest --watch",
"test:ci": "jest --ci", "test:ci": "jest --ci",
"type": "tsc --noEmit", "type": "tsc --noEmit",
"check": "npm run type && npm run lint" "check": "npm run type && npm run lint && npm run lint:copy"
}, },
"dependencies": { "dependencies": {
"@emotion/cache": "^11.14.0", "@emotion/cache": "^11.14.0",
"@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",
@@ -30,11 +30,14 @@
"copy-to-clipboard": "latest", "copy-to-clipboard": "latest",
"jalaali-js": "^2.0.0", "jalaali-js": "^2.0.0",
"js-cookie": "^3.0.8", "js-cookie": "^3.0.8",
"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": {
@@ -44,6 +47,7 @@
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/leaflet": "^1.9.21",
"@types/node": "^25.9.3", "@types/node": "^25.9.3",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
-5
View File
@@ -1,5 +0,0 @@
User-agent: *
Disallow: /private/
User-agent: *
Allow: /
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env node
/**
* Lints client/messages/fa.json against the banned-orthography-variant rules in
* docs/rules/client/i18n.md §4 — enforces the phase-12 copy sweep so it cannot silently regress.
* Exits non-zero (and prints every offending key) on any match.
*
* The rules below are the machine-checkable subset. The full Persian style guide (glossary, register,
* shell naming, the ZWNJ cases a grep can't express) lives in that doc; keep the two in step.
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const FA_MESSAGES_PATH = join(__dirname, '..', 'messages', 'fa.json');
// Each rule scans the *raw value* of every leaf string in fa.json (flattened key path → value).
// `pattern` is a plain substring (not regex) so ZWNJ/hamza characters stay literal and unambiguous.
const RULES = [
{
name: 'brand name must use ZWNJ (بالین‌یار), never a plain space',
pattern: 'بالین یار',
},
{
name: 'تأیید must use hamza — تایید (hamza-less) is banned',
pattern: 'تایید',
},
{
name: 'جستجو is the one standard form — جست‌وجو is banned',
pattern: 'جست‌وجو',
},
{
name: 'جستجو is the one standard form — جست و جو (spaced) is banned',
pattern: 'جست و جو',
},
{
name: 'the indefinite «ی» misattachment bug — «بازی» (game) instead of «ی» on the right word',
// Word-boundary trap: catches "... بازی " (space after) so it doesn't also flag words that
// legitimately contain "بازی" as a substring of something else — in this catalog there are none,
// but the trailing-space anchor keeps the check honest if one is ever added on purpose elsewhere.
pattern: 'بازی ',
},
{
// Leading space distinguishes the archaic passive auxiliary ("X می‌گردد" = "is X-ed") from the
// unrelated, entirely legitimate verb «برمی‌گردد» (returns/comes back), which fuses «بر» directly
// onto «می‌گردد» with no space and must never be flagged.
name: 'archaic passive می‌گردد is banned — use می‌شود',
pattern: ' می‌گردد',
},
];
function flattenStrings(value, path, out) {
if (typeof value === 'string') {
out.push([path, value]);
return;
}
if (Array.isArray(value)) {
value.forEach((item, index) => flattenStrings(item, `${path}[${index}]`, out));
return;
}
if (value && typeof value === 'object') {
for (const [key, child] of Object.entries(value)) {
flattenStrings(child, path ? `${path}.${key}` : key, out);
}
}
}
function main() {
const raw = readFileSync(FA_MESSAGES_PATH, 'utf8');
const messages = JSON.parse(raw);
const leaves = [];
flattenStrings(messages, '', leaves);
const failures = [];
for (const rule of RULES) {
for (const [path, value] of leaves) {
if (value.includes(rule.pattern)) {
failures.push({ rule: rule.name, path, value });
}
}
}
if (failures.length === 0) {
console.log(`check-copy: ${leaves.length} strings checked, 0 banned variants found.`);
return;
}
console.error(`check-copy: found ${failures.length} banned copy variant(s):\n`);
for (const failure of failures) {
console.error(` [${failure.rule}]`);
console.error(` fa.json → ${failure.path}: "${failure.value}"\n`);
}
process.exitCode = 1;
}
main();
@@ -1,14 +1,25 @@
'use client'; 'use client';
import { FormEvent, FunctionComponent, useEffect, useState } from 'react'; import { FunctionComponent, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { Avatar, Box, InputAdornment, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material'; import { Avatar, Box, ButtonBase, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, CategoryTile, EmptyState, ErrorState } from '@/components'; import {
AppButton,
AppIcon,
AppIconButton,
AppLoading,
CategoryTile,
EmptyState,
ErrorState,
SurfaceCard,
} from '@/components';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { useMe } from '@/services/auth'; import { useMe } from '@/services/auth';
import { usePatients } from '@/services/patients'; import { usePatients } from '@/services/patients';
import { useServiceCategories } from '@/services/catalog'; import { useServiceCategories } from '@/services/catalog';
import { pickCatalogName } from '@/services/catalog/names'; import { pickCatalogName } from '@/services/catalog/names';
import { useBookingDetail, useBookingList } from '@/services/bookings';
import type { BookingListItemDto } from '@/services/bookings/types';
interface NudgeCardProps { interface NudgeCardProps {
icon: string; icon: string;
@@ -16,17 +27,20 @@ interface NudgeCardProps {
body: string; body: string;
ctaLabel: string; ctaLabel: string;
to: string; to: string;
/** Optional dismiss affordance (session-scoped) — omit for the always-relevant profile nudge. */
onDismiss?: () => void;
dismissLabel?: string;
} }
const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLabel, to }) => ( 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 }} 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 }}> <Stack sx={{ gap: 1, flexGrow: 1, minWidth: 0 }}>
<Stack sx={{ gap: 0.25 }}> <Stack sx={{ gap: 0.25 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}> <Typography variant="subtitle1" sx={{ fontWeight: 700, pr: onDismiss ? 4 : 0 }}>
{title} {title}
</Typography> </Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
@@ -37,14 +51,28 @@ const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLa
{ctaLabel} {ctaLabel}
</AppButton> </AppButton>
</Stack> </Stack>
{onDismiss ? (
<AppIconButton
icon="close"
title={dismissLabel}
onClick={onDismiss}
size="small"
sx={{ position: 'absolute', insetInlineEnd: 8, insetBlockStart: 8 }}
/>
) : null}
</Paper> </Paper>
); );
// Session-scoped dismiss: a plain module variable (not a cookie/localStorage — this is ephemeral UI
// state, not app/auth state) survives client-side navigation within the same page load and resets on a
// hard reload, matching "dismissible for this session, not permanently".
let patientNudgeDismissedInSession = false;
/** /**
* A5 — the family Home: the front door of the app. Greeting + avatar, the search bar (which hands a * A5 — the family Home: the front door of the app. Greeting + avatar, a compact ambient trust strip, a
* query / chosen `service_category_id` toward the f6 search flow — results are not built here), the * tappable search entry point (routes to C1 — see `HomeSearchBar`), the **data-driven** service-category
* **data-driven** service-category grid (from the cached `services/catalog` reference data), and the * grid (from the cached `services/catalog` reference data), a completeness-gated patient-record nudge,
* complete-patient-record nudge (derived from the f2 patient cache — no extra fetch). * and a "رزرو دوباره" (rebook) shortcut row sourced from recent bookings.
* *
* First-login gate: a customer with no patients is sent into onboarding (A3). The redirect waits for * First-login gate: a customer with no patients is sent into onboarding (A3). The redirect waits for
* a settled list so a post-create refetch never bounces the user back to onboarding. * a settled list so a post-create refetch never bounces the user back to onboarding.
@@ -57,6 +85,7 @@ export default function HomeScreen() {
const { data: me } = useMe(); const { data: me } = useMe();
const { data, isError, refetch } = usePatients(); const { data, isError, refetch } = usePatients();
const [nudgeDismissed, setNudgeDismissed] = useState(patientNudgeDismissedInSession);
const isEmpty = data?.total === 0; const isEmpty = data?.total === 0;
@@ -78,6 +107,16 @@ export default function HomeScreen() {
const greeting = firstName ? t('greeting_named', { name: firstName }) : t('greeting_plain'); const greeting = firstName ? t('greeting_named', { name: firstName }) : t('greeting_plain');
const avatarInitial = firstName ? firstName.charAt(0).toUpperCase() : null; const avatarInitial = firstName ? firstName.charAt(0).toUpperCase() : null;
// Completeness signal derived from the cached patients data (no extra fetch): a patient with no
// conditions recorded yet is an incomplete record — never a forever-nudge once every record is filled.
const hasIncompletePatient = data.items.some((patient) => patient.conditions.length === 0);
const showPatientNudge = hasIncompletePatient && !nudgeDismissed;
const dismissPatientNudge = () => {
patientNudgeDismissedInSession = true;
setNudgeDismissed(true);
};
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}> <Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
@@ -94,17 +133,25 @@ export default function HomeScreen() {
</Box> </Box>
</Stack> </Stack>
<TrustStrip />
<HomeSearchBar /> <HomeSearchBar />
<CategoryGrid onSelect={(categoryId) => router.push(`${href(ROUTES.SEARCH)}?category_id=${categoryId}`)} /> <CategoryGrid onSelect={(categoryId) => router.push(`${href(ROUTES.SEARCH)}?category_id=${categoryId}`)} />
<NudgeCard <RebookRow />
icon="patients"
title={t('nudge_patient_title')} {showPatientNudge ? (
body={t('nudge_patient_body')} <NudgeCard
ctaLabel={t('nudge_patient_cta')} icon="patients"
to={href(ROUTES.PATIENTS)} title={t('nudge_patient_title')}
/> body={t('nudge_patient_body')}
ctaLabel={t('nudge_patient_cta')}
to={href(ROUTES.PATIENTS)}
onDismiss={dismissPatientNudge}
dismissLabel={tc('close')}
/>
) : null}
{!profileComplete ? ( {!profileComplete ? (
<NudgeCard <NudgeCard
icon="profile" icon="profile"
@@ -119,41 +166,63 @@ export default function HomeScreen() {
} }
/** /**
* The Home search field. Rendering + query capture live here; **execution is f6** — submitting * Quiet, one-line ambient reassurance under the greeting — not a hero. Three icon+label items: escrow
* navigates toward the (future) search route carrying the typed query. Results/filters are DEFERRED * payment, verified nurses, support. Purely presentational; tokens only.
* → frontend-phase-6-b7. */
const TrustStrip: FunctionComponent = () => {
const t = useTranslations('home');
const items: Array<{ icon: string; label: string }> = [
{ icon: 'lock', label: t('trust_escrow') },
{ icon: 'verification', label: t('trust_verified_nurses') },
{ icon: 'support', label: t('trust_support') },
];
return (
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between' }}>
{items.map((item) => (
<Stack key={item.icon} direction="row" sx={{ gap: 0.5, alignItems: 'center', minWidth: 0 }}>
<AppIcon icon={item.icon} size={16} color="var(--bal-primary)" />
<Typography variant="caption" noWrap sx={{ color: 'text.secondary' }}>
{item.label}
</Typography>
</Stack>
))}
</Stack>
);
};
/**
* The Home search entry point — a tappable faux-input (never a half-working free-text field: the search
* index has no text column, variant names aren't client-queryable, and the only matchable dataset — 56
* cached category names — is already better served by the category grid directly below). Routes straight
* to C1 (`/search`). **Upgrade path**: once the backend serves a `q` param on `search/nurses` (REQ-041,
* matching nurse/variant/category names), this can become a real typeahead — the placeholder copy is
* already written for that future, so only the tap target need change, not the copy/i18n keys.
*/ */
const HomeSearchBar: FunctionComponent = () => { const HomeSearchBar: FunctionComponent = () => {
const t = useTranslations('home'); const t = useTranslations('home');
const router = useRouter(); const router = useRouter();
const locale = useLocale(); const locale = useLocale();
const [query, setQuery] = useState('');
const submit = (event: FormEvent) => {
event.preventDefault();
const q = query.trim();
router.push(`/${locale}${ROUTES.SEARCH}${q ? `?q=${encodeURIComponent(q)}` : ''}`);
};
return ( return (
<Box component="form" onSubmit={submit} role="search"> <ButtonBase
<TextField onClick={() => router.push(`/${locale}${ROUTES.SEARCH}`)}
fullWidth aria-label={t('search_action')}
value={query} sx={{
onChange={(event) => setQuery(event.target.value)} justifyContent: 'flex-start',
placeholder={t('search_placeholder')} gap: 1,
aria-label={t('search_action')} width: '100%',
slotProps={{ px: 2,
input: { py: 1.5,
startAdornment: ( borderRadius: 'var(--bal-radius-md)',
<InputAdornment position="start"> border: '1px solid',
<AppIcon icon="search" size={22} color="var(--bal-text-secondary)" /> borderColor: 'divider',
</InputAdornment> bgcolor: 'background.paper',
), color: 'text.secondary',
}, }}
}} >
/> <AppIcon icon="search" size={22} color="var(--bal-text-secondary)" />
</Box> <Typography variant="body1">{t('search_placeholder')}</Typography>
</ButtonBase>
); );
}; };
@@ -174,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 ? (
@@ -196,3 +265,72 @@ const CategoryGrid: FunctionComponent<{ onSelect: (categoryId: number) => void }
</Stack> </Stack>
); );
}; };
/**
* The "رزرو دوباره" shortcut row — repeat care is the dominant pattern in home nursing. Sourced from the
* existing `useBookingList('customer')` cache (no extra list fetch); renders up to 2 cards, deduplicated
* by nurse, deep-linking to the nurse's C3 profile. Renders nothing (no empty state) when there is no
* past-bookings history.
*/
const RebookRow: FunctionComponent = () => {
const { data, isLoading, isError } = useBookingList('customer', { pageSize: 5 });
const items = data?.items ?? [];
if (isLoading || isError || items.length === 0) return null;
const seen = new Set<string>();
const candidates: BookingListItemDto[] = [];
for (const item of items) {
if (seen.has(item.counterpartyName)) continue;
seen.add(item.counterpartyName);
candidates.push(item);
if (candidates.length === 2) break;
}
if (candidates.length === 0) return null;
return (
<Stack sx={{ gap: 1 }}>
{candidates.map((booking) => (
<RebookCard key={booking.id} booking={booking} />
))}
</Stack>
);
};
/** One rebook card — resolves the booking's `nurseId` (not on the list row) via the cached booking
* detail, then deep-links to the nurse's C3 profile. Renders nothing while resolving. */
const RebookCard: FunctionComponent<{ booking: BookingListItemDto }> = ({ booking }) => {
const t = useTranslations('home');
const router = useRouter();
const locale = useLocale();
const { data: detail } = useBookingDetail(booking.id, 'customer');
if (!detail) return null;
const open = () => router.push(`/${locale}${ROUTES.SEARCH_NURSE}/${detail.nurseId}`);
return (
<SurfaceCard
padding="sm"
onClick={open}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
open();
}
}}
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2, cursor: 'pointer' }}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', minWidth: 0 }}>
<AppIcon icon="history" size={20} color="var(--bal-primary)" />
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>
{t('rebook_with', { name: booking.counterpartyName })}
</Typography>
</Stack>
<AppIcon icon="forward" size={18} color="var(--bal-text-secondary)" />
</SurfaceCard>
);
};
@@ -2,8 +2,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { Box, Dialog, DialogActions, DialogContent, DialogTitle, Skeleton, Stack, Typography } from '@mui/material'; import { Box, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, EmptyState } from '@/components'; import { AppButton, ConfirmDialog, EmptyState, ErrorState, FormDialogShell } from '@/components';
import { AddressCard, AddressForm } from '@/components/geography'; import { AddressCard, AddressForm } from '@/components/geography';
import { import {
useAddresses, useAddresses,
@@ -16,9 +16,9 @@ import type { CreateAddressInput, CustomerAddress } from '@/services/addresses/t
/** /**
* The customer address book — a cached, invalidate-on-mutation list of the customer's saved * The customer address book — a cached, invalidate-on-mutation list of the customer's saved
* addresses with add/edit (the cascading dropdowns + map pin in a dialog), soft-delete (confirm), * addresses with add/edit (the cascading dropdowns + map pin in a full-screen-on-mobile dialog),
* and set-primary (exactly one badge). Loading skeleton + empty state both handled. The chosen * soft-delete (confirm), and set-primary (exactly one badge). Loading skeleton, error (with
* address later feeds the f7 booking request. * retry), and empty states are all handled. The chosen address later feeds the f7 booking request.
*/ */
export default function AddressesPage() { export default function AddressesPage() {
const t = useTranslations('address'); const t = useTranslations('address');
@@ -26,13 +26,14 @@ export default function AddressesPage() {
const locale = useLocale(); const locale = useLocale();
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const { data, isLoading } = useAddresses(); const { data, isLoading, isError, refetch } = useAddresses();
const createAddress = useCreateAddress(); const createAddress = useCreateAddress();
const updateAddress = useUpdateAddress(); const updateAddress = useUpdateAddress();
const deleteAddress = useDeleteAddress(); const deleteAddress = useDeleteAddress();
const setPrimary = useSetPrimaryAddress(); const setPrimary = useSetPrimaryAddress();
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
const [formDirty, setFormDirty] = useState(false);
const [editing, setEditing] = useState<CustomerAddress | null>(null); const [editing, setEditing] = useState<CustomerAddress | null>(null);
const [deleteTarget, setDeleteTarget] = useState<CustomerAddress | null>(null); const [deleteTarget, setDeleteTarget] = useState<CustomerAddress | null>(null);
@@ -78,7 +79,7 @@ export default function AddressesPage() {
}; };
const addresses = data?.items ?? []; const addresses = data?.items ?? [];
const isEmpty = !isLoading && addresses.length === 0; const isEmpty = !isLoading && !isError && addresses.length === 0;
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
@@ -91,7 +92,7 @@ export default function AddressesPage() {
{t('subtitle')} {t('subtitle')}
</Typography> </Typography>
</Box> </Box>
{!isEmpty ? ( {!isEmpty && !isError ? (
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ flexShrink: 0 }}> <AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ flexShrink: 0 }}>
{t('add')} {t('add')}
</AppButton> </AppButton>
@@ -104,6 +105,8 @@ export default function AddressesPage() {
<Skeleton key={key} variant="rounded" height={104} /> <Skeleton key={key} variant="rounded" height={104} />
))} ))}
</Stack> </Stack>
) : isError ? (
<ErrorState message={t('load_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />
) : isEmpty ? ( ) : isEmpty ? (
<EmptyState <EmptyState
icon="location" icon="location"
@@ -125,6 +128,9 @@ export default function AddressesPage() {
addressLine={address.addressLine} addressLine={address.addressLine}
isPrimary={address.isPrimary} isPrimary={address.isPrimary}
primaryLabel={t('primary')} primaryLabel={t('primary')}
hasPin={address.latitude != null && address.longitude != null}
pinSetLabel={t('pin_set')}
pinMissingLabel={t('pin_missing')}
onEdit={() => openEdit(address)} onEdit={() => openEdit(address)}
onDelete={() => setDeleteTarget(address)} onDelete={() => setDeleteTarget(address)}
onSetPrimary={() => onSetPrimary={() =>
@@ -142,50 +148,52 @@ export default function AddressesPage() {
</Stack> </Stack>
)} )}
<Dialog open={formOpen} onClose={closeForm} fullWidth maxWidth="sm"> <FormDialogShell
<DialogTitle>{editing ? t('edit_title') : t('add_title')}</DialogTitle> open={formOpen}
<DialogContent> title={editing ? t('edit_title') : t('add_title')}
<Box sx={{ pt: 1 }}> dirty={formDirty}
<AddressForm onClose={closeForm}
key={editing?.id ?? 'new'} closeLabel={tc('close')}
initial={ discardTitle={tc('discard_title')}
editing discardBody={tc('discard_body')}
? { discardConfirmLabel={tc('discard_confirm')}
title: editing.title, discardCancelLabel={tc('cancel')}
provinceId: editing.provinceId, >
cityId: editing.cityId, <AddressForm
districtId: editing.districtId, key={editing?.id ?? 'new'}
addressLine: editing.addressLine, initial={
latitude: editing.latitude, editing
longitude: editing.longitude, ? {
isPrimary: editing.isPrimary, title: editing.title,
} provinceId: editing.provinceId,
: undefined cityId: editing.cityId,
} districtId: editing.districtId,
submitting={createAddress.isPending || updateAddress.isPending} addressLine: editing.addressLine,
onSubmit={handleSubmit} latitude: editing.latitude,
onCancel={closeForm} longitude: editing.longitude,
/> isPrimary: editing.isPrimary,
</Box> recipientName: editing.recipientName,
</DialogContent> recipientPhone: editing.recipientPhone,
</Dialog> }
: undefined
}
submitting={createAddress.isPending || updateAddress.isPending}
onSubmit={handleSubmit}
onCancel={closeForm}
onDirtyChange={setFormDirty}
/>
</FormDialogShell>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}> <ConfirmDialog
<DialogTitle>{t('delete_title')}</DialogTitle> open={Boolean(deleteTarget)}
<DialogContent> title={t('delete_title')}
<Typography variant="body2" sx={{ color: 'text.secondary' }}> body={t('delete_body')}
{t('delete_body')} confirmLabel={t('delete_confirm')}
</Typography> cancelLabel={tc('cancel')}
</DialogContent> confirmColor="error"
<DialogActions> onClose={() => setDeleteTarget(null)}
<AppButton variant="text" onClick={() => setDeleteTarget(null)}> onConfirm={confirmDelete}
{tc('cancel')} />
</AppButton>
<AppButton color="error" variant="contained" onClick={confirmDelete}>
{t('delete_confirm')}
</AppButton>
</DialogActions>
</Dialog>
</Box> </Box>
); );
} }
@@ -1,63 +1,212 @@
'use client'; 'use client';
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 { Box, Paper, Skeleton, Stack, Typography } from '@mui/material'; import { Badge, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
import { AppButton, EmptyState, ErrorState, Money, StatusChip } from '@/components'; import { AccentCard, AppButton, CountdownTimer, EmptyState, ErrorState, Money, RatingInput, StatusChip } from '@/components';
import type { AccentTone, StatusKind } from '@/components';
import { BOOKING_STATUS_KIND } from '@/components/booking/statusKind'; import { BOOKING_STATUS_KIND } from '@/components/booking/statusKind';
import { ROUTES } from '@/constants'; import { bookingReviewPath, ROUTES } from '@/constants';
import { formatShamsiDate } from '@/utils'; import { formatShamsiDate, localeTag } from '@/utils';
import { useBookingList } from '@/services/bookings'; import { useBookingList } from '@/services/bookings';
import type { BookingListItemDto } from '@/services/bookings/types'; import { BOOKINGS_PAGE_SIZE } from '@/services/bookings/constants';
import type { BookingListItemDto, BookingStatus } from '@/services/bookings/types';
import { useCustomerRequests } from '@/services/bookingRequests';
import type { BookingRequestListItem, BookingRequestStatus } from '@/services/bookingRequests/types';
import { useReviewEligibility } from '@/services/reviews';
type BookingsTab = 'pending' | 'active' | 'past';
/** `pending_payment`/`confirmed`/`in_progress` are still unfolding; the rest are resolved. */
const ACTIVE_BOOKING_STATUSES: readonly BookingStatus[] = ['pending_payment', 'confirmed', 'in_progress'];
const PAST_BOOKING_STATUSES: readonly BookingStatus[] = ['completed', 'disputed', 'closed', 'cancelled'];
const PENDING_REQUEST_STATUSES: readonly BookingRequestStatus[] = [
'pending_nurse_response',
'accepted_awaiting_payment',
];
const KIND_TO_ACCENT: Record<StatusKind, AccentTone> = {
neutral: 'neutral',
info: 'info',
pending: 'primary',
verified: 'success',
active: 'success',
rejected: 'error',
};
/** /**
* Customer رزروها — the "My bookings" list. Reads `useBookingList('customer')`; each row opens the * Customer رزروها — the lifecycle home. Three tabs so a money-adjacent pending request is never orphaned
* booking detail (`/bookings/{id}`). This is the customer entry to the f8 booking-detail surface (the C5 * once the user leaves C5: **در انتظار پاسخ** wires the exported-but-previously-unused
* `converted` state also lands here). Amounts render in Toman via the money util. * `useCustomerRequests` (live mini-countdown per row, deep-linking back to C5); **فعال** / **گذشته** split
* `useBookingList('customer')` by status. Rows carry a soft status chip + a matching `borderInlineStart`
* accent and are fully tappable (keyboard-focusable). Pagination is a "load more" over a single growing
* `pageSize` (the C2 results pattern) — booking #21+ stays reachable.
*/ */
export default function BookingsScreen() { export default function BookingsScreen() {
const t = useTranslations('booking'); const t = useTranslations('booking');
const { data, isLoading, isError, refetch } = useBookingList('customer'); const router = useRouter();
const items = data?.items ?? []; const locale = useLocale();
const [tab, setTab] = useState<BookingsTab>('active');
const [pageSize, setPageSize] = useState(BOOKINGS_PAGE_SIZE);
const pendingQuery = useCustomerRequests();
const pendingItems = (pendingQuery.data?.items ?? []).filter((item) =>
PENDING_REQUEST_STATUSES.includes(item.status),
);
const bookingsQuery = useBookingList('customer', { page: 1, pageSize });
const allBookings = bookingsQuery.data?.items ?? [];
const total = bookingsQuery.data?.total ?? 0;
const hasMore = allBookings.length < total;
const activeItems = allBookings.filter((item) => ACTIVE_BOOKING_STATUSES.includes(item.status));
const pastItems = allBookings.filter((item) => PAST_BOOKING_STATUSES.includes(item.status));
const openBooking = (id: number) => router.push(`/${locale}${ROUTES.BOOKINGS}/${id}`);
const openRequest = (id: number) => router.push(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${id}`);
const goToSearch = () => router.push(`/${locale}${ROUTES.SEARCH}`);
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Stack sx={{ gap: 3 }}>
<Box> <Stack>
<Typography variant="h5" component="h1"> <Typography variant="h5" component="h1">
{t('list_title')} {t('list_title')}
</Typography> </Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('list_subtitle')} {t('list_subtitle')}
</Typography> </Typography>
</Box> </Stack>
{isLoading ? ( <Tabs value={tab} onChange={(_event, value: BookingsTab) => setTab(value)} variant="fullWidth">
<Stack sx={{ gap: 2 }}> <Tab
{[0, 1].map((key) => ( value="pending"
<Skeleton key={key} variant="rounded" height={120} /> data-tab="pending"
))} label={
</Stack> pendingItems.length > 0 ? (
) : isError ? ( <Badge badgeContent={pendingItems.length} color="secondary" sx={{ '& .MuiBadge-badge': { insetInlineEnd: -12 } }}>
<ErrorState message={t('list_error')} retryLabel={t('retry')} onRetry={() => refetch()} /> {t('tab_pending')}
) : items.length === 0 ? ( </Badge>
<EmptyState icon="bookings" title={t('list_empty_title')} body={t('list_empty_body')} /> ) : (
) : ( t('tab_pending')
<Stack sx={{ gap: 2 }}> )
{items.map((item) => ( }
<BookingRow key={item.id} item={item} /> />
))} <Tab value="active" data-tab="active" label={t('tab_active')} />
</Stack> <Tab value="past" data-tab="past" label={t('tab_past')} />
)} </Tabs>
</Box>
{tab === 'pending' ? (
pendingQuery.isLoading ? (
<ListSkeleton />
) : pendingQuery.isError ? (
<ErrorState message={t('inbox_error')} retryLabel={t('retry')} onRetry={() => pendingQuery.refetch()} />
) : pendingItems.length === 0 ? (
<EmptyState
icon="pending"
title={t('pending_empty_title')}
body={t('pending_empty_body')}
action={
<AppButton variant="outlined" color="primary" startIcon="search" onClick={goToSearch}>
{t('missing_nurse_cta')}
</AppButton>
}
/>
) : (
<Stack sx={{ gap: 2 }}>
{pendingItems.map((item) => (
<PendingRequestRow key={item.id} item={item} locale={locale} onOpen={() => openRequest(item.id)} />
))}
</Stack>
)
) : null}
{tab === 'active' ? (
bookingsQuery.isLoading ? (
<ListSkeleton />
) : bookingsQuery.isError ? (
<ErrorState message={t('list_error')} retryLabel={t('retry')} onRetry={() => bookingsQuery.refetch()} />
) : activeItems.length === 0 ? (
<EmptyState
icon="bookings"
title={t('active_empty_title')}
body={t('active_empty_body')}
action={
<AppButton variant="outlined" color="primary" startIcon="search" onClick={goToSearch}>
{t('missing_nurse_cta')}
</AppButton>
}
/>
) : (
<BookingRows items={activeItems} locale={locale} onOpen={openBooking} hasMore={hasMore} onLoadMore={() => setPageSize((size) => size + BOOKINGS_PAGE_SIZE)} loadingMore={bookingsQuery.isFetching} loadMoreLabel={t('load_more')} />
)
) : null}
{tab === 'past' ? (
bookingsQuery.isLoading ? (
<ListSkeleton />
) : bookingsQuery.isError ? (
<ErrorState message={t('list_error')} retryLabel={t('retry')} onRetry={() => bookingsQuery.refetch()} />
) : pastItems.length === 0 ? (
<EmptyState icon="bookings" title={t('past_empty_title')} body={t('past_empty_body')} />
) : (
<BookingRows items={pastItems} locale={locale} onOpen={openBooking} hasMore={hasMore} onLoadMore={() => setPageSize((size) => size + BOOKINGS_PAGE_SIZE)} loadingMore={bookingsQuery.isFetching} loadMoreLabel={t('load_more')} />
)
) : null}
</Stack>
); );
} }
function BookingRow({ item }: { item: BookingListItemDto }) { function BookingRows({
items,
locale,
onOpen,
hasMore,
onLoadMore,
loadingMore,
loadMoreLabel,
}: {
items: BookingListItemDto[];
locale: string;
onOpen: (id: number) => void;
hasMore: boolean;
onLoadMore: () => void;
loadingMore: boolean;
loadMoreLabel: string;
}) {
return (
<Stack sx={{ gap: 2 }}>
{items.map((item) => (
<BookingRow key={item.id} item={item} locale={locale} onOpen={() => onOpen(item.id)} />
))}
{hasMore ? (
<AppButton variant="outlined" color="primary" onClick={onLoadMore} disabled={loadingMore} sx={{ alignSelf: 'center' }}>
{loadMoreLabel}
</AppButton>
) : null}
</Stack>
);
}
function BookingRow({ item, locale, onOpen }: { item: BookingListItemDto; locale: string; onOpen: () => void }) {
const t = useTranslations('booking'); const t = useTranslations('booking');
const locale = useLocale(); const kind = BOOKING_STATUS_KIND[item.status];
const router = useRouter(); const isCompleted = item.status === 'completed' || item.status === 'closed';
return ( return (
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}> <AccentCard
tone={KIND_TO_ACCENT[kind]}
role="button"
tabIndex={0}
onClick={onOpen}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onOpen();
}
}}
data-booking-row={item.id}
sx={{ cursor: 'pointer', '&:focus-visible': { outline: '2px solid var(--bal-primary)', outlineOffset: 2 } }}
>
<Stack sx={{ gap: 1.5 }}> <Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5, flexWrap: 'wrap' }}> <Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5, flexWrap: 'wrap' }}>
<Stack sx={{ gap: 0.25, minWidth: 0 }}> <Stack sx={{ gap: 0.25, minWidth: 0 }}>
@@ -68,23 +217,112 @@ function BookingRow({ item }: { item: BookingListItemDto }) {
{formatShamsiDate(item.scheduledDate, locale)} · {t('session_count', { count: item.sessionCount })} {formatShamsiDate(item.scheduledDate, locale)} · {t('session_count', { count: item.sessionCount })}
</Typography> </Typography>
</Stack> </Stack>
<StatusChip status={BOOKING_STATUS_KIND[item.status]} label={t(`bstatus_${item.status}`)} /> <StatusChip status={kind} label={t(`bstatus_${item.status}`)} />
</Stack> </Stack>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}> <Typography variant="body2" sx={{ fontWeight: 700 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}> {t('list_total')}: <Money amountIrr={item.amountIrr} size="sm" sx={{ fontWeight: 700 }} />
{t('list_total')}: <Money amountIrr={item.amountIrr} size="sm" sx={{ fontWeight: 700 }} /> </Typography>
</Typography>
<AppButton {isCompleted ? <CompletedReviewStrip bookingId={item.id} enabled={isCompleted} /> : null}
variant="outlined"
color="primary"
endIcon="bookings"
onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${item.id}`)}
>
{t('view_booking')}
</AppButton>
</Stack>
</Stack> </Stack>
</Paper> </AccentCard>
);
}
/** A completed booking without a review gets a compact star-strip CTA deep-linking into the review page.
* The eligibility read is gated to completed/closed rows only (`enabled`) — an active booking never fires
* it, and `canReview: false` (already reviewed or otherwise ineligible) renders nothing extra. */
function CompletedReviewStrip({ bookingId, enabled }: { bookingId: number; enabled: boolean }) {
const t = useTranslations('reviews');
const router = useRouter();
const locale = useLocale();
const eligibility = useReviewEligibility(bookingId, { enabled });
if (!eligibility.data?.canReview) return null;
return (
<AppButton
variant="text"
color="primary"
size="small"
startIcon={<RatingInput value={0} readOnly size={16} ariaLabel={t('cta_leave')} />}
onClick={(event) => {
event.stopPropagation();
router.push(`/${locale}${bookingReviewPath(bookingId)}`);
}}
sx={{ alignSelf: 'flex-start', px: 0 }}
>
{t('cta_leave')}
</AppButton>
);
}
/** «در انتظار پاسخ» row — a pending or accepted-awaiting-payment request, with a live mini-countdown. */
function PendingRequestRow({
item,
locale,
onOpen,
}: {
item: BookingRequestListItem;
locale: string;
onOpen: () => void;
}) {
const t = useTranslations('booking');
const accepted = item.status === 'accepted_awaiting_payment';
const deadline = accepted ? item.paymentDeadlineAt : item.nurseResponseDeadlineAt;
const timeFmt = new Intl.DateTimeFormat(localeTag(locale), { hour: '2-digit', minute: '2-digit' });
const startDate = new Date(`${item.requestedDate}T${item.requestedTimeStart}`);
const dateLabel = formatShamsiDate(startDate, locale);
const timeLabel = timeFmt.format(startDate);
return (
<AccentCard
tone={accepted ? 'secondary' : 'primary'}
role="button"
tabIndex={0}
onClick={onOpen}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onOpen();
}
}}
data-request-row={item.id}
sx={{ cursor: 'pointer', '&:focus-visible': { outline: '2px solid var(--bal-primary)', outlineOffset: 2 } }}
>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{item.counterpartyName}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{dateLabel} ·{' '}
<Typography component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>
{timeLabel}
</Typography>
</Typography>
<StatusChip status={accepted ? 'active' : 'pending'} label={t(`status_${item.status}`)} />
</Stack>
{deadline ? (
<CountdownTimer
deadlineIso={deadline}
elapsedText={t(accepted ? 'payment_elapsed' : 'response_elapsed')}
urgent={accepted}
size="sm"
/>
) : null}
</Stack>
</AccentCard>
);
}
function ListSkeleton() {
return (
<Stack sx={{ gap: 2 }}>
{[0, 1].map((key) => (
<Skeleton key={key} variant="rounded" height={96} />
))}
</Stack>
); );
} }
@@ -2,13 +2,17 @@
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 type { TicketCategory } from '@/services/tickets/types';
import { ApiError } from '@/lib/api/errors'; import { ApiError } from '@/lib/api/errors';
import { bookingRefundStatusPath, ROUTES } from '@/constants'; import { bookingRefundStatusPath, ROUTES } from '@/constants';
import { useCancelBooking, useCancellationPolicyPreview } from '@/services/refunds'; import { useCancelBooking, useCancellationPolicyPreview } from '@/services/refunds';
@@ -32,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;
@@ -54,9 +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); const [supportDialogCategory, setSupportDialogCategory] = useState<TicketCategory | null>(null);
const [reasonCategory, setReasonCategory] = useState<CancelReasonCategory>('changed_mind'); // `reasonCategory` is never pre-defaulted (that would make the reason analytics lie) — the continue
const [reasonNotes, setReasonNotes] = useState(''); // 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}`;
@@ -104,18 +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,
reasonCategory, // Guaranteed non-empty: step 1 is only reachable once a reason is chosen (the continue CTA gate).
reasonNotes: reasonNotes.trim() || undefined, reasonCategory: values.reasonCategory as CancelReasonCategory,
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')}
@@ -124,33 +145,62 @@ export default function CancelBookingPage() {
{step === 0 ? ( {step === 0 ? (
<> <>
{/* Off-ramps before the kill switch — exits, not obstacles; the destructive path stays fully
available below. Real rescheduling is DEFERRED (product decision + backend); this opens a
coordination ticket instead. */}
<Stack sx={{ gap: 1, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('offramp_note')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton
variant="outlined"
color="primary"
startIcon="schedule"
onClick={() => setSupportDialogCategory('coordination')}
>
{t('reschedule_cta')}
</AppButton>
<AppButton
variant="outlined"
color="primary"
startIcon="support"
onClick={() => setSupportDialogCategory('support')}
>
{t('contact_support_cta')}
</AppButton>
</Stack>
</Stack>
<CancellationPolicyDisclosure preview={preview} /> <CancellationPolicyDisclosure preview={preview} />
<TextField <RhfTextField<CancelFormValues> name="reasonCategory" select label={t('reason_field_label')} fullWidth>
select <MenuItem value="" disabled>
label={t('reason_field_label')} {t('reason_placeholder')}
value={reasonCategory} </MenuItem>
onChange={(event) => setReasonCategory(event.target.value as CancelReasonCategory)}
fullWidth
>
{REASON_CATEGORIES.map((category) => ( {REASON_CATEGORIES.map((category) => (
<MenuItem key={category} value={category}> <MenuItem key={category} value={category}>
{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
/> />
<FormControlLabel <RhfControlGroup<CancelFormValues> name="acknowledged">
control={<Checkbox checked={acknowledged} onChange={(event) => setAcknowledged(event.target.checked)} />} {({ field }) => (
label={t('acknowledge_label')} <FormControlLabel
/> control={
<Checkbox checked={Boolean(field.value)} onChange={(event) => field.onChange(event.target.checked)} />
}
label={t('acknowledge_label')}
/>
)}
</RhfControlGroup>
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', flexWrap: 'wrap' }}> <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)}>
@@ -159,16 +209,24 @@ export default function CancelBookingPage() {
<AppButton <AppButton
variant="contained" variant="contained"
color="primary" color="primary"
disabled={!acknowledged} disabled={!acknowledged || reasonCategory === ''}
onClick={() => setStep(1)} onClick={() => setStep(1)}
> >
{t('continue_cta')} {t('continue_cta')}
</AppButton> </AppButton>
</Stack> </Stack>
<ContactSupportDialog
open={supportDialogCategory !== null}
onClose={() => setSupportDialogCategory(null)}
role="customer"
bookingId={bookingId}
defaultCategory={supportDialogCategory ?? 'support'}
/>
</> </>
) : ( ) : (
<> <>
<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>
@@ -209,5 +267,6 @@ export default function CancelBookingPage() {
</> </>
)} )}
</Stack> </Stack>
</FormProvider>
); );
} }
@@ -1,12 +1,14 @@
'use client'; 'use client';
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 { Divider, GlobalStyles, Paper, Skeleton, Stack, Typography } from '@mui/material'; import { Box, Divider, GlobalStyles, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, PriceBreakdown, StatusChip, type StatusKind } from '@/components'; import { AppButton, AppIcon, PriceBreakdown, StatusChip, type StatusKind } from '@/components';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors'; import { ApiError } from '@/lib/api/errors';
import { formatShamsiDate, localeTag, parseIrr } from '@/utils'; import { formatShamsiDate, localeTag, parseIrr } from '@/utils';
import { useInvoice } from '@/services/payment'; import { useInvoice } from '@/services/payment';
import { useBookingDetail } from '@/services/bookings';
import { useCustomerProfile } from '@/services/profiles';
import type { MoadianStatus } from '@/services/payment/types'; import type { MoadianStatus } from '@/services/payment/types';
/** The printable region — everything else is hidden by the print rules below. */ /** The printable region — everything else is hidden by the print rules below. */
@@ -20,13 +22,26 @@ const MOADIAN_KIND: Record<MoadianStatus, StatusKind> = {
failed: 'rejected', failed: 'rejected',
}; };
/** Best-effort read of the frozen variant display name from the booking's variant snapshot (mirrors the
* same tolerant parse `BookingDetailView`/the review page use — REQ-045 proposes a typed shape). */
function variantName(snapshotJson: string): string | null {
try {
const parsed = JSON.parse(snapshotJson) as { displayName?: string };
return parsed?.displayName ?? null;
} catch {
return null;
}
}
/** /**
* The booking's commission invoice (b11 `GET invoices/{bookingId}`): header (`invoiceNumber`, Shamsi * The booking's commission invoice (b11 `GET invoices/{bookingId}`): header (`invoiceNumber`, Shamsi
* issue date), the reconciling lines with the **VAT-on-commission** line explicitly labelled (product * issue date), a buyer/service/visit-date recap (composed client-side from the customer's own profile +
* rule: VAT is on Balinyaar's commission — the taxable supply — never the nurse's earnings), and the * the booking detail read — a UI join, not money math), the reconciling lines with the **VAT-on-commission**
* مودیان state read-only. Downloads the served `pdfUrl` when present; otherwise prints a clean receipt * line explicitly labelled (product rule: VAT is on Balinyaar's commission — the taxable supply — never the
* (`window.print()` + a print-scoped visibility rule). Every figure via the money util — no float math; * nurse's earnings), the payment method + transaction reference, a seller fiscal-identity block, and the
* the service line is the exact integer remainder of served amounts (gross commission VAT). * مودیان state read-only. Downloads the served `pdfUrl` when present; otherwise prints a clean A4 receipt
* (`window.print()` + a print-scoped visibility rule + `@page` sizing). Every figure via the money util —
* no float math; the service line is the exact integer remainder of served amounts (gross commission VAT).
*/ */
export default function BookingInvoicePage() { export default function BookingInvoicePage() {
const t = useTranslations('payment'); const t = useTranslations('payment');
@@ -38,11 +53,13 @@ export default function BookingInvoicePage() {
const validId = Number.isInteger(bookingId) && bookingId > 0; const validId = Number.isInteger(bookingId) && bookingId > 0;
const { data: invoice, isLoading, error, refetch } = useInvoice(validId ? bookingId : undefined); const { data: invoice, isLoading, error, refetch } = useInvoice(validId ? bookingId : undefined);
const { data: booking } = useBookingDetail(validId ? bookingId : undefined, 'customer');
const { data: customerProfile } = useCustomerProfile();
// 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 }}>
@@ -72,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 }}>
@@ -123,10 +140,21 @@ export default function BookingInvoicePage() {
maximumFractionDigits: 2, maximumFractionDigits: 2,
}).format(invoice.vatRate); }).format(invoice.vatRate);
const buyerName = [customerProfile?.firstName, customerProfile?.lastName].filter(Boolean).join(' ').trim();
const serviceLabel = booking ? variantName(booking.variantSnapshotJson) : null;
const visitDatesLabel = booking
? booking.sessionCount > 1
? t('invoice_visit_dates_multi', { date: formatShamsiDate(booking.scheduledDate, locale), count: booking.sessionCount })
: formatShamsiDate(booking.scheduledDate, locale)
: null;
const methodLabel =
invoice.paymentMethod === 'card' ? t('method_card') : invoice.paymentMethod === 'bnpl' ? t('invoice_method_bnpl') : null;
return ( return (
<Stack sx={{ gap: 2 }}> <Stack sx={{ gap: 2 }}>
<GlobalStyles <GlobalStyles
styles={{ styles={{
'@page': { size: 'A4', margin: '16mm' },
'@media print': { '@media print': {
'body *': { visibility: 'hidden' }, 'body *': { visibility: 'hidden' },
[`.${PRINT_AREA_CLASS}, .${PRINT_AREA_CLASS} *`]: { visibility: 'visible' }, [`.${PRINT_AREA_CLASS}, .${PRINT_AREA_CLASS} *`]: { visibility: 'visible' },
@@ -138,15 +166,13 @@ 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 }}>
<Typography variant="h6" component="h1"> <Typography variant="h6" component="h1">
{t('invoice_title')} {t('invoice_title')}
</Typography> </Typography>
{/* The issuer line uses the product spelling «بالین‌یار» — fa `common.brand` currently reads
«بلینیار» (the auth wordmark); a fiscal document must match the product/docs spelling. */}
<Typography variant="subtitle2" sx={{ color: 'var(--bal-primary)', fontWeight: 700 }}> <Typography variant="subtitle2" sx={{ color: 'var(--bal-primary)', fontWeight: 700 }}>
{t('issuer_platform')} {t('issuer_platform')}
</Typography> </Typography>
@@ -156,6 +182,10 @@ export default function BookingInvoicePage() {
<Stack sx={{ gap: 0.75 }}> <Stack sx={{ gap: 0.75 }}>
<MetaRow label={t('invoice_number_label')} value={invoice.invoiceNumber} ltr /> <MetaRow label={t('invoice_number_label')} value={invoice.invoiceNumber} ltr />
<MetaRow label={t('invoice_issued_at')} value={formatShamsiDate(invoice.issuedAt, locale)} /> <MetaRow label={t('invoice_issued_at')} value={formatShamsiDate(invoice.issuedAt, locale)} />
{buyerName ? <MetaRow label={t('invoice_buyer_label')} value={buyerName} /> : null}
{serviceLabel ? <MetaRow label={t('invoice_service_label')} value={serviceLabel} /> : null}
{visitDatesLabel ? <MetaRow label={t('invoice_visit_dates_label')} value={visitDatesLabel} /> : null}
<MetaRow label={t('receipt_booking_ref_label')} value={String(invoice.bookingId)} ltr />
</Stack> </Stack>
<PriceBreakdown <PriceBreakdown
@@ -172,6 +202,15 @@ export default function BookingInvoicePage() {
totalAmountIrr={invoice.grossIrr} totalAmountIrr={invoice.grossIrr}
/> />
{methodLabel || invoice.transactionReference ? (
<Stack sx={{ gap: 0.75 }}>
{methodLabel ? <MetaRow label={t('receipt_method_label')} value={methodLabel} /> : null}
{invoice.transactionReference ? (
<MetaRow label={t('invoice_transaction_ref_label')} value={invoice.transactionReference} ltr />
) : null}
</Stack>
) : null}
{invoice.moadianStatus ? ( {invoice.moadianStatus ? (
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}> <Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
@@ -180,6 +219,39 @@ export default function BookingInvoicePage() {
<StatusChip status={MOADIAN_KIND[invoice.moadianStatus]} label={t(`moadian_${invoice.moadianStatus}`)} /> <StatusChip status={MOADIAN_KIND[invoice.moadianStatus]} label={t(`moadian_${invoice.moadianStatus}`)} />
</Stack> </Stack>
) : null} ) : null}
{invoice.sellerFiscalIdentity ? (
<>
<Divider />
<Stack sx={{ gap: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 700 }}>
{invoice.sellerFiscalIdentity.legalName}
</Typography>
{invoice.sellerFiscalIdentity.economicCode ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
{t('invoice_seller_economic_code_label')}: {invoice.sellerFiscalIdentity.economicCode}
</Typography>
) : null}
{invoice.sellerFiscalIdentity.address ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{invoice.sellerFiscalIdentity.address}
</Typography>
) : null}
</Stack>
</>
) : null}
{/* Print-only document footer — invoice number + issue date (+ مودیان reference when present). */}
<Box sx={{ display: 'none', '@media print': { display: 'block', mt: 2, pt: 1, borderTop: '1px solid', borderColor: 'divider' } }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('invoice_footer_reference', { number: invoice.invoiceNumber, date: formatShamsiDate(invoice.issuedAt, locale) })}
</Typography>
{invoice.moadianReferenceNumber ? (
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }} dir="ltr">
{t('invoice_footer_moadian', { ref: invoice.moadianReferenceNumber })}
</Typography>
) : null}
</Box>
</Stack> </Stack>
</Paper> </Paper>
@@ -1,18 +1,45 @@
'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 { Paper, Skeleton, Stack, TextField, Typography } from '@mui/material'; import { Avatar, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, EmptyState, RatingInput, ReviewTagSelector, StatusChip } 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';
import type { BookingDetailDto } from '@/services/bookings/types';
import { useReviewEligibility, useMyReviewForBooking, useCreateReview } from '@/services/reviews'; import { useReviewEligibility, useMyReviewForBooking, useCreateReview } from '@/services/reviews';
import { REVIEW_TAG_CODES, type ModerationStatus } from '@/services/reviews/types'; import { REVIEW_TAG_CODES, type ModerationStatus } from '@/services/reviews/types';
/** Best-effort read of the frozen variant display name from the booking's variant snapshot. */
function variantName(snapshotJson: string): string | null {
try {
const parsed = JSON.parse(snapshotJson) as { displayName?: string };
return parsed?.displayName ?? null;
} catch {
return 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',
@@ -39,23 +66,26 @@ export default function LeaveReviewPage() {
const bookingId = Number.isInteger(rawId) && rawId > 0 ? rawId : -1; const bookingId = Number.isInteger(rawId) && rawId > 0 ? rawId : -1;
const { data: booking } = useBookingDetail(bookingId, 'customer'); const { data: booking } = useBookingDetail(bookingId, 'customer');
const reviewable = booking?.status === 'completed' || booking?.status === 'closed';
const eligibility = useReviewEligibility(bookingId); const eligibility = useReviewEligibility(bookingId);
const myReview = useMyReviewForBooking(bookingId); // Gated exactly like the booking-detail page's identical call — a review can only ever exist for a
// completed/closed booking, so an in-flight/active booking never fires this query.
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;
@@ -69,7 +99,8 @@ export default function LeaveReviewPage() {
return ( return (
<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} />
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}> {booking ? <ReviewContextRecap booking={booking} locale={locale} /> : null}
<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}`)} />
@@ -129,47 +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}
<Stack sx={{ gap: 1 }}> {/* Moderation expectation, up front — not only after submit. */}
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}> <Stack direction="row" sx={{ gap: 1, alignItems: 'center', p: 1.5, borderRadius: 'var(--bal-radius-md)', bgcolor: 'var(--bal-info-soft)' }}>
{t('rating_label')} <AppIcon icon="info" size={18} color="var(--bal-info)" />
<Typography variant="body2" sx={{ color: 'var(--bal-info)' }}>
{t('moderation_note')}
</Typography> </Typography>
<RatingInput value={rating} onChange={setRating} ariaLabel={t('rating_label')} />
</Stack> </Stack>
<TextField <RhfControlGroup<ReviewFormValues>
name="rating"
label={t('rating_label')}
rules={{ validate: (value) => Number(value ?? 0) >= 1 }}
>
{({ field }) => (
<RatingInput value={Number(field.value) || 0} onChange={field.onChange} ariaLabel={t('rating_label')} />
)}
</RhfControlGroup>
<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')} <ReviewTagSelector
</Typography> codes={REVIEW_TAG_CODES}
<ReviewTagSelector selected={(field.value as string[]) ?? []}
codes={REVIEW_TAG_CODES} onChange={field.onChange}
selected={tagCodes} labelFor={(code) => (t.has(`tag_${code}`) ? t(`tag_${code}`) : code)}
onChange={setTagCodes} disabled={createReview.isPending}
labelFor={(code) => (t.has(`tag_${code}`) ? t(`tag_${code}`) : code)} />
disabled={createReview.isPending} )}
/> </RhfControlGroup>
</Stack>
<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"
> >
@@ -177,6 +220,32 @@ export default function LeaveReviewPage() {
</AppButton> </AppButton>
</Stack> </Stack>
</Stack> </Stack>
</FormProvider>
);
}
/** "What you're reviewing" recap — service, Shamsi visit date, nurse — off the already-cached booking. */
function ReviewContextRecap({ booking, locale }: { booking: BookingDetailDto; locale: string }) {
const t = useTranslations('reviews');
const service = variantName(booking.variantSnapshotJson);
const name = booking.nurseName.trim();
return (
<SurfaceCard padding="sm">
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<Avatar sx={{ width: 40, height: 40, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}>
{(name || t('recap_fallback_nurse')).charAt(0)}
</Avatar>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{service ?? t('recap_fallback_service')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{name || t('recap_fallback_nurse')} · {formatShamsiDate(booking.scheduledDate, locale)}
</Typography>
</Stack>
</Stack>
</SurfaceCard>
); );
} }
@@ -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, 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,62 +118,74 @@ 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}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}> <Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 2 }}>
{t('eligibility_title')} <Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
</Typography> {t('eligibility_title')}
</Typography>
<TextField <RhfTextField<EligibilityFormValues>
label={t('national_id_label')} name="nationalId"
placeholder={t('national_id_placeholder')} label={t('national_id_label')}
value={nationalId} placeholder={t('national_id_placeholder')}
onChange={(e) => setNationalId(digitsOnly(e.target.value).slice(0, NATIONAL_ID_LENGTH))} transform={(raw) => digitsOnly(raw).slice(0, NATIONAL_ID_LENGTH)}
error={nationalIdError} rules={{ validate: (value) => NATIONAL_ID_PATTERN.test(String(value ?? '')) || t('national_id_invalid') }}
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 />
/>
<PhoneNumberField <PhoneNumberField
label={t('mobile_label')} label={t('mobile_label')}
value={sessionMobile} value={sessionMobile}
onChange={() => undefined} onChange={() => undefined}
disabled slotProps={{ input: { readOnly: true } }}
fullWidth fullWidth
/> />
<FormControlLabel <RhfControlGroup<EligibilityFormValues> name="consent">
control={<Checkbox checked={consent} onChange={(e) => setConsent(e.target.checked)} color="secondary" />} {({ field }) => (
label={ <FormControlLabel
<Typography variant="body2" sx={{ color: 'text.secondary' }}> control={
{t('consent_label', { provider: providerName })} <Checkbox
</Typography> checked={Boolean(field.value)}
} onChange={(event) => field.onChange(event.target.checked)}
sx={{ alignItems: 'flex-start', m: 0 }} color="secondary"
/> />
}
label={
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('consent_label', { provider: providerName })}
</Typography>
}
sx={{ alignItems: 'flex-start', m: 0 }}
/>
)}
</RhfControlGroup>
<Stack sx={{ gap: 1 }}> <Stack sx={{ gap: 1 }}>
<AppButton <AppButton
color="secondary" type="submit"
variant="contained" color="secondary"
size="large" variant="contained"
disabled={!consent || check.isPending} size="large"
onClick={handleSubmit} disabled={!consent || check.isPending}
> startIcon={check.isPending ? <CircularProgress size={18} color="inherit" /> : undefined}
{t('check_eligibility')} >
</AppButton> {check.isPending ? t('checking_eligibility') : t('check_eligibility')}
<AppButton variant="text" color="primary" onClick={onPayWithCard}> </AppButton>
{t('pay_with_card')} <AppButton variant="text" color="primary" onClick={onPayWithCard}>
</AppButton> {t('pay_with_card')}
</AppButton>
</Stack>
</Stack> </Stack>
</Stack> </FormProvider>
); );
}; };
@@ -189,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)" />
@@ -2,7 +2,7 @@
import { FunctionComponent } from 'react'; import { FunctionComponent } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Box, ButtonBase, Paper, Stack, Typography } from '@mui/material'; import { Box, ButtonBase, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, EmptyState, Money } from '@/components'; import { AppButton, AppIcon, BnplProviderLogo, EmptyState, Money } from '@/components';
import type { BnplOptions, BnplProvider, ProviderCode } from '@/services/bnpl/types'; import type { BnplOptions, BnplProvider, ProviderCode } from '@/services/bnpl/types';
interface MethodStepProps { interface MethodStepProps {
@@ -13,15 +13,6 @@ interface MethodStepProps {
onPayWithCard: () => void; onPayWithCard: () => void;
} }
/** Two-letter provider glyph for the logo stand-in (real logos land with the provider assets). */
const PROVIDER_GLYPH: Record<ProviderCode, string> = {
digipay: 'DG',
snapppay: 'SP',
balinyaar: 'ب',
tara: 'TA',
torobpay: 'TP',
};
/** /**
* D1 · روش پرداخت — the branch off C6. Shows the payable amount, the full-card option (returns to the f9 * D1 · روش پرداخت — the branch off C6. Shows the payable amount, the full-card option (returns to the f9
* card flow — never rebuilt here), and the installment providers loaded **from the contract/mock** (never * card flow — never rebuilt here), and the installment providers loaded **from the contract/mock** (never
@@ -45,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')}
@@ -60,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',
@@ -137,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',
@@ -146,22 +137,7 @@ function ProviderOption({
}} }}
> >
<Stack direction="row" sx={{ alignItems: 'center', gap: 1.5 }}> <Stack direction="row" sx={{ alignItems: 'center', gap: 1.5 }}>
<Box <BnplProviderLogo providerCode={provider.providerCode} size={40} />
sx={{
width: 40,
height: 28,
borderRadius: 1,
flex: 'none',
display: 'grid',
placeItems: 'center',
fontWeight: 800,
fontSize: 11,
color: 'var(--bal-secondary-dark)',
backgroundColor: 'var(--bal-secondary-soft)',
}}
>
{PROVIDER_GLYPH[provider.providerCode]}
</Box>
<Stack sx={{ flex: 1, gap: 0.25 }}> <Stack sx={{ flex: 1, gap: 0.25 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}> <Typography variant="body2" sx={{ fontWeight: 700 }}>
{t(`provider_${provider.providerCode}`)} {t(`provider_${provider.providerCode}`)}
@@ -3,10 +3,13 @@ import { FunctionComponent } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Paper, Stack, Typography } from '@mui/material'; import { Paper, Stack, Typography } from '@mui/material';
import { AppButton, BnplPlanCard, EmptyState, Money } from '@/components'; import { AppButton, BnplPlanCard, EmptyState, Money } from '@/components';
import { parseIrr } from '@/utils';
import type { BnplPlanOption, ProviderCode } from '@/services/bnpl/types'; import type { BnplPlanOption, ProviderCode } from '@/services/bnpl/types';
interface PlanStepProps { interface PlanStepProps {
providerCode: ProviderCode; providerCode: ProviderCode;
/** D1's payable gross — the interest-free baseline `BnplPlanCard`'s fee delta compares against. */
orderAmountIrr: string;
plans: BnplPlanOption[]; plans: BnplPlanOption[];
selectedPlanId: string | null; selectedPlanId: string | null;
onSelectPlan: (planId: string) => void; onSelectPlan: (planId: string) => void;
@@ -14,6 +17,13 @@ interface PlanStepProps {
onBack: () => void; onBack: () => void;
} }
/** The same term/installment-count label `BnplPlanCard` shows — reused here to name the header's total. */
function termLabelFor(plan: BnplPlanOption, t: ReturnType<typeof useTranslations>): string {
return plan.termMonths != null
? t('plan_term_months', { months: plan.termMonths })
: t('plan_installments', { count: plan.installmentCount });
}
/** /**
* D2 · انتخاب طرح اقساط — the plan selector for the chosen provider. Shows the total amount and the plan * D2 · انتخاب طرح اقساط — the plan selector for the chosen provider. Shows the total amount and the plan
* options the contract returned (monthly amount + down-payment %) as a single-select terracotta card group. * options the contract returned (monthly amount + down-payment %) as a single-select terracotta card group.
@@ -22,6 +32,7 @@ interface PlanStepProps {
*/ */
const PlanStep: FunctionComponent<PlanStepProps> = ({ const PlanStep: FunctionComponent<PlanStepProps> = ({
providerCode, providerCode,
orderAmountIrr,
plans, plans,
selectedPlanId, selectedPlanId,
onSelectPlan, onSelectPlan,
@@ -43,8 +54,11 @@ const PlanStep: FunctionComponent<PlanStepProps> = ({
} }
// The plan total is a per-plan served figure (interest-free plans = order gross; fee plans add the fee). // The plan total is a per-plan served figure (interest-free plans = order gross; fee plans add the fee).
// Use the selected plan's total, falling back to the first plan's for the header before any selection. // No default fallback to plans[0] — the header only shows a total once a plan is actually selected, so
const shownPlan = plans.find((p) => p.planId === selectedPlanId) ?? plans[0]; // it never silently morphs before the user has chosen anything.
const shownPlan = plans.find((p) => p.planId === selectedPlanId) ?? null;
const feeIrr = shownPlan ? (parseIrr(shownPlan.totalIrr) - parseIrr(orderAmountIrr)).toString() : null;
const hasFee = shownPlan != null && shownPlan.feePercent > 0 && feeIrr != null && parseIrr(feeIrr) > BigInt(0);
return ( return (
<Stack sx={{ gap: 2 }}> <Stack sx={{ gap: 2 }}>
@@ -52,23 +66,39 @@ const PlanStep: FunctionComponent<PlanStepProps> = ({
{t('plan_title', { provider: t(`provider_${providerCode}`) })} {t('plan_title', { provider: t(`provider_${providerCode}`) })}
</Typography> </Typography>
<Paper {shownPlan ? (
elevation={0} <Paper
sx={{ p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }} elevation={0}
> sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}> >
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Stack sx={{ gap: 0.5 }}>
{t('total_amount')} <Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
</Typography> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
<Money amountIrr={shownPlan.totalIrr} tone="emphasis" size="sm" /> {t('total_amount_named', { plan: termLabelFor(shownPlan, t) })}
</Stack> </Typography>
</Paper> <Money amountIrr={shownPlan.totalIrr} tone="emphasis" size="sm" />
</Stack>
{hasFee && feeIrr != null ? (
<Stack direction="row" sx={{ gap: 0.25, alignItems: 'baseline', justifyContent: 'flex-end' }}>
<Typography variant="caption" sx={{ color: 'var(--bal-money-emphasis)' }}>
+
</Typography>
<Money amountIrr={feeIrr} size="sm" sx={{ color: 'var(--bal-money-emphasis)' }} />
<Typography variant="caption" sx={{ color: 'var(--bal-money-emphasis)' }}>
{t('plan_fee_amount_suffix')}
</Typography>
</Stack>
) : null}
</Stack>
</Paper>
) : null}
<Stack sx={{ gap: 1 }}> <Stack sx={{ gap: 1 }}>
{plans.map((plan) => ( {plans.map((plan) => (
<BnplPlanCard <BnplPlanCard
key={plan.planId} key={plan.planId}
plan={plan} plan={plan}
orderAmountIrr={orderAmountIrr}
selected={selectedPlanId === plan.planId} selected={selectedPlanId === plan.planId}
onSelect={onSelectPlan} onSelect={onSelectPlan}
/> />
@@ -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)',
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { Suspense } from 'react'; import { Suspense } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation'; import { notFound, useRouter, useSearchParams } from 'next/navigation';
import { Stack } from '@mui/material'; import { Stack } from '@mui/material';
import { AppButton, AppLoading, EmptyState } from '@/components'; import { AppButton, AppLoading, EmptyState } from '@/components';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
@@ -22,6 +22,12 @@ import type { BnplHandoffOutcome, ProviderCode } from '@/services/bnpl/types';
* real path the `redirectUrl` is the provider's absolute URL and this page is never reached. * real path the `redirectUrl` is the provider's absolute URL and this page is never reached.
*/ */
export default function BnplGatewayPage() { export default function BnplGatewayPage() {
// A test harness must never be reachable in a production build — mirrors how the card-gateway harness
// was retired (refinement-phase-4). Unlike the card path, BNPL stays mock-primary, so this one is
// env-gated rather than deleted: still reachable in `next dev`, a clean 404 everywhere else.
if (process.env.NODE_ENV !== 'development') {
notFound();
}
return ( return (
<Suspense fallback={<AppLoading />}> <Suspense fallback={<AppLoading />}>
<BnplGatewayScreen /> <BnplGatewayScreen />
@@ -2,8 +2,8 @@
import { Suspense, useState } from 'react'; import { Suspense, 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 { Paper, Skeleton, Stack, Typography } from '@mui/material'; import { Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, StepperHeader } from '@/components'; import { AppButton, AppLoading, PaymentStateCard, StepperHeader } from '@/components';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { useAuth } from '@/context/auth'; import { useAuth } from '@/context/auth';
import { useBnplOptions } from '@/services/bnpl'; import { useBnplOptions } from '@/services/bnpl';
@@ -57,38 +57,47 @@ function BnplCheckoutScreen() {
if (!validId) { if (!validId) {
return ( return (
<MessageCard icon="error" tone="var(--bal-error)" title={t('error_title')} ctaLabel={tb('bd_my_bookings')} onCta={toBookings} /> <PaymentStateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
<AppButton variant="contained" color="primary" onClick={toBookings}>
{tb('bd_my_bookings')}
</AppButton>
</PaymentStateCard>
); );
} }
if (isError) { if (isError) {
return <MessageCard icon="error" tone="var(--bal-error)" title={t('error_title')} body={t('error_body')} ctaLabel={tc('retry')} onCta={() => refetch()} />; return (
<PaymentStateCard icon="error" tone="var(--bal-error)" title={t('error_title')} body={t('error_body')}>
<AppButton variant="contained" color="primary" onClick={() => refetch()}>
{tc('retry')}
</AppButton>
</PaymentStateCard>
);
} }
if (isLoading || !options) return <WizardSkeleton />; if (isLoading || !options) return <WizardSkeleton />;
// Only an accepted, awaiting-payment request is payable — converge/explain otherwise (mirrors C6). // Only an accepted, awaiting-payment request is payable — converge/explain otherwise (mirrors C6).
if (options.requestStatus === 'converted') { if (options.requestStatus === 'converted') {
return ( return (
<MessageCard <PaymentStateCard icon="verified" tone="var(--bal-success)" title={tp('already_paid_title')} body={tp('already_paid_body')}>
icon="verified" <AppButton variant="contained" color="primary" onClick={toRequest}>
tone="var(--bal-success)" {tb('converted_cta')}
title={tp('already_paid_title')} </AppButton>
body={tp('already_paid_body')} </PaymentStateCard>
ctaLabel={tb('converted_cta')}
onCta={toRequest}
/>
); );
} }
if (options.requestStatus !== 'accepted_awaiting_payment') { if (options.requestStatus !== 'accepted_awaiting_payment') {
const expired = options.requestStatus === 'payment_deadline_expired'; const expired = options.requestStatus === 'payment_deadline_expired';
return ( return (
<MessageCard <PaymentStateCard
icon="pending" icon="pending"
tone="var(--bal-warning)" tone="var(--bal-warning)"
title={expired ? tp('window_expired_title') : tp('not_payable_title')} title={expired ? tp('window_expired_title') : tp('not_payable_title')}
body={expired ? tp('window_expired_body') : undefined} body={expired ? tp('window_expired_body') : undefined}
ctaLabel={t('pay_with_card')} >
onCta={toCard} <AppButton variant="contained" color="primary" onClick={toCard}>
/> {t('pay_with_card')}
</AppButton>
</PaymentStateCard>
); );
} }
@@ -139,6 +148,7 @@ function BnplCheckoutScreen() {
{step === 'plan' && providerCode && activeProvider ? ( {step === 'plan' && providerCode && activeProvider ? (
<PlanStep <PlanStep
providerCode={providerCode} providerCode={providerCode}
orderAmountIrr={options.orderAmountIrr}
plans={activeProvider.plans} plans={activeProvider.plans}
selectedPlanId={planId} selectedPlanId={planId}
onSelectPlan={setPlanId} onSelectPlan={setPlanId}
@@ -175,39 +185,6 @@ function BnplCheckoutScreen() {
); );
} }
function MessageCard({
icon,
tone,
title,
body,
ctaLabel,
onCta,
}: {
icon: string;
tone: string;
title: string;
body?: string;
ctaLabel: string;
onCta: () => void;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: body ? 0.5 : 2 }}>
{title}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{body}
</Typography>
) : null}
<AppButton variant="contained" color="primary" onClick={onCta}>
{ctaLabel}
</AppButton>
</Paper>
);
}
function WizardSkeleton() { function WizardSkeleton() {
return ( return (
<Stack sx={{ gap: 2 }}> <Stack sx={{ gap: 2 }}>
@@ -1,10 +1,10 @@
'use client'; 'use client';
import { Suspense, useEffect, useRef, type ReactNode } from 'react'; import { Suspense, useEffect, useRef } 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 { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { CircularProgress, Paper, Stack, Typography } from '@mui/material'; import { CircularProgress } from '@mui/material';
import { AppButton, AppIcon, AppLoading } from '@/components'; import { AppButton, AppLoading, PaymentStateCard } from '@/components';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { useAcceptBnplSchedule, useBnplOrder } from '@/services/bnpl'; import { useAcceptBnplSchedule, useBnplOrder } from '@/services/bnpl';
import { invalidateAfterBnplSettlement } from '@/services/bnpl/invalidations'; import { invalidateAfterBnplSettlement } from '@/services/bnpl/invalidations';
@@ -41,6 +41,7 @@ export default function BnplReturnPage() {
function BnplReturnScreen() { function BnplReturnScreen() {
const t = useTranslations('bnpl'); const t = useTranslations('bnpl');
const tp = useTranslations('payment'); const tp = useTranslations('payment');
const tb = useTranslations('booking');
const locale = useLocale(); const locale = useLocale();
const router = useRouter(); const router = useRouter();
const params = useSearchParams(); const params = useSearchParams();
@@ -97,11 +98,13 @@ function BnplReturnScreen() {
if (!validId) { if (!validId) {
return ( return (
<StateCard icon="error" tone="var(--bal-error)" title={t('error_title')}> <PaymentStateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
{/* No recoverable request id — the label must match the destination (the bookings list), never
promise a card-payment action the click can't perform. */}
<AppButton variant="contained" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}> <AppButton variant="contained" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}>
{t('pay_with_card')} {tb('bd_my_bookings')}
</AppButton> </AppButton>
</StateCard> </PaymentStateCard>
); );
} }
@@ -109,20 +112,20 @@ function BnplReturnScreen() {
// The payment window lapsed during the handoff — card payment is impossible now, so route to the // The payment window lapsed during the handoff — card payment is impossible now, so route to the
// request (not the card checkout). Reuse the f9 window-expired copy + the matching back-to-request CTA. // request (not the card checkout). Reuse the f9 window-expired copy + the matching back-to-request CTA.
return ( return (
<StateCard icon="pending" tone="var(--bal-warning)" title={tp('window_expired_title')} body={tp('window_expired_body')}> <PaymentStateCard icon="pending" tone="var(--bal-warning)" title={tp('window_expired_title')} body={tp('window_expired_body')}>
<AppButton <AppButton
variant="contained" variant="contained"
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)} onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
> >
{tp('back_to_request')} {tp('back_to_request')}
</AppButton> </AppButton>
</StateCard> </PaymentStateCard>
); );
} }
if (failed) { if (failed) {
return ( return (
<StateCard icon="error" tone="var(--bal-error)" title={t('settle_failed_title')} body={t('settle_failed_body')}> <PaymentStateCard icon="error" tone="var(--bal-error)" title={t('settle_failed_title')} body={t('settle_failed_body')}>
<AppButton <AppButton
color="secondary" color="secondary"
variant="contained" variant="contained"
@@ -137,48 +140,17 @@ function BnplReturnScreen() {
> >
{t('pay_with_card')} {t('pay_with_card')}
</AppButton> </AppButton>
</StateCard> </PaymentStateCard>
); );
} }
// Settle-pending (and the brief succeeded → confirmation hand-off): a calm waiting state. // Settle-pending (and the brief succeeded → confirmation hand-off): a calm waiting state.
return ( return (
<StateCard icon="installments" tone="var(--bal-secondary)" title={t('settling_title')} body={t('settling_body')}> <PaymentStateCard icon="installments" tone="var(--bal-secondary)" title={t('settling_title')} body={t('settling_body')}>
<CircularProgress color="secondary" size="2.5rem" /> <CircularProgress color="secondary" size="2.5rem" />
<AppButton variant="text" disabled={orderQuery.isFetching} onClick={() => orderQuery.refetch()}> <AppButton variant="text" disabled={orderQuery.isFetching} onClick={() => orderQuery.refetch()}>
{t('check_again')} {t('check_again')}
</AppButton> </AppButton>
</StateCard> </PaymentStateCard>
);
}
function StateCard({
icon,
tone,
title,
body,
children,
}: {
icon: string;
tone: string;
title: string;
body?: string;
children?: ReactNode;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{body}
</Typography>
) : null}
{children}
</Stack>
</Paper>
); );
} }
@@ -1,12 +1,26 @@
'use client'; 'use client';
import { Suspense } from 'react'; import { Suspense, type ReactNode } 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 { Paper, Stack, Typography } from '@mui/material'; import { useSnackbar } from 'notistack';
import { AppButton, AppIcon, AppLoading, Money } from '@/components'; import { Box, Divider, Skeleton, Stack, Typography } from '@mui/material';
import {
AppButton,
AppIcon,
AppIconButton,
AppLoading,
ErrorState,
EscrowExplainer,
Money,
StatusTimeline,
SurfaceCard,
type TimelineNode,
} from '@/components';
import { bookingInvoicePath, ROUTES } from '@/constants'; import { bookingInvoicePath, ROUTES } from '@/constants';
import { useCheckoutSummary } from '@/services/payment'; import { formatShamsiDateTime } from '@/utils';
import { useCheckoutSummary, usePaymentOutcome } from '@/services/payment';
import { CHECKOUT_QUERY_BOOKING_ID, CHECKOUT_QUERY_REQUEST_ID } from '@/services/payment/constants'; import { CHECKOUT_QUERY_BOOKING_ID, CHECKOUT_QUERY_REQUEST_ID } from '@/services/payment/constants';
import { useBnplOrder } from '@/services/bnpl';
import { import {
BNPL_QUERY_PROVIDER, BNPL_QUERY_PROVIDER,
CHECKOUT_METHOD_BNPL, CHECKOUT_METHOD_BNPL,
@@ -14,12 +28,13 @@ import {
} from '@/services/bnpl/constants'; } from '@/services/bnpl/constants';
/** /**
* Post-payment confirmation — the booking is now **confirmed** (flipped by cache invalidation on the * Post-payment confirmation — a screenshot-worthy receipt (Iranian users screenshot payment receipts): the
* return surface, never a blanket refetch). Links back to the f8 booking detail («مشاهده رزرو») and to * paid total, a copyable LTR کد پیگیری, the Shamsi payment date-time, the payment method, the booking
* the invoice («دانلود فاکتور»). Reused by both the f9 card flow and the f11 BNPL branch: when reached * reference, the escrow reassurance, and a "what happens next" 2-step strip. The booking is now
* with `?method=bnpl` it also renders a «پرداخت‌شده با اقساط» line (a settled BNPL order is, to * **confirmed** (flipped by cache invalidation on the return surface, never a blanket refetch). Reused by
* Balinyaar, a card payment net-of-fee — there is no separate BNPL confirmation). Without a `booking_id` * both the f9 card flow and the f11 BNPL branch: reached with `?method=bnpl` it reads the settled BNPL
* (REQ-017/024 unmet on the real path) the deep-links fall back to the bookings list. * order instead of the payment outcome for the tracking reference + paid-at timestamp. Real loading/error
* states — a failed fetch must never silently erase the paid amount.
*/ */
export default function CheckoutConfirmationPage() { export default function CheckoutConfirmationPage() {
return ( return (
@@ -31,20 +46,51 @@ export default function CheckoutConfirmationPage() {
function ConfirmationScreen() { function ConfirmationScreen() {
const t = useTranslations('payment'); const t = useTranslations('payment');
const tc = useTranslations('common');
const tBnpl = useTranslations('bnpl'); const tBnpl = useTranslations('bnpl');
const locale = useLocale(); const locale = useLocale();
const router = useRouter(); const router = useRouter();
const params = useSearchParams(); const params = useSearchParams();
const { enqueueSnackbar } = useSnackbar();
const requestId = Number(params.get(CHECKOUT_QUERY_REQUEST_ID)); const requestId = Number(params.get(CHECKOUT_QUERY_REQUEST_ID));
const validRequestId = Number.isInteger(requestId) && requestId > 0;
const bookingIdParam = params.get(CHECKOUT_QUERY_BOOKING_ID); const bookingIdParam = params.get(CHECKOUT_QUERY_BOOKING_ID);
const bookingId = bookingIdParam ? Number(bookingIdParam) : null; const bookingId = bookingIdParam ? Number(bookingIdParam) : null;
const isBnpl = params.get(CHECKOUT_QUERY_METHOD) === CHECKOUT_METHOD_BNPL; const isBnpl = params.get(CHECKOUT_QUERY_METHOD) === CHECKOUT_METHOD_BNPL;
const bnplProvider = params.get(BNPL_QUERY_PROVIDER) ?? ''; const bnplProvider = params.get(BNPL_QUERY_PROVIDER) ?? '';
const { data: summary } = useCheckoutSummary( const {
Number.isInteger(requestId) && requestId > 0 ? requestId : undefined, data: summary,
); isLoading,
isError,
refetch,
} = useCheckoutSummary(validRequestId ? requestId : undefined);
// The receipt reference/timestamp come from whichever leg actually settled this request — the card
// outcome or the BNPL order — never fabricated when the real path hasn't served them yet (REQ-046).
const outcomeQuery = usePaymentOutcome(validRequestId && !isBnpl ? requestId : undefined);
const orderQuery = useBnplOrder(validRequestId && isBnpl ? requestId : undefined);
const trackingCode = isBnpl
? (orderQuery.data?.id != null ? String(orderQuery.data.id) : null)
: (outcomeQuery.data?.trackingCode ?? null);
const paidAt = isBnpl ? (orderQuery.data?.settledAt ?? null) : (outcomeQuery.data?.paidAt ?? null);
const methodLabel = isBnpl
? t('method_bnpl_provider', { provider: bnplProvider ? tBnpl(`provider_${bnplProvider}`) : tBnpl('installments_heading') })
: t('method_card');
const nextStepsNodes: TimelineNode[] = [
{ key: 'nurse_notified', label: t('next_step_nurse_notified'), state: 'completed' },
{ key: 'visit_checkin', label: t('next_step_visit_checkin'), state: 'pending' },
];
const handleCopy = () => {
if (!trackingCode) return;
navigator.clipboard.writeText(trackingCode).then(() => {
enqueueSnackbar(t('tracking_code_copied'), { variant: 'success' });
});
};
return ( return (
<Stack sx={{ gap: 3, alignItems: 'center', textAlign: 'center' }}> <Stack sx={{ gap: 3, alignItems: 'center', textAlign: 'center' }}>
@@ -58,29 +104,62 @@ function ConfirmationScreen() {
</Typography> </Typography>
</Stack> </Stack>
{summary ? ( {isLoading ? (
<Paper <ReceiptSkeleton />
elevation={0} ) : isError || !summary ? (
sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider', width: '100%' }} <ErrorState message={t('error_body')} retryLabel={tc('retry')} onRetry={() => refetch()} />
> ) : (
<Stack sx={{ gap: 0.5, alignItems: 'center' }}> <SurfaceCard sx={{ width: '100%' }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('total_paid_label')} {t('total_paid_label')}
</Typography> </Typography>
<Money amountIrr={summary.totalIrr} tone="emphasis" size="lg" /> <Money amountIrr={summary.totalIrr} tone="emphasis" size="xl" sx={{ fontWeight: 800 }} />
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
{summary.variantLabel} · {summary.nurseName} {summary.variantLabel} · {summary.nurseName}
</Typography> </Typography>
{isBnpl ? (
<Typography variant="caption" sx={{ color: 'var(--bal-secondary)', fontWeight: 500, mt: 0.5 }}> <Divider sx={{ width: '100%', my: 0.5 }} />
{tBnpl('paid_via_installments', {
provider: bnplProvider ? tBnpl(`provider_${bnplProvider}`) : tBnpl('installments_heading'), <Stack sx={{ width: '100%', gap: 1 }}>
})} {trackingCode ? (
</Typography> <ReceiptRow label={t('receipt_tracking_code_label')}>
) : null} <Stack direction="row" sx={{ alignItems: 'center', gap: 0.5 }}>
<Box component="span" dir="ltr" sx={{ fontWeight: 700 }}>
{trackingCode}
</Box>
<AppIconButton icon="copy" size="small" title={t('copy_tracking_code')} onClick={handleCopy} />
</Stack>
</ReceiptRow>
) : null}
{paidAt ? (
<ReceiptRow label={t('receipt_paid_at_label')} value={formatShamsiDateTime(paidAt, locale)} />
) : null}
<ReceiptRow label={t('receipt_method_label')} value={methodLabel} />
{bookingId != null ? (
<ReceiptRow label={t('receipt_booking_ref_label')}>
<Box component="span" dir="ltr" sx={{ fontWeight: 700 }}>
{bookingId}
</Box>
</ReceiptRow>
) : null}
</Stack>
</Stack> </Stack>
</Paper> </SurfaceCard>
) : null} )}
<Stack sx={{ width: '100%' }}>
<EscrowExplainer />
</Stack>
<SurfaceCard sx={{ width: '100%' }}>
<Stack sx={{ gap: 1.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('next_steps_title')}
</Typography>
<StatusTimeline nodes={nextStepsNodes} />
</Stack>
</SurfaceCard>
<Stack sx={{ gap: 1, width: '100%' }}> <Stack sx={{ gap: 1, width: '100%' }}>
<AppButton <AppButton
@@ -107,3 +186,28 @@ function ConfirmationScreen() {
</Stack> </Stack>
); );
} }
function ReceiptRow({ label, value, children }: { label: string; value?: string; children?: ReactNode }) {
return (
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{label}
</Typography>
{children ?? (
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{value}
</Typography>
)}
</Stack>
);
}
function ReceiptSkeleton() {
return (
<Stack sx={{ gap: 1.5, width: '100%' }}>
<Skeleton variant="text" width="40%" height={24} sx={{ mx: 'auto' }} />
<Skeleton variant="text" width="60%" height={48} sx={{ mx: 'auto' }} />
<Skeleton variant="rounded" height={140} />
</Stack>
);
}
@@ -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>
);
}
@@ -2,17 +2,21 @@
import { Suspense, useRef } from 'react'; import { Suspense, useRef } 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 { Paper, Skeleton, Stack, Typography } from '@mui/material'; import { Avatar, Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { import {
AppButton, AppButton,
AppIcon, AppIcon,
AppLoading, AppLoading,
CountdownTimer, CountdownTimer,
EscrowNotice, EscrowExplainer,
Money,
PaymentStateCard,
PriceBreakdown, PriceBreakdown,
StatusChip, StatusChip,
TrustBadge,
} from '@/components'; } from '@/components';
import AppAlert from '@/components/common/AppAlert'; import AppAlert from '@/components/common/AppAlert';
import StickyActionBar from '@/components/common/StickyActionBar';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors'; import { ApiError } from '@/lib/api/errors';
import { formatShamsiDate, localeTag } from '@/utils'; import { formatShamsiDate, localeTag } from '@/utils';
@@ -25,10 +29,11 @@ import {
import type { CheckoutSummaryDto } from '@/services/payment/types'; import type { CheckoutSummaryDto } from '@/services/payment/types';
/** /**
* C6 — خلاصه و پرداخت (summary & pay). The acceptance badge, the served & reconciling * C6 — خلاصه و پرداخت (summary & pay). The acceptance badge, the identity moment (nurse avatar + verified
* service-cost / commission / VAT / total breakdown, the load-bearing escrow trust notice, and the * badge), a prominent total, the served & reconciling service-cost / commission / VAT / total breakdown,
* «ادامه پرداخت ←» CTA that initiates the card payment and follows the gateway redirect. Reached from * the load-bearing escrow trust notice, and a safe-area-aware sticky pay bar that initiates the card
* C5's accept CTA with `?request_id=`. `useSearchParams` needs a Suspense boundary. * payment and follows the gateway redirect. Reached from C5's accept CTA with `?request_id=`.
* `useSearchParams` needs a Suspense boundary.
*/ */
export default function CheckoutPage() { export default function CheckoutPage() {
return ( return (
@@ -60,26 +65,20 @@ function CheckoutScreen() {
// `checkout_summary/undefined` (a manual refetch() bypasses the query's `enabled` gate). // `checkout_summary/undefined` (a manual refetch() bypasses the query's `enabled` gate).
if (!validId) { if (!validId) {
return ( return (
<MessageCard <PaymentStateCard icon="error" tone="var(--bal-error)" title={t('error_title')} body={t('invalid_link_body')}>
icon="error" <AppButton variant="contained" color="primary" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}>
tone="var(--bal-error)" {tb('bd_my_bookings')}
title={t('error_title')} </AppButton>
body={t('invalid_link_body')} </PaymentStateCard>
ctaLabel={tb('bd_my_bookings')}
onCta={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}
/>
); );
} }
if (isError) { if (isError) {
return ( return (
<MessageCard <PaymentStateCard icon="error" tone="var(--bal-error)" title={t('error_title')} body={t('error_body')}>
icon="error" <AppButton variant="contained" color="primary" onClick={() => refetch()}>
tone="var(--bal-error)" {tc('retry')}
title={t('error_title')} </AppButton>
body={t('error_body')} </PaymentStateCard>
ctaLabel={tc('retry')}
onCta={() => refetch()}
/>
); );
} }
if (isLoading || !summary) return <CheckoutSkeleton />; if (isLoading || !summary) return <CheckoutSkeleton />;
@@ -92,27 +91,35 @@ function CheckoutScreen() {
// Anything other than "awaiting payment" cannot show a pay CTA — converge or explain instead. // Anything other than "awaiting payment" cannot show a pay CTA — converge or explain instead.
if (summary.requestStatus === 'converted') { if (summary.requestStatus === 'converted') {
return ( return (
<MessageCard <PaymentStateCard
icon="verified" icon="verified"
tone="var(--bal-success)" tone="var(--bal-success)"
title={t('already_paid_title')} title={t('already_paid_title')}
body={t('already_paid_body')} body={t('already_paid_body')}
ctaLabel={tb('converted_cta')} >
onCta={() => router.replace(returnUrl())} <AppButton variant="contained" color="primary" onClick={() => router.replace(returnUrl())}>
/> {tb('converted_cta')}
</AppButton>
</PaymentStateCard>
); );
} }
if (summary.requestStatus !== 'accepted_awaiting_payment') { if (summary.requestStatus !== 'accepted_awaiting_payment') {
const expired = summary.requestStatus === 'payment_deadline_expired'; const expired = summary.requestStatus === 'payment_deadline_expired';
return ( return (
<MessageCard <PaymentStateCard
icon="pending" icon="pending"
tone="var(--bal-warning)" tone="var(--bal-warning)"
title={expired ? t('window_expired_title') : t('not_payable_title')} title={expired ? t('window_expired_title') : t('not_payable_title')}
body={expired ? t('window_expired_body') : undefined} body={expired ? t('window_expired_body') : undefined}
ctaLabel={t('back_to_request')} >
onCta={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)} <AppButton
/> variant="contained"
color="primary"
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
>
{t('back_to_request')}
</AppButton>
</PaymentStateCard>
); );
} }
@@ -152,6 +159,56 @@ function CheckoutScreen() {
? t('initiate_failed') ? t('initiate_failed')
: null; : null;
const payActions = (
<Stack sx={{ gap: 1 }}>
{inlineError ? (
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
{inlineError}
</AppAlert>
) : null}
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between', gap: 2 }}>
<Stack sx={{ gap: 0 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('row_total')}
</Typography>
<Money amountIrr={summary.totalIrr} tone="emphasis" size="md" sx={{ fontWeight: 800 }} />
</Stack>
<AppButton
color="secondary"
variant="contained"
size="large"
disabled={busy}
onClick={handlePay}
endIcon="forward"
sx={{ py: 1.25, flex: 'none', minWidth: 168 }}
>
{initiate.isPending ? t('state_initiating') : initiate.isSuccess ? t('state_redirecting') : t('cta_pay')}
</AppButton>
</Stack>
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center', justifyContent: 'center' }}>
<AppIcon icon="lock" size={14} color="var(--bal-text-secondary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('secure_gateway_notice')}
</Typography>
</Stack>
{/* The f11 BNPL branch (D1): «پرداخت اقساطی» → the installment wizard, reached with `?request_id=`. */}
{BNPL_ENABLED ? (
<AppButton
variant="outlined"
color="secondary"
startIcon="installments"
disabled={busy}
onClick={() => router.push(`/${locale}${ROUTES.CHECKOUT_BNPL}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)}
>
{t('bnpl_option')}
</AppButton>
) : null}
</Stack>
);
return ( return (
<Stack sx={{ gap: 3 }}> <Stack sx={{ gap: 3 }}>
<Stack sx={{ gap: 1, alignItems: 'center', textAlign: 'center' }}> <Stack sx={{ gap: 1, alignItems: 'center', textAlign: 'center' }}>
@@ -159,74 +216,92 @@ function CheckoutScreen() {
<Typography variant="h6" component="h1"> <Typography variant="h6" component="h1">
{t('title_checkout')} {t('title_checkout')}
</Typography> </Typography>
</Stack>
<EngagementSummary summary={summary} locale={locale} />
{summary.paymentDeadlineAt ? (
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<CountdownTimer
deadlineIso={summary.paymentDeadlineAt}
label={tb('payment_countdown_label')}
elapsedText={tb('payment_elapsed')}
urgent
onElapsed={() => refetch()}
/>
</Paper>
) : null}
<PriceBreakdown
rows={[
{
key: 'service_cost',
// Quantity context per the wireframe («هزینه خدمت (۸ ساعت)») — the visit count is the only
// quantity that always matches the charged gross (variant price × session count).
label: t('row_service_cost_with_count', { count: summary.sessionCount }),
amountIrr: summary.serviceCostIrr,
},
{ key: 'commission', label: t('row_commission'), amountIrr: summary.commissionIrr },
{ key: 'vat', label: t('row_vat'), amountIrr: summary.vatIrr },
]}
totalLabel={t('row_total')}
totalAmountIrr={summary.totalIrr}
/>
<EscrowNotice />
{inlineError ? (
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
{inlineError}
</AppAlert>
) : null}
<Stack sx={{ gap: 1 }}>
<AppButton <AppButton
color="secondary" variant="text"
variant="contained" color="primary"
size="large" size="small"
disabled={busy} startIcon="chevron_start"
onClick={handlePay} onClick={() => router.push(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
sx={{ py: 1.25 }} sx={{ px: 0.5 }}
> >
{initiate.isPending ? t('state_initiating') : initiate.isSuccess ? t('state_redirecting') : t('cta_pay')} {t('back_to_request')}
</AppButton> </AppButton>
{/* The f11 BNPL branch (D1): «پرداخت اقساطی» → the installment wizard, reached with `?request_id=`. */}
{BNPL_ENABLED ? (
<AppButton
variant="outlined"
color="secondary"
startIcon="installments"
onClick={() => router.push(`/${locale}${ROUTES.CHECKOUT_BNPL}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)}
>
{t('bnpl_option')}
</AppButton>
) : null}
</Stack> </Stack>
{/* Above ~900px (`md`), a two-column layout: the summary/breakdown/escrow content on the reading
side, a sticky order-summary card (the desktop analogue of the mobile sticky pay bar) on the
other — the same `payActions` content either way, never duplicated logic. */}
<Stack direction={{ xs: 'column', md: 'row' }} sx={{ gap: 3, alignItems: 'flex-start' }}>
<Stack sx={{ gap: 3, width: '100%', minWidth: 0, flex: { md: '1 1 62%' } }}>
<EngagementSummary summary={summary} locale={locale} />
{/* The prominent total — the single most important figure on a payment screen, never buried in
the breakdown. Same served `totalIrr` PriceBreakdown reconciles below; never recomputed. */}
<Stack sx={{ alignItems: 'center', gap: 0.25 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('total_payable_label')}
</Typography>
<Money amountIrr={summary.totalIrr} tone="emphasis" size="xl" sx={{ fontWeight: 800 }} />
</Stack>
{summary.paymentDeadlineAt ? (
<Paper elevation={0} sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
<CountdownTimer
deadlineIso={summary.paymentDeadlineAt}
label={tb('payment_countdown_label')}
elapsedText={tb('payment_elapsed')}
urgent
onElapsed={() => refetch()}
/>
</Paper>
) : null}
<PriceBreakdown
rows={[
{
key: 'service_cost',
// Quantity context per the wireframe («هزینه خدمت (۸ ساعت)») — the visit count is the only
// quantity that always matches the charged gross (variant price × session count).
label: t('row_service_cost_with_count', { count: summary.sessionCount }),
amountIrr: summary.serviceCostIrr,
},
{ key: 'commission', label: t('row_commission'), amountIrr: summary.commissionIrr },
{ key: 'vat', label: t('row_vat'), amountIrr: summary.vatIrr },
]}
totalLabel={t('row_total')}
totalAmountIrr={summary.totalIrr}
/>
<EscrowExplainer />
</Stack>
<Box
sx={{
display: { xs: 'none', md: 'block' },
position: 'sticky',
top: 96,
flex: '0 0 300px',
width: 300,
}}
>
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
{payActions}
</Paper>
</Box>
</Stack>
{/* Mobile-only: the same actions in the bottom sticky bar; desktop already shows them in the side
panel above. Spacer keeps the sticky bar from overlapping the last content on a short viewport. */}
<Box sx={{ display: { xs: 'block', md: 'none' } }}>
<Stack sx={{ pb: 1 }} />
<StickyActionBar>{payActions}</StickyActionBar>
</Box>
</Stack> </Stack>
); );
} }
/** Nurse/service/schedule mini-summary — page-only composition (C6 needs no address or price-per-unit). */ /** Nurse/service/schedule mini-summary — the C6 identity moment: avatar + verified badge answer "who am
* I paying for" at the moment of payment. */
function EngagementSummary({ summary, locale }: { summary: CheckoutSummaryDto; locale: string }) { function EngagementSummary({ summary, locale }: { summary: CheckoutSummaryDto; locale: string }) {
const start = new Date(`${summary.requestedDate}T${summary.requestedTimeStart}`); const start = new Date(`${summary.requestedDate}T${summary.requestedTimeStart}`);
const end = new Date(`${summary.requestedDate}T${summary.requestedTimeEnd}`); const end = new Date(`${summary.requestedDate}T${summary.requestedTimeEnd}`);
@@ -235,55 +310,33 @@ 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 sx={{ gap: 0.5 }}> <Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}> <Avatar
{summary.variantLabel} src={summary.nurseAvatarUrl ?? undefined}
</Typography> sx={{ width: 48, height: 48, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}
<Typography variant="body2" sx={{ color: 'text.secondary' }}> >
{summary.nurseName} · {summary.patientName} {summary.nurseName.trim().charAt(0)}
</Typography> </Avatar>
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Stack sx={{ gap: 0.5, flex: 1, minWidth: 0 }}>
{formatShamsiDate(start, locale)} · {timeFmt.format(start)} {timeFmt.format(end)} <Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
</Typography> <Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{summary.nurseName}
</Typography>
<TrustBadge state={summary.nurseVerified ? 'verified' : 'unverified'} />
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{summary.variantLabel} · {summary.patientName}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{formatShamsiDate(start, locale)} · {timeFmt.format(start)} {timeFmt.format(end)}
</Typography>
</Stack>
</Stack> </Stack>
</Paper> </Paper>
); );
} }
function MessageCard({
icon,
tone,
title,
body,
ctaLabel,
onCta,
}: {
icon: string;
tone: string;
title: string;
body?: string;
ctaLabel: string;
onCta: () => void;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: body ? 0.5 : 2 }}>
{title}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{body}
</Typography>
) : null}
<AppButton variant="contained" color="primary" onClick={onCta}>
{ctaLabel}
</AppButton>
</Paper>
);
}
function CheckoutSkeleton() { function CheckoutSkeleton() {
return ( return (
<Stack sx={{ gap: 3 }}> <Stack sx={{ gap: 3 }}>
@@ -292,6 +345,7 @@ function CheckoutSkeleton() {
<Skeleton variant="text" width="50%" height={32} /> <Skeleton variant="text" width="50%" height={32} />
</Stack> </Stack>
<Skeleton variant="rounded" height={96} /> <Skeleton variant="rounded" height={96} />
<Skeleton variant="text" width="40%" height={48} sx={{ mx: 'auto' }} />
<Skeleton variant="rounded" height={64} /> <Skeleton variant="rounded" height={64} />
<Skeleton variant="rounded" height={160} /> <Skeleton variant="rounded" height={160} />
<Skeleton variant="rounded" height={56} /> <Skeleton variant="rounded" height={56} />
@@ -1,10 +1,10 @@
'use client'; 'use client';
import { Suspense, useEffect, useRef, type ReactNode } from 'react'; import { Suspense, useEffect, useRef } 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 { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { CircularProgress, Paper, Stack, Typography } from '@mui/material'; import { Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, PaymentStatusBadge } from '@/components'; import { AppButton, AppLoading, PaymentStateCard, StatusTimeline, type TimelineNode } from '@/components';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { useConfirmGatewayReturn, usePaymentOutcome } from '@/services/payment'; import { useConfirmGatewayReturn, usePaymentOutcome } from '@/services/payment';
import { invalidateAfterPaymentSuccess } from '@/services/payment/invalidations'; import { invalidateAfterPaymentSuccess } from '@/services/payment/invalidations';
@@ -18,10 +18,12 @@ import {
/** /**
* Return-from-gateway surface — drives the tail of the checkout state machine: report the return * Return-from-gateway surface — drives the tail of the checkout state machine: report the return
* (`useConfirmGatewayReturn`; the capture trigger in the mock, an outcome read on the real path), then a * (`useConfirmGatewayReturn`; the capture trigger in the mock, an outcome read on the real path), then a
* **pending-callback** state backed by the backoff poll ("PSP received ≠ cash in bank" — pending is * **pending-callback** state — a staged 2-node progress («بازگشت از درگاه ✓» → «در انتظار تایید بانک»,
* normal, reflected calmly) until a terminal outcome: succeeded → invalidate the booking/request caches * the calm animated `StatusTimeline` `current` pulse) with an expected-duration hint, backed by the
* and hand off to the confirmation screen; failed → a retry affordance (a fresh C6 mount = a new attempt * backoff poll ("PSP received ≠ cash in bank" — pending is normal, reflected calmly) — until a terminal
* with a NEW idempotency key); window lapsed → back to the request's terminal card. * outcome: succeeded → invalidate the booking/request caches and hand off to the confirmation screen;
* failed → a retry affordance (a fresh C6 mount = a new attempt with a NEW idempotency key); window
* lapsed → back to the request's terminal card.
*/ */
export default function CheckoutReturnPage() { export default function CheckoutReturnPage() {
return ( return (
@@ -31,6 +33,11 @@ export default function CheckoutReturnPage() {
); );
} }
const PENDING_NODES = (returnedLabel: string, confirmingLabel: string): TimelineNode[] => [
{ key: 'returned', label: returnedLabel, state: 'completed' },
{ key: 'confirming', label: confirmingLabel, state: 'current' },
];
function ReturnScreen() { function ReturnScreen() {
const t = useTranslations('payment'); const t = useTranslations('payment');
const locale = useLocale(); const locale = useLocale();
@@ -87,90 +94,75 @@ function ReturnScreen() {
if (!validId) { if (!validId) {
return ( return (
<StateCard icon="error" tone="var(--bal-error)" title={t('error_title')}> <PaymentStateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
<AppButton variant="contained" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}> <AppButton variant="contained" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}>
{t('view_booking')} {t('view_booking')}
</AppButton> </AppButton>
</StateCard> </PaymentStateCard>
); );
} }
if (windowExpired) { if (windowExpired) {
return ( return (
<StateCard icon="pending" tone="var(--bal-warning)" title={t('window_expired_title')} body={t('window_expired_body')}> <PaymentStateCard
icon="pending"
tone="var(--bal-warning)"
title={t('window_expired_title')}
body={t('window_expired_body')}
>
<AppButton <AppButton
variant="contained" variant="contained"
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)} onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
> >
{t('back_to_request')} {t('back_to_request')}
</AppButton> </AppButton>
</StateCard> </PaymentStateCard>
); );
} }
if (failed) { if (failed) {
return ( return (
<StateCard icon="error" tone="var(--bal-error)" title={t('state_failed_title')} body={t('state_failed_hint')}> <PaymentStateCard icon="error" tone="var(--bal-error)" title={t('state_failed_title')} body={t('state_failed_hint')}>
<PaymentStatusBadge status="failed" /> <Stack sx={{ gap: 1, width: '100%' }}>
<AppButton <AppButton
color="secondary" color="secondary"
variant="contained" variant="contained"
size="large" size="large"
onClick={() => onClick={() =>
router.replace(`/${locale}${ROUTES.CHECKOUT}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`) router.replace(`/${locale}${ROUTES.CHECKOUT}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)
} }
> >
{t('retry_payment')} {t('retry_payment')}
</AppButton> </AppButton>
<AppButton <AppButton
variant="text" variant="text"
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)} onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
> >
{t('back_to_request')} {t('back_to_request')}
</AppButton> </AppButton>
</StateCard> </Stack>
</PaymentStateCard>
); );
} }
// Pending-callback (and the brief succeeded → confirmation hand-off): a calm waiting state. // Pending-callback (and the brief succeeded → confirmation hand-off): a staged 2-node progress instead
// of a bare spinner+chip+title stack — the flow's calmest, most designed wait state.
return ( return (
<StateCard icon="payment" tone="var(--bal-secondary)" title={t('state_pending_title')} body={t('state_pending_hint')}> <Paper elevation={0} sx={{ p: 4, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<CircularProgress color="primary" size="2.5rem" /> <Stack sx={{ gap: 2.5, alignItems: 'center' }}>
<PaymentStatusBadge status="pending" /> <Typography variant="subtitle1" sx={{ fontWeight: 700, textAlign: 'center' }}>
{/* Manual re-check — covers the bounded poll giving up on a very slow callback. */} {t('state_pending_title')}
<AppButton variant="text" disabled={outcomeQuery.isFetching} onClick={() => outcomeQuery.refetch()}>
{t('check_again')}
</AppButton>
</StateCard>
);
}
function StateCard({
icon,
tone,
title,
body,
children,
}: {
icon: string;
tone: string;
title: string;
body?: string;
children?: ReactNode;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title}
</Typography> </Typography>
{body ? ( <Stack sx={{ alignSelf: 'stretch', maxWidth: 320, mx: 'auto' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <StatusTimeline nodes={PENDING_NODES(t('stage_returned'), t('stage_confirming'))} />
{body} </Stack>
</Typography> <Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}>
) : null} {t('state_pending_duration_hint')}
{children} </Typography>
{/* Manual re-check — covers the bounded poll giving up on a very slow callback. */}
<AppButton variant="text" disabled={outcomeQuery.isFetching} onClick={() => outcomeQuery.refetch()}>
{t('check_again')}
</AppButton>
</Stack> </Stack>
</Paper> </Paper>
); );
@@ -2,21 +2,31 @@
import { useState } from 'react'; 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 { Paper, Skeleton, Stack, Typography } from '@mui/material';
import { import {
Dialog, AppButton,
DialogActions, AppIcon,
DialogContent, BookingRequestSummaryCard,
DialogTitle, ConfirmDialog,
Paper, CountdownTimer,
Skeleton, StatusChip,
Stack, StepperHeader,
Typography, } from '@/components';
} from '@mui/material';
import { AppButton, AppIcon, BookingRequestSummaryCard, CountdownTimer, StatusChip, StepperHeader } from '@/components';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { useBookingRequest, useCancelBookingRequest } from '@/services/bookingRequests'; import { useBookingRequest, useCancelBookingRequest } from '@/services/bookingRequests';
import type { BookingRequestDto } from '@/services/bookingRequests/types'; import type { BookingRequestDto } from '@/services/bookingRequests/types';
const MINUTES_PER_HOUR = 60;
/** Freeform-text heuristic (the DTO carries no structured rejection-reason code — REQ-044): suppress the
* "same nurse, different time" recovery when the nurse's reason reads like a hard gender/coverage block. */
const RETRY_BLOCK_KEYWORDS = ['gender', 'coverage', 'area', 'جنسیت', 'پوشش', 'منطقه', 'محدوده'];
function rejectionAllowsSameNurseRetry(reason: string | null): boolean {
if (!reason) return true;
const lower = reason.toLowerCase();
return !RETRY_BLOCK_KEYWORDS.some((keyword) => lower.includes(keyword));
}
/** /**
* C5 — Awaiting nurse acceptance (در انتظار تایید پرستار). Keyed by the request id, it **polls** the * C5 — Awaiting nurse acceptance (در انتظار تایید پرستار). Keyed by the request id, it **polls** the
* request (`useBookingRequest`, stopping at a terminal status) so the accept / reject / expire transition * request (`useBookingRequest`, stopping at a terminal status) so the accept / reject / expire transition
@@ -46,14 +56,37 @@ export default function BookingRequestStatusPage() {
icon="error" icon="error"
tone="var(--bal-error)" tone="var(--bal-error)"
title={t('error_title')} title={t('error_title')}
body={t('error_body')} primary={{ label: t('retry'), onClick: () => refetch() }}
ctaLabel={t('retry')}
onCta={() => refetch()}
/> />
); );
} }
const goToSearch = () => router.push(`/${locale}${ROUTES.SEARCH}`); const goToSearch = () => router.push(`/${locale}${ROUTES.SEARCH}`);
/** Region + gender-carried search — "پرستاران مشابه": same city/district + the same caregiver-gender
* intent, recovering the search context rather than restarting discovery from zero. */
const goToSimilarNurses = () => {
const searchParams = new URLSearchParams();
searchParams.set('city_id', String(request.cityId));
if (request.districtId != null) searchParams.set('district_id', String(request.districtId));
if (request.requiredCaregiverGender === 'male' || request.requiredCaregiverGender === 'female') {
searchParams.set('nurse_gender', request.requiredCaregiverGender);
}
router.push(`/${locale}${ROUTES.SEARCH}?${searchParams.toString()}`);
};
/** Reopens C4 for the SAME nurse/variant/patient/address, only the date/time left to re-pick — recovers
* the booking intent instead of restarting from search. */
const goToReRequestSameNurse = () => {
const requestParams = new URLSearchParams();
requestParams.set('nurse_id', String(request.nurseId));
requestParams.set('variant_id', String(request.variantId));
if (request.requiredCaregiverGender) requestParams.set('required_gender', request.requiredCaregiverGender);
requestParams.set('patient_id', String(request.patientId));
requestParams.set('address_id', String(request.customerAddressId));
router.push(`/${locale}${ROUTES.BOOKING_REQUEST}?${requestParams.toString()}`);
};
const addressLabel = customerAddressLabel(request, locale, t('address_whole_city')); const addressLabel = customerAddressLabel(request, locale, t('address_whole_city'));
const summary = ( const summary = (
@@ -73,6 +106,7 @@ export default function BookingRequestStatusPage() {
// Terminal states — each is its own card with a re-request path back into discovery (or booking). // Terminal states — each is its own card with a re-request path back into discovery (or booking).
if (request.status === 'rejected_by_nurse') { if (request.status === 'rejected_by_nurse') {
const canRetrySameNurse = rejectionAllowsSameNurseRetry(request.nurseRejectionReason);
return ( return (
<Stack sx={{ gap: 3 }}> <Stack sx={{ gap: 3 }}>
{summary} {summary}
@@ -81,8 +115,12 @@ export default function BookingRequestStatusPage() {
tone="var(--bal-error)" tone="var(--bal-error)"
title={t('rejected_title')} title={t('rejected_title')}
body={request.nurseRejectionReason ? `${t('rejected_reason_label')}: ${request.nurseRejectionReason}` : undefined} body={request.nurseRejectionReason ? `${t('rejected_reason_label')}: ${request.nurseRejectionReason}` : undefined}
ctaLabel={t('terminal_rerequest')} primary={
onCta={goToSearch} canRetrySameNurse
? { label: t('terminal_rerequest_same_nurse'), onClick: goToReRequestSameNurse }
: { label: t('terminal_similar_nurses'), onClick: goToSimilarNurses }
}
secondary={canRetrySameNurse ? { label: t('terminal_similar_nurses'), onClick: goToSimilarNurses } : undefined}
/> />
</Stack> </Stack>
); );
@@ -91,7 +129,13 @@ export default function BookingRequestStatusPage() {
return ( return (
<Stack sx={{ gap: 3 }}> <Stack sx={{ gap: 3 }}>
{summary} {summary}
<TerminalCard icon="pending" tone="var(--bal-warning)" title={t('expired_title')} ctaLabel={t('terminal_rerequest')} onCta={goToSearch} /> <TerminalCard
icon="pending"
tone="var(--bal-warning)"
title={t('expired_title')}
primary={{ label: t('terminal_rerequest_same_nurse'), onClick: goToReRequestSameNurse }}
secondary={{ label: t('terminal_similar_nurses'), onClick: goToSimilarNurses }}
/>
</Stack> </Stack>
); );
} }
@@ -99,7 +143,12 @@ export default function BookingRequestStatusPage() {
return ( return (
<Stack sx={{ gap: 3 }}> <Stack sx={{ gap: 3 }}>
{summary} {summary}
<TerminalCard icon="pending" tone="var(--bal-warning)" title={t('payment_expired_title')} ctaLabel={t('terminal_rerequest')} onCta={goToSearch} /> <TerminalCard
icon="pending"
tone="var(--bal-warning)"
title={t('payment_expired_title')}
primary={{ label: t('terminal_rerequest'), onClick: goToSearch }}
/>
</Stack> </Stack>
); );
} }
@@ -107,7 +156,12 @@ export default function BookingRequestStatusPage() {
return ( return (
<Stack sx={{ gap: 3 }}> <Stack sx={{ gap: 3 }}>
{summary} {summary}
<TerminalCard icon="rejected" tone="var(--bal-text-secondary)" title={t('cancelled_title')} ctaLabel={t('terminal_rerequest')} onCta={goToSearch} /> <TerminalCard
icon="rejected"
tone="var(--bal-text-secondary)"
title={t('cancelled_title')}
primary={{ label: t('terminal_rerequest'), onClick: goToSearch }}
/>
</Stack> </Stack>
); );
} }
@@ -119,13 +173,14 @@ export default function BookingRequestStatusPage() {
icon="verified" icon="verified"
tone="var(--bal-success)" tone="var(--bal-success)"
title={t('converted_title')} title={t('converted_title')}
ctaLabel={t('converted_cta')} primary={{
// Deep-link the booking when the id is known (client-augmented, REQ-017); list fallback otherwise. label: t('converted_cta'),
onCta={() => // Deep-link the booking when the id is known (client-augmented, REQ-017); list fallback otherwise.
router.push( onClick: () =>
`/${locale}${request.bookingId != null ? `${ROUTES.BOOKINGS}/${request.bookingId}` : ROUTES.BOOKINGS}`, router.push(
) `/${locale}${request.bookingId != null ? `${ROUTES.BOOKINGS}/${request.bookingId}` : ROUTES.BOOKINGS}`,
} ),
}}
/> />
</Stack> </Stack>
); );
@@ -158,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,
@@ -183,7 +238,7 @@ export default function BookingRequestStatusPage() {
color="secondary" color="secondary"
variant="contained" variant="contained"
size="large" size="large"
endIcon="payment" endIcon="forward"
onClick={() => router.push(`/${locale}${ROUTES.CHECKOUT}?request_id=${request.id}`)} onClick={() => router.push(`/${locale}${ROUTES.CHECKOUT}?request_id=${request.id}`)}
sx={{ py: 1.25 }} sx={{ py: 1.25 }}
> >
@@ -192,13 +247,20 @@ 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' }}>
<CountdownTimer <Stack sx={{ gap: 1, alignItems: 'center' }}>
deadlineIso={request.nurseResponseDeadlineAt} <CountdownTimer
label={t('response_countdown_label')} deadlineIso={request.nurseResponseDeadlineAt}
elapsedText={t('response_elapsed')} windowStart={request.createdAt}
onElapsed={() => refetch()} label={t('response_countdown_label')}
/> elapsedText={t('response_elapsed')}
coarseLabel={(minutes) => coarseResponseLabel(minutes, t)}
onElapsed={() => refetch()}
/>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('response_notify_note')}
</Typography>
</Stack>
</Paper> </Paper>
)} )}
@@ -212,33 +274,32 @@ export default function BookingRequestStatusPage() {
{cancelRequest.isPending ? t('cancelling') : t('cancel_request')} {cancelRequest.isPending ? t('cancelling') : t('cancel_request')}
</AppButton> </AppButton>
<Dialog open={confirmCancel} onClose={() => setConfirmCancel(false)}> <ConfirmDialog
<DialogTitle>{t('cancel_confirm_title')}</DialogTitle> open={confirmCancel}
<DialogContent> title={t('cancel_confirm_title')}
<Typography variant="body2" sx={{ color: 'text.secondary' }}> body={t('cancel_confirm_body')}
{t('cancel_confirm_body')} cancelLabel={t('cancel_confirm_keep')}
</Typography> confirmLabel={t('cancel_confirm_destructive')}
</DialogContent> confirmColor="error"
<DialogActions> loading={cancelRequest.isPending}
<AppButton variant="text" onClick={() => setConfirmCancel(false)}> onClose={() => setConfirmCancel(false)}
{t('cancel_request')} onConfirm={() => {
</AppButton> setConfirmCancel(false);
<AppButton cancelRequest.mutate(request.id);
color="error" }}
variant="contained" />
onClick={() => {
setConfirmCancel(false);
cancelRequest.mutate(request.id);
}}
>
{t('cancel_confirm_yes')}
</AppButton>
</DialogActions>
</Dialog>
</Stack> </Stack>
); );
} }
/** Humanized minutes-remaining copy above the coarse threshold («حدود ۳ ساعت» / «حدود ۲۵ دقیقه»). */
function coarseResponseLabel(minutes: number, t: (key: string, values?: Record<string, number>) => string): string {
if (minutes >= MINUTES_PER_HOUR) {
return t('countdown_about_hours', { hours: Math.round(minutes / MINUTES_PER_HOUR) });
}
return t('countdown_about_minutes', { minutes });
}
/** "title · city · district" (or "· whole city"), locale-aware — the customer view carries the full address. */ /** "title · city · district" (or "· whole city"), locale-aware — the customer view carries the full address. */
function customerAddressLabel(request: BookingRequestDto, locale: string, wholeCityLabel: string): string { function customerAddressLabel(request: BookingRequestDto, locale: string, wholeCityLabel: string): string {
const city = locale === 'en' ? request.cityNameEn : request.cityNameFa; const city = locale === 'en' ? request.cityNameEn : request.cityNameFa;
@@ -247,23 +308,28 @@ function customerAddressLabel(request: BookingRequestDto, locale: string, wholeC
return `${request.addressTitle} · ${city} · ${district}`; return `${request.addressTitle} · ${city} · ${district}`;
} }
interface TerminalAction {
label: string;
onClick: () => void;
}
function TerminalCard({ function TerminalCard({
icon, icon,
tone, tone,
title, title,
body, body,
ctaLabel, primary,
onCta, secondary,
}: { }: {
icon: string; icon: string;
tone: string; tone: string;
title: string; title: string;
body?: string; body?: string;
ctaLabel: string; primary: TerminalAction;
onCta: () => void; 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}
@@ -273,9 +339,16 @@ function TerminalCard({
{body} {body}
</Typography> </Typography>
) : null} ) : null}
<AppButton variant="contained" color="primary" onClick={onCta}> <Stack direction="row" sx={{ gap: 1, justifyContent: 'center', flexWrap: 'wrap' }}>
{ctaLabel} <AppButton variant="contained" color="primary" onClick={primary.onClick}>
</AppButton> {primary.label}
</AppButton>
{secondary ? (
<AppButton variant="outlined" color="primary" onClick={secondary.onClick}>
{secondary.label}
</AppButton>
) : null}
</Stack>
</Paper> </Paper>
); );
} }
@@ -2,10 +2,12 @@
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,
Box, Box,
Chip,
MenuItem, MenuItem,
Paper,
Skeleton, Skeleton,
Stack, Stack,
TextField, TextField,
@@ -13,23 +15,61 @@ import {
ToggleButtonGroup, ToggleButtonGroup,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import { AppButton, AppLoading, EmptyState, PriceDisplay } from '@/components'; import {
import { AddressMapPicker } from '@/components/geography'; AppButton,
AppIcon,
AppLoading,
EmptyState,
JalaliDateIntentPicker,
PriceDisplay,
RhfControlGroup,
RhfTextField,
StepperHeader,
TrustBadge,
} from '@/components';
import { todayIso } from '@/components/common/JalaliDatePicker';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors'; import { ApiError } from '@/lib/api/errors';
import { cityCentroid } from '@/services/geography/constants';
import { usePatients } from '@/services/patients'; import { usePatients } from '@/services/patients';
import { useAddresses } from '@/services/addresses'; import { useAddresses } from '@/services/addresses';
import { useNurseProfile } from '@/services/search'; import { useNurseProfile } from '@/services/search';
import type { NurseProfile } from '@/services/search/types';
import { useCreateBookingRequest } from '@/services/bookingRequests'; import { useCreateBookingRequest } from '@/services/bookingRequests';
import { CUSTOMER_NOTES_MAX_LENGTH } from '@/services/bookingRequests/constants'; import { CUSTOMER_NOTES_MAX_LENGTH } from '@/services/bookingRequests/constants';
import { formatNumber } from '@/utils';
import type { import type {
BookingRequestDisplayContext, BookingRequestDisplayContext,
RequiredCaregiverGender, RequiredCaregiverGender,
} from '@/services/bookingRequests/types'; } from '@/services/bookingRequests/types';
import type { CustomerAddress } from '@/services/addresses/types';
import type { Patient } from '@/services/patients/types';
const GENDER_OPTIONS: RequiredCaregiverGender[] = ['female', 'male', 'any']; const GENDER_OPTIONS: RequiredCaregiverGender[] = ['female', 'male', 'any'];
interface TimeWindowOption {
key: 'morning' | 'afternoon' | 'evening';
start: string;
end: string;
}
const TIME_WINDOWS: TimeWindowOption[] = [
{ key: 'morning', start: '08:00', end: '12:00' },
{ key: 'afternoon', start: '12:00', end: '16:00' },
{ key: 'evening', start: '16:00', end: '20:00' },
];
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
* `nurse_id`, an optional `variant_id`, and the same-gender `required_gender` intent from search). The * `nurse_id`, an optional `variant_id`, and the same-gender `required_gender` intent from search). The
@@ -46,9 +86,18 @@ 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 tAddress = useTranslations('address');
const locale = useLocale(); const locale = useLocale();
const router = useRouter(); const router = useRouter();
const query = useSearchParams(); const query = useSearchParams();
@@ -57,85 +106,145 @@ function BookingRequestForm() {
const hasNurse = Number.isInteger(nurseId) && nurseId > 0; const hasNurse = Number.isInteger(nurseId) && nurseId > 0;
const variantIdParam = Number(query.get('variant_id')) || null; const variantIdParam = Number(query.get('variant_id')) || null;
const genderParam = query.get('required_gender'); const genderParam = query.get('required_gender');
// Recovery hand-off from C5's "request again with another time" — reopens this same nurse/variant
// prefilled with the patient + address of the terminal request, extending the C3 handoff params.
const patientIdParam = Number(query.get('patient_id')) || null;
const addressIdParam = Number(query.get('address_id')) || null;
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 [addressEditing, setAddressEditing] = useState(false);
const [patientId, setPatientId] = useState<number | ''>('');
const [variantSel, setVariantSel] = useState<number | ''>(variantIdParam ?? '');
const [addressSel, setAddressSel] = useState<number | ''>('');
const [gender, setGender] = useState<RequiredCaregiverGender | ''>(
genderParam === 'male' || genderParam === 'female' ? genderParam : '',
);
const [date, setDate] = useState('');
const [timeStart, setTimeStart] = useState('09:00');
const [timeEnd, setTimeEnd] = useState('13:00');
const [notes, setNotes] = useState('');
const [attempted, setAttempted] = useState(false);
const [pastDateError, setPastDateError] = useState(false);
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
// 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 = (): string => { const regionLabel = (address: CustomerAddress): string => {
if (!selectedAddress) return ''; const city = locale === 'en' ? address.cityNameEn : address.cityNameFa;
const city = locale === 'en' ? selectedAddress.cityNameEn : selectedAddress.cityNameFa;
const district = const district =
selectedAddress.districtId == null address.districtId == null
? t('address_whole_city') ? t('address_whole_city')
: locale === 'en' : locale === 'en'
? selectedAddress.districtNameEn ? address.districtNameEn
: selectedAddress.districtNameFa; : address.districtNameFa;
return `${selectedAddress.title} · ${city} · ${district}`; return `${address.title} · ${city} · ${district}`;
}; };
const handleSubmit = () => { const selectWindow = (option: TimeWindowOption) => {
setAttempted(true); setValue('window', option.key, { shouldDirty: true });
setValue('timeStart', option.start, { shouldValidate: true });
setValue('timeEnd', option.end, { shouldValidate: true });
// The date's past-guard is a cross-field rule over the start time — re-run it now that one exists.
if (getValues('date')) void form.trigger('date');
};
const submit = (formValues: RequestFormValues) => {
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 =
@@ -166,143 +275,122 @@ 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 (
<EmptyState
icon="search"
title={t('missing_nurse_title')}
body={t('missing_nurse_body')}
action={
<AppButton
variant="contained"
color="primary"
onClick={() => router.push(`/${locale}${ROUTES.SEARCH}`)}
sx={{ m: 0 }}
>
{t('missing_nurse_cta')}
</AppButton>
}
/>
);
}
if (profileQuery.isLoading) return <FormSkeleton />;
const timeError = attempted && timeStart !== '' && timeEnd !== '' && timeEnd <= timeStart;
const pastError = pastDateError;
return ( return (
<Stack sx={{ gap: 3 }}> <FormProvider {...form}>
<Box> <Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 3 }}>
<Typography variant="h5" component="h1"> {profile ? <NurseIdentityBar profile={profile} /> : null}
{t('request_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('form_subtitle')}
</Typography>
</Box>
{/* Patient */} <Box>
{patients.length === 0 ? ( <Typography variant="h5" component="h1">
<FieldEmpty {t('request_title')}
label={t('patient_label')} </Typography>
message={t('patient_empty')} <Typography variant="body2" sx={{ color: 'text.secondary' }}>
ctaLabel={t('patient_add_cta')} {t('form_subtitle')}
onCta={() => router.push(`/${locale}${ROUTES.PATIENTS}`)} </Typography>
/>
) : (
<TextField
select
label={t('patient_label')}
value={patientId}
error={attempted && patientId === ''}
helperText={attempted && patientId === '' ? t('error_patient_required') : undefined}
onChange={(event) => setPatientId(Number(event.target.value))}
fullWidth
>
<MenuItem value="" disabled>
{t('patient_placeholder')}
</MenuItem>
{patients.map((patient) => (
<MenuItem key={patient.id} value={patient.id}>
{patient.displayName}
</MenuItem>
))}
</TextField>
)}
{/* Service variant */}
{services.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('service_empty')}
</Typography>
) : (
<TextField
select
label={t('service_label')}
value={variantId}
error={attempted && variantId === ''}
helperText={attempted && variantId === '' ? t('error_service_required') : undefined}
onChange={(event) => setVariantSel(Number(event.target.value))}
fullWidth
>
<MenuItem value="" disabled>
{t('service_placeholder')}
</MenuItem>
{services.map((service) => (
<MenuItem key={service.variantId} value={service.variantId}>
{service.displayName}
</MenuItem>
))}
</TextField>
)}
{selectedVariant ? (
<Box sx={{ mt: -1.5 }}>
<PriceDisplay
price={selectedVariant.priceIrr}
priceUnit={selectedVariant.priceUnit}
sessionCount={selectedVariant.sessionCount}
/>
</Box> </Box>
) : null}
{/* Address */} <Box>
{addresses.length === 0 ? ( <Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>
<FieldEmpty {t('whathappens_title')}
label={t('address_label')} </Typography>
message={t('address_empty')} <StepperHeader steps={[t('step_submitted'), t('step_awaiting'), t('step_payment')]} activeStep={0} />
ctaLabel={t('address_add_cta')} </Box>
onCta={() => router.push(`/${locale}${ROUTES.ADDRESSES}`)}
/> {/* Patient */}
) : ( {patients.length === 0 ? (
<FieldEmpty
label={t('patient_label')}
message={t('patient_empty')}
ctaLabel={t('patient_add_cta')}
onCta={() => router.push(`/${locale}${ROUTES.PATIENTS}`)}
/>
) : (
<RhfTextField<RequestFormValues>
name="patientId"
select
label={t('patient_label')}
rules={{ validate: (value) => value !== '' || t('error_patient_required') }}
fullWidth
>
<MenuItem value="" disabled>
{t('patient_placeholder')}
</MenuItem>
{patients.map((patient) => (
<MenuItem key={patient.id} value={patient.id}>
{patient.displayName}
</MenuItem>
))}
</RhfTextField>
)}
{/* Service variant */}
<Stack sx={{ gap: 1 }}> <Stack sx={{ gap: 1 }}>
<TextField {services.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('service_empty')}
</Typography>
) : (
<RhfTextField<RequestFormValues>
name="variantId"
select
label={t('service_label')}
rules={{ validate: (value) => value !== '' || t('error_service_required') }}
fullWidth
>
<MenuItem value="" disabled>
{t('service_placeholder')}
</MenuItem>
{services.map((service) => (
<MenuItem key={service.variantId} value={service.variantId}>
{service.displayName}
</MenuItem>
))}
</RhfTextField>
)}
{selectedVariant ? (
<PriceDisplay
price={selectedVariant.priceIrr}
priceUnit={selectedVariant.priceUnit}
sessionCount={selectedVariant.sessionCount}
/>
) : null}
</Stack>
{/* Address — a compact confirmation row once resolved, with a way back to the select. */}
{addresses.length === 0 ? (
<FieldEmpty
label={t('address_label')}
message={t('address_empty')}
ctaLabel={t('address_add_cta')}
onCta={() => router.push(`/${locale}${ROUTES.ADDRESSES}`)}
/>
) : addressEditing || !selectedAddress ? (
<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={attempted && addressId === ''} // Collapses back to the compact summary row once the menu closes — picking a different
helperText={attempted && addressId === '' ? t('error_address_required') : undefined} // address is the normal exit, and dismissing without picking leaves the current one shown.
onChange={(event) => setAddressSel(Number(event.target.value))} slotProps={{ select: { onClose: () => setAddressEditing(false) } }}
fullWidth fullWidth
> >
<MenuItem value="" disabled> <MenuItem value="" disabled>
@@ -313,143 +401,258 @@ function BookingRequestForm() {
{address.title} · {locale === 'en' ? address.cityNameEn : address.cityNameFa} {address.title} · {locale === 'en' ? address.cityNameEn : address.cityNameFa}
</MenuItem> </MenuItem>
))} ))}
</TextField> </RhfTextField>
{selectedAddress ? ( ) : (
<Paper elevation={0} sx={{ p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}> <Stack
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1 }}> direction="row"
{regionLabel()} sx={{
{selectedAddress.addressLine ? `${selectedAddress.addressLine}` : ''} gap: 1.5,
alignItems: 'center',
p: 1.5,
border: '1px solid',
borderColor: 'divider',
borderRadius: 'var(--bal-radius-md)',
}}
>
<AppIcon icon="location" size={20} color="var(--bal-text-secondary)" />
<Stack sx={{ gap: 0.25, minWidth: 0, flexGrow: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>
{regionLabel(selectedAddress)}
</Typography> </Typography>
{selectedAddress.latitude != null && selectedAddress.longitude != null ? ( {selectedAddress.addressLine ? (
// Read-only preview of the address's stored pin (the pin itself is set in the f3 book). <Typography variant="caption" sx={{ color: 'text.secondary' }} noWrap>
<Box sx={{ pointerEvents: 'none' }}> {selectedAddress.addressLine}
<AddressMapPicker </Typography>
value={{ latitude: selectedAddress.latitude, longitude: selectedAddress.longitude }}
onChange={() => undefined}
center={cityCentroid(selectedAddress.cityId)}
helperText={regionLabel()}
latLabel={tAddress('map_lat')}
lngLabel={tAddress('map_lng')}
/>
</Box>
) : null} ) : null}
</Paper> </Stack>
) : null} <AppButton variant="text" size="small" onClick={() => setAddressEditing(true)} sx={{ flexShrink: 0 }}>
</Stack> {t('address_change_cta')}
)} </AppButton>
</Stack>
)}
{/* Date + time */} {/* Date — the past-date guard is a cross-field rule against the chosen start time. */}
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}> <RhfControlGroup<RequestFormValues>
<TextField name="date"
type="date"
label={t('date_label')} label={t('date_label')}
value={date} rules={{
error={(attempted && date === '') || pastError} validate: {
helperText={pastError ? t('error_past_date') : attempted && date === '' ? t('error_date_required') : undefined} chosen: (value) => value !== '' || t('error_date_required'),
onChange={(event) => { future: (value, all) =>
setDate(event.target.value); !all.timeStart || Date.parse(`${value}T${all.timeStart}`) >= Date.now() || t('error_past_date'),
if (pastDateError) setPastDateError(false);
}}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
type="time"
label={t('time_start_label')}
value={timeStart}
onChange={(event) => {
setTimeStart(event.target.value);
if (pastDateError) setPastDateError(false);
}}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
type="time"
label={t('time_end_label')}
value={timeEnd}
error={timeError}
helperText={timeError ? t('error_time_range') : undefined}
onChange={(event) => setTimeEnd(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
</Stack>
{/* Caregiver gender — first-class, three-way, never silently defaulted */}
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('gender_label')}
</Typography>
<ToggleButtonGroup
exclusive
color="primary"
value={gender || null}
onChange={(_event, next: RequiredCaregiverGender | null) => {
if (next) setGender(next);
}}
sx={{
'& .MuiToggleButton-root': {
flex: 1,
py: 1.25,
fontWeight: 700,
borderColor: attempted && gender === '' ? 'var(--bal-error)' : undefined,
}, },
}} }}
> >
{GENDER_OPTIONS.map((option) => ( {({ field }) => (
<ToggleButton key={option} value={option} data-gender={option}> <JalaliDateIntentPicker
{t(`gender_${option}`)} value={(field.value as string) ?? ''}
</ToggleButton> onChange={field.onChange}
))} min={todayIso()}
</ToggleButtonGroup> todayLabel={t('date_today')}
<Typography variant="caption" sx={{ color: 'text.secondary' }}> tomorrowLabel={t('date_tomorrow')}
{t('gender_hint')} pickOtherLabel={t('date_pick_other')}
</Typography> />
{attempted && gender === '' ? ( )}
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}> </RhfControlGroup>
{t('error_gender_required')}
{/* Time window — presets kill the end<=start error class; «زمان دلخواه» reveals free time fields. */}
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('time_window_label')}
</Typography> </Typography>
) : null} <Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{TIME_WINDOWS.map((option) => (
<Chip
key={option.key}
clickable
label={t(`window_${option.key}`)}
onClick={() => selectWindow(option)}
color={windowSel === option.key ? 'primary' : undefined}
variant={windowSel === option.key ? 'filled' : 'outlined'}
data-window={option.key}
/>
))}
<Chip
clickable
label={t('window_custom')}
onClick={() => setValue('window', 'custom', { shouldDirty: true })}
color={windowSel === 'custom' ? 'primary' : undefined}
variant={windowSel === 'custom' ? 'filled' : 'outlined'}
data-window="custom"
/>
</Stack>
{windowSel === 'custom' ? (
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<RhfTextField<RequestFormValues>
name="timeStart"
type="time"
label={t('time_start_label')}
rules={{ validate: (value) => value !== '' || t('error_time_required') }}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<RhfTextField<RequestFormValues>
name="timeEnd"
type="time"
label={t('time_end_label')}
rules={{
validate: {
chosen: (value) => value !== '' || t('error_time_required'),
after: (value, all) => !all.timeStart || String(value) > all.timeStart || t('error_time_range'),
},
}}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
</Stack>
) : null}
</Stack>
{/* Caregiver gender — first-class, three-way, never silently defaulted */}
<RhfControlGroup<RequestFormValues>
name="gender"
label={t('gender_label')}
hint={t('gender_hint')}
rules={{ validate: (value) => value !== '' || t('error_gender_required') }}
>
{({ field, hasError }) => (
<ToggleButtonGroup
exclusive
color="primary"
value={field.value || null}
onChange={(_event, next: RequiredCaregiverGender | null) => {
if (next) field.onChange(next);
}}
sx={{
'& .MuiToggleButton-root': {
flex: 1,
py: 1.25,
fontWeight: 700,
borderColor: hasError ? 'var(--bal-error)' : undefined,
},
}}
>
{GENDER_OPTIONS.map((option) => (
<ToggleButton key={option} value={option} data-gender={option}>
{t(`gender_${option}`)}
</ToggleButton>
))}
</ToggleButtonGroup>
)}
</RhfControlGroup>
{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}
{/* Stage-1 notes */}
<Stack sx={{ gap: 0.5 }}>
<RhfTextField<RequestFormValues>
name="notes"
label={t('notes_label')}
placeholder={t('notes_placeholder')}
helperText={t('notes_hint')}
transform={(raw) => raw.slice(0, CUSTOMER_NOTES_MAX_LENGTH)}
multiline
minRows={3}
fullWidth
/>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'end' }}>
{t('notes_counter', { count: notes.length, max: CUSTOMER_NOTES_MAX_LENGTH })}
</Typography>
</Stack>
{formError ? (
<Typography variant="body2" sx={{ color: 'var(--bal-error)' }}>
{formError}
</Typography>
) : null}
<Stack sx={{ gap: 1 }}>
<AppButton
type="submit"
color="primary"
variant="contained"
size="large"
startIcon="requests"
disabled={!requiredChosen || genderMismatch || createRequest.isPending}
sx={{ py: 1.5 }}
>
{createRequest.isPending ? t('submitting') : t('submit')}
</AppButton>
{!requiredChosen ? (
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}>
{t('cta_missing_caption', { fields: missingFieldLabels.join(locale === 'fa' ? '، ' : ', ') })}
</Typography>
) : null}
</Stack>
</Stack> </Stack>
</FormProvider>
);
}
{/* Stage-1 notes */} /** The sticky "who you're inviting home" identity summary — avatar, name, rating, trust badge, gender. */
<TextField function NurseIdentityBar({ profile }: { profile: NurseProfile }) {
label={t('notes_label')} const t = useTranslations('booking');
placeholder={t('notes_placeholder')} const locale = useLocale();
value={notes} const name = profile.nurseName.trim() || t('unnamed_nurse');
onChange={(event) => setNotes(event.target.value.slice(0, CUSTOMER_NOTES_MAX_LENGTH))} const ratingLabel = formatNumber(profile.averageRating, locale, {
multiline minimumFractionDigits: 1,
minRows={3} maximumFractionDigits: 1,
fullWidth });
helperText={t('notes_hint')}
/>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'end', mt: -2 }}>
{t('notes_counter', { count: notes.length, max: CUSTOMER_NOTES_MAX_LENGTH })}
</Typography>
{formError ? ( return (
<Typography variant="body2" sx={{ color: 'var(--bal-error)' }}> <Stack
{formError} direction="row"
</Typography> data-nurse-identity-bar
) : null} sx={{
position: 'sticky',
<AppButton // Sticks just below the shell's pinned header rather than behind it (AppFrame publishes the
color="primary" // height); `0px` in a chrome-free shell.
variant="contained" top: 'var(--bal-chrome-top, 0px)',
size="large" zIndex: 2,
startIcon="requests" gap: 1.5,
disabled={!requiredChosen || genderMismatch || createRequest.isPending} alignItems: 'center',
onClick={handleSubmit} p: 1.5,
sx={{ py: 1.5 }} border: '1px solid',
borderColor: 'divider',
borderRadius: 'var(--bal-radius-md)',
bgcolor: 'background.paper',
}}
>
<Avatar
src={profile.avatarUrl ?? undefined}
sx={{ width: 44, height: 44, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}
> >
{createRequest.isPending ? t('submitting') : t('submit')} {name.charAt(0)}
</AppButton> </Avatar>
<Stack sx={{ gap: 0.25, minWidth: 0, flexGrow: 1 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }} noWrap>
{name}
</Typography>
<TrustBadge state={profile.isVerified ? 'verified' : 'unverified'} />
</Stack>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="star" size={15} color="var(--bal-rating)" />
<Typography variant="caption" sx={{ fontWeight: 700 }}>
{ratingLabel}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
({formatNumber(profile.totalReviews, locale)})
</Typography>
</Stack>
<Chip
size="small"
variant="outlined"
label={t(`gender_${profile.nurseGender}`)}
sx={{ height: 20, fontSize: '0.7rem' }}
/>
</Stack>
</Stack>
</Stack> </Stack>
); );
} }
@@ -512,7 +715,9 @@ function FieldEmpty({
function FormSkeleton() { function FormSkeleton() {
return ( return (
<Stack sx={{ gap: 2.5 }}> <Stack sx={{ gap: 2.5 }}>
<Skeleton variant="rounded" height={76} />
<Skeleton variant="text" width="50%" height={36} /> <Skeleton variant="text" width="50%" height={36} />
<Skeleton variant="rounded" height={48} />
{[0, 1, 2, 3].map((key) => ( {[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={56} /> <Skeleton key={key} variant="rounded" height={56} />
))} ))}
File diff suppressed because it is too large Load Diff
@@ -3,26 +3,17 @@ 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 { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { import { Box, Skeleton, Stack, Typography } from '@mui/material';
Box, import { AppButton, ConfirmDialog, EmptyState, ErrorState, FormDialogShell, PatientCard, PatientForm } from '@/components';
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Skeleton,
Stack,
Typography,
} from '@mui/material';
import { AppButton, EmptyState, ErrorState, PatientCard, PatientForm } from '@/components';
import { patientRecordPath } from '@/constants'; import { patientRecordPath } from '@/constants';
import { usePatients, useCreatePatient, useUpdatePatient, useArchivePatient } from '@/services/patients'; import { usePatients, useCreatePatient, useUpdatePatient, useArchivePatient } from '@/services/patients';
import { birthDateToAge } from '@/services/patients/age'; import { birthDateToAge } from '@/services/patients/age';
import type { CreatePatientInput, Patient } from '@/services/patients/types'; import type { CreatePatientInput, Patient } from '@/services/patients/types';
/** /**
* E1 the Patients tab: a cached, invalidate-on-mutation list of the customer's patients * E1 the care-circle tab («حلقه مراقبت»): a cached, invalidate-on-mutation list of the people the
* with add/edit (the A4 form reused in a dialog) and soft archive (confirm). Loading skeleton * customer arranges care for, with add/edit (the A4 form in a full-screen-on-mobile dialog) and soft
* and an empty state with the add CTA are both handled. * archive (confirm). Loading skeleton and an empty state with the add CTA are both handled.
*/ */
export default function PatientsPage() { export default function PatientsPage() {
const t = useTranslations('patients'); const t = useTranslations('patients');
@@ -38,6 +29,7 @@ export default function PatientsPage() {
const archivePatient = useArchivePatient(); const archivePatient = useArchivePatient();
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
const [formDirty, setFormDirty] = useState(false);
const [editing, setEditing] = useState<Patient | null>(null); const [editing, setEditing] = useState<Patient | null>(null);
const [archiveTarget, setArchiveTarget] = useState<Patient | null>(null); const [archiveTarget, setArchiveTarget] = useState<Patient | null>(null);
@@ -143,50 +135,52 @@ export default function PatientsPage() {
</Stack> </Stack>
)} )}
<Dialog open={formOpen} onClose={closeForm} fullWidth maxWidth="sm"> <FormDialogShell
<DialogTitle>{editing ? t('edit_title') : t('add_title')}</DialogTitle> open={formOpen}
<DialogContent> title={editing ? t('edit_title') : t('add_title')}
<Box sx={{ pt: 1 }}> dirty={formDirty}
<PatientForm onClose={closeForm}
key={editing?.id ?? 'new'} closeLabel={tc('close')}
initial={ discardTitle={tc('discard_title')}
editing discardBody={tc('discard_body')}
? { discardConfirmLabel={tc('discard_confirm')}
displayName: editing.displayName, discardCancelLabel={tc('cancel')}
birthDate: editing.birthDate, >
gender: editing.gender, <PatientForm
conditions: editing.conditions, key={editing?.id ?? 'new'}
relation: editing.relation, initial={
} editing
: undefined ? {
} displayName: editing.displayName,
showRelation firstName: editing.firstName,
submitLabel={tc('save')} lastName: editing.lastName,
submitting={createPatient.isPending || updatePatient.isPending} birthDate: editing.birthDate,
onSubmit={handleSubmit} gender: editing.gender,
onCancel={closeForm} conditions: editing.conditions,
cancelLabel={tc('cancel')} relation: editing.relation,
/> }
</Box> : undefined
</DialogContent> }
</Dialog> showRelation
submitLabel={tc('save')}
submitting={createPatient.isPending || updatePatient.isPending}
onSubmit={handleSubmit}
onCancel={closeForm}
cancelLabel={tc('cancel')}
onDirtyChange={setFormDirty}
/>
</FormDialogShell>
<Dialog open={Boolean(archiveTarget)} onClose={() => setArchiveTarget(null)}> <ConfirmDialog
<DialogTitle>{t('archive_title')}</DialogTitle> open={Boolean(archiveTarget)}
<DialogContent> title={t('archive_title')}
<Typography variant="body2" sx={{ color: 'text.secondary' }}> body={t('archive_body')}
{t('archive_body')} confirmLabel={t('archive_confirm')}
</Typography> cancelLabel={tc('cancel')}
</DialogContent> confirmColor="error"
<DialogActions> onClose={() => setArchiveTarget(null)}
<AppButton variant="text" onClick={() => setArchiveTarget(null)}> onConfirm={confirmArchive}
{tc('cancel')} />
</AppButton>
<AppButton color="error" variant="contained" onClick={confirmArchive}>
{t('archive_confirm')}
</AppButton>
</DialogActions>
</Dialog>
</Box> </Box>
); );
} }
@@ -1,172 +1,388 @@
'use client'; 'use client';
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 { FormProvider, useForm, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { Box, Divider, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material'; import { Box, Divider, MenuItem, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, ErrorState, PhoneNumberField } from '@/components'; import {
AppButton,
AppIcon,
ConfirmDialog,
ErrorState,
FormDialogShell,
PhoneNumberField,
ProfileSummary,
RhfControlGroup,
RhfTextField,
} from '@/components';
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';
import { ActorSwitcher } from '@/layout';
import { useCustomerProfile, useUpsertCustomerProfile } from '@/services/profiles'; import { useCustomerProfile, useUpsertCustomerProfile } from '@/services/profiles';
import { useMe } from '@/services/auth'; import { useMe, useLogout } from '@/services/auth';
import type { CustomerProfile } from '@/services/profiles/types'; import type { CustomerProfile } from '@/services/profiles/types';
/** Customer profile — name, preferred language, and the emergency contact. No national-ID KYC. */ /** Customer profile — the account hub: identity header, grouped rows, emergency-contact status card. */
export default function CustomerProfilePage() { export default function CustomerProfilePage() {
const t = useTranslations('profile'); const t = useTranslations('profile');
const tc = useTranslations('common'); const tc = useTranslations('common');
const { data: profile, isLoading, isError, refetch } = useCustomerProfile(); const { data: profile, isLoading, isError, refetch } = useCustomerProfile();
const { data: me } = useMe(); const { data: me, isLoading: meLoading } = useMe();
if (isLoading) return <AppLoading />;
if (isLoading || meLoading) return <ProfileSkeleton />;
// The form must never render on a failed fetch — it would otherwise show blank/undefined fields // The form must never render on a failed fetch — it would otherwise show blank/undefined fields
// whose save could overwrite server truth. // whose save could overwrite server truth.
if (isError) return <ErrorState message={t('load_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />; if (isError) return <ErrorState message={t('load_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />;
// The customer name is owned by `/me` (REQ-007), not `CustomerProfileDto` — prefill it from there so
// editing the emergency contact never blanks (and re-saves as null) the existing name.
return ( return (
<CustomerProfileForm initial={profile ?? null} nameFallback={{ firstName: me?.firstName ?? null, lastName: me?.lastName ?? null }} /> <AccountHub
initial={profile ?? null}
nameFallback={{ firstName: me?.firstName ?? null, lastName: me?.lastName ?? null }}
phone={me?.phone}
/>
); );
} }
const CustomerProfileForm: FunctionComponent<{ interface AccountFormValues {
firstName: string;
lastName: string;
language: string;
emergencyName: string;
emergencyPhone: string;
}
const AccountHub: FunctionComponent<{
initial: CustomerProfile | null; initial: CustomerProfile | null;
nameFallback: { firstName: string | null; lastName: string | null }; nameFallback: { firstName: string | null; lastName: string | null };
}> = ({ initial, nameFallback }) => { phone?: string;
}> = ({ initial, nameFallback, phone }) => {
const t = useTranslations('profile'); const t = useTranslations('profile');
const ta = useTranslations('address');
const tc = useTranslations('common'); const tc = useTranslations('common');
const locale = useLocale(); const locale = useLocale();
const router = useRouter();
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const upsert = useUpsertCustomerProfile(); const upsert = useUpsertCustomerProfile();
const logout = useLogout();
const [firstName, setFirstName] = useState(initial?.firstName ?? nameFallback.firstName ?? ''); const [personalSheetOpen, setPersonalSheetOpen] = useState(false);
const [lastName, setLastName] = useState(initial?.lastName ?? nameFallback.lastName ?? ''); const [languageSheetOpen, setLanguageSheetOpen] = useState(false);
const [language, setLanguage] = useState(initial?.preferredLanguage ?? 'fa'); const [emergencySheetOpen, setEmergencySheetOpen] = useState(false);
const [emergencyName, setEmergencyName] = useState(initial?.defaultEmergencyContactName ?? ''); const [signOutOpen, setSignOutOpen] = useState(false);
const [emergencyPhone, setEmergencyPhone] = useState(digitsOnly(initial?.defaultEmergencyContactPhone ?? ''));
const [nameError, setNameError] = useState(false);
const [phoneError, setPhoneError] = useState(false);
const isComplete = Boolean(initial?.defaultEmergencyContactName && initial?.defaultEmergencyContactPhone); // ONE form behind all three sheets. Each sheet edits its own slice, but every save writes the whole
// profile (the wire upsert has no PATCH semantics), so the untouched fields have to come from
// somewhere — a single form is that somewhere, and `dirtyFields` then answers per-sheet "is there
// unsaved work here?" without a hand-written comparison per section.
const form = useForm<AccountFormValues>({
mode: 'onTouched',
defaultValues: {
firstName: initial?.firstName ?? nameFallback.firstName ?? '',
lastName: initial?.lastName ?? nameFallback.lastName ?? '',
language: initial?.preferredLanguage ?? 'fa',
emergencyName: initial?.defaultEmergencyContactName ?? '',
emergencyPhone: digitsOnly(initial?.defaultEmergencyContactPhone ?? ''),
},
});
const { control, formState, getValues, reset, trigger } = form;
const { dirtyFields } = formState;
const watched = useWatch({ control });
const handleSave = () => { const displayName = [watched.firstName, watched.lastName].filter(Boolean).join(' ').trim() || phone || '';
const nameInvalid = emergencyName.trim().length === 0; const emergencyComplete = Boolean(watched.emergencyName?.trim() && watched.emergencyPhone);
const phoneInvalid = !isIranianMobile(emergencyPhone);
setNameError(nameInvalid);
setPhoneError(phoneInvalid);
if (nameInvalid || phoneInvalid) return;
const save = (onDone: () => void) => {
const values = getValues();
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,
},
{
onSuccess: () => {
enqueueSnackbar(t('saved'), { variant: 'success' });
// Re-baseline so the saved slice stops counting as unsaved work in its sheet's discard guard.
reset(getValues());
onDone();
},
}, },
{ onSuccess: () => enqueueSnackbar(t('saved'), { variant: 'success' }) },
); );
}; };
return ( const savePersonal = () => save(() => setPersonalSheetOpen(false));
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 520 }}> const saveLanguage = () => save(() => setLanguageSheetOpen(false));
<Box> const saveEmergency = async () => {
<Typography variant="h5" component="h1"> if (!(await trigger(['emergencyName', 'emergencyPhone']))) return;
{t('title')} save(() => setEmergencySheetOpen(false));
</Typography> };
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
<Typography variant="body2" sx={{ mt: 0.5, color: isComplete ? 'var(--bal-success)' : 'text.secondary' }}>
{isComplete ? t('completion_done') : t('completion_todo')}
</Typography>
</Box>
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}> const personalDirty = Boolean(dirtyFields.firstName || dirtyFields.lastName);
<TextField label={t('first_name')} value={firstName} onChange={(e) => setFirstName(e.target.value)} fullWidth /> const languageDirty = Boolean(dirtyFields.language);
<TextField label={t('last_name')} value={lastName} onChange={(e) => setLastName(e.target.value)} fullWidth /> const emergencyDirty = Boolean(dirtyFields.emergencyName || dirtyFields.emergencyPhone);
const goTo = (path: string) => router.push(`/${locale}${path}`);
// Shared discard-confirm strings for every FormDialogShell instance below.
const closeLabel = tc('close');
const discardTitle = tc('discard_title');
const discardBody = tc('discard_body');
const discardConfirmLabel = tc('discard_confirm');
const cancelLabel = tc('cancel');
return (
<FormProvider {...form}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 520 }}>
<ProfileSummary displayName={displayName} phone={phone} initialsFallback={displayName || undefined} />
<Stack sx={{ gap: 1 }}>
<AccountRow icon="account" label={t('row_personal')} onClick={() => setPersonalSheetOpen(true)} />
<EmergencyContactCard
complete={emergencyComplete}
name={watched.emergencyName ?? ''}
phone={watched.emergencyPhone ?? ''}
onEdit={() => setEmergencySheetOpen(true)}
/>
<AccountRow icon="location" label={t('row_addresses')} onClick={() => goTo(ROUTES.ADDRESSES)} />
<AccountRow icon="language" label={t('row_language')} onClick={() => setLanguageSheetOpen(true)} />
{/* The app's appearance control lives here (and in each other actor's settings hub) it
used to occupy a permanent slot in every top bar for a preference set once. */}
<ThemeModeSetting />
<AccountRow icon="notifications" label={t('row_notifications')} onClick={() => goTo(ROUTES.NOTIFICATIONS)} />
<AccountRow icon="support" label={t('row_support')} onClick={() => goTo(ROUTES.SUPPORT_TICKETS)} />
</Stack> </Stack>
<TextField {/* Renders nothing for a single-role session — see ActorSwitcher's own doc. */}
select <ActorSwitcher target="nurse" />
label={t('language')}
value={language} <Stack sx={{ gap: 1 }}>
onChange={(e) => setLanguage(e.target.value)} <Divider sx={{ my: 0.5 }} />
sx={{ maxWidth: 220 }} <AccountRow icon="logout" label={t('sign_out')} onClick={() => setSignOutOpen(true)} tone="error" />
</Stack>
{/* اطلاعات شخصی */}
<FormDialogShell
open={personalSheetOpen}
title={t('row_personal')}
dirty={personalDirty}
onClose={() => setPersonalSheetOpen(false)}
closeLabel={closeLabel}
discardTitle={discardTitle}
discardBody={discardBody}
discardConfirmLabel={discardConfirmLabel}
discardCancelLabel={cancelLabel}
> >
<MenuItem value="fa">{t('language_fa')}</MenuItem> <Stack sx={{ gap: 2.5 }}>
<MenuItem value="en">{t('language_en')}</MenuItem> <RhfTextField<AccountFormValues> name="firstName" label={t('first_name')} fullWidth />
</TextField> <RhfTextField<AccountFormValues> name="lastName" label={t('last_name')} fullWidth />
<SheetActions onCancel={() => setPersonalSheetOpen(false)} onSave={savePersonal} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
</Stack>
</FormDialogShell>
<Divider /> {/* زبان — the row owns the server-stored preference; the actual UI locale switch is phase 2's LocaleSwitcher, reused verbatim. */}
<FormDialogShell
<Box> open={languageSheetOpen}
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}> title={t('row_language')}
{t('emergency_section')} dirty={languageDirty}
</Typography> onClose={() => setLanguageSheetOpen(false)}
<Typography variant="body2" sx={{ color: 'text.secondary' }}> closeLabel={closeLabel}
{t('emergency_hint')} discardTitle={discardTitle}
</Typography> discardBody={discardBody}
</Box> discardConfirmLabel={discardConfirmLabel}
discardCancelLabel={cancelLabel}
<TextField
label={t('emergency_name')}
value={emergencyName}
onChange={(e) => {
setEmergencyName(e.target.value);
if (nameError) setNameError(false);
}}
error={nameError}
fullWidth
/>
<PhoneNumberField
label={t('emergency_phone')}
value={emergencyPhone}
onChange={(value) => {
setEmergencyPhone(value);
if (phoneError) setPhoneError(false);
}}
error={phoneError}
helperText={phoneError ? t('emergency_phone_invalid') : undefined}
fullWidth
/>
<AppButton
color="primary"
variant="contained"
onClick={handleSave}
disabled={upsert.isPending}
sx={{ alignSelf: 'flex-start' }}
> >
{upsert.isPending ? tc('saving') : t('save')} <Stack sx={{ gap: 2.5 }}>
</AppButton> <Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
<Divider /> {t('app_language')}
{/* Address book lives alongside the profile in the customer area; a booking (f7) needs a
chosen address, so the entry point is surfaced here on the settings hub. */}
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', gap: 2 }}>
<AppIcon icon="location" size={28} color="var(--bal-primary)" />
<Stack sx={{ gap: 1, flexGrow: 1 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{ta('manage_title')}
</Typography> </Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
{ta('manage_body')} {t('app_language_hint')}
</Typography> </Typography>
</Box> <LocaleSwitcher />
<AppButton </Stack>
color="primary" <Divider />
variant="outlined" <RhfTextField<AccountFormValues> name="language" select label={t('language')} helperText={t('language_hint')}>
startIcon="location" <MenuItem value="fa">{t('language_fa')}</MenuItem>
to={`/${locale}${ROUTES.ADDRESSES}`} <MenuItem value="en">{t('language_en')}</MenuItem>
sx={{ alignSelf: 'flex-start' }} </RhfTextField>
> <SheetActions onCancel={() => setLanguageSheetOpen(false)} onSave={saveLanguage} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
{ta('manage_cta')}
</AppButton>
</Stack> </Stack>
</Paper> </FormDialogShell>
{/* مخاطب اضطراری */}
<FormDialogShell
open={emergencySheetOpen}
title={t('emergency_section')}
dirty={emergencyDirty}
onClose={() => setEmergencySheetOpen(false)}
closeLabel={closeLabel}
discardTitle={discardTitle}
discardBody={discardBody}
discardConfirmLabel={discardConfirmLabel}
discardCancelLabel={cancelLabel}
>
<Stack sx={{ gap: 2.5 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('emergency_hint')}
</Typography>
<RhfTextField<AccountFormValues>
name="emergencyName"
label={t('emergency_name')}
rules={{ validate: (value) => String(value ?? '').trim().length > 0 }}
fullWidth
/>
<RhfControlGroup<AccountFormValues>
name="emergencyPhone"
rules={{ validate: (value) => isIranianMobile(String(value ?? '')) }}
>
{({ field, hasError }) => (
<PhoneNumberField
label={t('emergency_phone')}
value={(field.value as string) ?? ''}
onChange={field.onChange}
error={hasError}
helperText={hasError ? t('emergency_phone_invalid') : undefined}
fullWidth
/>
)}
</RhfControlGroup>
<SheetActions onCancel={() => setEmergencySheetOpen(false)} onSave={saveEmergency} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
</Stack>
</FormDialogShell>
<ConfirmDialog
open={signOutOpen}
title={t('sign_out_confirm_title')}
body={t('sign_out_confirm_body')}
confirmLabel={t('sign_out')}
cancelLabel={cancelLabel}
confirmColor="error"
onClose={() => setSignOutOpen(false)}
onConfirm={() => {
setSignOutOpen(false);
logout.mutate();
}}
/>
</Box>
</FormProvider>
);
};
const AccountRow: FunctionComponent<{
icon: string;
label: string;
onClick: () => void;
tone?: 'default' | 'error';
}> = ({ icon, label, onClick, tone = 'default' }) => (
<Box
component="button"
type="button"
onClick={onClick}
sx={{ background: 'none', border: 'none', p: 0, width: '100%', textAlign: 'start', font: 'inherit', cursor: 'pointer' }}
>
<Stack
direction="row"
sx={{
alignItems: 'center',
gap: 1.5,
p: 1.5,
borderRadius: 'var(--bal-radius-md)',
color: tone === 'error' ? 'var(--bal-error)' : 'text.primary',
'&:hover': { bgcolor: 'action.hover' },
}}
>
<AppIcon icon={icon} size={22} color={tone === 'error' ? 'var(--bal-error)' : 'var(--bal-primary)'} />
<Typography variant="body1" sx={{ flexGrow: 1, fontWeight: 500 }}>
{label}
</Typography>
{tone !== 'error' ? <AppIcon icon="forward" size={18} color="var(--bal-text-secondary)" /> : null}
</Stack>
</Box>
);
const EmergencyContactCard: FunctionComponent<{
complete: boolean;
name: string;
phone: string;
onEdit: () => void;
}> = ({ complete, name, phone, onEdit }) => {
const t = useTranslations('profile');
return (
<Box sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1.5 }}>
<AppIcon icon={complete ? 'verified' : 'emergency'} size={22} color={complete ? 'var(--bal-success)' : 'var(--bal-warning)'} />
<Stack sx={{ flexGrow: 1, gap: 0.25, minWidth: 0 }}>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
{t('emergency_section')}
</Typography>
{complete ? (
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{name}
</Typography>
<Typography
component="a"
href={`tel:${phone}`}
dir="ltr"
variant="body2"
sx={{ color: 'var(--bal-primary)', textDecoration: 'none' }}
>
{phone}
</Typography>
</Stack>
) : (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('emergency_hint')}
</Typography>
)}
</Stack>
<AppButton variant="text" color="primary" onClick={onEdit}>
{t('edit')}
</AppButton>
</Stack>
</Box> </Box>
); );
}; };
const SheetActions: FunctionComponent<{
onCancel: () => void;
onSave: () => void;
saving: boolean;
saveLabel: string;
cancelLabel: string;
}> = ({ onCancel, onSave, saving, saveLabel, cancelLabel }) => {
const tc = useTranslations('common');
return (
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
<AppButton variant="text" onClick={onCancel} disabled={saving}>
{cancelLabel}
</AppButton>
<AppButton color="primary" variant="contained" onClick={onSave} disabled={saving}>
{saving ? tc('saving') : saveLabel}
</AppButton>
</Stack>
);
};
function ProfileSkeleton() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 520 }}>
<Stack sx={{ alignItems: 'center', gap: 1 }}>
<Skeleton variant="circular" width={56} height={56} />
<Skeleton variant="text" width={140} />
<Skeleton variant="text" width={100} />
</Stack>
<Stack sx={{ gap: 1 }}>
{[0, 1, 2, 3, 4, 5].map((key) => (
<Skeleton key={key} variant="rounded" height={56} />
))}
</Stack>
</Box>
);
}
@@ -2,32 +2,32 @@
import { Suspense, type FunctionComponent, type ReactNode } from 'react'; import { Suspense, type FunctionComponent, type ReactNode } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { Box, InputAdornment, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { import {
Box, AppButton,
InputAdornment, AppLoading,
Skeleton, CategoryTile,
Stack, ErrorState,
TextField, GenderToggle,
ToggleButton, JalaliDateIntentPicker,
ToggleButtonGroup, StickyActionBar,
Typography, } from '@/components';
} from '@mui/material';
import { AppButton, AppLoading, CategoryTile, ErrorState } from '@/components';
import CascadingRegionSelect from '@/components/geography/CascadingRegionSelect'; import CascadingRegionSelect from '@/components/geography/CascadingRegionSelect';
import { todayIso } from '@/components/common/JalaliDatePicker';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { useServiceCategories } from '@/services/catalog'; import { useServiceCategories } from '@/services/catalog';
import { pickCatalogName } from '@/services/catalog/names'; import { pickCatalogName } from '@/services/catalog/names';
import { useNurseSearch } from '@/services/search'; import { useNurseSearch } from '@/services/search';
import { filtersToSearchParams } from '@/services/search/filterParams'; import { filtersToSearchParams } from '@/services/search/filterParams';
import type { NurseGender } from '@/services/search/types';
import { useSearchFilters } from './useSearchFilters'; import { useSearchFilters } from './useSearchFilters';
/** /**
* C1 Search & filter (جستجو و فیلتر): the discovery entry screen. Pick a care category (reusing the * C1 Search & filter (جستجو و فیلتر): the discovery entry screen. Pick a care category (reusing the
* f4 catalog grid), a city (reusing the f3 cascading region picker; district optional = whole city), * f4 catalog grid), a city (reusing the f3 cascading region picker; district optional = whole city),
* the **prominent same-gender facet**, and an optional Toman price range; a live result count drives the * the **prominent same-gender facet** (the shared `GenderToggle`, `allowAny`), a Jalali date-intent chip
* "مشاهده N پرستار" CTA into C2. Availability (date) is intent-only at MVP it is carried to booking, * strip, and an optional Toman price range; a live result count drives the sticky "مشاهده N پرستار" CTA
* never used to hard-filter results. `useSearchParams` needs a Suspense boundary under static rendering. * into C2. Availability (date) is intent-only at MVP it is carried to booking, never used to hard-filter
* results. `useSearchParams` needs a Suspense boundary under static rendering.
*/ */
export default function SearchScreen() { export default function SearchScreen() {
return ( return (
@@ -37,32 +37,24 @@ export default function SearchScreen() {
); );
} }
const GENDER_OPTIONS: readonly (NurseGender | 'any')[] = ['female', 'male', 'any'];
function SearchFilterScreen() { function SearchFilterScreen() {
const t = useTranslations('search'); const t = useTranslations('search');
const router = useRouter(); const router = useRouter();
const locale = useLocale(); const locale = useLocale();
const params = useSearchParams(); const params = useSearchParams();
const initialCategoryRaw = Number(params.get('category_id')); const controller = useSearchFilters(params);
const initialCategoryId = Number.isInteger(initialCategoryRaw) && initialCategoryRaw > 0 ? initialCategoryRaw : undefined;
const controller = useSearchFilters(initialCategoryId);
const { data, isFetching } = useNurseSearch(controller.filters); const { data, isFetching } = useNurseSearch(controller.filters);
const count = data?.total; const count = data?.total;
const goToResults = () => { const goToResults = () => {
const query = filtersToSearchParams(controller.filters); const query = filtersToSearchParams(controller.filters);
if (controller.region.provinceId) query.set('province_id', String(controller.region.provinceId));
if (controller.dateIntent) query.set('date', controller.dateIntent); if (controller.dateIntent) query.set('date', controller.dateIntent);
router.push(`/${locale}${ROUTES.SEARCH_RESULTS}?${query.toString()}`); router.push(`/${locale}${ROUTES.SEARCH_RESULTS}?${query.toString()}`);
}; };
const ctaLabel = !controller.isReady const zeroResults = controller.isReady && !isFetching && count === 0;
? t('cta_choose_category_city')
: isFetching || count == null
? t('cta_loading')
: t('cta_view_results', { count });
return ( return (
<Stack sx={{ gap: 3 }}> <Stack sx={{ gap: 3 }}>
@@ -82,31 +74,19 @@ function SearchFilterScreen() {
</FilterSection> </FilterSection>
<FilterSection title={t('section_gender')} hint={t('gender_hint')}> <FilterSection title={t('section_gender')} hint={t('gender_hint')}>
<ToggleButtonGroup <GenderToggle
exclusive allowAny
fullWidth
color="primary"
value={controller.gender ?? 'any'} value={controller.gender ?? 'any'}
onChange={(_event, value: NurseGender | 'any' | null) => { onChange={(value) => controller.setGender(value === 'any' ? undefined : value)}
if (value != null) controller.setGender(value === 'any' ? undefined : value); maleLabel={t('gender_male')}
}} femaleLabel={t('gender_female')}
> anyLabel={t('gender_any')}
{GENDER_OPTIONS.map((option) => ( ariaLabel={t('section_gender')}
<ToggleButton key={option} value={option} sx={{ fontWeight: 700 }}> />
{t(`gender_${option}`)}
</ToggleButton>
))}
</ToggleButtonGroup>
</FilterSection> </FilterSection>
<FilterSection title={t('section_date')} hint={t('date_hint')}> <FilterSection title={t('section_date')} hint={t('date_hint')}>
<TextField <DateIntentFilter value={controller.dateIntent} onChange={controller.setDateIntent} />
type="date"
fullWidth
value={controller.dateIntent}
onChange={(event) => controller.setDateIntent(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
</FilterSection> </FilterSection>
<FilterSection title={t('section_price')} hint={t('price_hint')}> <FilterSection title={t('section_price')} hint={t('price_hint')}>
@@ -126,17 +106,35 @@ function SearchFilterScreen() {
</Stack> </Stack>
</FilterSection> </FilterSection>
<AppButton <StickyActionBar>
color="primary" {zeroResults ? (
variant="contained" <Stack sx={{ gap: 0.25 }} data-search-cta="zero">
size="large" <Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
disabled={!controller.isReady} {t('cta_zero_title')}
onClick={goToResults} </Typography>
startIcon="search" <Typography variant="caption" sx={{ color: 'text.secondary' }}>
sx={{ py: 1.5 }} {t('cta_zero_hint')}
> </Typography>
{ctaLabel} </Stack>
</AppButton> ) : (
<AppButton
color="primary"
variant="contained"
size="large"
disabled={!controller.isReady}
onClick={goToResults}
startIcon="search"
sx={{ py: 1.5, width: '100%' }}
data-search-cta="view-results"
>
{!controller.isReady
? t('cta_choose_category_city')
: isFetching || count == null
? t('cta_loading')
: t('cta_view_results', { count })}
</AppButton>
)}
</StickyActionBar>
</Stack> </Stack>
); );
} }
@@ -177,6 +175,29 @@ const PriceField: FunctionComponent<{
/> />
); );
/**
* The Jalali date-intent picker: a horizontal «امروز»/«فردا» + day-chip strip (the next 7 days) plus a
* calendar-icon entry into the full Jalali grid for later dates. Intent-only the value stays the same
* ISO string the flow already carries and never hard-filters results.
*/
const DateIntentFilter: FunctionComponent<{ value: string; onChange: (iso: string) => void }> = ({
value,
onChange,
}) => {
const t = useTranslations('search');
return (
<JalaliDateIntentPicker
value={value}
onChange={onChange}
min={todayIso()}
todayLabel={t('date_today')}
tomorrowLabel={t('date_tomorrow')}
pickOtherLabel={t('date_pick_other')}
/>
);
};
/** The reused f4 category grid (data-driven from the cached catalog reference data), with selection. */ /** The reused f4 category grid (data-driven from the cached catalog reference data), with selection. */
const CategorySelect: FunctionComponent<{ selectedId: number | null; onSelect: (id: number) => void }> = ({ const CategorySelect: FunctionComponent<{ selectedId: number | null; onSelect: (id: number) => void }> = ({
selectedId, selectedId,
@@ -192,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 ? (
@@ -3,22 +3,40 @@ import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { Avatar, Box, Chip, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material'; import { Avatar, Box, Chip, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
import { AppButton, AppIcon, EmptyState, ErrorState, RatingInput, ServicePriceRow, TrustBadge } from '@/components'; import {
AppButton,
AppIcon,
EmptyState,
ErrorState,
PriceDisplay,
RatingInput,
ServicePriceRow,
StickyActionBar,
SurfaceCard,
TrustBadge,
VerificationPanel,
} from '@/components';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors'; import { ApiError } from '@/lib/api/errors';
import { formatNumber, formatShamsiDate } from '@/utils'; import { formatNumber, formatShamsiDate } from '@/utils';
import { useNurseProfile } from '@/services/search'; import { useNurseProfile } from '@/services/search';
import type { NurseProfile } from '@/services/search/types'; import type { NurseProfile, NurseProfileServiceRow } from '@/services/search/types';
import { useNurseReviews } from '@/services/reviews'; import { useNurseReviews } from '@/services/reviews';
import type { ReviewListItem } from '@/services/reviews/types'; import type { ReviewListItem } from '@/services/reviews/types';
import { useNurseTrustBadge } from '@/services/verification';
type ProfileTab = 'services' | 'reviews'; type ProfileTab = 'services' | 'reviews';
/** /**
* C3 Nurse profile (پروفایل پرستار): identity + trust badges ( تاییدشده, نظام پرستاری), attribute * C3 Nurse profile (پروفایل پرستار): the trust dossier identity header (completed visits + rating),
* chips, and a **tabbed** body «خدمات» (the priced services list) and «نظرات» (the f13 published-reviews * a tappable تاییدشده badge + «نظام پرستاری» chip, the shared `VerificationPanel` (what Balinyaar
* tab: aggregate rating + count + an infinite list). Only `published` reviews are ever requested/rendered. * verified, fed by the public trust-badge read), attribute chips, and a **tabbed** body «خدمات» (the
* The primary CTA "درخواست رزرو" hands the selected nurse + variant + `required_caregiver_gender` to f7. * priced services list + an optional latest-review snippet) and «نظرات» (the f13 published-reviews tab:
* fractional aggregate rating + count + an infinite list). Only `published` reviews are ever
* requested/rendered. The primary "درخواست رزرو" CTA is a **sticky bottom bar** (price-from beside the
* button) so it survives the infinite reviews list, and hands the selected nurse + variant +
* `required_caregiver_gender` to f7. The profile DTO does not yet serve `nurseGender` (REQ-042) the
* header intentionally omits a gender chip rather than render the client's placeholder stub.
*/ */
export default function NurseProfilePage() { export default function NurseProfilePage() {
const t = useTranslations('search'); const t = useTranslations('search');
@@ -54,8 +72,11 @@ export default function NurseProfilePage() {
if (!profile) return null; if (!profile) return null;
const carriedVariant = query.get('variant_id');
const primaryService: NurseProfileServiceRow | undefined =
profile.services.find((service) => String(service.variantId) === carriedVariant) ?? profile.services[0];
const requestBooking = () => { const requestBooking = () => {
const carriedVariant = query.get('variant_id');
const variantId = carriedVariant ?? String(profile.services[0]?.variantId ?? ''); const variantId = carriedVariant ?? String(profile.services[0]?.variantId ?? '');
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('nurse_id', String(profile.nurseId)); params.set('nurse_id', String(profile.nurseId));
@@ -76,6 +97,7 @@ export default function NurseProfilePage() {
<Stack sx={{ gap: 3 }}> <Stack sx={{ gap: 3 }}>
<ProfileHeader profile={profile} /> <ProfileHeader profile={profile} />
<AttributeChips profile={profile} /> <AttributeChips profile={profile} />
<VerificationSection nurseId={profile.nurseId} />
<Tabs value={tab} onChange={(_, next: ProfileTab) => setTab(next)} sx={{ borderBottom: 1, borderColor: 'divider' }}> <Tabs value={tab} onChange={(_, next: ProfileTab) => setTab(next)} sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tab value="services" label={t('tab_services')} sx={{ textTransform: 'none', fontWeight: 700 }} /> <Tab value="services" label={t('tab_services')} sx={{ textTransform: 'none', fontWeight: 700 }} />
@@ -84,16 +106,28 @@ export default function NurseProfilePage() {
{tab === 'services' ? <ServicesSection profile={profile} /> : <ReviewsPanel nurseId={profile.nurseId} />} {tab === 'services' ? <ServicesSection profile={profile} /> : <ReviewsPanel nurseId={profile.nurseId} />}
<AppButton <StickyActionBar>
color="primary" <Stack direction="row" sx={{ gap: 2, alignItems: 'center', justifyContent: 'space-between' }}>
variant="contained" {primaryService ? (
size="large" <Box>
onClick={requestBooking} <Typography variant="caption" sx={{ color: 'text.secondary' }}>
startIcon="bookings" {t('price_from')}
sx={{ py: 1.5 }} </Typography>
> <PriceDisplay price={primaryService.priceIrr} priceUnit={primaryService.priceUnit} align="start" />
{t('request_booking')} </Box>
</AppButton> ) : null}
<AppButton
color="primary"
variant="contained"
size="large"
onClick={requestBooking}
startIcon="bookings"
sx={{ py: 1.5, flexGrow: 1 }}
>
{t('request_booking')}
</AppButton>
</Stack>
</StickyActionBar>
</Stack> </Stack>
); );
} }
@@ -106,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 }}>
@@ -121,7 +158,7 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) {
{name} {name}
</Typography> </Typography>
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}> <Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="star" size={18} color="var(--bal-warning)" /> <AppIcon icon="star" size={18} color="var(--bal-rating)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}> <Typography variant="body2" sx={{ fontWeight: 700 }}>
{rating} {rating}
</Typography> </Typography>
@@ -129,11 +166,14 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) {
{t('reviews_count', { count: profile.totalReviews })} {t('reviews_count', { count: profile.totalReviews })}
</Typography> </Typography>
</Stack> </Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('completed_visits', { count: formatNumber(profile.totalCompletedBookings, locale) })}
</Typography>
</Stack> </Stack>
</Stack> </Stack>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}> <Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<TrustBadge state="verified" /> <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)" />}
@@ -152,6 +192,23 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) {
); );
} }
/** "What Balinyaar verified" — the shared `VerificationPanel`, fed by the public trust-badge read. */
function VerificationSection({ nurseId }: { nurseId: number }) {
const t = useTranslations('verification');
const { data: badge, isLoading, isError } = useNurseTrustBadge(nurseId);
return (
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('explainer_title')}
</Typography>
<SurfaceCard padding="md">
<VerificationPanel badge={badge} isLoading={isLoading} isError={isError} />
</SurfaceCard>
</Stack>
);
}
function AttributeChips({ profile }: { profile: NurseProfile }) { function AttributeChips({ profile }: { profile: NurseProfile }) {
const t = useTranslations('search'); const t = useTranslations('search');
const locale = useLocale(); const locale = useLocale();
@@ -177,7 +234,7 @@ function AttributeChips({ profile }: { profile: NurseProfile }) {
function ServicesSection({ profile }: { profile: NurseProfile }) { function ServicesSection({ profile }: { profile: NurseProfile }) {
const t = useTranslations('search'); const t = useTranslations('search');
return ( return (
<Stack sx={{ gap: 1 }}> <Stack sx={{ gap: 2 }}>
{profile.services.length === 0 ? ( {profile.services.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('services_empty')} {t('services_empty')}
@@ -195,10 +252,34 @@ function ServicesSection({ profile }: { profile: NurseProfile }) {
))} ))}
</Box> </Box>
)} )}
{profile.latestReview ? <LatestReviewSnippet review={profile.latestReview} /> : null}
</Stack> </Stack>
); );
} }
/** The already-fetched latest-review snippet — a small taste of the dossier's reviews tab. */
function LatestReviewSnippet({ review }: { review: NonNullable<NurseProfile['latestReview']> }) {
const t = useTranslations('search');
const tr = useTranslations('reviews');
const locale = useLocale();
return (
<SurfaceCard padding="sm">
<Stack sx={{ gap: 0.75 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t('latest_review_title')}
</Typography>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<RatingInput value={review.rating} readOnly size={16} ariaLabel={tr('rating_label')} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{review.authorMasked} · {formatShamsiDate(review.createdAt, locale)}
</Typography>
</Stack>
{review.body ? <Typography variant="body2">{review.body}</Typography> : null}
</Stack>
</SurfaceCard>
);
}
/** /**
* The f13 reviews tab the aggregate rating + count and an infinite list of **published** reviews. Never * The f13 reviews tab the aggregate rating + count and an infinite list of **published** reviews. Never
* requests or renders `pending_moderation`/`hidden`/`rejected` content; the aggregate is the server's * requests or renders `pending_moderation`/`hidden`/`rejected` content; the aggregate is the server's
@@ -277,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' }}>
@@ -2,20 +2,38 @@
import { Suspense, useCallback, useMemo, useState } from 'react'; import { Suspense, useCallback, useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { MenuItem, Stack, TextField, Typography } from '@mui/material'; import { Box, Chip, Stack, Typography } from '@mui/material';
import type { SxProps, Theme } from '@mui/material';
import { AppButton, AppLoading, EmptyState, ErrorState, NurseResultCard } from '@/components'; import { AppButton, AppLoading, EmptyState, ErrorState, NurseResultCard } from '@/components';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { useServiceCategories } from '@/services/catalog';
import { pickCatalogName } from '@/services/catalog/names';
import { useCities, useDistricts } from '@/services/geography';
import { pickRegionName } from '@/services/geography/names';
import { useNurseSearch } from '@/services/search'; import { useNurseSearch } from '@/services/search';
import { searchParamsToFilters } from '@/services/search/filterParams'; import { searchParamsToFilters } from '@/services/search/filterParams';
import { SEARCH_PAGE_SIZE } from '@/services/search/constants'; import { SEARCH_PAGE_SIZE } from '@/services/search/constants';
import { formatIrrToToman } from '@/utils';
import type { NurseSearchResult } from '@/services/search/types'; import type { NurseSearchResult } from '@/services/search/types';
/** Single column on mobile; two columns above `md` (~900px) so the extra desktop width goes toward
* wider cards instead of one long phone-column list (§3.10 a full list+detail split is DEFERRED). */
const RESULTS_GRID_SX: SxProps<Theme> = {
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' },
gap: 1.5,
alignItems: 'start',
};
/** /**
* C2 Results (نتایج جستجو): the rating-sorted list of **only verified, accepting** nurses for the * C2 Results (نتایج جستجو): the rating-sorted list of **only verified, accepting** nurses for the
* carried filter set. The filter set lives in the URL (the deep-linkable, back/forward-safe cache key), * carried filter set. The filter set lives in the URL (the deep-linkable, back/forward-safe cache key),
* so returning to a prior filter URL is a cache hit with zero network calls (`useNurseSearch` + * so returning to a prior filter URL is a cache hit with zero network calls (`useNurseSearch` +
* `keepPreviousData`). Renders all four states (loading skeletons / empty "relax filters" / error-retry * `keepPreviousData`). A tappable **filter-recap chip row** (category · region · gender · price) deep-
* / populated). Tapping a card opens C3, carrying the nurse + variant + gender intent. * links back to C1 carrying the *entire* current query string every filter C1 set, including the
* client-only `province_id`/`date` params so C1 hydrates fully instead of resetting to just the
* category. Renders all four states (loading skeletons / empty "relax filters" / error-retry /
* populated). Tapping a card opens C3, carrying the nurse + variant + gender intent.
*/ */
export default function SearchResultsPage() { export default function SearchResultsPage() {
return ( return (
@@ -37,12 +55,48 @@ function ResultsScreen() {
// The URL is the source of truth for the filter set; grow only the page size for "load more". // The URL is the source of truth for the filter set; grow only the page size for "load more".
const filters = useMemo(() => ({ ...searchParamsToFilters(params), pageSize }), [params, pageSize]); const filters = useMemo(() => ({ ...searchParamsToFilters(params), pageSize }), [params, pageSize]);
const dateIntent = params.get('date') ?? undefined; const dateIntent = params.get('date') ?? undefined;
const provinceIdParam = params.get('province_id');
const { data, isLoading, isError, isFetching, refetch } = useNurseSearch(filters); const { data, isLoading, isError, isFetching, refetch } = useNurseSearch(filters);
const items = data?.items ?? []; const items = data?.items ?? [];
const total = data?.total ?? 0; const total = data?.total ?? 0;
const hasMore = items.length < total; const hasMore = items.length < total;
const { data: categoriesData } = useServiceCategories();
const categories = useMemo(() => categoriesData?.items ?? [], [categoriesData]);
const categoryLabelById = useMemo(() => {
const map = new Map<number, string>();
categories.forEach((category) => map.set(category.id, pickCatalogName(category, locale)));
return map;
}, [categories, locale]);
const categoryLabel = categoryLabelById.get(filters.serviceCategoryId);
const { data: cities } = useCities(provinceIdParam ? Number(provinceIdParam) : undefined);
const { data: districts } = useDistricts(filters.cityId || undefined);
const city = cities?.find((candidate) => candidate.id === filters.cityId);
const district = filters.districtId ? districts?.find((candidate) => candidate.id === filters.districtId) : undefined;
const regionLabel = city
? `${pickRegionName(city, locale)} · ${district ? pickRegionName(district, locale) : t('whole_city')}`
: undefined;
const genderLabel = t(`gender_${filters.nurseGender ?? 'any'}`);
const priceLabel = filters.priceMin
? filters.priceMax
? t('price_chip_range', {
min: formatIrrToToman(filters.priceMin, locale),
max: formatIrrToToman(filters.priceMax, locale),
})
: t('price_chip_min', { min: formatIrrToToman(filters.priceMin, locale) })
: filters.priceMax
? t('price_chip_max', { max: formatIrrToToman(filters.priceMax, locale) })
: undefined;
const backToFilters = useCallback(
() => router.push(`/${locale}${ROUTES.SEARCH}?${params.toString()}`),
[router, locale, params],
);
const openProfile = useCallback( const openProfile = useCallback(
(nurse: NurseSearchResult) => { (nurse: NurseSearchResult) => {
const query = new URLSearchParams(); const query = new URLSearchParams();
@@ -56,34 +110,49 @@ function ResultsScreen() {
[router, locale, filters.serviceCategoryId, filters.cityId, filters.nurseGender, dateIntent], [router, locale, filters.serviceCategoryId, filters.cityId, filters.nurseGender, dateIntent],
); );
const backToFilters = () => router.push(`/${locale}${ROUTES.SEARCH}`);
return ( return (
<Stack sx={{ gap: 2 }}> <Stack sx={{ gap: 2 }}>
<Stack direction="row" sx={{ gap: 2, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}> <Stack sx={{ gap: 0.25 }}>
<Typography variant="h6" component="h1"> <Typography variant="h6" component="h1">
{isLoading ? t('results_loading_title') : t('results_count', { count: total })} {isLoading ? t('results_loading_title') : t('results_count', { count: total })}
</Typography> </Typography>
{/* Rating is the only MVP sort; rendered as a control with a single option. Other sorts DEFERRED. */} {/* Rating is the only MVP sort a static caption, not a dead-interactive dropdown. Other
<TextField select size="small" label={t('sort_label')} value="rating" sx={{ minWidth: 160 }}> sorts are DEFERRED until the API grows them. */}
<MenuItem value="rating">{t('sort_rating')}</MenuItem> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
</TextField> {t('sort_static')}
</Typography>
</Stack>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{categoryLabel ? (
<Chip label={categoryLabel} onClick={backToFilters} data-recap-chip="category" />
) : null}
{regionLabel ? <Chip label={regionLabel} onClick={backToFilters} data-recap-chip="region" /> : null}
<Chip label={genderLabel} onClick={backToFilters} data-recap-chip="gender" />
{priceLabel ? <Chip label={priceLabel} onClick={backToFilters} data-recap-chip="price" /> : null}
</Stack> </Stack>
{isLoading ? ( {isLoading ? (
<Stack sx={{ gap: 1.5 }}> <Box sx={RESULTS_GRID_SX}>
{[0, 1, 2, 3].map((key) => ( {[0, 1, 2, 3].map((key) => (
<NurseResultCard.Skeleton key={key} /> <NurseResultCard.Skeleton key={key} />
))} ))}
</Stack> </Box>
) : isError ? ( ) : isError ? (
<ErrorState message={t('results_error')} retryLabel={tc('retry')} onRetry={() => refetch()} /> <ErrorState message={t('results_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />
) : items.length === 0 ? ( ) : items.length === 0 ? (
<RelaxFiltersEmptyState onRelax={backToFilters} /> <RelaxFiltersEmptyState onRelax={backToFilters} />
) : ( ) : (
<Stack sx={{ gap: 1.5 }}> // Above ~900px (`md`), a two-column grid uses the extra width instead of one long phone-column
// (the doc's "wider cards" option — a full list+detail split is DEFERRED, see the phase doc).
<Box sx={RESULTS_GRID_SX}>
{items.map((nurse) => ( {items.map((nurse) => (
<NurseResultCard key={`${nurse.nurseId}-${nurse.variantId}`} nurse={nurse} onSelect={openProfile} /> <NurseResultCard
key={`${nurse.nurseId}-${nurse.variantId}`}
nurse={nurse}
serviceLabel={categoryLabelById.get(nurse.serviceCategoryId) ?? t('unnamed_service')}
onSelect={openProfile}
/>
))} ))}
{hasMore ? ( {hasMore ? (
<AppButton <AppButton
@@ -91,12 +160,12 @@ function ResultsScreen() {
color="primary" color="primary"
onClick={() => setPageSize((size) => size + SEARCH_PAGE_SIZE)} onClick={() => setPageSize((size) => size + SEARCH_PAGE_SIZE)}
disabled={isFetching} disabled={isFetching}
sx={{ alignSelf: 'center' }} sx={{ alignSelf: 'center', gridColumn: '1 / -1' }}
> >
{t('load_more')} {t('load_more')}
</AppButton> </AppButton>
) : null} ) : null}
</Stack> </Box>
)} )}
</Stack> </Stack>
); );
@@ -118,7 +187,7 @@ function RelaxFiltersEmptyState({ onRelax }: { onRelax: () => void }) {
{t('empty_suggest_district')} {t('empty_suggest_district')}
</Typography> </Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('empty_suggest_city')} {t('empty_suggest_date')}
</Typography> </Typography>
</Stack> </Stack>
} }
@@ -1,11 +1,16 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { toEnglishDigits, tomanToRial } from '@/utils'; import { toEnglishDigits, tomanToRial } from '@/utils';
import { rialToToman } from '@/utils/money';
import { useDebouncedValue } from '@/services/search'; import { useDebouncedValue } from '@/services/search';
import { SEARCH_FILTER_DEBOUNCE_MS, SEARCH_PAGE_SIZE } from '@/services/search/constants'; import { SEARCH_FILTER_DEBOUNCE_MS, SEARCH_PAGE_SIZE } from '@/services/search/constants';
import { parsePositiveInt, searchParamsToFilters } from '@/services/search/filterParams';
import type { NurseGender, NurseSearchFilters } from '@/services/search/types'; import type { NurseGender, NurseSearchFilters } from '@/services/search/types';
import type { CascadingRegionValue } from '@/components/geography/CascadingRegionSelect'; import type { CascadingRegionValue } from '@/components/geography/CascadingRegionSelect';
const EMPTY_REGION: CascadingRegionValue = { provinceId: null, cityId: null, districtId: null }; /** Minimal read surface shared by `URLSearchParams` and Next's `ReadonlyURLSearchParams`. */
interface ParamReader {
get(name: string): string | null;
}
/** Toman input → IRR-Rial digit-string at the field boundary; undefined for blank/invalid input. */ /** Toman input → IRR-Rial digit-string at the field boundary; undefined for blank/invalid input. */
function tomanInputToIrr(toman: string): string | undefined { function tomanInputToIrr(toman: string): string | undefined {
@@ -14,20 +19,41 @@ function tomanInputToIrr(toman: string): string | undefined {
return tomanToRial(digits); return tomanToRial(digits);
} }
/** IRR digit-string (or undefined) → the whole-Toman string the price fields display. */
function irrToTomanInput(irr: string | undefined): string {
return irr ? String(rialToToman(irr)) : '';
}
/** /**
* The C1 filter controller fast-changing UI state kept **colocated** (not in a high context provider, * The C1 filter controller fast-changing UI state kept **colocated** (not in a high context provider,
* phase §5). Holds the category, cascading region, same-gender facet, and Toman price inputs, and * phase §5). Holds the category, cascading region, same-gender facet, and Toman price inputs, and
* derives the canonical `NurseSearchFilters` that becomes the live-count query key and the C2 URL. The * derives the canonical `NurseSearchFilters` that becomes the live-count query key and the C2 URL. The
* price inputs are **debounced** so typing doesn't fan out one search per keystroke before the value * price inputs are **debounced** so typing doesn't fan out one search per keystroke before the value
* joins the query key. `districtId = null` (whole city) is carried as an omitted filter, never a bogus id. * joins the query key. `districtId = null` (whole city) is carried as an omitted filter, never a bogus id.
*
* `params` seeds the **initial** state only (a lazy `useState` read) either a bare `?category_id=`
* (the Home tile handoff) or a full filter set carried back from a C2 recap chip (`searchParamsToFilters`
* reads every field C2's URL carries). `province_id` is a client-only convenience param (not part of
* `NurseSearchFilters`/the search query key) so `CascadingRegionSelect` can prefill the city dropdown
* without a server round trip; `goToResults` re-carries it so the round trip back to C1 keeps working.
*/ */
export function useSearchFilters(initialCategoryId?: number) { export function useSearchFilters(params: ParamReader) {
const [categoryId, setCategoryId] = useState<number | null>(initialCategoryId ?? null); const [categoryId, setCategoryId] = useState<number | null>(() => {
const [region, setRegion] = useState<CascadingRegionValue>(EMPTY_REGION); const raw = searchParamsToFilters(params).serviceCategoryId;
const [gender, setGender] = useState<NurseGender | undefined>(undefined); return raw > 0 ? raw : null;
const [priceMinToman, setPriceMinToman] = useState(''); });
const [priceMaxToman, setPriceMaxToman] = useState(''); const [region, setRegion] = useState<CascadingRegionValue>(() => {
const [dateIntent, setDateIntent] = useState(''); const initial = searchParamsToFilters(params);
return {
provinceId: parsePositiveInt(params.get('province_id')) ?? null,
cityId: initial.cityId > 0 ? initial.cityId : null,
districtId: initial.districtId ?? null,
};
});
const [gender, setGender] = useState<NurseGender | undefined>(() => searchParamsToFilters(params).nurseGender);
const [priceMinToman, setPriceMinToman] = useState(() => irrToTomanInput(searchParamsToFilters(params).priceMin));
const [priceMaxToman, setPriceMaxToman] = useState(() => irrToTomanInput(searchParamsToFilters(params).priceMax));
const [dateIntent, setDateIntent] = useState(() => params.get('date') ?? '');
const debouncedMin = useDebouncedValue(priceMinToman, SEARCH_FILTER_DEBOUNCE_MS); const debouncedMin = useDebouncedValue(priceMinToman, SEARCH_FILTER_DEBOUNCE_MS);
const debouncedMax = useDebouncedValue(priceMaxToman, SEARCH_FILTER_DEBOUNCE_MS); const debouncedMax = useDebouncedValue(priceMaxToman, SEARCH_FILTER_DEBOUNCE_MS);
@@ -8,12 +8,12 @@ import { useWalletInstallments } from '@/services/bnpl';
import type { WalletInstallmentPlan } from '@/services/bnpl/types'; import type { WalletInstallmentPlan } from '@/services/bnpl/types';
/** /**
* D5 · پیگیری اقساط the Wallet view of active installment plans. It reads `useWalletInstallments` and * D5 · پیگیری اقساط the Wallet «اقساط» section (active installment plans). It reads
* renders **provider-reported** status: an outstanding-balance card (terracotta), the next-installment * `useWalletInstallments` and renders **provider-reported** status: an outstanding-balance card
* date + a provider hand-off «پرداخت زودهنگام» (early-pay is a *provider* action, never a Balinyaar * (terracotta), the next-installment date + a provider hand-off «پرداخت زودهنگام» (early-pay is a
* transaction), the per-installment due list with status chips, and the ownership note (Balinyaar displays, * *provider* action, never a Balinyaar transaction), the per-installment due list with status chips, and
* it does not manage, this schedule). Self-contained under the Wallet route so f12 nurse-earnings content * the ownership note (Balinyaar displays, it does not manage, this schedule). Section body only the
* can land beside it later. * page-level heading + tab strip live in `WalletScreen`.
*/ */
const WalletInstallments: FunctionComponent = () => { const WalletInstallments: FunctionComponent = () => {
const t = useTranslations('bnpl'); const t = useTranslations('bnpl');
@@ -21,11 +21,7 @@ const WalletInstallments: FunctionComponent = () => {
const { data: plans, isLoading, isError, refetch } = useWalletInstallments(); const { data: plans, isLoading, isError, refetch } = useWalletInstallments();
return ( return (
<Stack sx={{ gap: 2, maxWidth: 560, mx: 'auto', width: '100%' }}> <Stack sx={{ gap: 2, width: '100%' }}>
<Typography variant="h6" component="h1">
{t('wallet_title')}
</Typography>
{isLoading ? ( {isLoading ? (
<Stack sx={{ gap: 1.5 }}> <Stack sx={{ gap: 1.5 }}>
<Skeleton variant="rounded" height={128} /> <Skeleton variant="rounded" height={128} />
@@ -33,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 }}>
@@ -75,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')}
@@ -0,0 +1,62 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Skeleton, Stack, Typography } from '@mui/material';
import { AppLink, EmptyState, ErrorState, Money, PaymentStatusBadge, SurfaceCard } from '@/components';
import { ROUTES } from '@/constants';
import { formatShamsiDateTime } from '@/utils';
import { useWalletHistoryRows } from './useWalletHistoryRows';
/**
* The wallet «پرداختها» section every card + BNPL payment (down-payment) the customer made, newest
* first, with a deep-link to the booking. For a card-paying customer (the default path) this is what
* finally fills the previously permanently-empty Wallet tab.
*/
const WalletPaymentHistory: FunctionComponent = () => {
const t = useTranslations('bnpl');
const tc = useTranslations('common');
const locale = useLocale();
const { rows, isLoading, bothErrored, refetch } = useWalletHistoryRows();
if (isLoading) {
return (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="rounded" height={72} />
<Skeleton variant="rounded" height={72} />
</Stack>
);
}
if (bothErrored) {
return <ErrorState message={t('wallet_error_body')} retryLabel={tc('retry')} onRetry={refetch} />;
}
if (rows.length === 0) {
return <EmptyState icon="payment" title={t('history_empty_title')} body={t('history_empty_body')} />;
}
return (
<Stack sx={{ gap: 1.5 }}>
{rows.map((row) => (
<SurfaceCard key={row.key} padding="sm">
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
<Stack sx={{ gap: 0.25 }}>
<Money amountIrr={row.amountIrr} tone="emphasis" size="sm" sx={{ fontWeight: 700 }} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{formatShamsiDateTime(row.createdAt, locale)}
</Typography>
</Stack>
<Stack sx={{ alignItems: 'flex-end', gap: 0.5 }}>
<PaymentStatusBadge status={row.status} />
{row.bookingId != null ? (
<AppLink to={`/${locale}${ROUTES.BOOKINGS}/${row.bookingId}`} sx={{ fontSize: '0.75rem' }}>
{t('history_view_booking')}
</AppLink>
) : null}
</Stack>
</Stack>
</SurfaceCard>
))}
</Stack>
);
};
export default WalletPaymentHistory;
@@ -0,0 +1,66 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, EmptyState, ErrorState, Money, SurfaceCard } from '@/components';
import { bookingInvoicePath } from '@/constants';
import { formatShamsiDateTime } from '@/utils';
import { useWalletHistoryRows } from './useWalletHistoryRows';
/**
* The wallet «رسیدها» section no receipts endpoint exists; every succeeded, booking-linked payment
* (card or BNPL down-payment) derives its invoice deep-link client-side (a UI join over the same rows the
* «پرداختها» tab renders, filtered to `succeeded` + a known `bookingId` no money math).
*/
const WalletReceipts: FunctionComponent = () => {
const t = useTranslations('bnpl');
const tp = useTranslations('payment');
const tc = useTranslations('common');
const locale = useLocale();
const { rows, isLoading, bothErrored, refetch } = useWalletHistoryRows();
if (isLoading) {
return (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="rounded" height={72} />
</Stack>
);
}
if (bothErrored) {
return <ErrorState message={t('wallet_error_body')} retryLabel={tc('retry')} onRetry={refetch} />;
}
const receipts = rows.filter((row) => row.status === 'succeeded' && row.bookingId != null);
if (receipts.length === 0) {
return <EmptyState icon="document" title={t('receipts_empty_title')} body={t('receipts_empty_body')} />;
}
return (
<Stack sx={{ gap: 1.5 }}>
{receipts.map((row) => (
<SurfaceCard key={row.key} padding="sm">
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
<Stack sx={{ gap: 0.25 }}>
<Money amountIrr={row.amountIrr} tone="emphasis" size="sm" sx={{ fontWeight: 700 }} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{formatShamsiDateTime(row.createdAt, locale)}
</Typography>
</Stack>
<AppButton
variant="outlined"
color="primary"
size="small"
startIcon="document"
to={`/${locale}${bookingInvoicePath(row.bookingId as number)}`}
>
{tp('view_invoice_cta')}
</AppButton>
</Stack>
</SurfaceCard>
))}
</Stack>
);
};
export default WalletReceipts;
@@ -0,0 +1,54 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Skeleton, Stack, Typography } from '@mui/material';
import { AppLink, EmptyState, ErrorState } from '@/components';
import RefundStatusCard from '@/components/RefundStatusCard';
import { bookingRefundStatusPath } from '@/constants';
import { useMyRefunds } from '@/services/refunds';
/**
* The wallet «استردادها» section every refund the customer owns (REQ-048), each rendered via the shared
* `RefundStatusCard` (step timeline + amount + per-channel ETA) with a link back to its booking.
*/
const WalletRefunds: FunctionComponent = () => {
const t = useTranslations('refunds');
const tw = useTranslations('bnpl');
const tc = useTranslations('common');
const locale = useLocale();
const { data: refunds, isLoading, isError, refetch } = useMyRefunds();
if (isLoading) {
return (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="rounded" height={160} />
</Stack>
);
}
if (isError) {
return <ErrorState message={tw('wallet_error_body')} retryLabel={tc('retry')} onRetry={() => refetch()} />;
}
if (!refunds || refunds.length === 0) {
return <EmptyState icon="refunds" title={t('wallet_empty_title')} body={t('wallet_empty_body')} />;
}
return (
<Stack sx={{ gap: 3 }}>
{refunds.map((refund) => (
<Stack key={refund.id} sx={{ gap: 1 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('wallet_booking_label', { id: refund.bookingId })}
</Typography>
<AppLink to={`/${locale}${bookingRefundStatusPath(refund.bookingId)}`} sx={{ fontSize: '0.75rem' }}>
{t('view_refund_status')}
</AppLink>
</Stack>
<RefundStatusCard refund={refund} />
</Stack>
))}
</Stack>
);
};
export default WalletRefunds;
@@ -0,0 +1,57 @@
'use client';
import { FunctionComponent, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Box, Stack, Tab, Tabs } from '@mui/material';
import { AppIcon, PageHeader } from '@/components';
import WalletPaymentHistory from './WalletPaymentHistory';
import WalletInstallments from './WalletInstallments';
import WalletRefunds from './WalletRefunds';
import WalletReceipts from './WalletReceipts';
type WalletTab = 'payments' | 'installments' | 'refunds' | 'receipts';
/**
* /wallet the customer money hub (ui-phase-6). Four sections replace the old installments-only shell so
* a card-paying customer (the default path) finally sees something other than a permanently empty tab:
* «پرداختها» (payment history), «اقساط» (the unchanged f11 D5 installment tracker), «استردادها» (refunds,
* REQ-048), «رسیدها» (client-derived invoice links). All four read at the shell's shared `CONTENT_MAX_WIDTH`
* no local width override.
*/
const WalletScreen: FunctionComponent = () => {
const t = useTranslations('bnpl');
const [tab, setTab] = useState<WalletTab>('payments');
return (
<Stack sx={{ gap: 2 }}>
<PageHeader title={t('wallet_hub_title')} />
<Tabs
value={tab}
onChange={(_event, value: WalletTab) => setTab(value)}
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
sx={{ borderBottom: '1px solid', borderColor: 'divider' }}
>
<Tab value="payments" label={t('tab_payments')} icon={<AppIcon icon="payment" size={18} />} iconPosition="start" />
<Tab
value="installments"
label={t('tab_installments')}
icon={<AppIcon icon="installments" size={18} />}
iconPosition="start"
/>
<Tab value="refunds" label={t('tab_refunds')} icon={<AppIcon icon="refunds" size={18} />} iconPosition="start" />
<Tab value="receipts" label={t('tab_receipts')} icon={<AppIcon icon="document" size={18} />} iconPosition="start" />
</Tabs>
<Box role="tabpanel">
{tab === 'payments' ? <WalletPaymentHistory /> : null}
{tab === 'installments' ? <WalletInstallments /> : null}
{tab === 'refunds' ? <WalletRefunds /> : null}
{tab === 'receipts' ? <WalletReceipts /> : null}
</Box>
</Stack>
);
};
export default WalletScreen;
@@ -1,10 +1,9 @@
import WalletInstallments from './WalletInstallments'; import WalletScreen from './WalletScreen';
/** /**
* /wallet the customer Wallet tab. Today it hosts the f11 D5 installment-status section (provider-reported, * /wallet — the customer money hub (ui-phase-6): پرداخت‌ها / اقساط / استردادها / رسیدها. Thin route shell;
* self-contained so the f12 nurse-earnings Wallet content can land beside it later). The section is a client * the tabbed body is a client component (TanStack Query).
* component (TanStack Query); this page is the thin route shell.
*/ */
export default function WalletPage() { export default function WalletPage() {
return <WalletInstallments />; return <WalletScreen />;
} }
@@ -0,0 +1,54 @@
import { useMemo } from 'react';
import { usePaymentHistory } from '@/services/payment';
import { useWalletInstallments } from '@/services/bnpl';
import type { PaymentTransactionStatus } from '@/services/payment/types';
export interface WalletHistoryRow {
key: string;
amountIrr: string;
createdAt: string;
status: PaymentTransactionStatus;
bookingId: number | null;
}
/**
* Merges the two independent seams a wallet history/receipt row can come from card transactions
* (`services/payment`, REQ-047) and each settled BNPL plan's own down-payment leg (`services/bnpl`) into
* one newest-first list. Shared by the wallet «پرداختها» and «رسیدها» tabs so the merge logic lives once.
* Degrades gracefully: either source failing alone still renders the other's rows.
*/
export function useWalletHistoryRows() {
const paymentHistory = usePaymentHistory();
const walletInstallments = useWalletInstallments();
const rows = useMemo<WalletHistoryRow[]>(() => {
const cardRows: WalletHistoryRow[] = (paymentHistory.data ?? []).map((row) => ({
key: `card-${row.transactionId}`,
amountIrr: row.amountIrr,
createdAt: row.createdAt,
status: row.status,
bookingId: row.bookingId,
}));
const bnplRows: WalletHistoryRow[] = (walletInstallments.data ?? []).map((plan) => {
const downPayment = plan.installments.find((i) => i.kind === 'down_payment');
return {
key: `bnpl-${plan.bnplTransactionId}`,
amountIrr: downPayment?.amountIrr ?? '0',
createdAt: plan.createdAt,
status: 'succeeded' as const,
bookingId: plan.bookingId,
};
});
return [...cardRows, ...bnplRows].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}, [paymentHistory.data, walletInstallments.data]);
return {
rows,
isLoading: paymentHistory.isLoading || walletInstallments.isLoading,
bothErrored: paymentHistory.isError && walletInstallments.isError,
refetch: () => {
paymentHistory.refetch();
walletInstallments.refetch();
},
};
}
@@ -0,0 +1,19 @@
'use client';
import type { ReactNode } from 'react';
import { FocusedLayout } from '@/layout';
import { RoleGuard } from '@/components/auth';
import { APP_ROLES } from '@/constants';
/*
* Customer-focused route group a chrome-free counterpart to `(customer)` for flows the user
* should not tab away from mid-task (today only first-run onboarding, ui-phase-3 §3.5). A route
* group adds chrome without adding a URL segment, so `/onboarding` is unchanged. RoleGuard still
* gates it on a resolved customer role, identically to the full `(customer)` shell.
*/
export default function CustomerFocusedRouteLayout({ children }: { children: ReactNode }) {
return (
<RoleGuard expected={APP_ROLES.CUSTOMER}>
<FocusedLayout>{children}</FocusedLayout>
</RoleGuard>
);
}
@@ -4,7 +4,8 @@ import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { Box, Stack, Typography } from '@mui/material'; import { Box, Stack, Typography } from '@mui/material';
import { AppButton, PatientForm, RelationSelect, StepperHeader } from '@/components'; import { AppButton, AppIcon, PatientForm, RelationSelect, StepperHeader } from '@/components';
import BrandMark from '@/components/auth/BrandMark';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { useCreatePatient } from '@/services/patients'; import { useCreatePatient } from '@/services/patients';
import { RELATION_CODES } from '@/services/patients/constants'; import { RELATION_CODES } from '@/services/patients/constants';
@@ -12,12 +13,26 @@ import type { CreatePatientInput, Relation } from '@/services/patients/types';
const ONBOARDING_MAX_WIDTH = 520; const ONBOARDING_MAX_WIDTH = 520;
// Distinct per-option glyph (the prior defect: all four relations shared the generic 'account'
// icon) — 'elderly' fits a parent, 'favorite' a spouse, 'infant' a child, 'account' one's self.
const RELATION_ICONS: Record<string, string> = {
parent: 'elderly',
spouse: 'favorite',
child: 'infant',
self: 'account',
};
type Phase = 'welcome' | 'relation' | 'patient';
/** /**
* A3 A4 onboarding wizard: pick who care is for, then register the first patient. The * The chrome-free A3 A4 first-run journey: a one-screen welcome moment, then pick who care is
* chosen relation pre-shapes the patient (it is hidden on the A4 form since it's already * for, then register the first patient. `FocusedLayout` (the route group above this) strips the
* chosen here). On save it creates the patient and lands on Home (A5). * bottom nav/bell so there's nothing to tab away to mid-setup. The welcome screen doesn't count
* as a stepper step; relation patient does. The chosen relation pre-shapes the patient (hidden
* on the A4 form since it's already chosen here). On save it creates the patient and lands on
* Home (A5).
*/ */
export default function OnboardingPage() { export default function OnboardingScreen() {
const t = useTranslations('onboarding'); const t = useTranslations('onboarding');
const tc = useTranslations('common'); const tc = useTranslations('common');
const router = useRouter(); const router = useRouter();
@@ -25,10 +40,14 @@ export default function OnboardingPage() {
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const createPatient = useCreatePatient(); const createPatient = useCreatePatient();
const [step, setStep] = useState(0); const [phase, setPhase] = useState<Phase>('welcome');
const [relation, setRelation] = useState<Relation | null>(null); const [relation, setRelation] = useState<Relation | null>(null);
const relationOptions = RELATION_CODES.map((code) => ({ code, label: t(`relation_${code}`), icon: 'account' })); const relationOptions = RELATION_CODES.map((code) => ({
code,
label: t(`relation_${code}`),
icon: RELATION_ICONS[code] ?? 'account',
}));
const handleCreate = (input: CreatePatientInput) => { const handleCreate = (input: CreatePatientInput) => {
createPatient.mutate( createPatient.mutate(
@@ -42,11 +61,32 @@ export default function OnboardingPage() {
); );
}; };
if (phase === 'welcome') {
return (
<Stack sx={{ alignItems: 'center', justifyContent: 'center', minHeight: '70vh', gap: 3, textAlign: 'center' }}>
<BrandMark />
<Stack sx={{ gap: 1, maxWidth: ONBOARDING_MAX_WIDTH }}>
<Typography variant="h5" component="h1">
{t('welcome_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('welcome_subtitle')}
</Typography>
</Stack>
<AppButton color="primary" variant="contained" onClick={() => setPhase('relation')}>
{t('welcome_cta')}
</AppButton>
</Stack>
);
}
const activeStep = phase === 'relation' ? 0 : 1;
return ( return (
<Box sx={{ maxWidth: ONBOARDING_MAX_WIDTH, mx: 'auto', display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ maxWidth: ONBOARDING_MAX_WIDTH, mx: 'auto', display: 'flex', flexDirection: 'column', gap: 3 }}>
<StepperHeader steps={[t('step_relation'), t('step_patient')]} activeStep={step} /> <StepperHeader steps={[t('step_relation'), t('step_patient')]} activeStep={activeStep} />
{step === 0 ? ( {phase === 'relation' ? (
<Stack sx={{ gap: 2 }}> <Stack sx={{ gap: 2 }}>
<Stack sx={{ gap: 0.5 }}> <Stack sx={{ gap: 0.5 }}>
<Typography variant="h6" component="h1"> <Typography variant="h6" component="h1">
@@ -66,7 +106,8 @@ export default function OnboardingPage() {
variant="contained" variant="contained"
fullWidth fullWidth
disabled={!relation} disabled={!relation}
onClick={() => setStep(1)} onClick={() => setPhase('patient')}
endIcon={<AppIcon icon="forward" size={18} aria-hidden="true" />}
> >
{t('continue')} {t('continue')}
</AppButton> </AppButton>
@@ -86,7 +127,7 @@ export default function OnboardingPage() {
submitLabel={t('save_continue')} submitLabel={t('save_continue')}
submitting={createPatient.isPending} submitting={createPatient.isPending}
onSubmit={handleCreate} onSubmit={handleCreate}
onCancel={() => setStep(0)} onCancel={() => setPhase('relation')}
cancelLabel={tc('back')} cancelLabel={tc('back')}
/> />
</Stack> </Stack>
@@ -0,0 +1,13 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import OnboardingScreen from './OnboardingScreen';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'onboarding' });
return { title: t('welcome_title') };
}
export default function Page() {
return <OnboardingScreen />;
}
@@ -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;
@@ -1,15 +1,21 @@
'use client'; 'use client';
import { useState } from 'react'; import { Suspense, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { Box, MenuItem, Skeleton, Stack, TextField } from '@mui/material'; import { Box, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
import { AppLoading } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfirmDialog, SupportAlertCard } from '@/components/admin'; import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfirmDialog, SupportAlertCard } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks'; import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { useAuth } from '@/context/auth'; import { useAuth } from '@/context/auth';
import { ADMIN_PAGE_SIZE } from '@/services/admin/constants'; import { ADMIN_PAGE_SIZE } from '@/services/admin/constants';
import type { SupportAlert, SupportAlertStatus, SupportAlertType } from '@/services/admin/types'; import type { SupportAlert, SupportAlertStatus, SupportAlertType } from '@/services/admin/types';
import { useSupportAlerts, useAssignSupportAlert, useResolveSupportAlert } from '@/services/admin'; import { useSupportAlerts, useAssignSupportAlert, useResolveSupportAlert } from '@/services/admin';
interface AlertFilters {
status?: SupportAlertStatus;
type?: SupportAlertType;
}
const STATUSES: readonly SupportAlertStatus[] = ['open', 'assigned', 'resolved']; const STATUSES: readonly SupportAlertStatus[] = ['open', 'assigned', 'resolved'];
const TYPES: readonly SupportAlertType[] = [ const TYPES: readonly SupportAlertType[] = [
'low_rating', 'low_rating',
@@ -23,32 +29,60 @@ const TYPES: readonly SupportAlertType[] = [
'emergency', 'emergency',
]; ];
const EMPTY: AlertFilters = { status: 'open' };
function parseFilters(params: URLSearchParams): AlertFilters {
const status = params.get('status') as SupportAlertStatus | null;
const type = params.get('type') as SupportAlertType | null;
return {
status: status && STATUSES.includes(status) ? status : undefined,
type: type && TYPES.includes(type) ? type : undefined,
};
}
function serializeFilters(filters: AlertFilters): Record<string, string> {
const r: Record<string, string> = {};
if (filters.status) r.status = filters.status;
if (filters.type) r.type = filters.type;
return r;
}
/** /**
* Support-alert triage board (f15) the **internal-only** worklist over `support_alerts`. Filter by * Support-alert triage board (f15) the **internal-only** worklist over `support_alerts`. Filter by
* type/status; assign to self or resolve with a note. This data appears in **no** customer/nurse/partner * type/status; assign to self or resolve with a note. This data appears in **no** customer/nurse/partner
* surface (phase §5). Server enforces the role scope; `canManageAlerts` only hides the controls. * surface (phase §5). Server enforces the role scope; `canManageAlerts` only hides the controls.
*/ */
export default function AdminAlertsPage() { export default function AdminAlertsPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminAlertsPageInner />
</Suspense>
);
}
function AdminAlertsPageInner() {
const t = useTranslations('admin'); const t = useTranslations('admin');
const caps = useAdminCapabilities(); const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const [authState] = useAuth(); const [authState] = useAuth();
const meId = authState.currentUser?.id ?? 1; const meId = authState.currentUser?.id;
const [status, setStatus] = useState<SupportAlertStatus | ''>('open'); const listState = useAdminListState<AlertFilters>({ parse: parseFilters, serialize: serializeFilters, empty: EMPTY });
const [type, setType] = useState<SupportAlertType | ''>(''); const { applied: filters, page } = listState;
const [page, setPage] = useState(1);
const [resolving, setResolving] = useState<SupportAlert | null>(null); const [resolving, setResolving] = useState<SupportAlert | null>(null);
const filters = { status: status || undefined, type: type || undefined };
const alerts = useSupportAlerts(filters, page); const alerts = useSupportAlerts(filters, page);
const assign = useAssignSupportAlert(); const assign = useAssignSupportAlert();
const resolve = useResolveSupportAlert(); const resolve = useResolveSupportAlert();
const items = alerts.data?.items ?? []; const items = alerts.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((alerts.data?.total ?? 0) / ADMIN_PAGE_SIZE)); const total = alerts.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / ADMIN_PAGE_SIZE));
// Assign-to-self MUST NEVER default to a guessed user (the fixed defect: `?? 1`) — the button is simply
// disabled with a "loading your account" tooltip until the real id has hydrated.
const onAssignSelf = (alert: SupportAlert) => { const onAssignSelf = (alert: SupportAlert) => {
if (meId == null) return;
assign.mutate( assign.mutate(
{ alertId: alert.id, ownerUserId: meId }, { alertId: alert.id, ownerUserId: meId },
{ onSuccess: () => enqueueSnackbar(t('alert_assigned'), { variant: 'success' }) }, { onSuccess: () => enqueueSnackbar(t('alert_assigned'), { variant: 'success' }) },
@@ -68,6 +102,11 @@ export default function AdminAlertsPage() {
); );
}; };
const setStatusFilter = (value: SupportAlertStatus | '') =>
listState.applyFilters({ ...filters, status: value || undefined });
const setTypeFilter = (value: SupportAlertType | '') =>
listState.applyFilters({ ...filters, type: value || undefined });
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader <AdminPageHeader
@@ -79,11 +118,8 @@ export default function AdminAlertsPage() {
select select
size="small" size="small"
label={t('alert_col_status')} label={t('alert_col_status')}
value={status} value={filters.status ?? ''}
onChange={(e) => { onChange={(e) => setStatusFilter(e.target.value as SupportAlertStatus | '')}
setStatus(e.target.value as SupportAlertStatus | '');
setPage(1);
}}
sx={{ minWidth: 140 }} sx={{ minWidth: 140 }}
> >
<MenuItem value="">{t('filter_all')}</MenuItem> <MenuItem value="">{t('filter_all')}</MenuItem>
@@ -97,11 +133,8 @@ export default function AdminAlertsPage() {
select select
size="small" size="small"
label={t('alert_col_type')} label={t('alert_col_type')}
value={type} value={filters.type ?? ''}
onChange={(e) => { onChange={(e) => setTypeFilter(e.target.value as SupportAlertType | '')}
setType(e.target.value as SupportAlertType | '');
setPage(1);
}}
sx={{ minWidth: 180 }} sx={{ minWidth: 180 }}
> >
<MenuItem value="">{t('filter_all')}</MenuItem> <MenuItem value="">{t('filter_all')}</MenuItem>
@@ -130,6 +163,8 @@ export default function AdminAlertsPage() {
canAct={caps.canManageAlerts} canAct={caps.canManageAlerts}
onAssignSelf={onAssignSelf} onAssignSelf={onAssignSelf}
onResolve={setResolving} onResolve={setResolving}
assignSelfDisabled={meId == null}
assignSelfDisabledTitle={t('assign_me_loading')}
/> />
))} ))}
</Stack> </Stack>
@@ -138,11 +173,11 @@ export default function AdminAlertsPage() {
<AdminPager <AdminPager
page={page} page={page}
pageCount={pageCount} pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))} onPrev={() => listState.goToPage(Math.max(1, page - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))} onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')} prevLabel={t('prev_page')}
nextLabel={t('next_page')} nextLabel={t('next_page')}
indicator={t('page_indicator', { page })} indicator={t('page_indicator', { page, total: pageCount })}
/> />
<ConfirmDialog <ConfirmDialog
@@ -1,15 +1,35 @@
'use client'; 'use client';
import { useState } from 'react'; import { Suspense, useMemo } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Box, Skeleton, Stack, TextField } from '@mui/material'; import { Box, Skeleton, Stack, TextField } from '@mui/material';
import { AppButton } from '@/components'; import { AppButton, AppLoading, JalaliDateField } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, AuditLogRow } from '@/components/admin'; import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, AuditLogRow } from '@/components/admin';
import { actorLabelFrom } from '@/components/admin/AuditLogRow';
import { useAdminListState } from '@/hooks';
import { AUDIT_PAGE_SIZE } from '@/services/admin/constants'; import { AUDIT_PAGE_SIZE } from '@/services/admin/constants';
import type { AuditFilters } from '@/services/admin/types'; import type { AuditFilters } from '@/services/admin/types';
import { useAuditLogs } from '@/services/admin'; import { useAuditLogs, useUserLookup } from '@/services/admin';
const EMPTY: AuditFilters = {}; const EMPTY: AuditFilters = {};
function parseFilters(params: URLSearchParams): AuditFilters {
return {
entityType: params.get('entityType') ?? undefined,
entityId: params.get('entityId') ?? undefined,
from: params.get('from') ?? undefined,
to: params.get('to') ?? undefined,
};
}
function serializeFilters(filters: AuditFilters): Record<string, string> {
const r: Record<string, string> = {};
if (filters.entityType) r.entityType = filters.entityType;
if (filters.entityId) r.entityId = filters.entityId;
if (filters.from) r.from = filters.from;
if (filters.to) r.to = filters.to;
return r;
}
/** /**
* Append-only audit-log viewer (f15) a read-only, filtered, paginated table of every admin state change, * Append-only audit-log viewer (f15) a read-only, filtered, paginated table of every admin state change,
* each row expandable to its `changed_fields` diff. There is **no** edit/delete affordance (phase §5). The * each row expandable to its `changed_fields` diff. There is **no** edit/delete affordance (phase §5). The
@@ -17,24 +37,33 @@ const EMPTY: AuditFilters = {};
* page are the cache key, so switching filters/pages never refetches data already held. * page are the cache key, so switching filters/pages never refetches data already held.
*/ */
export default function AdminAuditPage() { export default function AdminAuditPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminAuditPageInner />
</Suspense>
);
}
function AdminAuditPageInner() {
const t = useTranslations('admin'); const t = useTranslations('admin');
const [draft, setDraft] = useState<AuditFilters>(EMPTY); const listState = useAdminListState<AuditFilters>({ parse: parseFilters, serialize: serializeFilters, empty: EMPTY });
const [applied, setApplied] = useState<AuditFilters>(EMPTY); const { draft, setDraft, applied, page } = listState;
const [page, setPage] = useState(1);
const audit = useAuditLogs(applied, page); const audit = useAuditLogs(applied, page);
const items = audit.data?.items ?? []; const items = audit.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((audit.data?.total ?? 0) / AUDIT_PAGE_SIZE)); const total = audit.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / AUDIT_PAGE_SIZE));
const from = items.length === 0 ? 0 : (page - 1) * AUDIT_PAGE_SIZE + 1;
const to = (page - 1) * AUDIT_PAGE_SIZE + items.length;
const apply = () => { // Batch id→name resolve (3.2/3.6) — one request for every actor rendered on this page, never one per row.
setApplied(draft); const actorIds = useMemo(
setPage(1); () => [...new Set((audit.data?.items ?? []).map((e) => e.actorUserId).filter((id): id is number => id != null))].sort(
}; (a, b) => a - b,
const clear = () => { ),
setDraft(EMPTY); [audit.data?.items],
setApplied(EMPTY); );
setPage(1); const userLookup = useUserLookup(actorIds);
};
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
@@ -42,7 +71,7 @@ export default function AdminAuditPage() {
<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"
@@ -60,26 +89,24 @@ export default function AdminAuditPage() {
onChange={(e) => setDraft((d) => ({ ...d, entityId: e.target.value || undefined }))} onChange={(e) => setDraft((d) => ({ ...d, entityId: e.target.value || undefined }))}
sx={{ minWidth: 120 }} sx={{ minWidth: 120 }}
/> />
<TextField <JalaliDateField
size="small"
type="date"
label={t('audit_from')} label={t('audit_from')}
value={draft.from ?? ''} value={draft.from ?? null}
onChange={(e) => setDraft((d) => ({ ...d, from: e.target.value || undefined }))} onChange={(iso) => setDraft((d) => ({ ...d, from: iso }))}
slotProps={{ inputLabel: { shrink: true } }} max={draft.to}
sx={{ minWidth: 160 }}
/> />
<TextField <JalaliDateField
size="small"
type="date"
label={t('audit_to')} label={t('audit_to')}
value={draft.to ?? ''} value={draft.to ?? null}
onChange={(e) => setDraft((d) => ({ ...d, to: e.target.value || undefined }))} onChange={(iso) => setDraft((d) => ({ ...d, to: iso }))}
slotProps={{ inputLabel: { shrink: true } }} min={draft.from}
sx={{ minWidth: 160 }}
/> />
<AppButton variant="contained" color="primary" onClick={apply}> <AppButton variant="contained" color="primary" onClick={listState.apply}>
{t('apply')} {t('apply')}
</AppButton> </AppButton>
<AppButton variant="text" color="inherit" onClick={clear}> <AppButton variant="text" color="inherit" onClick={listState.clear}>
{t('clear')} {t('clear')}
</AppButton> </AppButton>
</Stack> </Stack>
@@ -93,19 +120,22 @@ export default function AdminAuditPage() {
) : ( ) : (
<Stack sx={{ gap: 1 }}> <Stack sx={{ gap: 1 }}>
{items.map((entry) => ( {items.map((entry) => (
<AuditLogRow key={entry.id} entry={entry} /> <AuditLogRow key={entry.id} entry={entry} actorLabel={actorLabelFrom(userLookup.data, entry.actorUserId)} />
))} ))}
</Stack> </Stack>
)} )}
{items.length > 0 ? (
<Box sx={{ typography: 'caption', color: 'text.secondary' }}>{t('showing_range', { from, to, total })}</Box>
) : null}
<AdminPager <AdminPager
page={page} page={page}
pageCount={pageCount} pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))} onPrev={() => listState.goToPage(Math.max(1, page - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))} onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')} prevLabel={t('prev_page')}
nextLabel={t('next_page')} nextLabel={t('next_page')}
indicator={t('page_indicator', { page })} indicator={t('page_indicator', { page, total: pageCount })}
/> />
</Box> </Box>
); );
@@ -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>
);
}
@@ -1,5 +1,5 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { Suspense, useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { import {
@@ -17,10 +17,10 @@ import {
TextField, TextField,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import { AppButton, AppIcon } from '@/components'; import { AppButton, AppIcon, AppLoading } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, ConfigRow } from '@/components/admin'; import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfigRow } from '@/components/admin';
import { formatShamsiDateTime } from '@/utils'; import { formatShamsiDateTime } from '@/utils';
import { useAdminCapabilities } from '@/hooks'; import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { CONFIG_GROUPS, RATE_CONFIG_KEYS } from '@/services/admin/constants'; import { CONFIG_GROUPS, RATE_CONFIG_KEYS } from '@/services/admin/constants';
import type { PlatformConfig } from '@/services/admin/types'; import type { PlatformConfig } from '@/services/admin/types';
import { usePlatformConfigs, useUpdatePlatformConfig, useConfigChangeHistory } from '@/services/admin'; import { usePlatformConfigs, useUpdatePlatformConfig, useConfigChangeHistory } from '@/services/admin';
@@ -58,9 +58,18 @@ function validate(config: PlatformConfig, value: string): string | null {
* `data_type` (phase §5). * `data_type` (phase §5).
*/ */
export default function AdminConfigPage() { export default function AdminConfigPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminConfigPageInner />
</Suspense>
);
}
function AdminConfigPageInner() {
const t = useTranslations('admin'); const t = useTranslations('admin');
const caps = useAdminCapabilities(); const caps = useAdminCapabilities();
const configs = usePlatformConfigs(1); const listState = useAdminListState<Record<string, never>>({ parse: () => ({}), serialize: () => ({}), empty: {} });
const configs = usePlatformConfigs(listState.page);
const [editing, setEditing] = useState<PlatformConfig | null>(null); const [editing, setEditing] = useState<PlatformConfig | null>(null);
const [historyKey, setHistoryKey] = useState<string | null>(null); const [historyKey, setHistoryKey] = useState<string | null>(null);
@@ -101,6 +110,19 @@ export default function AdminConfigPage() {
)) ))
)} )}
<AdminPager
page={listState.page}
pageCount={Math.max(1, Math.ceil((configs.data?.total ?? 0) / (configs.data?.pageSize ?? 1)))}
onPrev={() => listState.goToPage(Math.max(1, listState.page - 1))}
onNext={() => listState.goToPage(listState.page + 1)}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', {
page: listState.page,
total: Math.max(1, Math.ceil((configs.data?.total ?? 0) / (configs.data?.pageSize ?? 1))),
})}
/>
{editing ? <ConfigEditDialog config={editing} onClose={() => setEditing(null)} /> : null} {editing ? <ConfigEditDialog config={editing} onClose={() => setEditing(null)} /> : null}
<ConfigHistoryDrawer configKey={historyKey} onClose={() => setHistoryKey(null)} /> <ConfigHistoryDrawer configKey={historyKey} onClose={() => setHistoryKey(null)} />
</Box> </Box>
@@ -149,7 +171,6 @@ function ConfigEditDialog({ config, onClose }: { config: PlatformConfig; onClose
autoFocus autoFocus
multiline={config.dataType === 'json'} multiline={config.dataType === 'json'}
minRows={config.dataType === 'json' ? 4 : 1} minRows={config.dataType === 'json' ? 4 : 1}
type={config.dataType === 'int' || config.dataType === 'decimal' ? 'text' : 'text'}
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
label={t('cfg_col_value')} label={t('cfg_col_value')}
@@ -179,15 +200,22 @@ function ConfigEditDialog({ config, onClose }: { config: PlatformConfig; onClose
function ConfigHistoryDrawer({ configKey, onClose }: { configKey: string | null; onClose: () => void }) { function ConfigHistoryDrawer({ configKey, onClose }: { configKey: string | null; onClose: () => void }) {
const t = useTranslations('admin'); const t = useTranslations('admin');
const locale = useLocale(); const locale = useLocale();
const history = useConfigChangeHistory(configKey, 1, configKey != null); const [page, setPage] = useState(1);
const history = useConfigChangeHistory(configKey, page, configKey != null);
// RTL-aware: the drawer slides from the reading-end (left on fa/RTL, right on en/LTR). // RTL-aware: the drawer slides from the reading-end (left on fa/RTL, right on en/LTR).
const anchor = locale === 'fa' ? 'left' : 'right'; const anchor = locale === 'fa' ? 'left' : 'right';
const pageCount = Math.max(1, Math.ceil((history.data?.total ?? 0) / (history.data?.pageSize ?? 1)));
const close = () => {
setPage(1);
onClose();
};
return ( return (
<Drawer anchor={anchor} open={configKey != null} onClose={onClose} slotProps={{ paper: { sx: { width: { xs: '100%', sm: 420 }, p: 3 } } }}> <Drawer anchor={anchor} open={configKey != null} onClose={close} slotProps={{ paper: { sx: { width: { xs: '100%', sm: 420 }, p: 3 } } }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> <Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">{configKey ? t('cfg_history_title', { key: configKey }) : ''}</Typography> <Typography variant="h6">{configKey ? t('cfg_history_title', { key: configKey }) : ''}</Typography>
<AppButton variant="text" color="inherit" onClick={onClose} sx={{ minWidth: 0 }}> <AppButton variant="text" color="inherit" onClick={close} sx={{ minWidth: 0 }}>
<AppIcon icon="close" /> <AppIcon icon="close" />
</AppButton> </AppButton>
</Stack> </Stack>
@@ -201,10 +229,16 @@ 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 }}>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 700 }}> <Stack direction="row" sx={{ alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
{t('cfg_history_change', { old: change.oldValue ?? '—', new: change.newValue ?? '—' })} <Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 700 }}>
</Typography> {t('cfg_history_change_old', { old: change.oldValue ?? '—' })}
</Typography>
<AppIcon icon="forward" size={14} color="var(--bal-text-secondary)" />
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 700 }}>
{t('cfg_history_change_new', { new: change.newValue ?? '—' })}
</Typography>
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}> <Typography variant="caption" sx={{ color: 'text.secondary' }}>
{formatShamsiDateTime(change.occurredAt, locale)} {formatShamsiDateTime(change.occurredAt, locale)}
{change.actorUserId != null ? ` · #${change.actorUserId}` : ''} {change.actorUserId != null ? ` · #${change.actorUserId}` : ''}
@@ -213,6 +247,16 @@ function ConfigHistoryDrawer({ configKey, onClose }: { configKey: string | null;
))} ))}
</Stack> </Stack>
)} )}
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', { page, total: pageCount })}
/>
</Drawer> </Drawer>
); );
} }
@@ -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 { 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,15 +15,25 @@ import {
Skeleton, Skeleton,
Stack, Stack,
Switch, Switch,
TextField,
} from '@mui/material'; } from '@mui/material';
import { AppButton } from '@/components'; import { AppButton, AppLoading, RhfControlGroup, RhfJalaliDateField, RhfTextField } from '@/components';
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, 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 } from '@/hooks'; import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { ADMIN_PAGE_SIZE } from '@/services/admin/constants';
import type { Holiday, HolidayInput, HolidayType } from '@/services/admin/types'; import type { Holiday, HolidayInput, HolidayType } from '@/services/admin/types';
import { useHolidays, useUpsertHoliday } from '@/services/admin'; import { useHolidays, useUpsertHoliday } from '@/services/admin';
/** Today's LOCAL date as ISO `YYYY-MM-DD` never `toISOString()`, which converts to UTC first and can
* land on the wrong day near local midnight (the same class of bug fixed in the payout window default). */
function todayLocalIso(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
const HOLIDAY_TYPES: readonly HolidayType[] = ['official', 'religious', 'national']; const HOLIDAY_TYPES: readonly HolidayType[] = ['official', 'religious', 'national'];
/** /**
@@ -32,11 +43,21 @@ const HOLIDAY_TYPES: readonly HolidayType[] = ['official', 'religious', 'nationa
* computes the shift itself (phase §5). * computes the shift itself (phase §5).
*/ */
export default function AdminHolidaysPage() { export default function AdminHolidaysPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminHolidaysPageInner />
</Suspense>
);
}
function AdminHolidaysPageInner() {
const t = useTranslations('admin'); const t = useTranslations('admin');
const locale = useLocale(); const locale = useLocale();
const caps = useAdminCapabilities(); const caps = useAdminCapabilities();
const holidays = useHolidays({}, 1); const listState = useAdminListState<Record<string, never>>({ parse: () => ({}), serialize: () => ({}), empty: {} });
const holidays = useHolidays({}, listState.page);
const [editing, setEditing] = useState<Holiday | 'new' | null>(null); const [editing, setEditing] = useState<Holiday | 'new' | null>(null);
const pageCount = Math.max(1, Math.ceil((holidays.data?.total ?? 0) / ADMIN_PAGE_SIZE));
const columns: AdminTableColumn<Holiday>[] = [ const columns: AdminTableColumn<Holiday>[] = [
{ key: 'date', header: t('hol_col_date'), render: (h) => formatShamsiDate(h.holidayDate, locale) }, { key: 'date', header: t('hol_col_date'), render: (h) => formatShamsiDate(h.holidayDate, locale) },
@@ -96,6 +117,16 @@ export default function AdminHolidaysPage() {
<AdminDataTable columns={columns} rows={holidays.data?.items ?? []} getRowKey={(h) => h.id} ariaLabel={t('hol_title')} /> <AdminDataTable columns={columns} rows={holidays.data?.items ?? []} getRowKey={(h) => h.id} ariaLabel={t('hol_title')} />
)} )}
<AdminPager
page={listState.page}
pageCount={pageCount}
onPrev={() => listState.goToPage(Math.max(1, listState.page - 1))}
onNext={() => listState.goToPage(Math.min(pageCount, listState.page + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', { page: listState.page, total: pageCount })}
/>
{editing ? ( {editing ? (
<HolidayDialog holiday={editing === 'new' ? null : editing} onClose={() => setEditing(null)} /> <HolidayDialog holiday={editing === 'new' ? null : editing} onClose={() => setEditing(null)} />
) : null} ) : null}
@@ -103,25 +134,24 @@ export default function AdminHolidaysPage() {
); );
} }
const TODAY_ISO = ''; // seeded below via state default so no Date at module load
function HolidayDialog({ holiday, onClose }: { holiday: Holiday | null; onClose: () => void }) { function HolidayDialog({ holiday, onClose }: { holiday: Holiday | null; onClose: () => void }) {
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>({
holidayDate: holiday?.holidayDate?.slice(0, 10) ?? TODAY_ISO, mode: 'onTouched',
nameFa: holiday?.nameFa ?? '', defaultValues: {
type: holiday?.type ?? 'official', holidayDate: holiday?.holidayDate?.slice(0, 10) ?? todayLocalIso(),
isBankClosed: holiday?.isBankClosed ?? true, nameFa: holiday?.nameFa ?? '',
type: holiday?.type ?? 'official',
isBankClosed: holiday?.isBankClosed ?? true,
},
}); });
const { handleSubmit, formState } = form;
const valid = form.holidayDate.length > 0 && form.nameFa.trim().length > 0; const onSave = (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' });
@@ -129,52 +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>
<DialogContent> <FormProvider {...form}>
<Stack sx={{ gap: 2, mt: 1 }}> <DialogContent>
<TextField {/* A real <form> so Enter submits from any field; the footer button (outside DialogContent)
type="date" calls the same handler directly rather than relying on cross-element form association. */}
label={t('hol_col_date')} <Stack component="form" noValidate onSubmit={handleSubmit(onSave)} sx={{ gap: 2, mt: 1 }}>
value={form.holidayDate} <RhfJalaliDateField<HolidayInput>
onChange={(e) => setForm((f) => ({ ...f, holidayDate: e.target.value }))} name="holidayDate"
disabled={!!holiday} label={t('hol_col_date')}
slotProps={{ inputLabel: { shrink: true } }} rules={{ validate: (value) => String(value ?? '').length > 0 }}
/> disabled={!!holiday}
<TextField />
label={t('hol_name_fa')} <RhfTextField<HolidayInput>
value={form.nameFa} name="nameFa"
onChange={(e) => setForm((f) => ({ ...f, nameFa: e.target.value }))} label={t('hol_name_fa')}
/> rules={{ validate: (value) => String(value ?? '').trim().length > 0 }}
<TextField />
select <RhfTextField<HolidayInput> name="type" select label={t('hol_col_type')}>
label={t('hol_col_type')} {HOLIDAY_TYPES.map((ty) => (
value={form.type} <MenuItem key={ty} value={ty}>
onChange={(e) => setForm((f) => ({ ...f, type: e.target.value as HolidayType }))} {t(`htype_${ty}`)}
</MenuItem>
))}
</RhfTextField>
<RhfControlGroup<HolidayInput> name="isBankClosed">
{({ field }) => (
<FormControlLabel
control={
<Switch checked={Boolean(field.value)} onChange={(event) => field.onChange(event.target.checked)} />
}
label={t('hol_bank_hint')}
/>
)}
</RhfControlGroup>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={upsert.isPending}>
{t('cancel')}
</AppButton>
<AppButton
variant="contained"
color="primary"
onClick={handleSubmit(onSave)}
disabled={!formState.isValid || upsert.isPending}
> >
{HOLIDAY_TYPES.map((ty) => ( {upsert.isPending ? t('saving') : t('save')}
<MenuItem key={ty} value={ty}> </AppButton>
{t(`htype_${ty}`)} </DialogActions>
</MenuItem> </FormProvider>
))}
</TextField>
<FormControlLabel
control={<Switch checked={form.isBankClosed} onChange={(e) => setForm((f) => ({ ...f, isBankClosed: e.target.checked }))} />}
label={t('hol_bank_hint')}
/>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={upsert.isPending}>
{t('cancel')}
</AppButton>
<AppButton variant="contained" color="primary" onClick={onSave} disabled={!valid || upsert.isPending}>
{upsert.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</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 />;
} }
@@ -1,13 +1,14 @@
'use client'; 'use client';
import { ReactNode, useState } from 'react'; import { ReactNode, useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { Box, Divider, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material'; import { Box, Divider, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, StatusChip, TrustBadge } from '@/components'; import { AppButton, PageHeader, StatusChip, TrustBadge } from '@/components';
import { AdminEmptyState, AdminErrorState, ConfirmDialog } from '@/components/admin'; import { AdminEmptyState, AdminErrorState, ConfirmDialog, NursePicker } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks'; import { useAdminCapabilities, useAdminBackToList } from '@/hooks';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import type { AdminUserSummary } from '@/services/admin/types';
import { import {
usePartnerCenter, usePartnerCenter,
useCenterSponsoredNurses, useCenterSponsoredNurses,
@@ -27,9 +28,9 @@ import { CENTER_STATE_KIND, PartnerCenterFormDialog } from '../page';
export default function AdminPartnerCenterDetailPage() { export default function AdminPartnerCenterDetailPage() {
const t = useTranslations('admin'); const t = useTranslations('admin');
const locale = useLocale(); const locale = useLocale();
const router = useRouter();
const caps = useAdminCapabilities(); const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const goBack = useAdminBackToList(`/${locale}${ROUTES.ADMIN_PARTNERS}`);
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const parsed = Number(params?.id); const parsed = Number(params?.id);
@@ -43,7 +44,7 @@ export default function AdminPartnerCenterDetailPage() {
const [confirmVerify, setConfirmVerify] = useState(false); const [confirmVerify, setConfirmVerify] = useState(false);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [assignId, setAssignId] = useState(''); const [assignNurseUser, setAssignNurseUser] = useState<AdminUserSummary | null>(null);
const data = center.data; const data = center.data;
@@ -57,14 +58,14 @@ export default function AdminPartnerCenterDetailPage() {
}; };
const onAssign = () => { const onAssign = () => {
const nurseProfileId = Number(assignId); const nurseProfileId = assignNurseUser?.nurseProfileId;
if (!Number.isFinite(nurseProfileId) || nurseProfileId <= 0) return; if (nurseProfileId == null) return;
assignNurse.mutate( assignNurse.mutate(
{ nurseProfileId, unlink: false }, { nurseProfileId, unlink: false },
{ {
onSuccess: () => { onSuccess: () => {
enqueueSnackbar(t('partner_nurse_assigned'), { variant: 'success' }); enqueueSnackbar(t('partner_nurse_assigned'), { variant: 'success' });
setAssignId(''); setAssignNurseUser(null);
}, },
}, },
); );
@@ -77,17 +78,7 @@ export default function AdminPartnerCenterDetailPage() {
); );
}; };
const back = ( const back = <PageHeader title={t('partner_detail_title')} onBack={goBack} backLabel={t('back')} />;
<AppButton
variant="text"
color="primary"
startIcon="partners"
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_PARTNERS}`)}
sx={{ alignSelf: 'flex-start' }}
>
{t('back')}
</AppButton>
);
if (center.isLoading) { if (center.isLoading) {
return ( return (
@@ -124,20 +115,15 @@ export default function AdminPartnerCenterDetailPage() {
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack sx={{ gap: 0.75 }}> <PageHeader
{back} title={t('partner_detail_title')}
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}> subtitle={data.name}
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}> onBack={goBack}
{t('partner_detail_title')} backLabel={t('back')}
</Typography> meta={<StatusChip status={CENTER_STATE_KIND[data.onboardingState]} label={t(`center_state_${data.onboardingState}`)} />}
<StatusChip status={CENTER_STATE_KIND[data.onboardingState]} label={t(`center_state_${data.onboardingState}`)} /> />
</Stack>
<Typography variant="subtitle1" sx={{ color: 'text.secondary' }}>
{data.name}
</Typography>
</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 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>
@@ -188,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
@@ -221,21 +207,22 @@ export default function AdminPartnerCenterDetailPage() {
{caps.canManagePartners ? ( {caps.canManagePartners ? (
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start', flexWrap: 'wrap' }}> <Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<TextField <Box sx={{ minWidth: 260 }}>
size="small" <NursePicker
type="number" value={assignNurseUser}
label={t('partner_assign_nurse_ph')} onChange={setAssignNurseUser}
value={assignId} label={t('partner_assign_nurse_ph')}
onChange={(e) => setAssignId(e.target.value)} placeholder={t('user_picker_search_ph')}
slotProps={{ htmlInput: { min: 1, step: 1 } }} noOptionsText={t('user_picker_no_options')}
sx={{ minWidth: 200 }} loadingText={t('user_picker_loading')}
/> />
</Box>
<AppButton <AppButton
variant="contained" variant="contained"
color="primary" color="primary"
startIcon="assign" startIcon="assign"
onClick={onAssign} onClick={onAssign}
disabled={assignNurse.isPending || Number(assignId) <= 0} disabled={assignNurse.isPending || assignNurseUser?.nurseProfileId == null}
sx={{ mt: 0.25 }} sx={{ mt: 0.25 }}
> >
{t('partner_assign_nurse')} {t('partner_assign_nurse')}
@@ -1,7 +1,8 @@
'use client'; 'use client';
import { 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, 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,
@@ -24,14 +24,21 @@ import {
AdminErrorState, AdminErrorState,
AdminPageHeader, AdminPageHeader,
AdminPager, AdminPager,
UserPicker,
type AdminTableColumn, type AdminTableColumn,
} from '@/components/admin'; } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks'; import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { adminPartnerCenterPath } from '@/constants'; import { adminPartnerCenterPath } from '@/constants';
import { PARTNER_PAGE_SIZE } from '@/services/partnerCenter/constants'; import { PARTNER_PAGE_SIZE } from '@/services/partnerCenter/constants';
import type { CenterOnboardingState, PartnerCenter, PartnerCenterInput } from '@/services/partnerCenter/types'; import type { CenterOnboardingState, PartnerCenter, PartnerCenterInput } from '@/services/partnerCenter/types';
import type { AdminUserSummary } from '@/services/admin/types';
import { useUserLookup } from '@/services/admin';
import { usePartnerCenters, useCreatePartnerCenter, useUpdatePartnerCenter } from '@/services/partnerCenter'; import { usePartnerCenters, useCreatePartnerCenter, useUpdatePartnerCenter } from '@/services/partnerCenter';
/** No list-level filters today (the list is unfiltered) — `useAdminListState` still URL-syncs the page. */
type PartnersListFilters = Record<string, never>;
const EMPTY_FILTERS: PartnersListFilters = {};
/** State → semantic chip color. verified = green, pending = amber, suspended = red, draft = neutral. */ /** State → semantic chip color. verified = green, pending = amber, suspended = red, draft = neutral. */
export const CENTER_STATE_KIND: Record<CenterOnboardingState, StatusKind> = { export const CENTER_STATE_KIND: Record<CenterOnboardingState, StatusKind> = {
verified: 'verified', verified: 'verified',
@@ -48,16 +55,33 @@ export const CENTER_STATE_KIND: Record<CenterOnboardingState, StatusKind> = {
* it is only ever entered here, never displayed (the list carries no IBAN at all). * it is only ever entered here, never displayed (the list carries no IBAN at all).
*/ */
export default function AdminPartnersPage() { export default function AdminPartnersPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminPartnersPageInner />
</Suspense>
);
}
function AdminPartnersPageInner() {
const t = useTranslations('admin'); const t = useTranslations('admin');
const locale = useLocale(); const locale = useLocale();
const router = useRouter(); const router = useRouter();
const caps = useAdminCapabilities(); const caps = useAdminCapabilities();
const [page, setPage] = useState(1);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const listState = useAdminListState<PartnersListFilters>({
parse: () => EMPTY_FILTERS,
serialize: () => ({}),
empty: EMPTY_FILTERS,
});
const page = listState.page;
const centers = usePartnerCenters({}, page); const centers = usePartnerCenters({}, page);
const items = centers.data?.items ?? []; const items = centers.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((centers.data?.total ?? 0) / PARTNER_PAGE_SIZE)); const total = centers.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PARTNER_PAGE_SIZE));
const from = items.length === 0 ? 0 : (page - 1) * PARTNER_PAGE_SIZE + 1;
const to = (page - 1) * PARTNER_PAGE_SIZE + items.length;
const columns: AdminTableColumn<PartnerCenter>[] = [ const columns: AdminTableColumn<PartnerCenter>[] = [
{ key: 'name', header: t('partner_col_name'), render: (c) => c.name }, { key: 'name', header: t('partner_col_name'), render: (c) => c.name },
@@ -97,17 +121,18 @@ export default function AdminPartnersPage() {
getRowKey={(c) => c.id} getRowKey={(c) => c.id}
ariaLabel={t('partner_title')} ariaLabel={t('partner_title')}
onRowClick={(c) => router.push(`/${locale}${adminPartnerCenterPath(c.id)}`)} onRowClick={(c) => router.push(`/${locale}${adminPartnerCenterPath(c.id)}`)}
footer={total > 0 ? t('showing_range', { from, to, total }) : undefined}
/> />
)} )}
<AdminPager <AdminPager
page={page} page={page}
pageCount={pageCount} pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))} onPrev={() => listState.goToPage(Math.max(1, page - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))} onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')} prevLabel={t('prev_page')}
nextLabel={t('next_page')} nextLabel={t('next_page')}
indicator={t('page_indicator', { page })} indicator={t('page_indicator', { page, total: pageCount })}
/> />
{creating ? <PartnerCenterFormDialog center={null} onClose={() => setCreating(false)} /> : null} {creating ? <PartnerCenterFormDialog center={null} onClose={() => setCreating(false)} /> : null}
@@ -115,7 +140,9 @@ export default function AdminPartnersPage() {
); );
} }
/** The editable slice of `PartnerCenterInput`, kept as strings for controlled text/number inputs. */ /** The editable slice of `PartnerCenterInput`, kept as strings for controlled text/number inputs except
* `adminUser`, the resolved picker selection (3.2), never a hand-typed id. `undefined` = untouched (the
* edit-mode existing admin, once resolved, still shows); `null` = the admin explicitly cleared the field. */
interface CenterFormState { interface CenterFormState {
name: string; name: string;
legalEntityType: string; legalEntityType: string;
@@ -125,7 +152,7 @@ interface CenterFormState {
settlementIban: string; settlementIban: string;
isMerchantOfRecord: boolean; isMerchantOfRecord: boolean;
commissionRate: string; commissionRate: string;
adminUserId: string; adminUser: AdminUserSummary | null | undefined;
} }
function initialForm(center: PartnerCenter | null): CenterFormState { function initialForm(center: PartnerCenter | null): CenterFormState {
@@ -139,7 +166,7 @@ function initialForm(center: PartnerCenter | null): CenterFormState {
settlementIban: '', settlementIban: '',
isMerchantOfRecord: center?.isMerchantOfRecord ?? false, isMerchantOfRecord: center?.isMerchantOfRecord ?? false,
commissionRate: center != null ? String(center.commissionRate) : '', commissionRate: center != null ? String(center.commissionRate) : '',
adminUserId: center?.adminUserId != null ? String(center.adminUserId) : '', adminUser: undefined,
}; };
} }
@@ -156,30 +183,31 @@ 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 set = <K extends keyof CenterFormState>(key: K, value: CenterFormState[K]) => const form = useForm<CenterFormState>({ mode: 'onTouched', defaultValues: initialForm(center) });
setForm((f) => ({ ...f, [key]: value })); const { control, handleSubmit, formState } = form;
const isMerchantOfRecord = useWatch({ control, name: 'isMerchantOfRecord' });
const adminUser = useWatch({ control, name: 'adminUser' });
const commission = Number(form.commissionRate); // Edit mode: the center already has an adminUserId (a plain number) — resolve it to a name so the picker
const commissionValid = form.commissionRate.trim() !== '' && Number.isFinite(commission) && commission >= 0 && commission < 1; // opens pre-filled with a person, never a bare id (3.2). Derived in render (never synced into form state):
// On edit a blank IBAN is allowed (it keeps the stored value); on create an MoR center must supply one. // once the admin actually picks someone, the form value wins over the resolved existing one.
const ibanValid = !form.isMerchantOfRecord || isEdit || form.settlementIban.trim() !== ''; const existingAdminId = center?.adminUserId ?? null;
const valid = const existingAdminLookup = useUserLookup(existingAdminId != null ? [existingAdminId] : []);
form.name.trim() !== '' && form.mohEstablishmentPermitNo.trim() !== '' && commissionValid && ibanValid; const resolvedExistingAdmin = existingAdminId != null ? (existingAdminLookup.data?.get(existingAdminId) ?? null) : null;
const displayedAdminUser = adminUser !== undefined ? adminUser : resolvedExistingAdmin;
const onSave = () => { const onSave = (values: CenterFormState) => {
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: form.adminUserId.trim() === '' ? null : Number(form.adminUserId), adminUserId: displayedAdminUser?.id ?? null,
}; };
mutation.mutate(input, { mutation.mutate(input, {
onSuccess: () => { onSuccess: () => {
@@ -192,66 +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>
<DialogContent> <FormProvider {...form}>
<Stack sx={{ gap: 2, mt: 1 }}> <DialogContent>
<TextField label={t('partner_name')} value={form.name} onChange={(e) => set('name', e.target.value)} /> {/* 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. */}
label={t('partner_legal_type')} <Stack component="form" noValidate onSubmit={handleSubmit(onSave)} sx={{ gap: 2, mt: 1 }}>
value={form.legalEntityType} <RhfTextField<CenterFormState>
onChange={(e) => set('legalEntityType', e.target.value)} name="name"
/> label={t('partner_name')}
<TextField rules={{ validate: (value) => String(value ?? '').trim() !== '' }}
label={t('partner_permit')}
value={form.mohEstablishmentPermitNo}
onChange={(e) => set('mohEstablishmentPermitNo', e.target.value)}
/>
<TextField
label={t('partner_tech_director_license')}
value={form.technicalDirectorLicenseNo}
onChange={(e) => set('technicalDirectorLicenseNo', e.target.value)}
/>
<TextField label={t('partner_enamad')} value={form.enamadCode} onChange={(e) => set('enamadCode', e.target.value)} />
<TextField
label={t('partner_iban')}
value={form.settlementIban}
onChange={(e) => set('settlementIban', e.target.value)}
helperText={t('partner_iban_write_hint')}
placeholder={center?.settlementIbanMasked ?? undefined}
slotProps={{ htmlInput: { dir: 'ltr' } }}
/>
<Box>
<FormControlLabel
control={<Switch checked={form.isMerchantOfRecord} onChange={(e) => set('isMerchantOfRecord', e.target.checked)} />}
label={t('partner_is_mor')}
/> />
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}> <RhfTextField<CenterFormState> name="legalEntityType" label={t('partner_legal_type')} />
{t('partner_is_mor_hint')} <RhfTextField<CenterFormState>
</Typography> name="mohEstablishmentPermitNo"
</Box> label={t('partner_permit')}
<TextField rules={{ validate: (value) => String(value ?? '').trim() !== '' }}
type="number" />
label={t('partner_commission')} <RhfTextField<CenterFormState>
value={form.commissionRate} name="technicalDirectorLicenseNo"
onChange={(e) => set('commissionRate', e.target.value)} label={t('partner_tech_director_license')}
slotProps={{ htmlInput: { min: 0, max: 0.999, step: 0.01 } }} />
/> <RhfTextField<CenterFormState> name="enamadCode" label={t('partner_enamad')} />
<TextField <RhfTextField<CenterFormState>
type="number" name="settlementIban"
label={t('partner_admin_user')} label={t('partner_iban')}
value={form.adminUserId} helperText={t('partner_iban_write_hint')}
onChange={(e) => set('adminUserId', e.target.value)} placeholder={center?.settlementIbanMasked ?? undefined}
slotProps={{ htmlInput: { min: 1, step: 1 } }} // 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() !== '' }}
</Stack> slotProps={{ htmlInput: { dir: 'ltr' } }}
</DialogContent> />
<DialogActions sx={{ px: 3, pb: 2 }}> <Box>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={mutation.isPending}> <RhfControlGroup<CenterFormState> name="isMerchantOfRecord">
{t('cancel')} {({ field }) => (
</AppButton> <FormControlLabel
<AppButton variant="contained" color="primary" onClick={onSave} disabled={!valid || mutation.isPending}> control={
{mutation.isPending ? t('saving') : t('save')} <Switch
</AppButton> checked={Boolean(field.value)}
</DialogActions> onChange={(event) => field.onChange(event.target.checked)}
/>
}
label={t('partner_is_mor')}
/>
)}
</RhfControlGroup>
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
{t('partner_is_mor_hint')}
</Typography>
</Box>
<RhfTextField<CenterFormState>
name="commissionRate"
type="number"
label={t('partner_commission')}
rules={{
validate: (value) => {
const raw = String(value ?? '').trim();
const rate = Number(raw);
return raw !== '' && Number.isFinite(rate) && rate >= 0 && rate < 1;
},
}}
slotProps={{ htmlInput: { min: 0, max: 0.999, step: 0.01 } }}
/>
<RhfControlGroup<CenterFormState> name="adminUser">
{({ field }) => (
<UserPicker
value={displayedAdminUser}
onChange={field.onChange}
label={t('partner_admin_user')}
placeholder={t('user_picker_search_ph')}
noOptionsText={t('user_picker_no_options')}
loadingText={t('user_picker_loading')}
/>
)}
</RhfControlGroup>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={mutation.isPending}>
{t('cancel')}
</AppButton>
<AppButton
variant="contained"
color="primary"
onClick={handleSubmit(onSave)}
disabled={!formState.isValid || mutation.isPending}
>
{mutation.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</FormProvider>
</Dialog> </Dialog>
); );
} }
@@ -1,18 +1,22 @@
'use client'; 'use client';
import { FunctionComponent, ReactNode, useState } from 'react'; import { FunctionComponent, ReactNode, Suspense, useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { Box, Chip, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material'; import { Box, Chip, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, StatusChip } from '@/components'; import { AppButton, AppLoading, PageHeader, StatusChip } from '@/components';
import type { StatusKind } from '@/components'; import type { StatusKind } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPager, ConfirmDialog } from '@/components/admin'; import { AdminEmptyState, AdminErrorState, AdminPager, ConfirmDialog } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks'; import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { formatIrrToToman, formatShamsiDate } from '@/utils'; import { formatIrrToToman, formatShamsiDate } from '@/utils';
import { usePayoutBatchDetail, useRecordTransferReference, useRetryPayout } from '@/services/payouts'; import { usePayoutBatchDetail, useRecordTransferReference, useRetryPayout } from '@/services/payouts';
import type { AdminPayoutRow, PayoutBatchStatus, PayoutStatus } from '@/services/payouts/types'; import type { AdminPayoutRow, PayoutBatchStatus, PayoutStatus } from '@/services/payouts/types';
/** This detail page has no filters — only a page number worth mirroring into the URL. */
type BatchRowsFilters = Record<string, never>;
const EMPTY_FILTERS: BatchRowsFilters = {};
const BATCH_STATUS_KIND: Record<PayoutBatchStatus, StatusKind> = { const BATCH_STATUS_KIND: Record<PayoutBatchStatus, StatusKind> = {
draft: 'neutral', draft: 'neutral',
processing: 'info', processing: 'info',
@@ -35,33 +39,37 @@ const PAYOUT_STATUS_KIND: Record<PayoutStatus, StatusKind> = {
* `canPayout`. Money is display-only Toman; the client never recomputes amounts, eligibility, or dates. * `canPayout`. Money is display-only Toman; the client never recomputes amounts, eligibility, or dates.
*/ */
export default function AdminPayoutBatchDetailPage() { export default function AdminPayoutBatchDetailPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminPayoutBatchDetailScreen />
</Suspense>
);
}
/** Wrapped in `<Suspense>` above — `useAdminListState` calls `useSearchParams()`, which requires it. */
function AdminPayoutBatchDetailScreen() {
const t = useTranslations('admin'); const t = useTranslations('admin');
const locale = useLocale(); const locale = useLocale();
const router = useRouter();
const caps = useAdminCapabilities(); const caps = useAdminCapabilities();
const params = useParams<{ batchId: string }>(); const params = useParams<{ batchId: string }>();
const batchId = Number(params?.batchId); const batchId = Number(params?.batchId);
const [page, setPage] = useState(1); const { page, goToPage } = useAdminListState<BatchRowsFilters>({
parse: () => EMPTY_FILTERS,
serialize: () => ({}),
empty: EMPTY_FILTERS,
});
const detail = usePayoutBatchDetail(Number.isFinite(batchId) ? batchId : null, page); const detail = usePayoutBatchDetail(Number.isFinite(batchId) ? batchId : null, page);
const data = detail.data; const data = detail.data;
const pageCount = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1; const pageCount = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1;
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack sx={{ gap: 0.5 }}> <PageHeader
<AppButton title={t('payout_batch_title', { id: batchId })}
variant="text" backTo={`/${locale}${ROUTES.ADMIN_PAYOUTS}`}
color="primary" backLabel={t('back')}
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_PAYOUTS}`)} />
sx={{ alignSelf: 'flex-start' }}
>
{t('back')}
</AppButton>
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
{t('payout_batch_title', { id: batchId })}
</Typography>
</Stack>
{detail.isLoading ? ( {detail.isLoading ? (
<Stack sx={{ gap: 2 }}> <Stack sx={{ gap: 2 }}>
@@ -75,7 +83,7 @@ export default function AdminPayoutBatchDetailPage() {
<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]}
@@ -116,11 +124,11 @@ export default function AdminPayoutBatchDetailPage() {
<AdminPager <AdminPager
page={page} page={page}
pageCount={pageCount} pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))} onPrev={() => goToPage(Math.max(1, page - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))} onNext={() => goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')} prevLabel={t('prev_page')}
nextLabel={t('next_page')} nextLabel={t('next_page')}
indicator={t('page_indicator', { page })} indicator={t('page_indicator', { page, total: pageCount })}
/> />
</> </>
)} )}
@@ -172,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"
@@ -209,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',
@@ -17,7 +17,7 @@ import {
TextField, TextField,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import { AppButton, Money, StatusChip } from '@/components'; import { AppButton, JalaliDateField, Money, StatusChip } from '@/components';
import type { StatusKind } from '@/components'; import type { StatusKind } from '@/components';
import { import {
AdminDataTable, AdminDataTable,
@@ -30,7 +30,7 @@ import {
import type { AdminTableColumn } from '@/components/admin'; import type { AdminTableColumn } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks'; import { useAdminCapabilities } from '@/hooks';
import { adminPayoutBatchPath } from '@/constants'; import { adminPayoutBatchPath } from '@/constants';
import { formatShamsiDate } from '@/utils'; import { formatNumber, formatShamsiDate } from '@/utils';
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants'; import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
import { usePayoutBatches, usePreviewPayoutBatch, useRunPayoutBatch } from '@/services/payouts'; import { usePayoutBatches, usePreviewPayoutBatch, useRunPayoutBatch } from '@/services/payouts';
import type { PayoutBatchStatus, PayoutBatchSummary } from '@/services/payouts/types'; import type { PayoutBatchStatus, PayoutBatchSummary } from '@/services/payouts/types';
@@ -52,8 +52,17 @@ const BATCH_STATUSES: readonly PayoutBatchStatus[] = [
'failed', 'failed',
]; ];
/** UTC ISO date (`YYYY-MM-DD`) — the wire shape for the batch window. */ /**
const isoDate = (d: Date): string => d.toISOString().slice(0, 10); * ISO date (`YYYY-MM-DD`) the wire shape for the batch window. Formats using the browser's **local**
* date fields, never `toISOString()` (which converts to UTC first): near Tehran local midnight that would
* silently roll the date back/forward a day from the admin's actual wall-clock date.
*/
const isoDate = (d: Date): string => {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
};
/** /**
* Admin payout-batch dashboard (f15) the reconciliation list of weekly `nurse_payout_batches` and the * Admin payout-batch dashboard (f15) the reconciliation list of weekly `nurse_payout_batches` and the
@@ -173,7 +182,7 @@ export default function AdminPayoutsPage() {
onNext={() => setPage((p) => Math.min(pageCount, p + 1))} onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
prevLabel={t('prev_page')} prevLabel={t('prev_page')}
nextLabel={t('next_page')} nextLabel={t('next_page')}
indicator={t('page_indicator', { page })} indicator={t('page_indicator', { page, total: pageCount })}
/> />
{previewOpen ? <PreviewBatchDialog canPayout={caps.canPayout} onClose={() => setPreviewOpen(false)} /> : null} {previewOpen ? <PreviewBatchDialog canPayout={caps.canPayout} onClose={() => setPreviewOpen(false)} /> : null}
@@ -230,22 +239,13 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
<DialogTitle sx={{ fontWeight: 800 }}>{t('payout_preview_title')}</DialogTitle> <DialogTitle sx={{ fontWeight: 800 }}>{t('payout_preview_title')}</DialogTitle>
<DialogContent> <DialogContent>
<Stack direction="row" sx={{ gap: 1.5, mt: 1, flexWrap: 'wrap', alignItems: 'center' }}> <Stack direction="row" sx={{ gap: 1.5, mt: 1, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField <JalaliDateField
type="date"
size="small" size="small"
label={t('payout_period_start')} label={t('payout_period_start')}
value={periodStart} value={periodStart}
onChange={(e) => setPeriodStart(e.target.value)} onChange={setPeriodStart}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
type="date"
size="small"
label={t('payout_period_end')}
value={periodEnd}
onChange={(e) => setPeriodEnd(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/> />
<JalaliDateField size="small" label={t('payout_period_end')} value={periodEnd} onChange={setPeriodEnd} />
<AppButton <AppButton
variant="outlined" variant="outlined"
color="primary" color="primary"
@@ -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}
@@ -361,12 +361,41 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
<ConfirmDialog <ConfirmDialog
open={runConfirmOpen} open={runConfirmOpen}
title={t('payout_run_confirm_title')} title={t('payout_run_confirm_title')}
body={t('payout_run_confirm_body')} body={
result ? (
<Stack sx={{ gap: 1.5, mt: 0.5 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('payout_run_summary_intro')}
</Typography>
<Money amountIrr={result.totalNetIrr} size="lg" tone="emphasis" />
<Stack sx={{ gap: 0.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', gap: 1 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('payout_run_count_label')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{formatNumber(result.eligible.length, locale)}
</Typography>
</Stack>
<Stack direction="row" sx={{ justifyContent: 'space-between', gap: 1 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('payout_col_processing')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{formatShamsiDate(result.processingDate, locale)}
</Typography>
</Stack>
</Stack>
</Stack>
) : null
}
confirmLabel={t('payout_run')} confirmLabel={t('payout_run')}
cancelLabel={t('cancel')} cancelLabel={t('cancel')}
onConfirm={onRunConfirm} onConfirm={onRunConfirm}
onClose={() => setRunConfirmOpen(false)} onClose={() => setRunConfirmOpen(false)}
loading={run.isPending} loading={run.isPending}
requireTypedConfirmation={result ? ['تایید', result.totalNetIrr] : []}
typedConfirmationLabel={t('payout_run_type_to_confirm')}
/> />
</Dialog> </Dialog>
); );
@@ -1,11 +1,11 @@
'use client'; 'use client';
import { useState } from 'react'; import { Suspense, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { Box, Chip, MenuItem, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material'; import { Box, Chip, MenuItem, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, RatingInput } from '@/components'; import { AppButton, AppLoading, RatingInput } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfirmDialog } from '@/components/admin'; import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfirmDialog } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks'; import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { REVIEWS_PAGE_SIZE } from '@/services/reviews/constants'; import { REVIEWS_PAGE_SIZE } from '@/services/reviews/constants';
import type { ModerationAction, ModerationQueueItem, ModerationStatus } from '@/services/reviews/types'; import type { ModerationAction, ModerationQueueItem, ModerationStatus } from '@/services/reviews/types';
import { useModerationQueue, useModerateReview } from '@/services/reviews'; import { useModerationQueue, useModerateReview } from '@/services/reviews';
@@ -29,13 +29,33 @@ const STATUS_CHIP_COLOR: Record<ModerationStatus, 'default' | 'success' | 'warni
* Publishing recomputes the nurse aggregate **server-side**; the mutation invalidates the queue so the row * Publishing recomputes the nurse aggregate **server-side**; the mutation invalidates the queue so the row
* leaves on success. `canModerate` only hides the controls the server enforces the role scope. * leaves on success. `canModerate` only hides the controls the server enforces the role scope.
*/ */
const DEFAULT_STATUS: ModerationStatus = 'pending_moderation';
function parseFilters(params: URLSearchParams): { status: ModerationStatus } {
const status = params.get('status') as ModerationStatus | null;
return { status: status && MODERATION_STATUSES.includes(status) ? status : DEFAULT_STATUS };
}
export default function AdminReviewsPage() { export default function AdminReviewsPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminReviewsPageInner />
</Suspense>
);
}
function AdminReviewsPageInner() {
const t = useTranslations('admin'); const t = useTranslations('admin');
const caps = useAdminCapabilities(); const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const [status, setStatus] = useState<ModerationStatus>('pending_moderation'); const listState = useAdminListState<{ status: ModerationStatus }>({
const [page, setPage] = useState(1); parse: parseFilters,
serialize: (f): Record<string, string> => (f.status !== DEFAULT_STATUS ? { status: f.status } : {}),
empty: { status: DEFAULT_STATUS },
});
const status = listState.applied.status;
const page = listState.page;
const [pending, setPending] = useState<{ item: ModerationQueueItem; action: ModerationAction } | null>(null); const [pending, setPending] = useState<{ item: ModerationQueueItem; action: ModerationAction } | null>(null);
const queue = useModerationQueue({ status }, page); const queue = useModerationQueue({ status }, page);
@@ -44,6 +64,8 @@ export default function AdminReviewsPage() {
const items = queue.data?.items ?? []; const items = queue.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((queue.data?.total ?? 0) / REVIEWS_PAGE_SIZE)); const pageCount = Math.max(1, Math.ceil((queue.data?.total ?? 0) / REVIEWS_PAGE_SIZE));
const setStatus = (next: ModerationStatus) => listState.applyFilters({ status: next });
const requireReason = pending?.action === 'hide' || pending?.action === 'reject'; const requireReason = pending?.action === 'hide' || pending?.action === 'reject';
const onConfirm = (reason?: string) => { const onConfirm = (reason?: string) => {
@@ -70,10 +92,7 @@ export default function AdminReviewsPage() {
size="small" size="small"
label={t('filter_label')} label={t('filter_label')}
value={status} value={status}
onChange={(e) => { onChange={(e) => setStatus(e.target.value as ModerationStatus)}
setStatus(e.target.value as ModerationStatus);
setPage(1);
}}
sx={{ minWidth: 180 }} sx={{ minWidth: 180 }}
> >
{MODERATION_STATUSES.map((s) => ( {MODERATION_STATUSES.map((s) => (
@@ -107,11 +126,11 @@ export default function AdminReviewsPage() {
<AdminPager <AdminPager
page={page} page={page}
pageCount={pageCount} pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))} onPrev={() => listState.goToPage(Math.max(1, page - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))} onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')} prevLabel={t('prev_page')}
nextLabel={t('next_page')} nextLabel={t('next_page')}
indicator={t('page_indicator', { page })} indicator={t('page_indicator', { page, total: pageCount })}
/> />
<ConfirmDialog <ConfirmDialog
@@ -147,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')} />
@@ -8,7 +8,7 @@
* grid lists **active** grants (revoked rows are filtered out); an audited confirm dialog fronts every * grid lists **active** grants (revoked rows are filtered out); an audited confirm dialog fronts every
* revoke and grant. * revoke and grant.
*/ */
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { import {
@@ -31,12 +31,13 @@ import {
AdminErrorState, AdminErrorState,
AdminPageHeader, AdminPageHeader,
ConfirmDialog, ConfirmDialog,
UserPicker,
type AdminTableColumn, type AdminTableColumn,
} from '@/components/admin'; } from '@/components/admin';
import { formatShamsiDate } from '@/utils'; import { formatShamsiDate } from '@/utils';
import { useAdminCapabilities } from '@/hooks'; import { useAdminCapabilities } from '@/hooks';
import type { AdminRole, RoleGrant } from '@/services/admin/types'; import type { AdminRole, AdminUserSummary, RoleGrant } from '@/services/admin/types';
import { useAdminRoles, useGrantRole, useRevokeRole } from '@/services/admin'; import { useAdminRoles, useGrantRole, useRevokeRole, useUserLookup } from '@/services/admin';
/** The fine-grained admin roles the grid grants (aligned with the b2 `AdminRole` enum). */ /** The fine-grained admin roles the grid grants (aligned with the b2 `AdminRole` enum). */
const ROLES: readonly AdminRole[] = ['super_admin', 'admin', 'support', 'finance', 'moderation']; const ROLES: readonly AdminRole[] = ['super_admin', 'admin', 'support', 'finance', 'moderation'];
@@ -56,8 +57,13 @@ export default function AdminRolesPage() {
// Only active grants — a revoked grant leaves the grid. // Only active grants — a revoked grant leaves the grid.
const active = (roles.data ?? []).filter((g) => g.revokedAt == null); const active = (roles.data ?? []).filter((g) => g.revokedAt == null);
// Batch id→name resolve (3.2) — one request for every grant row, never one per row.
const userIds = useMemo(() => [...new Set(active.map((g) => g.userId))].sort((a, b) => a - b), [active]);
const userLookup = useUserLookup(userIds);
const nameFor = (userId: number): string => userLookup.data?.get(userId)?.displayName ?? `#${userId}`;
const columns: AdminTableColumn<RoleGrant>[] = [ const columns: AdminTableColumn<RoleGrant>[] = [
{ key: 'user', header: t('role_col_user'), render: (g) => `#${g.userId}` }, { key: 'user', header: t('role_col_user'), render: (g) => nameFor(g.userId) },
{ {
key: 'role', key: 'role',
header: t('role_col_role'), header: t('role_col_role'),
@@ -131,7 +137,7 @@ export default function AdminRolesPage() {
<ConfirmDialog <ConfirmDialog
open={revoking != null} open={revoking != null}
title={t('role_revoke')} title={t('role_revoke')}
body={revoking ? t('role_revoke_confirm', { role: t(`role_${revoking.role}`), id: revoking.userId }) : undefined} body={revoking ? t('role_revoke_confirm', { role: t(`role_${revoking.role}`), name: nameFor(revoking.userId) }) : undefined}
confirmLabel={t('role_revoke')} confirmLabel={t('role_revoke')}
cancelLabel={t('cancel')} cancelLabel={t('cancel')}
confirmColor="error" confirmColor="error"
@@ -149,16 +155,15 @@ function GrantRoleDialog({ onClose }: { onClose: () => void }) {
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const grant = useGrantRole(); const grant = useGrantRole();
const [userId, setUserId] = useState(''); const [user, setUser] = useState<AdminUserSummary | null>(null);
const [role, setRole] = useState<AdminRole>('support'); const [role, setRole] = useState<AdminRole>('support');
const parsedId = Number(userId); const valid = user != null;
const valid = /^\d+$/.test(userId.trim()) && parsedId > 0;
const onGrant = () => { const onGrant = () => {
if (!valid) return; if (!user) return;
grant.mutate( grant.mutate(
{ userId: parsedId, role }, { userId: user.id, role },
{ {
onSuccess: () => { onSuccess: () => {
enqueueSnackbar(t('role_updated'), { variant: 'success' }); enqueueSnackbar(t('role_updated'), { variant: 'success' });
@@ -173,12 +178,13 @@ function GrantRoleDialog({ onClose }: { onClose: () => void }) {
<DialogTitle sx={{ fontWeight: 800 }}>{t('role_grant')}</DialogTitle> <DialogTitle sx={{ fontWeight: 800 }}>{t('role_grant')}</DialogTitle>
<DialogContent> <DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}> <Stack sx={{ gap: 2, mt: 1 }}>
<TextField <UserPicker
value={user}
onChange={setUser}
label={t('role_col_user')} label={t('role_col_user')}
type="number" placeholder={t('user_picker_search_ph')}
value={userId} noOptionsText={t('user_picker_no_options')}
onChange={(e) => setUserId(e.target.value)} loadingText={t('user_picker_loading')}
slotProps={{ htmlInput: { min: 1 } }}
/> />
<TextField select label={t('role_col_role')} value={role} onChange={(e) => setRole(e.target.value as AdminRole)}> <TextField select label={t('role_col_role')} value={role} onChange={(e) => setRole(e.target.value as AdminRole)}>
{ROLES.map((r) => ( {ROLES.map((r) => (
@@ -188,7 +194,7 @@ function GrantRoleDialog({ onClose }: { onClose: () => void }) {
))} ))}
</TextField> </TextField>
{valid ? ( {valid ? (
<DialogContentText>{t('role_grant_confirm', { role: t(`role_${role}`), id: parsedId })}</DialogContentText> <DialogContentText>{t('role_grant_confirm', { role: t(`role_${role}`), name: user.displayName })}</DialogContentText>
) : null} ) : null}
</Stack> </Stack>
</DialogContent> </DialogContent>
@@ -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>
);
}
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { import {
@@ -13,18 +13,22 @@ import {
TextField, TextField,
ToggleButton, ToggleButton,
ToggleButtonGroup, ToggleButtonGroup,
Typography, Tooltip,
} from '@mui/material'; } from '@mui/material';
import { AppButton, StatusChip } from '@/components'; import { AppButton, PageHeader, StatusChip } from '@/components';
import type { StatusKind } from '@/components'; import type { StatusKind } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminMessageBubble, RefundPanel } from '@/components/admin'; import { useThreadScroll } from '@/components/messaging';
import { useAdminCapabilities } from '@/hooks'; import { AdminEmptyState, AdminErrorState, AdminMessageBubble, ConfirmDialog, RefundPanel } from '@/components/admin';
import { useAdminBackToList, useAdminCapabilities } from '@/hooks';
import { useAuth } from '@/context/auth';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { useAdminTicket, usePostAdminMessage } from '@/services/tickets'; import { useAdminTicket, useAssignTicket, useCloseTicket, usePostAdminMessage, useReopenTicket } from '@/services/tickets';
import { TICKET_LIFECYCLE_ENABLED } from '@/services/tickets/constants';
import type { TicketAuthorRole, TicketStatus } from '@/services/tickets/types'; import type { TicketAuthorRole, TicketStatus } from '@/services/tickets/types';
/** Status → chip color: an open ticket is pending work, a closed one is neutral (mirrors the queue). */ /** Status → chip color: an open ticket is pending work, a closed one is neutral (mirrors the queue). */
const STATUS_KIND: Record<TicketStatus, StatusKind> = { open: 'pending', closed: 'neutral' }; const STATUS_KIND: Record<TicketStatus, StatusKind> = { open: 'pending', closed: 'neutral' };
const REFUND_PANEL_ID = 'admin-ticket-refund-panel';
/** The `tickets` author-label key. `admin` has no `author_admin` key — staff read as "support" (`author_support`). */ /** The `tickets` author-label key. `admin` has no `author_admin` key — staff read as "support" (`author_support`). */
function authorLabelKey(role: TicketAuthorRole): string { function authorLabelKey(role: TicketAuthorRole): string {
@@ -40,18 +44,22 @@ function makeClientMessageId(): string {
* The admin ticket thread (f15) the full conversation **including internal notes** (the admin lens carries * The admin ticket thread (f15) the full conversation **including internal notes** (the admin lens carries
* `isInternal`; the user app never does). Staff read the whole thread and reply as **either** a participant- * `isInternal`; the user app never does). Staff read the whole thread and reply as **either** a participant-
* visible reply **or** a staff-only internal note (the composer toggles `isInternal`; `AdminMessageBubble` * visible reply **or** a staff-only internal note (the composer toggles `isInternal`; `AdminMessageBubble`
* renders internal notes distinctly). Sends are optimistic (`usePostAdminMessage`) the draft clears only on * renders internal notes distinctly, and ui-phase-11 makes the composer itself amber-tinted in internal mode
* confirm. When the ticket is a **refund** case linked to a booking, the admin opens the `RefundPanel` inline * so a staff member can never post an internal note publicly by mistake). Sends are optimistic
* (it always initiates from a ticket, never a standalone form). Composer + refund are gated on the principal's * (`usePostAdminMessage`) the draft clears only on confirm; the thread opens scrolled to the newest message
* capabilities; the server is the real authority. * (`useThreadScroll`). When the ticket is a **refund** case linked to a booking, the admin opens the
* `RefundPanel` inline. Close/reopen/assign-to-me (REQ-063) are gated behind `TICKET_LIFECYCLE_ENABLED` no
* live route yet AND the principal's `canManageTickets` capability; the server is the real authority.
*/ */
export default function AdminTicketThreadPage() { export default function AdminTicketThreadPage() {
const t = useTranslations('admin'); const t = useTranslations('admin');
const tickets = useTranslations('tickets'); const tickets = useTranslations('tickets');
const locale = useLocale(); const locale = useLocale();
const router = useRouter();
const caps = useAdminCapabilities(); const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const [authState] = useAuth();
const meId = authState.currentUser?.id;
const goBack = useAdminBackToList(`/${locale}${ROUTES.ADMIN_TICKETS}`);
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const parsed = Number(params?.id); const parsed = Number(params?.id);
@@ -59,12 +67,20 @@ export default function AdminTicketThreadPage() {
const { data: detail, isLoading, isError, refetch } = useAdminTicket(ticketId || null); const { data: detail, isLoading, isError, refetch } = useAdminTicket(ticketId || null);
const post = usePostAdminMessage(ticketId); const post = usePostAdminMessage(ticketId);
const closeTicket = useCloseTicket(ticketId);
const reopenTicket = useReopenTicket(ticketId);
const assignTicket = useAssignTicket(ticketId);
const [mode, setMode] = useState<'reply' | 'internal'>('reply'); const [mode, setMode] = useState<'reply' | 'internal'>('reply');
const [body, setBody] = useState(''); const [body, setBody] = useState('');
const [refundShown, setRefundShown] = useState(false); const [refundShown, setRefundShown] = useState(false);
const [closeDialogOpen, setCloseDialogOpen] = useState(false);
const [reopenDialogOpen, setReopenDialogOpen] = useState(false);
const isInternal = mode === 'internal'; const isInternal = mode === 'internal';
const messageCount = detail?.messages.length ?? 0;
const lastMessage = detail?.messages[messageCount - 1];
const { bottomRef } = useThreadScroll(messageCount, lastMessage?.isMine ?? false);
const send = () => { const send = () => {
const trimmed = body.trim(); const trimmed = body.trim();
@@ -80,16 +96,62 @@ export default function AdminTicketThreadPage() {
); );
}; };
const onCloseConfirm = () => {
closeTicket.mutate(undefined, {
onSuccess: () => {
setCloseDialogOpen(false);
enqueueSnackbar(t('ticket_closed_ok'), { variant: 'success' });
},
});
};
const onReopenConfirm = () => {
reopenTicket.mutate(undefined, {
onSuccess: () => {
setReopenDialogOpen(false);
enqueueSnackbar(t('ticket_reopened_ok'), { variant: 'success' });
},
});
};
const onAssignMe = () => {
if (meId == null || assignTicket.isPending) return;
assignTicket.mutate({ ownerUserId: meId }, { onSuccess: () => enqueueSnackbar(t('ticket_assigned_me'), { variant: 'success' }) });
};
const showRefund = !!detail && detail.category === 'refund' && detail.bookingId != null && caps.canRefund; const showRefund = !!detail && detail.category === 'refund' && detail.bookingId != null && caps.canRefund;
const showLifecycle = TICKET_LIFECYCLE_ENABLED && caps.canManageTickets;
const lifecycleActions = showLifecycle && detail ? (
<>
{detail.status === 'open' ? (
<AppButton variant="outlined" color="primary" startIcon="close" onClick={() => setCloseDialogOpen(true)}>
{t('ticket_close')}
</AppButton>
) : (
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={() => setReopenDialogOpen(true)}>
{t('ticket_reopen')}
</AppButton>
)}
<Tooltip title={meId == null ? t('assign_me_loading') : ''}>
<span>
<AppButton
variant="outlined"
color="primary"
startIcon="assign"
onClick={onAssignMe}
disabled={meId == null || assignTicket.isPending}
>
{t('ticket_assign_me')}
</AppButton>
</span>
</Tooltip>
</>
) : null;
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 820, mx: 'auto', width: '100%' }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 820, mx: 'auto', width: '100%' }}>
<AppButton <AppButton variant="text" color="primary" onClick={goBack} sx={{ alignSelf: 'flex-start' }}>
variant="text"
color="primary"
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_TICKETS}`)}
sx={{ alignSelf: 'flex-start' }}
>
{t('back')} {t('back')}
</AppButton> </AppButton>
@@ -105,45 +167,48 @@ 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)' }}>
<Stack sx={{ gap: 1.5 }}> <PageHeader
<Typography variant="h6" component="h1" sx={{ fontWeight: 800 }} dir="ltr"> title={t('ticket_thread_title', { ref: detail.referenceCode })}
{t('ticket_thread_title', { ref: detail.referenceCode })} subtitle={detail.subject ?? undefined}
</Typography> actions={lifecycleActions}
{detail.subject ? ( meta={
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
{detail.subject} <Chip size="small" label={t(`tcat_${detail.category}`)} />
</Typography> <StatusChip status={STATUS_KIND[detail.status]} label={t(`tstatus_${detail.status}`)} />
) : null} {detail.bookingId != null ? (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap', alignItems: 'center' }}> <Chip size="small" variant="outlined" label={t('refund_linked_booking', { id: detail.bookingId })} />
<Chip size="small" label={t(`tcat_${detail.category}`)} /> ) : null}
<StatusChip status={STATUS_KIND[detail.status]} label={t(`tstatus_${detail.status}`)} /> {detail.refundId != null ? (
{detail.bookingId != null ? ( <Chip size="small" variant="outlined" label={t('ticket_linked_refund', { id: detail.refundId })} />
<Chip size="small" variant="outlined" label={t('refund_linked_booking', { id: detail.bookingId })} /> ) : null}
) : null} </Stack>
{detail.refundId != null ? ( }
<Chip size="small" variant="outlined" label={t('ticket_linked_refund', { id: detail.refundId })} /> />
) : null}
</Stack>
{showRefund ? ( {showRefund ? (
<Box> <Box sx={{ mt: 1.5 }}>
<AppButton <AppButton
variant="outlined" variant="outlined"
color="primary" color="primary"
startIcon="refunds" startIcon="refunds"
onClick={() => setRefundShown((v) => !v)} aria-expanded={refundShown}
aria-controls={REFUND_PANEL_ID}
onClick={() => setRefundShown((v) => !v)}
>
{t('refund_open')}
</AppButton>
<Collapse in={refundShown} unmountOnExit>
<Paper
id={REFUND_PANEL_ID}
elevation={0}
sx={{ mt: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
> >
{t('refund_open')} <RefundPanel bookingId={detail.bookingId as number} ticketId={detail.id} />
</AppButton> </Paper>
<Collapse in={refundShown} unmountOnExit> </Collapse>
<Paper elevation={0} sx={{ mt: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}> </Box>
<RefundPanel bookingId={detail.bookingId as number} ticketId={detail.id} /> ) : null}
</Paper>
</Collapse>
</Box>
) : null}
</Stack>
</Paper> </Paper>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, maxHeight: 520, overflowY: 'auto', p: 0.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, maxHeight: 520, overflowY: 'auto', p: 0.5 }}>
@@ -154,10 +219,20 @@ export default function AdminTicketThreadPage() {
authorLabel={tickets(authorLabelKey(m.authorRole))} authorLabel={tickets(authorLabelKey(m.authorRole))}
/> />
))} ))}
<div ref={bottomRef} />
</Box> </Box>
{caps.canManageTickets ? ( {caps.canManageTickets ? (
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}> <Paper
elevation={0}
sx={{
p: 2,
border: '1px solid',
borderColor: isInternal ? 'var(--bal-warning)' : 'divider',
borderRadius: 'var(--bal-radius-md)',
bgcolor: isInternal ? 'var(--bal-warning-soft)' : 'background.paper',
}}
>
<Stack sx={{ gap: 1.5 }}> <Stack sx={{ gap: 1.5 }}>
<ToggleButtonGroup <ToggleButtonGroup
size="small" size="small"
@@ -185,11 +260,34 @@ export default function AdminTicketThreadPage() {
disabled={post.isPending || body.trim().length === 0} disabled={post.isPending || body.trim().length === 0}
sx={{ alignSelf: 'flex-start' }} sx={{ alignSelf: 'flex-start' }}
> >
{t('ticket_send')} {isInternal ? t('ticket_send_internal') : t('ticket_send')}
</AppButton> </AppButton>
</Stack> </Stack>
</Paper> </Paper>
) : null} ) : null}
<ConfirmDialog
open={closeDialogOpen}
title={t('ticket_close')}
body={t('ticket_close_confirm')}
confirmLabel={t('ticket_close')}
cancelLabel={t('cancel')}
onConfirm={onCloseConfirm}
onClose={() => setCloseDialogOpen(false)}
loading={closeTicket.isPending}
confirmColor="primary"
/>
<ConfirmDialog
open={reopenDialogOpen}
title={t('ticket_reopen')}
body={t('ticket_reopen_confirm')}
confirmLabel={t('ticket_reopen')}
cancelLabel={t('cancel')}
onConfirm={onReopenConfirm}
onClose={() => setReopenDialogOpen(false)}
loading={reopenTicket.isPending}
confirmColor="primary"
/>
</> </>
)} )}
</Box> </Box>
@@ -1,9 +1,9 @@
'use client'; 'use client';
import { useState } from 'react'; import { Suspense } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { Box, Chip, MenuItem, Skeleton, Stack, TextField } from '@mui/material'; import { Box, Chip, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
import { AppButton, StatusChip } from '@/components'; import { AppButton, AppLoading, StatusChip } from '@/components';
import type { StatusKind } from '@/components'; import type { StatusKind } from '@/components';
import { import {
AdminDataTable, AdminDataTable,
@@ -14,9 +14,11 @@ import {
type AdminTableColumn, type AdminTableColumn,
} from '@/components/admin'; } from '@/components/admin';
import { adminTicketThreadPath } from '@/constants'; import { adminTicketThreadPath } from '@/constants';
import { useAdminListState } from '@/hooks';
import { useAdminTickets } from '@/services/tickets'; import { useAdminTickets } from '@/services/tickets';
import { TICKETS_PAGE_SIZE } from '@/services/tickets/constants'; import { TICKETS_PAGE_SIZE } from '@/services/tickets/constants';
import type { AdminTicketFilters, AdminTicketSummary, TicketCategory, TicketStatus } from '@/services/tickets/types'; import type { AdminTicketFilters, AdminTicketSummary, TicketCategory, TicketStatus } from '@/services/tickets/types';
import { formatShamsiDate } from '@/utils';
const STATUSES: readonly TicketStatus[] = ['open', 'closed']; const STATUSES: readonly TicketStatus[] = ['open', 'closed'];
const CATEGORIES: readonly TicketCategory[] = ['coordination', 'support', 'refund', 'emergency']; const CATEGORIES: readonly TicketCategory[] = ['coordination', 'support', 'refund', 'emergency'];
@@ -24,35 +26,55 @@ const CATEGORIES: readonly TicketCategory[] = ['coordination', 'support', 'refun
const STATUS_KIND: Record<TicketStatus, StatusKind> = { open: 'pending', closed: 'neutral' }; const STATUS_KIND: Record<TicketStatus, StatusKind> = { open: 'pending', closed: 'neutral' };
const EMPTY: AdminTicketFilters = {}; const EMPTY: AdminTicketFilters = {};
function parseFilters(params: URLSearchParams): AdminTicketFilters {
return {
status: (params.get('status') as TicketStatus | null) ?? undefined,
category: (params.get('category') as TicketCategory | null) ?? undefined,
referenceCode: params.get('referenceCode') ?? undefined,
};
}
function serializeFilters(filters: AdminTicketFilters): Record<string, string> {
const record: Record<string, string> = {};
if (filters.status) record.status = filters.status;
if (filters.category) record.category = filters.category;
if (filters.referenceCode) record.referenceCode = filters.referenceCode;
return record;
}
/** /**
* The admin global ticket queue (f15) EVERY ticket across the platform (not one viewer's), the entry point * The admin global ticket queue (f15) EVERY ticket across the platform (not one viewer's), the entry point
* into a case. Filter by status/category/reference; a row opens the admin thread where internal notes and the * into a case. Filter by status/category/reference; a row opens the admin thread where internal notes and the
* refund panel live. The filter **draft** commits to the query only on Apply, so typing a reference never * refund panel live. The filter **draft** commits to the query only on Apply, so typing a reference never
* refetches; the applied filters + page are the cache key (`useAdminTickets`), so revisiting a filter/page * refetches; the applied filters + page are mirrored into the URL (`useAdminListState`, ui-phase-11) so
* serves from cache. This surface is staff-only the server enforces the scope; the UI just routes here. * browser back/refresh/a pasted link all reproduce the exact same queue view. This surface is staff-only
* the server enforces the scope; the UI just routes here.
*/ */
export default function AdminTicketsPage() { export default function AdminTicketsPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminTicketsQueue />
</Suspense>
);
}
function AdminTicketsQueue() {
const t = useTranslations('admin'); const t = useTranslations('admin');
const locale = useLocale(); const locale = useLocale();
const router = useRouter(); const router = useRouter();
const [draft, setDraft] = useState<AdminTicketFilters>(EMPTY); const { draft, setDraft, applied, page, apply, clear, goToPage } = useAdminListState<AdminTicketFilters>({
const [applied, setApplied] = useState<AdminTicketFilters>(EMPTY); parse: parseFilters,
const [page, setPage] = useState(1); serialize: serializeFilters,
empty: EMPTY,
});
const tickets = useAdminTickets(applied, page); const tickets = useAdminTickets(applied, page);
const items = tickets.data?.items ?? []; const items = tickets.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((tickets.data?.total ?? 0) / TICKETS_PAGE_SIZE)); const total = tickets.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / TICKETS_PAGE_SIZE));
const apply = () => { const from = items.length === 0 ? 0 : (page - 1) * TICKETS_PAGE_SIZE + 1;
setApplied(draft); const to = items.length === 0 ? 0 : from + items.length - 1;
setPage(1);
};
const clear = () => {
setDraft(EMPTY);
setApplied(EMPTY);
setPage(1);
};
const columns: AdminTableColumn<AdminTicketSummary>[] = [ const columns: AdminTableColumn<AdminTicketSummary>[] = [
{ {
@@ -76,6 +98,12 @@ export default function AdminTicketsPage() {
render: (row) => <StatusChip status={STATUS_KIND[row.status]} label={t(`tstatus_${row.status}`)} />, render: (row) => <StatusChip status={STATUS_KIND[row.status]} label={t(`tstatus_${row.status}`)} />,
}, },
{ key: 'booking', header: t('ticket_col_booking'), render: (row) => row.bookingId ?? '—' }, { key: 'booking', header: t('ticket_col_booking'), render: (row) => row.bookingId ?? '—' },
{
key: 'activity',
header: t('ticket_activity_col'),
minWidth: 140,
render: (row) => formatShamsiDate(row.createdAt, locale),
},
]; ];
return ( return (
@@ -84,7 +112,7 @@ export default function AdminTicketsPage() {
<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
@@ -149,17 +177,18 @@ export default function AdminTicketsPage() {
getRowKey={(row) => row.id} getRowKey={(row) => row.id}
ariaLabel={t('ticket_title')} ariaLabel={t('ticket_title')}
onRowClick={(row) => router.push(`/${locale}${adminTicketThreadPath(row.id)}`)} onRowClick={(row) => router.push(`/${locale}${adminTicketThreadPath(row.id)}`)}
footer={t('showing_range', { from, to, total })}
/> />
)} )}
<AdminPager <AdminPager
page={page} page={page}
pageCount={pageCount} pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))} onPrev={() => goToPage(Math.max(1, page - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))} onNext={() => goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')} prevLabel={t('prev_page')}
nextLabel={t('next_page')} nextLabel={t('next_page')}
indicator={t('page_indicator', { page })} indicator={t('page_indicator', { page, total: pageCount })}
/> />
</Box> </Box>
); );
@@ -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,
},
]}
/>
);
}
@@ -1,8 +1,101 @@
import { getTranslations } from 'next-intl/server'; 'use client';
import { PlaceholderScreen } from '@/components'; import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Chip, Stack, TextField, Typography } from '@mui/material';
import { AdminDataTable, AdminEmptyState, AdminPageHeader, type AdminTableColumn } from '@/components/admin';
import { AppLink } from '@/components';
import { ROUTES } from '@/constants';
import { useUserSearch } from '@/services/admin';
import { useDebouncedValue } from '@/services/search';
import type { AdminUserSummary, DirectoryUserRole } from '@/services/admin/types';
export default async function AdminUsersPage() { const SEARCH_DEBOUNCE_MS = 300;
const t = await getTranslations('nav'); const MIN_QUERY_LENGTH = 2;
const tShell = await getTranslations('shell');
return <PlaceholderScreen icon="users" title={t('users')} description={tShell('placeholder_body')} />; /** Directory role → chip label key. */
const ROLE_LABEL_KEY: Record<DirectoryUserRole, string> = {
customer: 'user_role_customer',
nurse: 'user_role_nurse',
admin: 'user_role_admin',
partner: 'user_role_partner',
};
/**
* Read-first admin user directory (phase §3.8) search by name/phone over the same admin user-directory
* seam `UserPicker`/`NursePicker` use (REQ-061, gap mock-backed until delivered). Built because the REQ
* this console depends on is filed and mocked-behind-the-seam this same phase; it becomes real the moment
* `services/admin`'s seam flips. Each row links into the audit log filtered to that user (the one console
* that already supports an entity filter) a ticket-queue deep link isn't offered because the admin
* ticket queue has no actor/user filter to land on (would be a dishonest link).
*/
export default function AdminUsersPage() {
const t = useTranslations('admin');
const locale = useLocale();
const [query, setQuery] = useState('');
const debounced = useDebouncedValue(query, SEARCH_DEBOUNCE_MS);
const search = useUserSearch(debounced);
const items = search.data ?? [];
const hasQuery = debounced.trim().length >= MIN_QUERY_LENGTH;
const columns: AdminTableColumn<AdminUserSummary>[] = [
{ key: 'name', header: t('user_col_name'), render: (u) => u.displayName, minWidth: 180 },
{
key: 'phone',
header: t('user_col_phone'),
render: (u) => (
<Box component="span" dir="ltr">
{u.maskedPhone}
</Box>
),
},
{ key: 'id', header: t('user_col_id'), render: (u) => `#${u.id}` },
{
key: 'roles',
header: t('user_col_roles'),
render: (u) => (
<Stack direction="row" sx={{ gap: 0.5, flexWrap: 'wrap' }}>
{u.roles.map((r) => (
<Chip key={r} size="small" variant="outlined" label={t(ROLE_LABEL_KEY[r])} />
))}
</Stack>
),
},
{
key: 'actions',
header: '',
align: 'right' as const,
render: (u) => (
<AppLink to={`/${locale}${ROUTES.ADMIN_AUDIT}?entityType=User&entityId=${u.id}`}>{t('user_view_audit')}</AppLink>
),
},
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('user_title')} subtitle={t('user_subtitle')} />
<TextField
size="small"
label={t('user_search_label')}
placeholder={t('user_picker_search_ph')}
value={query}
onChange={(e) => setQuery(e.target.value)}
sx={{ maxWidth: 360 }}
/>
{!hasQuery ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('user_search_hint')}
</Typography>
) : search.isFetching ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('user_picker_loading')}
</Typography>
) : items.length === 0 ? (
<AdminEmptyState icon="users" title={t('user_empty')} />
) : (
<AdminDataTable columns={columns} rows={items} getRowKey={(u) => u.id} ariaLabel={t('user_title')} />
)}
</Box>
);
} }
@@ -1,7 +1,8 @@
'use client'; 'use client';
import { useState } from 'react'; import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { useParams, useRouter } 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,28 +13,23 @@ import {
DialogTitle, DialogTitle,
Skeleton, Skeleton,
Stack, Stack,
TextField,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import { AppButton, StatusChip } from '@/components'; import { AppButton, AppLoading, PageHeader, RhfJalaliDateField, RhfTextField, StatusChip } from '@/components';
import type { StatusKind } from '@/components'; import type { StatusKind } from '@/components';
import { import { AdminEmptyState, AdminErrorState, ConfirmDialog, DocumentViewer } from '@/components/admin';
AdminEmptyState, import { ROUTES, adminVerificationCasePath } from '@/constants';
AdminErrorState,
AdminPageHeader,
ConfirmDialog,
DocumentViewer,
} from '@/components/admin';
import { ROUTES } from '@/constants';
import { formatShamsiDate } from '@/utils'; import { formatShamsiDate } from '@/utils';
import { useAdminCapabilities } from '@/hooks'; import { useAdminBackToList, useAdminCapabilities } from '@/hooks';
import { import {
useApproveVerification, useApproveVerification,
useDecideStep, useDecideStep,
useRejectVerification, useRejectVerification,
useVerificationCase, useVerificationCase,
useVerificationQueue,
} from '@/services/verification'; } from '@/services/verification';
import type { AdminVerificationStepDetail, VerificationStepStatus } from '@/services/verification/types'; import type { AdminVerificationStepDetail, VerificationStepStatus } from '@/services/verification/types';
import { parseQueueFilters, queueCaseHref } from '../queueFilters';
/** The three credential-bearing step types — a Pass here opens the structured credential form. */ /** The three credential-bearing step types — a Pass here opens the structured credential form. */
const CREDENTIAL_STEP_CODES: readonly string[] = ['moh_competency_license', 'ino_membership', 'criminal_record']; const CREDENTIAL_STEP_CODES: readonly string[] = ['moh_competency_license', 'ino_membership', 'criminal_record'];
@@ -48,6 +44,14 @@ const STEP_STATUS_KIND: Record<VerificationStepStatus, StatusKind> = {
expired: 'rejected', expired: 'rejected',
}; };
export default function AdminVerificationCasePage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminVerificationCaseScreen />
</Suspense>
);
}
/** /**
* Verification case (b6 `AdminVerificationsController`) the trust desk works one nurse: the identity on * Verification case (b6 `AdminVerificationsController`) the trust desk works one nurse: the identity on
* file for cross-check, every step with its status + documents (each `DocumentViewer` re-signs its own * file for cross-check, every step with its status + documents (each `DocumentViewer` re-signs its own
@@ -55,17 +59,23 @@ const STEP_STATUS_KIND: Record<VerificationStepStatus, StatusKind> = {
* (encrypted) credential via a structured form; recorded credentials are listed by **type** only the * (encrypted) credential via a structured form; recorded credentials are listed by **type** only the
* number never crosses the wire. The whole verification is approvable only when every required step has * number never crosses the wire. The whole verification is approvable only when every required step has
* passed; a decision re-aggregates server-side (flipping `is_verified`) and removes the case from the queue. * passed; a decision re-aggregates server-side (flipping `is_verified`) and removes the case from the queue.
*
* «پرونده بعدی»/«پرونده قبلی» + arrow keys move through the **current queue page's order** without
* returning to the list: the case URL carries the queue's `status`/`search`/`page` (`queueCaseHref`), so
* re-deriving the same filters here hits `useVerificationQueue`'s cache the list already primed (no extra
* fetch in the common flow). Fetching adjacent pages is out of scope prev/next disable at the page's ends.
*/ */
export default function AdminVerificationCasePage() { function AdminVerificationCaseScreen() {
const t = useTranslations('admin'); const t = useTranslations('admin');
const locale = useLocale(); const locale = useLocale();
const router = useRouter(); const router = useRouter();
const params = useParams<{ nurseId: string }>(); const params = useParams<{ nurseId: string }>();
const searchParams = useSearchParams();
const nurseVerificationId = Number(params?.nurseId); const nurseVerificationId = Number(params?.nurseId);
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();
@@ -74,7 +84,36 @@ export default function AdminVerificationCasePage() {
const [approveOpen, setApproveOpen] = useState(false); const [approveOpen, setApproveOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false); const [rejectOpen, setRejectOpen] = useState(false);
const backToQueue = () => router.push(`/${locale}${ROUTES.ADMIN_VERIFICATION}`); const backToQueue = useAdminBackToList(`/${locale}${ROUTES.ADMIN_VERIFICATION}`);
// Re-derive the SAME filters/page the queue list would have parsed from its own URL, so this query
// reuses the list's cached page (React Query keys structurally) instead of an unfiltered refetch.
const queueFilters = useMemo(() => parseQueueFilters(searchParams), [searchParams]);
const queuePage = useMemo(() => {
const raw = Number(searchParams.get('page'));
return Number.isInteger(raw) && raw > 0 ? raw : 1;
}, [searchParams]);
const queueQuery = useVerificationQueue(queueFilters, queuePage);
const queueItems = queueQuery.data?.items ?? [];
const caseIndex = queueItems.findIndex((item) => item.nurseVerificationId === nurseVerificationId);
const prevCase = caseIndex > 0 ? queueItems[caseIndex - 1] : null;
const nextCase = caseIndex >= 0 && caseIndex < queueItems.length - 1 ? queueItems[caseIndex + 1] : null;
const goToCase = useCallback(
(id: number) => router.push(queueCaseHref(locale, adminVerificationCasePath(id), queueFilters, queuePage)),
[router, locale, queueFilters, queuePage],
);
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
const tag = (document.activeElement as HTMLElement | null)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (event.key === 'ArrowRight' && prevCase) goToCase(prevCase.nurseVerificationId);
else if (event.key === 'ArrowLeft' && nextCase) goToCase(nextCase.nurseVerificationId);
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [prevCase, nextCase, goToCase]);
const allPassed = !!data && data.steps.length > 0 && data.steps.every((step) => step.status === 'passed'); const allPassed = !!data && data.steps.length > 0 && data.steps.every((step) => step.status === 'passed');
@@ -103,12 +142,33 @@ export default function AdminVerificationCasePage() {
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack sx={{ gap: 1 }}> <PageHeader
<AppButton variant="text" color="primary" onClick={backToQueue} sx={{ alignSelf: 'flex-start' }}> title={t('ver_case_title')}
{t('back')} onBack={backToQueue}
</AppButton> backLabel={t('back')}
<AdminPageHeader title={t('ver_case_title')} /> actions={
</Stack> <Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton
variant="outlined"
color="inherit"
startIcon="back"
onClick={() => prevCase && goToCase(prevCase.nurseVerificationId)}
disabled={!prevCase}
>
{t('ver_prev_case')}
</AppButton>
<AppButton
variant="outlined"
color="inherit"
endIcon="forward"
onClick={() => nextCase && goToCase(nextCase.nurseVerificationId)}
disabled={!nextCase}
>
{t('ver_next_case')}
</AppButton>
</Stack>
}
/>
{isLoading ? ( {isLoading ? (
<Stack sx={{ gap: 2 }}> <Stack sx={{ gap: 2 }}>
@@ -122,7 +182,7 @@ export default function AdminVerificationCasePage() {
<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>
@@ -139,6 +199,8 @@ export default function AdminVerificationCasePage() {
step={step} step={step}
nurseVerificationId={nurseVerificationId} nurseVerificationId={nurseVerificationId}
canVerify={caps.canVerify} canVerify={caps.canVerify}
onReloadDocuments={refetch}
reloadingDocuments={isFetching}
/> />
))} ))}
</Stack> </Stack>
@@ -147,7 +209,7 @@ export default function AdminVerificationCasePage() {
<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' }}
@@ -230,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();
@@ -265,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}`)}
@@ -279,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 ? (
@@ -338,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,
@@ -350,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,
}, },
}, },
{ {
@@ -381,67 +458,58 @@ 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>
<DialogContent> <FormProvider {...form}>
<Stack sx={{ gap: 2, mt: 1 }}> <DialogContent>
<TextField {/* A real <form> so Enter submits from any field; the footer button (outside DialogContent)
fullWidth calls the same handler directly rather than relying on cross-element form association. */}
autoFocus <Stack component="form" noValidate onSubmit={handleSubmit(onSubmit)} sx={{ gap: 2, mt: 1 }}>
label={t('ver_credential_number')} <RhfTextField<CredentialDecisionValues>
value={credentialNumber} name="credentialNumber"
onChange={(e) => setCredentialNumber(e.target.value)} fullWidth
/> autoFocus
<TextField label={t('ver_credential_number')}
fullWidth />
label={t('ver_holder_name')} <RhfTextField<CredentialDecisionValues>
helperText={t('ver_holder_hint')} name="holderName"
value={holderName} fullWidth
onChange={(e) => setHolderName(e.target.value)} label={t('ver_holder_name')}
/> helperText={t('ver_holder_hint')}
<TextField />
fullWidth <RhfTextField<CredentialDecisionValues>
label={t('ver_issuing_authority')} name="issuingAuthority"
value={issuingAuthority} fullWidth
onChange={(e) => setIssuingAuthority(e.target.value)} label={t('ver_issuing_authority')}
/> />
<TextField <RhfJalaliDateField<CredentialDecisionValues> name="issuedAt" fullWidth label={t('ver_issued_at')} />
fullWidth <RhfJalaliDateField<CredentialDecisionValues>
type="date" name="expiresAt"
label={t('ver_issued_at')} fullWidth
value={issuedAt} label={t('ver_expires_at')}
onChange={(e) => setIssuedAt(e.target.value)} required={expiryRequired}
slotProps={{ inputLabel: { shrink: true } }} rules={{
/> validate: (value) => !expiryRequired || String(value ?? '').trim().length > 0 || t('ver_expiry_required'),
<TextField }}
fullWidth />
type="date" </Stack>
label={t('ver_expires_at')} </DialogContent>
value={expiresAt} <DialogActions sx={{ px: 3, pb: 2 }}>
onChange={(e) => setExpiresAt(e.target.value)} <AppButton variant="text" color="inherit" onClick={onClose} disabled={decide.isPending}>
required={expiryRequired} {t('cancel')}
error={expiryMissing} </AppButton>
helperText={expiryMissing ? t('ver_expiry_required') : undefined} <AppButton
slotProps={{ inputLabel: { shrink: true } }} variant="contained"
/> color="primary"
</Stack> onClick={handleSubmit(onSubmit)}
</DialogContent> disabled={decide.isPending || !formState.isValid}
<DialogActions sx={{ px: 3, pb: 2 }}> >
<AppButton variant="text" color="inherit" onClick={onClose} disabled={decide.isPending}> {decide.isPending ? t('saving') : t('save')}
{t('cancel')} </AppButton>
</AppButton> </DialogActions>
<AppButton </FormProvider>
variant="contained"
color="primary"
onClick={onSubmit}
disabled={decide.isPending || expiryMissing}
>
{decide.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</Dialog> </Dialog>
); );
} }
@@ -1,9 +1,9 @@
'use client'; 'use client';
import { useState } from 'react'; import { Suspense, useEffect, useRef } 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 { Box, Chip, MenuItem, Skeleton, Stack, TextField } from '@mui/material'; import { Box, Chip, Skeleton, Stack, Tab, Tabs, TextField } from '@mui/material';
import { AppIcon, StatusChip } from '@/components'; import { AppButton, AppIcon, AppLoading, StatusChip } from '@/components';
import type { StatusKind } from '@/components'; import type { StatusKind } from '@/components';
import { import {
AdminDataTable, AdminDataTable,
@@ -14,13 +14,21 @@ import {
} from '@/components/admin'; } from '@/components/admin';
import type { AdminTableColumn } from '@/components/admin'; import type { AdminTableColumn } from '@/components/admin';
import { adminVerificationCasePath } from '@/constants'; import { adminVerificationCasePath } from '@/constants';
import { formatShamsiDate } from '@/utils'; import { formatNumber, formatRelativeTime, formatShamsiDate } from '@/utils';
import { useAdminListState } from '@/hooks';
import { useVerificationQueue } from '@/services/verification'; import { useVerificationQueue } from '@/services/verification';
import { ADMIN_QUEUE_PAGE_SIZE } from '@/services/verification/constants'; import { ADMIN_QUEUE_PAGE_SIZE } from '@/services/verification/constants';
import type { AdminVerificationQueueItem, VerificationAggregateStatus } from '@/services/verification/types'; import type {
AdminVerificationQueueFilters,
AdminVerificationQueueItem,
VerificationAggregateStatus,
} from '@/services/verification/types';
import { EMPTY_QUEUE_FILTERS, parseQueueFilters, queueCaseHref, serializeQueueFilters } from './queueFilters';
/** The queue status filter — a subset of the aggregate statuses the desk works (default all). */ /** SLA thresholds for the waiting-time column's color (display-only client signal — never a server rule). */
type QueueStatusFilter = '' | 'pending' | 'in_review'; const WAITING_TIME_WARNING_HOURS = 48;
const WAITING_TIME_ALARM_HOURS = 96;
const MS_PER_HOUR = 60 * 60 * 1000;
/** Aggregate status → chip kind. `in_review` reads as informational; a rejected/suspended case shows red. */ /** Aggregate status → chip kind. `in_review` reads as informational; a rejected/suspended case shows red. */
const AGG_STATUS_KIND: Record<VerificationAggregateStatus, StatusKind> = { const AGG_STATUS_KIND: Record<VerificationAggregateStatus, StatusKind> = {
@@ -32,24 +40,66 @@ const AGG_STATUS_KIND: Record<VerificationAggregateStatus, StatusKind> = {
suspended: 'rejected', suspended: 'rejected',
}; };
export default function AdminVerificationQueuePage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminVerificationQueueScreen />
</Suspense>
);
}
/** /**
* Verification review queue (b6 `AdminVerificationsController`) the trust desk's worklist, one row per * Verification review queue (b6 `AdminVerificationsController`) the trust desk's worklist, one row per
* nurse folded from the per-step endpoint. Filter by status (all / pending / in_review); each row surfaces * nurse folded from the per-step endpoint. Status tabs (all / pending / in_review, badge-counted when the
* the step progress, the next pending step, when it was submitted, and a warning when a credential is * server serves `counts` REQ-062) replace the old lone select; a name/phone search follows the same
* expiring. A row opens its case. The filter + page are the query key, so switching them reuses cached * draft-vs-applied Apply/Clear pattern as `admin/tickets`/`admin/audit`. Filters + page are URL-synced via
* pages; a decision on a case invalidates the queue so the desk re-renders without a manual refresh. * `useAdminListState`, so a queue row carries them forward into the case URL (`queueCaseHref`) the case
* page re-derives the same query key to reuse this cache for next/prev case navigation.
*/ */
export default function AdminVerificationQueuePage() { function AdminVerificationQueueScreen() {
const t = useTranslations('admin'); const t = useTranslations('admin');
const locale = useLocale(); const locale = useLocale();
const router = useRouter(); const router = useRouter();
const [status, setStatus] = useState<QueueStatusFilter>(''); const state = useAdminListState<AdminVerificationQueueFilters>({
const [page, setPage] = useState(1); parse: parseQueueFilters,
serialize: serializeQueueFilters,
empty: EMPTY_QUEUE_FILTERS,
});
const queue = useVerificationQueue({ status: status || undefined }, page); // Tabs commit immediately (they're discrete, not free text) — `state.apply()` closes over the CURRENT
// render's `draft`, so calling it synchronously right after `setDraft` would still see the stale value.
// Deferring the commit to the render that follows the draft update reads the fresh `draft` correctly.
const applyPendingRef = useRef(false);
useEffect(() => {
if (applyPendingRef.current) {
applyPendingRef.current = false;
state.apply();
}
});
const selectStatus = (next: '' | 'pending' | 'in_review') => {
state.setDraft((d) => ({ ...d, status: next || undefined }));
applyPendingRef.current = true;
};
const queue = useVerificationQueue(state.applied, state.page);
const items = queue.data?.items ?? []; const items = queue.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((queue.data?.total ?? 0) / ADMIN_QUEUE_PAGE_SIZE)); const total = queue.data?.total ?? 0;
const counts = queue.data?.counts;
const pageCount = Math.max(1, Math.ceil(total / ADMIN_QUEUE_PAGE_SIZE));
const tabLabel = (base: string, count: number | undefined): string =>
count === undefined ? base : `${base} (${formatNumber(count, locale)})`;
const footerText =
total > 0
? t('showing_range', {
from: (state.page - 1) * ADMIN_QUEUE_PAGE_SIZE + 1,
to: Math.min(state.page * ADMIN_QUEUE_PAGE_SIZE, total),
total,
})
: undefined;
const columns: AdminTableColumn<AdminVerificationQueueItem>[] = [ const columns: AdminTableColumn<AdminVerificationQueueItem>[] = [
{ {
@@ -91,31 +141,56 @@ export default function AdminVerificationQueuePage() {
header: t('ver_col_submitted'), header: t('ver_col_submitted'),
render: (item) => (item.submittedAt ? formatShamsiDate(item.submittedAt, locale) : '—'), render: (item) => (item.submittedAt ? formatShamsiDate(item.submittedAt, locale) : '—'),
}, },
{
key: 'waiting',
header: t('ver_col_waiting'),
render: (item) => {
if (!item.submittedAt) return '—';
const hours = (Date.now() - new Date(item.submittedAt).getTime()) / MS_PER_HOUR;
const color =
hours >= WAITING_TIME_ALARM_HOURS
? 'var(--bal-error)'
: hours >= WAITING_TIME_WARNING_HOURS
? 'var(--bal-warning)'
: undefined;
return (
<Box component="span" sx={color ? { color } : undefined}>
{formatRelativeTime(item.submittedAt, locale, formatShamsiDate)}
</Box>
);
},
},
]; ];
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader <AdminPageHeader title={t('ver_title')} subtitle={t('ver_subtitle')} />
title={t('ver_title')}
subtitle={t('ver_subtitle')} <Tabs value={state.draft.status ?? ''} onChange={(_event, value: '' | 'pending' | 'in_review') => selectStatus(value)}>
actions={ <Tab value="" data-tab="all" label={t('filter_all')} />
<TextField <Tab value="pending" data-tab="pending" label={tabLabel(t('agg_pending'), counts?.pending)} />
select <Tab value="in_review" data-tab="in_review" label={tabLabel(t('agg_in_review'), counts?.in_review)} />
size="small" </Tabs>
label={t('ver_col_status')}
value={status} <Stack
onChange={(e) => { direction="row"
setStatus(e.target.value as QueueStatusFilter); sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
setPage(1); >
}} <TextField
sx={{ minWidth: 160 }} size="small"
> label={t('ver_search_label')}
<MenuItem value="">{t('filter_all')}</MenuItem> placeholder={t('ver_search_ph')}
<MenuItem value="pending">{t('agg_pending')}</MenuItem> value={state.draft.search ?? ''}
<MenuItem value="in_review">{t('agg_in_review')}</MenuItem> onChange={(e) => state.setDraft((d) => ({ ...d, search: e.target.value || undefined }))}
</TextField> sx={{ minWidth: 240 }}
} />
/> <AppButton variant="contained" color="primary" onClick={state.apply}>
{t('apply')}
</AppButton>
<AppButton variant="text" color="inherit" onClick={state.clear}>
{t('clear')}
</AppButton>
</Stack>
{queue.isLoading ? ( {queue.isLoading ? (
<Stack sx={{ gap: 1 }}>{[0, 1, 2, 3].map((k) => <Skeleton key={k} variant="rounded" height={56} />)}</Stack> <Stack sx={{ gap: 1 }}>{[0, 1, 2, 3].map((k) => <Skeleton key={k} variant="rounded" height={56} />)}</Stack>
@@ -129,18 +204,23 @@ export default function AdminVerificationQueuePage() {
rows={items} rows={items}
getRowKey={(item) => item.nurseVerificationId} getRowKey={(item) => item.nurseVerificationId}
ariaLabel={t('ver_title')} ariaLabel={t('ver_title')}
onRowClick={(item) => router.push(`/${locale}${adminVerificationCasePath(item.nurseVerificationId)}`)} footer={footerText}
onRowClick={(item) =>
router.push(
queueCaseHref(locale, adminVerificationCasePath(item.nurseVerificationId), state.applied, state.page),
)
}
/> />
)} )}
<AdminPager <AdminPager
page={page} page={state.page}
pageCount={pageCount} pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))} onPrev={() => state.goToPage(Math.max(1, state.page - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))} onNext={() => state.goToPage(Math.min(pageCount, state.page + 1))}
prevLabel={t('prev_page')} prevLabel={t('prev_page')}
nextLabel={t('next_page')} nextLabel={t('next_page')}
indicator={t('page_indicator', { page })} indicator={t('page_indicator', { page: state.page, total: pageCount })}
/> />
</Box> </Box>
); );
@@ -0,0 +1,41 @@
import type { AdminVerificationQueueFilters } from '@/services/verification/types';
/**
* The queue's URL-synced filter shape shared between the list (`page.tsx`, via `useAdminListState`)
* and the case detail (`[nurseId]/page.tsx`) so a case page can **re-derive the same query key** the
* list primed (`status`/`search`/`page` `useVerificationQueue`) and reuse its cached page for
* next/prev case navigation, with no extra URL param and no duplicate fetch in the common flow.
*/
export const EMPTY_QUEUE_FILTERS: AdminVerificationQueueFilters = {};
/** The URL query-param name for the name/phone search (REQ-062's proposed `q`). */
const SEARCH_PARAM = 'q';
export function parseQueueFilters(params: URLSearchParams): AdminVerificationQueueFilters {
const status = params.get('status');
return {
status: status === 'pending' || status === 'in_review' ? status : undefined,
search: params.get(SEARCH_PARAM) ?? undefined,
};
}
export function serializeQueueFilters(filters: AdminVerificationQueueFilters): Record<string, string> {
const record: Record<string, string> = {};
if (filters.status) record.status = filters.status;
if (filters.search) record[SEARCH_PARAM] = filters.search;
return record;
}
/** A case URL carrying the queue's current filters + page, so next/prev navigation (and a browser
* back/refresh) keeps resolving against the same cached queue page instead of an unfiltered default. */
export function queueCaseHref(
locale: string,
casePath: string,
filters: AdminVerificationQueueFilters,
page: number,
): string {
const params = new URLSearchParams(serializeQueueFilters(filters));
if (page > 1) params.set('page', String(page));
const qs = params.toString();
return `/${locale}${casePath}${qs ? `?${qs}` : ''}`;
}
@@ -0,0 +1,13 @@
'use client';
import { ActivationChecklist } from '@/components';
/**
* The dashboard's activation/go-live composition point **named and exported so a later phase can
* find it** (ui-phase-7's hand-off note). ui-phase-8 fills it with the real `ActivationChecklist`
* (the same shared component mounted on `/nurse/services`) replacing the placeholder single-row
* verification banner phase 7 left here. Extend this component in place, don't add a second slot.
* @component DashboardActivationSlot
*/
export default function DashboardActivationSlot() {
return <ActivationChecklist />;
}
@@ -0,0 +1,269 @@
'use client';
import { ReactNode } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Skeleton, Stack, Typography } from '@mui/material';
import {
AccentCard,
AppButton,
AppLink,
CountdownTimer,
EmptyState,
ErrorState,
Money,
PageHeader,
SurfaceCard,
} from '@/components';
import { ROUTES } from '@/constants';
import { formatRelativeTime, formatShamsiDate, localeTag, parseIrr } from '@/utils';
import { useNurseRequestInbox } from '@/services/bookingRequests';
import { useTodaySessions } from '@/services/bookings';
import { useNurseEarningsBalance } from '@/services/payouts';
import { coarseResponseLabel } from '@/services/bookingRequests/format';
import DashboardActivationSlot from './DashboardActivationSlot';
/** The pill's urgency tiers (ui-phase-7 §3.4): teal >2h · amber <2h · terracotta <30min. */
const URGENT_THRESHOLD_SECONDS = 30 * 60;
const WARN_THRESHOLD_SECONDS = 2 * 60 * 60;
/**
* The nurse «امروز» home the first bottom-nav destination.
*
* Rebuilt for the phone-width frame. The previous version stacked five same-weight cards, each
* repeating its own icon + bold heading + inline "see all" button; at 480px the buttons wrapped
* mid-word, the countdown collided with the request title, and nothing on the screen looked more
* important than anything else. This version gives the page one visual hierarchy: exactly one hero
* action (the next visit), then sections introduced by a plain label with a text link instead of a
* competing button.
*
* The greeting/identity strip that used to sit above all of it is gone: it spent the most valuable
* row on the screen restating the signed-in name to the person who typed the phone number, and the
* badge beside it duplicated the activation tracker further down. Identity now lives in the shell's
* top bar (`NurseAccountButton`), where it costs no content height and opens the account hub.
*
* Composition only every widget reads a query that is already cached elsewhere in the shell, and
* order encodes urgency: a missed request expires, an unread earnings figure does not.
*/
export default function NurseDashboardScreen() {
const t = useTranslations('dashboard');
return (
<Stack sx={{ gap: 2.5 }}>
{/* Load-bearing now that the bottom nav is icon-only: this is the only place the current
section is named, and the page's only h1. */}
<PageHeader title={t('title')} />
<NextVisitCard />
<RequestsSection />
<EarningsSection />
<DashboardActivationSlot />
</Stack>
);
}
/**
* A section label + an optional text link. Deliberately not a button: on a 480px row a
* `<Button>` labelled «مشاهده همه» wrapped to two lines and outweighed the section it introduced.
*/
function SectionHeader({ title, actionLabel, actionTo }: { title: string; actionLabel?: string; actionTo?: string }) {
const locale = useLocale();
return (
<Stack direction="row" sx={{ alignItems: 'baseline', justifyContent: 'space-between', gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{actionLabel && actionTo ? (
<AppLink to={`/${locale}${actionTo}`} variant="caption" color="primary" sx={{ flexShrink: 0 }}>
{actionLabel}
</AppLink>
) : null}
</Stack>
);
}
/** The page's one hero action: the next actionable session, with a full-width primary CTA. */
function NextVisitCard() {
const t = useTranslations('dashboard');
const locale = useLocale();
const { data, isLoading, isError, refetch } = useTodaySessions();
if (isLoading) return <Skeleton variant="rounded" height={150} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
if (isError) {
return <ErrorState message={t('next_visit_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
}
const next = (data?.items ?? []).find((item) => item.status === 'scheduled' || item.status === 'in_progress');
if (!next) {
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 timeRangeLabel = `${timeFmt.format(new Date(`${next.scheduledDate}T${next.scheduledTimeStart}`))} ${timeFmt.format(new Date(`${next.scheduledDate}T${next.scheduledTimeEnd}`))}`;
const timeUntil = formatRelativeTime(`${next.scheduledDate}T${next.scheduledTimeStart}`, locale, formatShamsiDate);
return (
<AccentCard tone="secondary" data-widget="next-visit">
<Stack sx={{ gap: 1.5 }}>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="overline" sx={{ color: 'text.secondary' }}>
{t('next_visit_title')}
</Typography>
<Typography variant="h6" sx={{ fontWeight: 700 }}>
{next.patientName}
</Typography>
<MetaLine
items={[
<Box key="range" component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>
{timeRangeLabel}
</Box>,
timeUntil ? t('next_visit_starts_in', { relative: timeUntil }) : null,
]}
/>
</Stack>
<AppButton variant="contained" color="secondary" startIcon="check_in" fullWidth to={`/${locale}${ROUTES.NURSE_VISITS}`}>
{t('next_visit_cta')}
</AppButton>
</Stack>
</AccentCard>
);
}
/** The most time-critical section: a pending request expires on its own if it isn't answered. */
function RequestsSection() {
const t = useTranslations('dashboard');
const tb = useTranslations('booking');
const locale = useLocale();
const { data, isLoading, isError, refetch } = useNurseRequestInbox();
if (isLoading) return <Skeleton variant="rounded" height={130} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
if (isError) {
return <ErrorState message={t('requests_strip_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
}
const items = data?.items ?? [];
const total = data?.total ?? 0;
if (items.length === 0) {
return (
<Stack sx={{ gap: 1 }}>
<SectionHeader title={t('requests_strip_title', { count: 0 })} />
<EmptyState icon="requests" title={t('requests_strip_empty')} />
</Stack>
);
}
const mostUrgent = items[0];
return (
<Stack sx={{ gap: 1 }} data-widget="requests-strip">
<SectionHeader
title={t('requests_strip_title', { count: total })}
actionLabel={t('requests_strip_cta')}
actionTo={ROUTES.NURSE_REQUESTS}
/>
<SurfaceCard>
<Stack sx={{ gap: 1.5 }}>
{/* Name and countdown are siblings on one row, with the pill `flexShrink: 0` the old
layout let the countdown wrap under a long Persian name and collide with the date. */}
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="body1" noWrap sx={{ fontWeight: 500 }}>
{mostUrgent.counterpartyName}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{formatShamsiDate(mostUrgent.requestedDate, locale)}
</Typography>
</Stack>
<Box sx={{ flexShrink: 0 }}>
<CountdownTimer
deadlineIso={mostUrgent.nurseResponseDeadlineAt}
elapsedText={tb('response_elapsed')}
warnThresholdSeconds={WARN_THRESHOLD_SECONDS}
urgentThresholdSeconds={URGENT_THRESHOLD_SECONDS}
coarseLabel={(minutes) => coarseResponseLabel(minutes, tb)}
size="sm"
/>
</Box>
</Stack>
<AppButton
variant="outlined"
color="primary"
fullWidth
to={`/${locale}${ROUTES.NURSE_REQUESTS}/${mostUrgent.id}`}
>
{t('requests_strip_open')}
</AppButton>
</Stack>
</SurfaceCard>
</Stack>
);
}
/** Two stat tiles — the signed net balance is never clamped, an "owed back" reads as an error tone. */
function EarningsSection() {
const t = useTranslations('dashboard');
const tp = useTranslations('payouts');
const { data, isLoading, isError, refetch } = useNurseEarningsBalance();
if (isLoading) return <Skeleton variant="rounded" height={110} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
if (isError) {
return <ErrorState message={t('earnings_snapshot_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
}
if (!data) return null;
const net = parseIrr(data.netPayableBalanceIrr);
const isOwed = net < BigInt(0);
const magnitude = isOwed ? -net : net;
return (
<Stack sx={{ gap: 1 }} data-widget="earnings-snapshot">
<SectionHeader
title={t('earnings_snapshot_title')}
actionLabel={t('earnings_snapshot_cta')}
actionTo={ROUTES.NURSE_EARNINGS}
/>
<Box sx={{ display: 'grid', gap: 1.5, gridTemplateColumns: '1fr 1fr' }}>
<StatTile
label={isOwed ? tp('balance_owed_label') : tp('balance_net_label')}
value={<Money amountIrr={String(magnitude)} size="lg" tone={isOwed ? 'error' : 'emphasis'} />}
/>
<StatTile label={tp('bucket_eligible')} value={<Money amountIrr={data.eligibleTotalIrr} size="lg" />} />
</Box>
</Stack>
);
}
function StatTile({ label, value }: { label: string; value: ReactNode }) {
return (
<SurfaceCard padding="sm">
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="caption" noWrap sx={{ color: 'text.secondary' }}>
{label}
</Typography>
{value}
</Stack>
</SurfaceCard>
);
}
/** 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,48 +1,50 @@
'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 } 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 submit an IBAN (شبا) + account-holder name, then watch the * Nurse payout bank settings an **accounts section**, not a one-shot form (ui-phase-8 §3.7): submit
* ownership inquiry resolve through its three states (pending verified / mismatch). The list * an IBAN (شبا) + account-holder name, then watch the ownership inquiry resolve through its three
* polls only while pending; a verified account shows the masked IBAN; mismatch offers re-enter. * states (pending verified / mismatch, `BankStatusPanel` unchanged). Once at least one account
* exists, a persistent «افزودن حساب دیگر» CTA replaces the old form-only-when-empty gate, so a nurse
* switching banks is never dead-ended the old account stays listed until the nurse makes the new
* one primary. A failed accounts query renders the error state with retry, **never** the empty-state
* form (which would invite a duplicate-IBAN submission blind).
*/ */
export default function NurseBankPage() { export default function NurseBankPage() {
const t = useTranslations('bank'); const t = useTranslations('bank');
const tc = useTranslations('common');
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const { data, isLoading } = useNurseBankAccounts(); const { data, isLoading, isError, refetch } = useNurseBankAccounts();
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 && (accounts.length === 0 || showForm); const showFormNow = !isLoading && !isError && (accounts.length === 0 || showForm);
const submit = () => {
const ibanInvalid = !isValidSheba(iban);
const holderInvalid = holder.trim().length === 0;
setIbanError(ibanInvalid);
setHolderError(holderInvalid);
if (ibanInvalid || holderInvalid) return;
const submit = (values: BankFormValues) => {
addAccount.mutate( 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' });
}, },
@@ -64,83 +66,105 @@ export default function NurseBankPage() {
{isLoading ? <AppLoading /> : null} {isLoading ? <AppLoading /> : null}
{accounts.map((account) => { {isError ? <ErrorState message={t('load_error')} retryLabel={tc('retry')} onRetry={() => refetch()} /> : null}
const status = deriveBankStatus(account);
return (
<Stack key={account.id} sx={{ gap: 1 }}>
<BankStatusPanel
status={status}
chipLabel={t(`status_${status}_chip`)}
title={t(`status_${status}_title`)}
body={t(`status_${status}_body`)}
ibanMasked={status === 'verified' ? account.ibanMasked : undefined}
ibanLabel={t('iban_masked_label')}
bankName={account.bankName || undefined}
isPrimary={account.isPrimary}
primaryLabel={t('primary')}
onReenter={status === 'mismatch' ? () => setShowForm(true) : undefined}
reenterLabel={t('reenter')}
/>
{/* Promote a verified non-primary account so payouts (gated on matchedNationalId) target it. */}
{status === 'verified' && !account.isPrimary ? (
<AppButton
variant="text"
color="primary"
disabled={setPrimary.isPending}
onClick={() =>
setPrimary.mutate(account.id, {
onSuccess: () => enqueueSnackbar(t('primary_set'), { variant: 'success' }),
})
}
sx={{ alignSelf: 'flex-start' }}
>
{t('make_primary')}
</AppButton>
) : null}
</Stack>
);
})}
{!isLoading && accounts.length === 0 ? ( {!isError
? accounts.map((account) => {
const status = deriveBankStatus(account);
return (
<Stack key={account.id} sx={{ gap: 1 }}>
<BankStatusPanel
status={status}
chipLabel={t(`status_${status}_chip`)}
title={t(`status_${status}_title`)}
body={t(`status_${status}_body`)}
ibanMasked={status === 'verified' ? account.ibanMasked : undefined}
ibanLabel={t('iban_masked_label')}
bankName={account.bankName || undefined}
isPrimary={account.isPrimary}
primaryLabel={t('primary')}
onReenter={status === 'mismatch' ? () => setShowForm(true) : undefined}
reenterLabel={t('reenter')}
/>
{/* Promote a verified non-primary account so payouts (gated on matchedNationalId) target it. */}
{status === 'verified' && !account.isPrimary ? (
<AppButton
variant="text"
color="primary"
disabled={setPrimary.isPending}
onClick={() =>
setPrimary.mutate(account.id, {
onSuccess: () => enqueueSnackbar(t('primary_set'), { variant: 'success' }),
onError: () => enqueueSnackbar(t('primary_set_error'), { variant: 'error' }),
})
}
sx={{ alignSelf: 'flex-start' }}
>
{t('make_primary')}
</AppButton>
) : null}
</Stack>
);
})
: null}
{!isLoading && !isError && accounts.length === 0 ? (
<EmptyState icon="bank" title={t('empty_title')} body={t('empty_body')} /> <EmptyState icon="bank" title={t('empty_title')} body={t('empty_body')} />
) : null} ) : null}
{/* An accounts section, not a one-shot form: once at least one account exists, a persistent CTA
(rather than "no account yet") lets a nurse switching banks add another the old account
stays listed until they make the new one primary. */}
{!isLoading && !isError && accounts.length > 0 && !showForm ? (
<AppButton
variant="outlined"
color="primary"
startIcon="add"
onClick={() => setShowForm(true)}
sx={{ alignSelf: 'flex-start' }}
>
{t('add_another')}
</AppButton>
) : null}
{showFormNow ? ( {showFormNow ? (
<Stack sx={{ gap: 2 }}> <FormProvider {...form}>
<TextField <Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 2 }}>
label={t('iban_label')} <RhfTextField<BankFormValues>
value={iban} name="iban"
onChange={(e) => { label={t('iban_label')}
setIban(e.target.value.toUpperCase()); helperText={t('iban_hint')}
if (ibanError) setIbanError(false); transform={(raw) => raw.toUpperCase()}
}} rules={{ validate: (value) => isValidSheba(String(value ?? '')) || t('iban_invalid') }}
error={ibanError} slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start', letterSpacing: 1 } } }}
helperText={ibanError ? t('iban_invalid') : t('iban_hint')} fullWidth
slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start', letterSpacing: 1 } } }} />
fullWidth <RhfTextField<BankFormValues>
/> name="holder"
<TextField label={t('holder_label')}
label={t('holder_label')} helperText={t('holder_hint')}
value={holder} rules={{ validate: (value) => String(value ?? '').trim().length > 0 || t('holder_required') }}
onChange={(e) => { fullWidth
setHolder(e.target.value); />
if (holderError) setHolderError(false); <Stack direction="row" sx={{ gap: 1 }}>
}} <AppButton type="submit" color="primary" variant="contained" startIcon="bank" disabled={addAccount.isPending}>
error={holderError} {addAccount.isPending ? t('submitting') : t('submit')}
helperText={holderError ? t('holder_required') : t('holder_hint')} </AppButton>
fullWidth {accounts.length > 0 ? (
/> <AppButton
<AppButton variant="text"
color="primary" onClick={() => {
variant="contained" setShowForm(false);
startIcon="bank" reset();
onClick={submit} }}
disabled={addAccount.isPending} disabled={addAccount.isPending}
sx={{ alignSelf: 'flex-start' }} >
> {tc('cancel')}
{addAccount.isPending ? t('submitting') : t('submit')} </AppButton>
</AppButton> ) : null}
</Stack> </Stack>
</Stack>
</FormProvider>
) : null} ) : null}
</Box> </Box>
); );
@@ -2,36 +2,25 @@
import { useState } from 'react'; import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { import { Box, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Paper, Skeleton, Stack, Typography } from '@mui/material';
Box,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Paper,
Skeleton,
Stack,
ToggleButton,
ToggleButtonGroup,
Typography,
} from '@mui/material';
import { AppButton, AppIcon } from '@/components'; import { AppButton, AppIcon } from '@/components';
import { CascadingRegionSelect, type CascadingRegionValue } from '@/components/geography'; import { CascadingRegionSelect, type CascadingRegionValue } from '@/components/geography';
import { ApiError } from '@/lib/api/errors'; import { ApiError } from '@/lib/api/errors';
import { useDistricts } from '@/services/geography';
import { useServiceAreas, useAddServiceArea, useRemoveServiceArea } from '@/services/serviceAreas'; import { useServiceAreas, useAddServiceArea, useRemoveServiceArea } from '@/services/serviceAreas';
import { areaExists, type NurseServiceArea } from '@/services/serviceAreas/types'; import { areaExists, type NurseServiceArea } from '@/services/serviceAreas/types';
type Scope = 'whole_city' | 'districts';
const EMPTY_REGION: CascadingRegionValue = { provinceId: null, cityId: null, districtId: null }; const EMPTY_REGION: CascadingRegionValue = { provinceId: null, cityId: null, districtId: null };
/** /**
* The nurse coverage-area editor the cities/districts a nurse will travel to, so search (f6) * The nurse coverage-area editor the cities/districts a nurse will travel to, so search (f6)
* can fan them out geographically. Areas render as chips (whole-city shown explicitly); the add * can fan them out geographically. Areas render as chips (whole-city shown explicitly).
* control is the cascading dropdowns + a whole-city vs specific-districts scope toggle. A *
* duplicate `(city, district)` is blocked inline before the request (and the server's 409 maps to * ui-phase-8: **one control owns the whole-city choice** `CascadingRegionSelect`'s own district
* the same message). Empty a warning that the nurse won't appear in search. * level, whose "کل شهر" empty option *is* the choice (`districtId = null`, matching the serviceAreas
* contract both ways). The separate scope toggle this page used to render is gone it let a nurse
* pick "specific districts" and then still land on the district select's own whole-city option,
* tripping a "district required" error the UI itself had offered. City is the only required field;
* leaving the district unset is a complete, valid whole-city submission, never an error state.
*/ */
export default function NurseCoveragePage() { export default function NurseCoveragePage() {
const t = useTranslations('coverage'); const t = useTranslations('coverage');
@@ -44,22 +33,12 @@ export default function NurseCoveragePage() {
const removeArea = useRemoveServiceArea(); const removeArea = useRemoveServiceArea();
const [region, setRegion] = useState<CascadingRegionValue>(EMPTY_REGION); const [region, setRegion] = useState<CascadingRegionValue>(EMPTY_REGION);
const [scope, setScope] = useState<Scope>('whole_city');
const [cityError, setCityError] = useState(false); const [cityError, setCityError] = useState(false);
const [districtError, setDistrictError] = useState(false);
const [duplicate, setDuplicate] = useState(false); const [duplicate, setDuplicate] = useState(false);
const [removeTarget, setRemoveTarget] = useState<NurseServiceArea | null>(null); const [removeTarget, setRemoveTarget] = useState<NurseServiceArea | null>(null);
const areas = data?.items ?? []; const areas = data?.items ?? [];
// A whole-city-only city (no districts, e.g. Mashhad) can't satisfy "specific districts" — reads the
// same cached districts query the cascade uses to force whole-city, so the toggle never dead-ends on a
// district that cannot exist.
const districtsQuery = useDistricts(region.cityId);
const cityHasNoDistricts =
region.cityId != null && districtsQuery.isSuccess && (districtsQuery.data?.length ?? 0) === 0;
const effectiveScope: Scope = cityHasNoDistricts ? 'whole_city' : scope;
const chipLabel = (area: NurseServiceArea) => { const chipLabel = (area: NurseServiceArea) => {
const city = locale === 'en' ? area.cityNameEn : area.cityNameFa; const city = locale === 'en' ? area.cityNameEn : area.cityNameFa;
if (area.isWholeCity) return `${city} · ${t('whole_city_chip')}`; if (area.isWholeCity) return `${city} · ${t('whole_city_chip')}`;
@@ -67,33 +46,21 @@ export default function NurseCoveragePage() {
return `${city} · ${district}`; return `${city} · ${district}`;
}; };
const changeScope = (next: Scope | null) => {
if (!next) return;
setScope(next);
setDistrictError(false);
setDuplicate(false);
// Whole-city ignores any picked district — clear it so the submitted pair is unambiguous.
if (next === 'whole_city') setRegion((prev) => ({ ...prev, districtId: null }));
};
const resetForm = () => { const resetForm = () => {
setRegion(EMPTY_REGION); setRegion(EMPTY_REGION);
setScope('whole_city');
setCityError(false); setCityError(false);
setDistrictError(false);
setDuplicate(false); setDuplicate(false);
}; };
const handleAdd = () => { const handleAdd = () => {
const cityInvalid = region.cityId == null; const cityInvalid = region.cityId == null;
const districtInvalid = effectiveScope === 'districts' && region.districtId == null;
setCityError(cityInvalid); setCityError(cityInvalid);
setDistrictError(districtInvalid);
setDuplicate(false); setDuplicate(false);
if (cityInvalid || districtInvalid) return; if (cityInvalid) return;
const cityId = region.cityId as number; const cityId = region.cityId as number;
const districtId = effectiveScope === 'whole_city' ? null : region.districtId; // Whatever the district select currently holds is the complete choice — `null` = whole city.
const districtId = region.districtId;
// Fast path: block a duplicate before firing (null district treated as a real value). // Fast path: block a duplicate before firing (null district treated as a real value).
if (areaExists(areas, cityId, districtId)) { if (areaExists(areas, cityId, districtId)) {
@@ -123,6 +90,7 @@ export default function NurseCoveragePage() {
setRemoveTarget(null); setRemoveTarget(null);
removeArea.mutate(id, { removeArea.mutate(id, {
onSuccess: () => enqueueSnackbar(t('removed'), { variant: 'success' }), onSuccess: () => enqueueSnackbar(t('removed'), { variant: 'success' }),
onError: () => enqueueSnackbar(t('remove_error'), { variant: 'error' }),
}); });
}; };
@@ -164,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,
@@ -185,44 +153,21 @@ 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')}
</Typography> </Typography>
<Stack sx={{ gap: 1 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('scope_label')}
</Typography>
<ToggleButtonGroup
exclusive
size="small"
color="primary"
value={effectiveScope}
onChange={(_event, next: Scope | null) => changeScope(next)}
>
<ToggleButton value="whole_city">{t('scope_whole_city')}</ToggleButton>
{/* A district-less city forces whole-city — disable the option rather than dead-end on it. */}
<ToggleButton value="districts" disabled={cityHasNoDistricts}>
{t('scope_districts')}
</ToggleButton>
</ToggleButtonGroup>
</Stack>
<CascadingRegionSelect <CascadingRegionSelect
value={region} value={region}
onChange={(next) => { onChange={(next) => {
setRegion(next); setRegion(next);
if (cityError && next.cityId != null) setCityError(false); if (cityError && next.cityId != null) setCityError(false);
if (districtError && next.districtId != null) setDistrictError(false);
setDuplicate(false); setDuplicate(false);
}} }}
includeDistrict={effectiveScope === 'districts'}
cityError={cityError} cityError={cityError}
cityErrorText={t('city_required')} cityErrorText={t('city_required')}
districtError={districtError}
districtErrorText={t('district_required')}
/> />
{duplicate ? ( {duplicate ? (
@@ -2,10 +2,11 @@
import { useMemo, useState } from 'react'; import { 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 { Box, Collapse, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material'; import { Box, ButtonBase, Collapse, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
import { AppButton, AppIcon, EarningsBalanceHeader, EarningsRow, EmptyState, ErrorState } from '@/components'; import { AppIcon, EarningsBalanceHeader, EarningsRow, EmptyState, ErrorState, Money, Pager, SurfaceCard } from '@/components';
import { nurseBookingDetailPath, nursePayoutDetailPath } from '@/constants'; import { CONTENT_MAX_WIDTH } from '@/components/config';
import { formatNumber } from '@/utils'; import { DISPUTE_WINDOW_HOURS, nurseBookingDetailPath, nursePayoutDetailPath } from '@/constants';
import { formatShamsiDate } from '@/utils';
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants'; import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
import { EARNINGS_STATES, type EarningsState } from '@/services/payouts/types'; import { EARNINGS_STATES, type EarningsState } from '@/services/payouts/types';
import { useNurseEarnings, useNurseEarningsBalance } from '@/services/payouts'; import { useNurseEarnings, useNurseEarningsBalance } from '@/services/payouts';
@@ -46,7 +47,7 @@ export default function NurseEarningsPage() {
}; };
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<Box> <Box>
<Typography variant="h5" component="h1"> <Typography variant="h5" component="h1">
{t('title')} {t('title')}
@@ -61,7 +62,10 @@ export default function NurseEarningsPage() {
) : balance.isError ? ( ) : balance.isError ? (
<ErrorState message={t('balance_error')} retryLabel={t('retry')} onRetry={() => balance.refetch()} /> <ErrorState message={t('balance_error')} retryLabel={t('retry')} onRetry={() => balance.refetch()} />
) : balance.data ? ( ) : balance.data ? (
<EarningsBalanceHeader summary={balance.data} /> <>
<EarningsBalanceHeader summary={balance.data} />
<ForecastLine nextPayoutDate={balance.data.nextPayoutDate} nextPayoutEligibleAmountIrr={balance.data.nextPayoutEligibleAmountIrr} />
</>
) : null} ) : null}
<ExplainerCard open={explainerOpen} onToggle={() => setExplainerOpen((v) => !v)} /> <ExplainerCard open={explainerOpen} onToggle={() => setExplainerOpen((v) => !v)} />
@@ -108,7 +112,42 @@ export default function NurseEarningsPage() {
); );
} }
/** Collapsible "how payouts work" — the cadence + dispute-window + method-invariant copy (both locales). */ /** The «برداشت بعدی» forecast server-served only (REQ-053); renders nothing until the earnings read
* serves both fields (never computed client-side holiday shifting + eligibility are backend truth). */
function ForecastLine({
nextPayoutDate,
nextPayoutEligibleAmountIrr,
}: {
nextPayoutDate: string | null | undefined;
nextPayoutEligibleAmountIrr: string | null | undefined;
}) {
const t = useTranslations('payouts');
const locale = useLocale();
if (!nextPayoutDate || !nextPayoutEligibleAmountIrr) return null;
return (
<SurfaceCard padding="sm" data-widget="payout-forecast">
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="calendar" size={18} color="var(--bal-primary)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('forecast_label')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('forecast_on_date', { date: formatShamsiDate(nextPayoutDate, locale) })}
</Typography>
</Stack>
<Money amountIrr={nextPayoutEligibleAmountIrr} size="md" tone="emphasis" sx={{ fontWeight: 700 }} />
</Stack>
</SurfaceCard>
);
}
const EXPLAINER_CONTENT_ID = 'nurse-earnings-explainer-content';
/** Collapsible "how payouts work" the cadence + dispute-window + method-invariant copy (both locales).
* A real `ButtonBase` toggle (`aria-expanded` + `aria-controls`) replaces the bare `onClick` Stack, and the
* registered `expand` chevron (rotated when open) replaces the eye icons. */
function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void }) { function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void }) {
const t = useTranslations('payouts'); const t = useTranslations('payouts');
const points = useMemo(() => ['explainer_point_1', 'explainer_point_2', 'explainer_point_3'] as const, []); const points = useMemo(() => ['explainer_point_1', 'explainer_point_2', 'explainer_point_3'] as const, []);
@@ -118,17 +157,18 @@ 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',
borderInlineStartColor: 'var(--bal-info)', borderInlineStartColor: 'var(--bal-info)',
}} }}
> >
<Stack <ButtonBase
direction="row"
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer' }}
onClick={onToggle} onClick={onToggle}
aria-expanded={open}
aria-controls={EXPLAINER_CONTENT_ID}
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)" />
@@ -136,13 +176,18 @@ function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void
{t('explainer_title')} {t('explainer_title')}
</Typography> </Typography>
</Stack> </Stack>
<AppIcon icon={open ? 'visibilityoff' : 'visibilityon'} size={18} color="var(--bal-text-secondary)" /> <AppIcon
</Stack> icon="expand"
<Collapse in={open}> size={18}
color="var(--bal-text-secondary)"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform var(--bal-motion-fast) var(--bal-easing-standard)' }}
/>
</ButtonBase>
<Collapse in={open} id={EXPLAINER_CONTENT_ID}>
<Stack component="ul" sx={{ gap: 0.75, mt: 1.5, mb: 0, pl: 2.5 }}> <Stack component="ul" sx={{ gap: 0.75, mt: 1.5, mb: 0, pl: 2.5 }}>
{points.map((key) => ( {points.map((key) => (
<Typography key={key} component="li" variant="body2" sx={{ color: 'text.secondary' }}> <Typography key={key} component="li" variant="body2" sx={{ color: 'text.secondary' }}>
{t(key)} {key === 'explainer_point_2' ? t(key, { hours: DISPUTE_WINDOW_HOURS }) : t(key)}
</Typography> </Typography>
))} ))}
</Stack> </Stack>
@@ -150,35 +195,3 @@ function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void
</Paper> </Paper>
); );
} }
/** Prev/next pager — rendered only when there is more than one page. */
function Pager({
page,
pageCount,
onPrev,
onNext,
}: {
page: number;
pageCount: number;
onPrev: () => void;
onNext: () => void;
}) {
const t = useTranslations('payouts');
const locale = useLocale();
if (pageCount <= 1) return null;
const fmt = (n: number) => formatNumber(n, locale);
return (
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'center' }}>
<AppButton variant="text" color="primary" onClick={onPrev} disabled={page <= 1}>
{t('page_prev')}
</AppButton>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('page_indicator', { page: fmt(page), total: fmt(pageCount) })}
</Typography>
<AppButton variant="text" color="primary" onClick={onNext} disabled={page >= pageCount}>
{t('page_next')}
</AppButton>
</Stack>
);
}
@@ -5,8 +5,10 @@ import { useLocale, useTranslations } from 'next-intl';
import { Box, Divider, Paper, Skeleton, Stack, Typography } from '@mui/material'; import { Box, Divider, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, Money, PriceBreakdown, StatusChip } from '@/components'; import { AppButton, AppIcon, Money, PriceBreakdown, StatusChip } from '@/components';
import type { StatusKind } from '@/components'; import type { StatusKind } from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { nurseBookingDetailPath, ROUTES } from '@/constants'; import { nurseBookingDetailPath, ROUTES } from '@/constants';
import { formatShamsiDate, parseIrr } from '@/utils'; import { formatShamsiDate, parseIrr } from '@/utils';
import { failureReasonLabelKey } from '@/services/payouts/failureReasons';
import { useNursePayoutDetail } from '@/services/payouts'; import { useNursePayoutDetail } from '@/services/payouts';
import type { PayoutBatchStatus, PayoutStatus } from '@/services/payouts/types'; import type { PayoutBatchStatus, PayoutStatus } from '@/services/payouts/types';
@@ -42,7 +44,7 @@ export default function NursePayoutDetailPage() {
const { data, isLoading, isError } = useNursePayoutDetail(Number.isFinite(payoutId) ? payoutId : undefined); const { data, isLoading, isError } = useNursePayoutDetail(Number.isFinite(payoutId) ? payoutId : undefined);
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<Stack sx={{ gap: 0.5 }}> <Stack sx={{ gap: 0.5 }}>
<AppButton <AppButton
variant="text" variant="text"
@@ -64,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 }}>
@@ -110,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',
@@ -123,9 +125,12 @@ export default function NursePayoutDetailPage() {
{t('failure_title')} {t('failure_title')}
</Typography> </Typography>
</Stack> </Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t(failureReasonLabelKey(data.failureReason))}
</Typography>
{data.failureReason ? ( {data.failureReason ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr"> <Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
{t('failure_reason_label')}: {data.failureReason} {data.failureReason}
</Typography> </Typography>
) : null} ) : null}
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}> <Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
@@ -169,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
@@ -3,9 +3,9 @@ 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 { Box, Skeleton, Stack, Typography } from '@mui/material'; import { Box, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, EmptyState, ErrorState, PayoutHistoryRow } from '@/components'; import { AppButton, EmptyState, ErrorState, Pager, PayoutHistoryRow } from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { nursePayoutDetailPath, ROUTES } from '@/constants'; import { nursePayoutDetailPath, ROUTES } from '@/constants';
import { formatNumber } from '@/utils';
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants'; import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
import { useNursePayoutHistory } from '@/services/payouts'; import { useNursePayoutHistory } from '@/services/payouts';
@@ -26,10 +26,9 @@ export default function NursePayoutHistoryPage() {
const items = history.data?.items ?? []; const items = history.data?.items ?? [];
const total = history.data?.total ?? 0; const total = history.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PAYOUTS_PAGE_SIZE)); const pageCount = Math.max(1, Math.ceil(total / PAYOUTS_PAGE_SIZE));
const fmt = (n: number) => formatNumber(n, locale);
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<Stack sx={{ gap: 0.5 }}> <Stack sx={{ gap: 0.5 }}>
<AppButton <AppButton
variant="text" variant="text"
@@ -70,19 +69,12 @@ export default function NursePayoutHistoryPage() {
</Stack> </Stack>
)} )}
{pageCount > 1 ? ( <Pager
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'center' }}> page={page}
<AppButton variant="text" color="primary" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}> pageCount={pageCount}
{t('page_prev')} onPrev={() => setPage((p) => Math.max(1, p - 1))}
</AppButton> onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
<Typography variant="body2" sx={{ color: 'text.secondary' }}> />
{t('page_indicator', { page: fmt(page), total: fmt(pageCount) })}
</Typography>
<AppButton variant="text" color="primary" onClick={() => setPage((p) => Math.min(pageCount, p + 1))} disabled={page >= pageCount}>
{t('page_next')}
</AppButton>
</Stack>
) : null}
</Box> </Box>
); );
} }
@@ -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 />;
}
@@ -1,6 +1,6 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { PlaceholderScreen } from '@/components'; import NurseDashboardScreen from './NurseDashboardScreen';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> { export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params; const { locale } = await params;
@@ -8,8 +8,6 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
return { title: t('dashboard') }; return { title: t('dashboard') };
} }
export default async function NurseDashboardPage() { export default function NurseDashboardPage() {
const t = await getTranslations('nav'); return <NurseDashboardScreen />;
const tShell = await getTranslations('shell');
return <PlaceholderScreen icon="dashboard" title={t('dashboard')} description={tShell('placeholder_body')} />;
} }
@@ -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 />;
}

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