Compare commits

...

68 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
hamid 370c1beefa ui phase 1 2026-07-17 17:10:39 +03:30
hamid f1cba6cf74 ui phase 0 2026-07-17 14:00:22 +03:30
hamid 9051bb3e18 some ui phase improvement planning 2026-07-17 13:22:04 +03:30
hamid 70fb0a9202 refinement phase 9 2026-07-13 22:52:57 +03:30
hamid ef3024ef2f refinement phase 8 2026-07-13 21:49:50 +03:30
hamid 7edadadea1 refinement phase 7 2026-07-13 17:48:50 +03:30
hamid 70268ecc06 refinement phase 6 2026-07-13 17:03:45 +03:30
hamid d4147342da refinement phase 5 2026-07-13 16:00:34 +03:30
hamid 64f6aa45c9 refinement phase 4 2026-07-13 12:29:00 +03:30
hamid 314763f764 refinement phase 3 2026-07-13 11:26:39 +03:30
hamid 1ce36f9414 refinement phase 2 2026-07-13 01:14:35 +03:30
hamid 0b45ec51f4 refinement phase 1 2026-07-13 00:49:24 +03:30
hamid 7acecda5c4 refinement phase 0 2026-07-12 01:09:11 +03:30
hamid 850cdf3414 add refinement phases 2026-07-10 20:59:47 +03:30
hamid a87b47bedb Add frontend phase 15 updates: complete MVP for admin backoffice and partner-center consoles, including new services and endpoints; document requests for backend enhancements. 2026-07-10 20:28:36 +03:30
hamid 70cf00ce4a frontend phase 15 2026-07-10 20:28:06 +03:30
hamid bc51cf59b4 frontend phase 14 2026-07-10 18:46:16 +03:30
hamid 85488bc25b frontend phase 13 2026-07-10 16:58:15 +03:30
hamid 6186f54294 frontend phase 12 2026-07-10 14:48:15 +03:30
hamid 67c028562e frontend phase 11 2026-07-10 13:55:42 +03:30
hamid ccfa27aff6 frontend phase 10 2026-07-10 12:51:53 +03:30
hamid 40cc1d163b frontend phase 9 2026-07-10 11:49:55 +03:30
hamid cd6c2591a6 backend phase 15 & frontend phase 8 2026-07-10 03:22:29 +03:30
hamid 93cc5ecb98 backend phase 14 & frontend phase 7 2026-07-09 15:30:03 +03:30
hamid de53f9d8a6 backend phase 13 & frontend phase 6 2026-07-09 04:09:35 +03:30
hamid dc64472631 frontend phase 5 & backend phase 12 2026-07-09 03:05:14 +03:30
hamid 465f75c29e backend phase 11 2026-07-09 02:13:30 +03:30
hamid 23605591eb ignore graphify 2026-07-09 01:04:44 +03:30
1557 changed files with 151242 additions and 43652 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.
+236 -45
View File
@@ -12,9 +12,22 @@ description: >-
# Balinyaar Frontend Designer
Build UI that looks like Balinyaar and behaves correctly in both locales and both
color schemes on the first try. This skill is the design contract; the engineering
contract (providers, fetch, cookies, routing) lives in [client/CLAUDE.md](../../../client/CLAUDE.md) — read it
before touching layout/provider/data code, **don't restate it**, and never violate it.
color schemes on the first try.
**Precedence.** This skill is the **design** contract — brand, tone, and the visual
decisions. The **engineering** contract is [client/CLAUDE.md](../../../client/CLAUDE.md)
(hard rules) plus [docs/rules/client/](../../../archive/docs/rules/client/) (one reference file
per area). Where the two overlap — tokens, typography, the component library, shells,
icons — **`docs/rules/client/` is authoritative and this skill defers to it.** Read the
relevant one before touching layout, provider, or data code; don't restate it here, and
never violate it.
| For | Read |
|-----|------|
| Tokens, palette, dark mode, RTL, fonts, motion | [docs/rules/client/theme.md](../../../archive/docs/rules/client/theme.md) |
| The `App*` library, shells, navigation, icons | [docs/rules/client/components.md](../../../archive/docs/rules/client/components.md) |
| Copy and Persian orthography | [docs/rules/client/i18n.md](../../../archive/docs/rules/client/i18n.md) |
| Forms | [docs/rules/client/forms.md](../../../archive/docs/rules/client/forms.md) |
**Stack:** Next.js 16 (App Router, Turbopack) · React 19 · MUI v9 (`@mui/material`) ·
Emotion (RTL via `stylis-plugin-rtl`) · next-intl v4 · notistack. Everything below
@@ -28,10 +41,24 @@ Balinyaar is a **trust-first home-nursing marketplace in Iran**. The visual tone
calm, warm, clinical-but-human — not a cold medical dashboard. Default audience is
Persian (RTL); English is secondary.
**Logo mark** (`product/balinyaar.html` seed deck): deep-teal square, lowercase
display glyph in cream, a single terracotta dot. That trio — **teal ground, cream
text, terracotta accent** — is the whole identity. Use terracotta sparingly as the
single accent; teal carries everything else.
**Logo mark** — this skill is the construction source of truth (the original
`product/balinyaar.html` seed deck no longer exists in the repo): a deep-teal
rounded-square ground (`var(--bal-primary)`), a cream lowercase "b" glyph built
from a stem + a ring bowl (`var(--bal-primary-contrast)`), and a single
terracotta dot accent (`var(--bal-secondary)`). That trio — **teal ground, cream
glyph, terracotta accent** — is the whole identity. Use terracotta sparingly as
the single accent; teal carries everything else. Implemented as two SVGs under
`components/common/AppIcon/icons/`:
- `LogoMark.tsx` — a monochrome `currentColor` version of just the glyph (no
ground square), registered as `ICONS.logo`. Use via `<AppIcon icon="logo">`
anywhere an inline, recolorable brand glyph is needed.
- `LogoLockup.tsx` — the full-color mark (ground + glyph + dot, token-driven so
it tracks the color scheme) for `BrandMark` (auth splash). The wordmark next
to it stays real, translated `<Typography>` — never bake locale text into an SVG.
- The favicon (`src/app/favicon.ico`) and `public/img/favicon/*.png` are
rasterized from the same construction (fixed brand hex, not CSS vars — static
binary assets are the one place a literal hex is correct). Regenerate with a
`sharp`-based script if the mark ever changes; don't hand-edit the PNGs/ICO.
| Role | Light | Dark |
|------|-------|------|
@@ -67,21 +94,70 @@ Colors exist in **two mirrored places** that must stay in sync. Pick the right o
- Adding/changing a color means editing `tokens.css` **and** `colors.ts` together (the
file headers call out the sync requirement).
**Beyond color**`tokens.css` also defines non-palette tokens (`colors.ts` never needs
these; they're define-only in CSS):
- **Radius** — `--bal-radius-sm` (6px, controls: buttons/inputs), `--bal-radius-md`
(8px = `theme.shape.borderRadius`, the house default: cards/paper), `--bal-radius-lg`
(12px: dialogs). Reference the token, **never a numeric `sx={{ borderRadius: n }}`**
that multiplies the shape unit, which is how the login card once ended up a 30px pill.
`MuiPaper` pins the md step so a Paper can't drift past it. `--bal-radius-pill` (999px)
is for shapes that genuinely *are* pills — the floating bottom nav, a segmented
control's active chip — never for a card.
- **Frame canvas** — `--bal-frame-canvas`, the backdrop `AppFrame` paints *outside* the
phone-width app column. Never a surface a component draws on.
- **Elevation** — `--bal-shadow-1/2/3`, teal-tinted (black-teal in dark mode) shadow
steps that back `theme.ts`'s `shadows` array — every MUI elevation (Paper, Dialog,
Menu, Popover, AppBar) resolves through these, never MUI's default grey stack.
- **Motion** — `--bal-motion-fast/base/slow` (120/200/300ms) + `--bal-easing-standard`.
Consumed by the phase-12 app-wide motion pass; use them for any transition you add now.
- **Focus** — `--bal-focus-ring`, the 2px ring `MuiCssBaseline`'s global `:focus-visible`
override uses. Don't hand-roll a focus style; it's already uniform everywhere.
- **Rating** — `--bal-rating` / `--bal-rating-empty` (filled/empty star colors) —
`RatingInput` uses these, not `--bal-warning`.
- **Trust** — `--bal-trust` / `--bal-trust-soft`, a distinct identity (not
primary/success) for verified marks — `TrustBadge` and any future verification UI.
- **Money emphasis** — `--bal-money-emphasis`, an AA-contrast-safe color for emphasized
money text. `--bal-secondary` (terracotta) fails AA contrast at small sizes on light
backgrounds — never use it for money text, use this token instead.
- **Soft fills** — every brand and semantic color has a `-soft` variant
(`--bal-primary-soft`, `--bal-warning-soft`, …) for a tinted background. Reach for it
before hand-mixing an alpha over a surface.
- **Avatar** — `--bal-avatar-1..6` (+ each `-contrast`), the six warm pairs
`InitialsAvatar` picks from by a deterministic name hash. Add a seventh to **both**
scheme blocks or don't add one.
- **Map** — `--bal-pin-shadow`, the address-picker pin.
Full catalogue, with what each group backs:
[docs/rules/client/theme.md](../../../archive/docs/rules/client/theme.md) §2.
---
## 3. Typography & fonts
- `shape.borderRadius: 10` (set in `src/theme/theme.ts`) — the house corner radius.
Don't override per-component unless deliberate; prefer multiples that read as related.
- Buttons: `textTransform: 'none'`, weight 600 (set globally in `typography.ts`). Never
- `shape.borderRadius: 8` (set in `src/theme/theme.ts`) — the house corner radius
(= `--bal-radius-md`). Don't override per-component unless deliberate; the radius
*scale* is `--bal-radius-sm` (6, controls) / `-md` (8, cards) / `-lg` (12, dialogs).
- **Weight system — never write `fontWeight: 600`.** Mikhak and Space Grotesk both load
only 400/500/700 (no 600 face), so a requested 600 silently renders full Bold. Use
**700** for headings (`h1``h6`) and buttons/strong emphasis, **500** for lighter
in-text emphasis (subtitles, row labels, chip text). This is enforced globally in
`typography.ts`; match it in any new `sx` you write.
- Buttons: `textTransform: 'none'`, weight 700 (set globally in `typography.ts`). Never
re-uppercase button text.
- Headings (`h1``h6`) use the display font; `h6` is weight 600, the rest 700.
- Persian type scale (`TYPOGRAPHY_RTL`): `letterSpacing: 0` on every variant (Persian is
a joined script — tracking breaks glyph connections), body line-height ≥1.7, heading
line-height ~1.41.5 (room for ascenders/descenders), and `responsiveFontSizes()`
wraps both themes (`theme.ts`) so heading sizes scale down on small viewports —
don't hand-roll per-breakpoint `fontSize` overrides.
- **Fonts are loaded per-locale in `src/app/[locale]/layout.tsx` only** — Mikhak
(`--font-mikhak`) for `fa`, system stack for `en` (Space Grotesk `--font-space-grotesk`
is declared but not yet wired). **Never load a font in a component or page.**
(`--font-mikhak`) for `fa`, **Space Grotesk** (`--font-space-grotesk`, via
`next/font/google`, self-hosted at build time) for `en`. Both `preload: false` with a
conditional `.variable` className so neither ships to the other locale.
**Never load a font in a component or page.**
- Use `<Typography variant=…>` for text — it inherits the correct direction-aware family
(`TYPOGRAPHY_RTL` = Mikhak everywhere for full Persian glyph coverage; `TYPOGRAPHY_LTR`).
Import neither directly in components; let the theme apply them.
(`TYPOGRAPHY_RTL` = Mikhak everywhere for full Persian glyph coverage; `TYPOGRAPHY_LTR`
= Space Grotesk headings + system-stack body). Import neither directly in components;
let the theme apply them.
---
@@ -96,15 +172,23 @@ wrapper over the bare MUI component — the wrappers carry the house defaults.
| `AppIconButton` | icon-only actions | takes an icon `name`, `title`, `to`/`onClick` |
| `AppIcon` | any icon | `icon="home"` by registered name (§6); `size`, `color` props |
| `AppLink` | internal/external links | locale-aware Next navigation; default underline `hover` |
| `AppAlert` | inline alerts | default `severity="error"`, `variant="filled"` |
| `AppImage` | images | wrapper around next/image conventions |
| `AppAlert` | inline alerts | defaults to a calm `severity="info"`, `variant="standard"` — pass `severity="error"` explicitly when it really is an error |
| `AppLoading` | loading state | default circular, `primary`, `3rem` |
| `ErrorBoundary` | wrapping fault-prone subtrees | already wraps page content in the shell |
| `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`,
`APP_ICON_SIZE = 24`, `CONTENT_MAX_WIDTH = 800`, `CONTENT_MIN_WIDTH = 320`, alert/link/
loading defaults). Change a default there, not per-call-site.
`APP_ICON_SIZE = 24`, `APP_ICON_STROKE_WIDTH = 1.75`, `APP_BUTTON_ICON_SIZE = 20`,
`CONTENT_MAX_WIDTH = 480`, `CONTENT_MIN_WIDTH = 320`, alert/link/loading defaults).
Change a default there, not per-call-site.
Beyond the `App*` wrappers there is a **state kit**`EmptyState`, `ErrorState`,
`QueryStateGate`, `PageHeader`, `ConfirmDialog`, `SurfaceCard`, `AccentCard`, `Money`,
`StatusTimeline`, the `Jalali*` date inputs, `StickyActionBar`, `Pager`, `NavHubList`,
`InitialsAvatar`, `FormDialogShell` — with **one pattern per state**. Never hand-roll a
dashed-border "nothing here" block or a per-screen pager; and **an error state is never an
empty state.** Catalogue in
[docs/rules/client/components.md](../../../archive/docs/rules/client/components.md).
For layout/spacing use MUI primitives directly: `Box`, `Stack`, `Container`, `Grid`,
`Paper`, `Card`. Use the `spacing`/`sx` system (theme spacing unit = 8px) — never inline
@@ -113,41 +197,131 @@ pixel margins for rhythm.
**New shared component?** Put it in `src/components/<Name>/<Name>.tsx` with an
`index.tsx` barrel, follow the `App*` prop-spreading + JSDoc style of `AppButton.tsx`,
and add a co-located `.test.tsx` (mandatory for anything imported in >1 place — see
CLAUDE.md "Unit Testing"; wrap with `<ThemeProvider>`, never mock MUI).
[docs/rules/client/testing.md](../../../archive/docs/rules/client/testing.md); wrap with
`<ThemeProvider>`, never mock MUI). If it goes at the top of the `@/components/common`
barrel, prefer **caller-owned copy** (required `title`/`body`/`retryLabel` string props)
over calling `useTranslations` inside it — `next-intl` is ESM-only and poisons every test
that transitively imports the barrel. `ErrorBoundary`/`ErrorState` are the model;
[components.md](../../../archive/docs/rules/client/components.md) has the why.
**Any form with more than one field is a react-hook-form form**, bound through the
`@/components/common/form` wrappers (`RhfTextField`, `RhfChipSelect`,
`RhfJalaliDateField`, `RhfControlGroup`) and grouped into `FormSection`s. A single-field
control is state, not a form. Full pattern:
[docs/rules/client/forms.md](../../../archive/docs/rules/client/forms.md).
---
## 5. Layout & page shells
- **Private (authenticated) screens** render inside `PrivateLayout`
`TopBarAndSideBarLayout` (`src/layout/`): a `TopBar` + a `SideBar` (variant
`sidebarPersistentOnDesktop`: persistent ≥desktop, temporary drawer on mobile) +
a dark-mode toggle. Sidebar nav items are `{ title, path, icon }` arrays built with
`useTranslations('nav')`. Page content is auto-wrapped in `ErrorBoundary`.
- **Public screens** use `PublicLayout`.
- Shell dimensions are constants in `src/layout/config.ts` (`SIDE_BAR_WIDTH = 240px`,
top-bar `56px` mobile / `64px` desktop, anchors). Respect them; don't hard-code.
**There is one layout: a phone.** `AppFrame` (`src/layout/AppFrame.tsx`) renders every
screen inside a centered `APP_FRAME_MAX_WIDTH` (480px) column on a `--bal-frame-canvas`
backdrop, at **every viewport**. A wider window gets more canvas, never a wider app —
design one set of states, verify one set of states. Do not add a `≥md` branch that widens
a shell, restores a sidebar, or lays a screen out in columns.
- `AppFrame` owns four structural guarantees, and is the only place any of them is
solved: the width cap; the **frame, not the document, owns the scroll** (a single
scrolling `<main>` fills the frame, with the bars pinned **`position: absolute`** over
it — never `fixed`, which would break out of the centered column — and `<main>`
reserving each bar's exact height as padding, so no page needs a top offset);
`overflowX: hidden` + `minWidth: 0`, so an over-wide child clips rather than dragging
the app sideways; and, above `sm`, the column **floats** as a rounded shadowed card
with a gutter all round (edge-to-edge on a phone). Genuinely wide content (a data
table) scrolls **inside its own container** — see `AdminDataTable`'s `TableContainer`.
- `AppFrame` also publishes **`--bal-chrome-top` / `--bal-chrome-bottom`** on the scroll
container (already including `env(safe-area-inset-*)`, and `0px` in a chrome-free
shell), so any `position: sticky` element can clear the bars without importing a
constant. `StickyActionBar` is the reference consumer — don't recompute an offset.
- **One authenticated shell**: `MobileShell` = `AppFrame` + a contextual `TopBar` (brand
lockup on a tab's own path, back chevron + `useRouteTitle()` on anything deeper) +
`BottomBar` + `ErrorBoundary` + `RouteFadeIn`. The four actor layouts (`CustomerLayout`
/ `NurseLayout` / `AdminLayout` / `PartnerLayout`, each wrapped in `RoleGuard`) supply
only `tabs` and `headerActions`. Add a destination by adding a tab or a hub row — never
by forking the shell.
- **The chrome is light, not structural.** The top bar is *not* an `AppBar` — no filled
surface, no rule, no elevation of its own; `AppFrame` wraps both bars in the shared
`FLOATING_BAR_SX`, so the header is the bottom bar mirrored: inset from the frame edges,
fully rounded (`--bal-radius-pill`), elevated. Neither should read as a slab sealing off
an edge of a 480px screen. The bottom bar is **icon-only** (at five tabs the caption was
the widest thing in it and cost a whole line — the label survives as `aria-label`/
`title`), each tab a fixed 44px circle laid out `space-around`.
- **A stateful card carries its state in its content, not a stripe.** `AccentCard`'s
colored edge stripe was removed — a column of them read as a row of loose vertical rules
down the RTL edge of the screen. `tone` survives as the semantic label (reaching the DOM
as `data-accent-tone`); the `StatusChip`, icon and copy inside carry the state.
**Do not reintroduce the stripe.**
- **Navigation is the bottom bar. There is no drawer.** Tabs are `LinkToPage` arrays
(`@/utils`) built with `useTranslations('nav')`, 35 of them, and by convention the last
is a settings/«بیشتر» hub. Active state comes from the shared `matchActivePath`
(longest-prefix, winner-takes-all) over each tab's own path **plus its `matchPaths`
claims — use `matchPaths` when a tab owns a destination outside its own URL subtree
(`/nurse/finance` owning `/nurse/earnings`). Never hand-roll `pathname.startsWith`.
- **A nav group's root is a real page**, not a drawer section: a short summary of that
domain (read only off queries that already answer it — never a fabricated figure) over a
`NavHubList` of its destinations. See `/nurse/practice`, `/nurse/finance`,
`/admin/trust`, `/admin/system`.
- **Chrome carries no preferences.** Language and appearance live in `SettingsPanel`
(`@/components/settings`), mounted in each actor's settings hub and nowhere else. The
top bar is for identity, the page title, and at most a notification bell. Appearance is a
three-way segmented control (light/dark/**system**) — never a boolean switch, which cannot
express the app's own default.
- **Public screens** use `PublicLayout` — the frame and nothing else, **no top bar**; the
step content (`AuthCard`) carries the only brand mark on screen. `FocusedLayout` is the
framed chrome-free shell for can't-tab-away flows (onboarding, `/select-role`).
- All chrome navigation goes through `@/i18n/navigation` (`Link`/`usePathname`/
`useRouter`) — never a raw `next/link` or a manual `` `/${locale}` `` prefix. (Inside a
*page*, `AppLink`/`AppButton`'s `to` is a plain `next/link` and still needs the prefix.)
- Page content is auto-wrapped in `ErrorBoundary` inside every shell.
- Shell dimensions are constants in `src/layout/config.ts` (`APP_FRAME_MAX_WIDTH`,
`TOP_BAR_HEIGHT`). Respect them; don't hard-code.
- A page is `src/app/[locale]/(private|public-routes)/…/page.tsx`. Keep page bodies to
composition + content; push reusable visuals into `src/components/`.
- Constrain reading width with `CONTENT_MAX_WIDTH` (800) for text-heavy views; full-bleed
is fine for dashboards/tables.
- Use `useIsMobile()` (`@/hooks`) for responsive branching, or MUI breakpoints in `sx`.
- `CONTENT_MAX_WIDTH` mirrors the frame width — a page column can never be wider than the
frame containing it.
- Prefer MUI breakpoints in `sx` for the little responsive branching that remains over
`useIsMobile()` (`@/hooks`) — the latter is JS/post-hydration and caused a real SSR
flash; reach for it only for genuinely non-structural, JS-only behavior.
---
## 6. Icons
Icons are a **name registry**, not free imports. `src/components/common/AppIcon/config.ts`
maps lowercase names → MUI/SVG components. Render with `<AppIcon icon="home" />` or pass
the name to `AppButton`/`AppIconButton` (`icon="search"`).
maps lowercase names → components. Render with `<AppIcon icon="home" />` or pass the name
to `AppButton`/`AppIconButton` (`icon="search"`).
Currently registered: `default, logo, close, menu, settings, visibilityon,
visibilityoff, daynight, night, day, search, info, home, account, signup, login,
logout, notifications, error`.
**One visual family: Lucide.** Every registered icon comes from `lucide-react` — a
contemporary outline family on a 24px grid with round caps/joins, which reads far lighter
than the filled glyphs this registry used to carry at the small sizes a phone-width app
actually uses. `@mui/icons-material` is **no longer a dependency**; never reintroduce it.
The house stroke weight is `APP_ICON_STROKE_WIDTH` (1.75 — Lucide ships at 2, which
competes with Mikhak's lighter Persian strokes).
**Need a new icon:** import it into `config.ts`, add a **lowercase** key to `ICONS`, then
reference by that name. Custom SVGs go in `AppIcon/icons/`. An unregistered name logs a
warning and falls back to `default` — never pass a raw MUI icon where a name is expected.
**The mapping is semantic, not incidental.** A name describes the domain concept
("verification", "earnings", "coverage") and the glyph depicts *that*, so swapping the
underlying glyph never leaks into call sites. Related concepts share a visual root on
purpose: trust/verification names are shields, money names are coins or cards, clinical
names are a pulse or a cross. ~110 names are registered — read `AppIcon/config.ts` for the
list rather than duplicating it here; the structural rules below are what won't drift.
**`size` drives real `width`/`height`.** Lucide sizes off SVG attributes, so
`<AppIcon icon="verified" size={48} />` is 48px with no `fontSize`/`1em` indirection.
Icons also default to `flexShrink: 0` — an icon squashed by a flex sibling was the one
layout bug this component kept quietly reintroducing on narrow rows.
**Directional icons mirror automatically.** Icons authored for LTR that must flip under
RTL (`back`, `chevron_start`, `chevron_end`, `forward`, `send`) are registered in `AppIcon/config.ts`'s
`DIRECTIONAL_ICONS` set. `AppIcon` stamps `data-icon-directional` on those, and one CSS
rule (`app/globals.css`) does
`[dir='rtl'] [data-icon-directional] { transform: scaleX(-1); }`. Adding a new directional
icon is a one-line registry addition — never hand-roll a per-component flip.
**Need a new icon:** import it from `lucide-react` into `config.ts`, add a **lowercase**
key to `ICONS`, then reference by that name. Custom SVGs (the brand mark) go in
`AppIcon/icons/` and must accept the same `size`/`color`/`strokeWidth` contract
(`AppIcon/utils.ts`'s `IconProps`). An unregistered name logs a dev-only warning and falls
back to `default` — never pass a raw icon component where a name is expected.
---
@@ -167,11 +341,16 @@ Every screen/component you produce must satisfy **all** of these:
switches automatically. Verify on both schemes — never assume a light background.
4. **Tokens, not hexes.** No raw color literals in `sx`/`styled`/components (§2).
5. **Constants, not magic values.** Cookie names, routes, repeated dimensions, event
names → named constants (CLAUDE.md "Constants").
names → named constants ([components.md](../../../archive/docs/rules/client/components.md) §5).
6. **Use the wrappers** (§4) and the **icon registry** (§6) before bare MUI.
7. **Shared component ⇒ co-located test** (§4).
8. **MUI v9 API only.** No v5/v6-era props (e.g. `Stack` `useFlexGap`, `storageWindow`).
Avoid deprecated APIs that throw.
9. **Persian copy follows the style guide** — «بالین‌یار» with a ZWNJ, تأیید with a hamza,
جستجو in one form, formal شما. `npm run lint:copy` fails the gate on a banned variant.
Glossary and the full rules: [i18n.md](../../../archive/docs/rules/client/i18n.md) §4.
10. **A screen never fabricates a figure.** A summary reads only off a query that already
answers it; a count still in flight is omitted, never faked or defaulted.
---
@@ -187,13 +366,15 @@ Every screen/component you produce must satisfy **all** of these:
5. **Verify the four axes:** `/fa` (RTL) and `/en` (LTR) × light and dark. The default
route is `/fa` — start there.
6. **Tests** for any new shared component; **never** add a layout above `[locale]`
(breaks locale/dir — see CLAUDE.md).
7. Data/fetch/auth/cookies/toasts → follow CLAUDE.md (`serverFetch`/`clientFetch`,
(breaks locale/dir — see [structure.md](../../../archive/docs/rules/client/structure.md)).
7. Data/fetch/auth/cookies/toasts → follow
[services.md](../../../archive/docs/rules/client/services.md) and
[auth.md](../../../archive/docs/rules/client/auth.md) (`serverFetch`/`clientFetch`,
`@/lib/cookies/*`, `dispatchToast`/`useSnackbar`). Don't reinvent these.
---
## 9. Anti-patterns (design-specific — CLAUDE.md has the full engineering list)
## 9. Anti-patterns (design-specific — `docs/rules/client/` has the full engineering list)
- Hard-coded hex/rgb in components → use palette keys or `--bal-*` tokens.
- MUI default success/error colors for feedback → use `--bal-*` semantic tokens.
@@ -204,6 +385,14 @@ Every screen/component you produce must satisfy **all** of these:
- Raw MUI icon where a registry name is expected → register it in `AppIcon/config.ts`.
- New shared component without a `.test.tsx`, or mocking MUI in tests.
- Re-introducing `src/app/layout.tsx` / any layout above `[locale]`.
- A `≥md` branch that widens a shell, restores a sidebar, or goes multi-column → there is
one layout, and it is a phone (§5).
- A numeric `sx={{ borderRadius: n }}` → it multiplies the shape unit; use the radius token.
- `fontWeight: 600` → neither face loads it, so it silently renders full Bold. 700/500/400.
- Reintroducing `AccentCard`'s edge stripe, a drawer, a top-bar theme/locale toggle, or a
caption under a bottom-nav icon → each was deliberately removed.
- A hand-rolled empty/error/loading block, or a per-screen pager → use the state kit (§4).
- A second `prefers-reduced-motion` branch → there is exactly one, in `globals.css`.
---
@@ -234,4 +423,6 @@ pushing code back into Figma.
| Layout shells | `client/src/layout/` |
| Layout dimensions | `client/src/layout/config.ts` |
| Messages (i18n) | `client/messages/{en,fa}.json` |
| Engineering contract | `client/CLAUDE.md` |
| Persian copy lint | `client/scripts/check-copy.mjs` |
| Engineering hard rules | `client/CLAUDE.md` |
| Engineering reference (per area) | `docs/rules/client/` |
+2 -1
View File
@@ -1 +1,2 @@
temp
temp
**/graphify-out/*
+6
View File
@@ -6,5 +6,11 @@ The canonical guidance for AI coding agents in this repository lives in **[CLAUD
- Frontend → [client/CLAUDE.md](client/CLAUDE.md)
- Backend → [server/CLAUDE.md](server/CLAUDE.md)
Those hold the **hard rules**. For current-state product truth — what to test, what's blocking launch,
what's missing — start at **[mvp/README.md](mvp/README.md)**. The engineering reasoning behind the hard
rules, and the full business-requirement docs, were archived on 2026-08-02 into
**[archive/docs/rules/](archive/docs/rules/index.md)** and **[archive/product/](archive/product/index.md)**
respectively — reference material, not actively maintained.
`CLAUDE.md` is the single source of truth at every level of this repo; these `AGENTS.md` files are
just pointers so the convention is discoverable under either name.
+102 -71
View File
@@ -1,114 +1,145 @@
# Balinyaar — Repository Guide (root)
This is the **shared, repo-wide** guide for AI coding agents. It is intentionally short.
Everything specific to one side of the stack lives in that project's own `CLAUDE.md`.
The **shared, repo-wide** guide for AI coding agents. It is intentionally short. Everything specific to one
side of the stack lives in that project's own `CLAUDE.md`.
> **Read the guide for the side you are editing — and only that one.**
> Working in `client/`? Read [client/CLAUDE.md](client/CLAUDE.md).
> Working in `server/`? Read [server/CLAUDE.md](server/CLAUDE.md) (+ [server/CONVENTIONS.md](server/CONVENTIONS.md)).
> Working in `server/`? Read [server/CLAUDE.md](server/CLAUDE.md).
> You almost never need both. A frontend change does not touch server files, and vice-versa.
> `AGENTS.md` files in this repo are thin pointers to the `CLAUDE.md` in the same folder.
> `CLAUDE.md` is the single source of truth at every level.
> Last verified: 2026-08-02 against commit `51e86a1`.
---
## What Balinyaar is
Balinyaar is a **trust-first home-nursing marketplace in Iran**. Independent nurses (and
nursing-company employees) list configurable services; families search, book, pay, and review.
The platform holds funds in an escrow-style ledger and pays nurses out weekly after a confirmed
check-out.
Balinyaar is a **trust-first home-nursing marketplace in Iran**. Independent nurses (and nursing-company
employees) list configurable services; families search, book, pay, and review. The platform holds funds in an
escrow-style ledger and pays nurses out weekly after a confirmed check-out.
Product/domain knowledge — business rules, the database model, payments/BNPL, escrow, the
verification pipeline — is **not** in the code. It lives in [`product/`](product/), organized as a
**structured docs tree** (one topic per file; start at [product/index.md](product/index.md) or its
[README](product/README.md)):
**Start at [`mvp/README.md`](mvp/README.md) for the current state of the product**, in three short,
non-technical files: how to manually test any user journey, what's broken and blocking a real launch, and
what's missing that isn't clearly scheduled. That folder is the live, load-bearing answer to "what's next."
| Folder | What it covers |
| --- | --- |
| [product/overview/](product/overview/platform-summary.md) | What Balinyaar is, the four cross-cutting ground truths, Persian glossary. **Read first.** |
| [product/business/](product/business/index.md) | The 14 functional/business requirement areas, one file each |
| [product/data-model/](product/data-model/index.md) | The ~54-table SQL Server schema across 13 domains + [diagrams](product/data-model/diagrams.md) |
| [product/payments/](product/payments/index.md) | BNPL, escrow ledger, settlement, VAT, integrations (with sources) |
| [product/research/](product/research/index.md) | Market/legal/verification research & go-to-market (EN) |
| [product/notes/](product/notes/open-questions.md) | Living notes: open questions, future ideas |
| [product/fa/](product/fa/index.html) | Farsi versions (research report + verification flow) |
Deeper product/business knowledge — the full business-requirement write-ups, the ~54-table database model,
payments/BNPL research, market/legal research — was consolidated into [`archive/product/`](archive/product/index.md)
during the 2026-08-02 documentation cleanup. It is **reference material, not required reading**: correct as
of that date, but not actively maintained going forward. Read it when `mvp/` doesn't answer your question in
enough depth — e.g. designing a new table, or needing the full reasoning behind a business rule.
**Read the relevant `product/` doc before designing any schema, API, or feature.** Don't infer
business rules from code — the code is young and the docs are the source of truth.
> **Docs format:** the `.md` files are canonical; matching `.html` files are a generated, cross-linked
> browsing view (`cd product && node build-docs.mjs`). Edit the Markdown and regenerate — never
> hand-edit the `.html`. If you add/rename a `.md`, update the `NAV` manifest in `product/build-docs.mjs`.
**Never infer business rules from code alone** — the code is young. If `mvp/` and `archive/product/` both go
silent on a money, auth, tenancy, or clinical-data rule, say so rather than guessing.
---
## Repository layout
This is **two independent projects in one repo**. There is no root-level build, package, or
solution — each project is built, linted, and run on its own.
This is **two independent projects in one repo**, plus their documentation. There is no root-level build,
package, or solution — each project is built, linted, and run on its own.
| Path | Project | Stack | Guide |
| Path | What it is | Stack | Guide |
| --- | --- | --- | --- |
| [`client/`](client/) | Web frontend | Next.js 16 (App Router) · React 19 · TypeScript · MUI v9 · next-intl | [client/CLAUDE.md](client/CLAUDE.md) |
| [`server/`](server/) | Backend API | ASP.NET Core (.NET 10) · Clean Architecture · CQRS · EF Core | [server/CLAUDE.md](server/CLAUDE.md) |
| [`product/`](product/) | Product docs | Markdown | — (see table above) |
| [`dev/`](dev/) | Build plan (not app code) | Markdown | [dev/README.md](dev/README.md) |
| [`mvp/`](mvp/README.md) | **Current truth** — plain-language test flows, launch blockers, missing MVP features | Markdown | [mvp/README.md](mvp/README.md) |
| [`archive/`](archive/README.md) | Everything else: business docs, engineering rules/contracts/flow-atlas, and the executed build history. **Reference/history, not instruction** — nothing to build from it, and nothing here is kept current | Markdown | [archive/README.md](archive/README.md) |
| [`telegram-otp-bot/`](telegram-otp-bot/) | OTP relay (standalone, the pre-launch demo rail) | Node 18+, zero deps | [telegram-otp-bot/README.md](telegram-otp-bot/README.md) |
| [`deploy/`](deploy/) | Reverse-proxy config | Caddyfile | [DEPLOY.md](DEPLOY.md) |
The two communicate over **HTTP/JSON** (optionally gRPC). The client reads the API base URL from
`NEXT_PUBLIC_API_URL`; the server listens on `https://localhost:5002` by default.
`AGENTS.md` files in this repo are thin pointers to the `CLAUDE.md` in the same folder. **`CLAUDE.md` is the
single source of truth at every level.**
[`dev/`](dev/README.md) holds the **phased build plan** that takes the repo from its current baseline to
the MVP: a chain of agent-runnable prompt files split into a `backend/` and a `frontend/` track
([dev/phases/](dev/phases/README.md)), the cross-project API [`contracts/`](dev/contracts/README.md), and
a [`shared-working-context/`](dev/shared-working-context/README.md) that lets a backend agent and a
frontend agent run in parallel without touching the same files. It is planning/tooling, **not** a third
project — there is nothing to build in it.
The two projects communicate over **HTTP/JSON** (optionally gRPC). The client reads the API base URL from
`NEXT_PUBLIC_API_URL`; the server listens on `http://localhost:5002` by default.
**Deployment** is three Docker containers — one `Dockerfile` per project directory, orchestrated by the root
[`docker-compose.yml`](docker-compose.yml) — behind an existing Caddy reverse proxy on the external `caddy_net`
network, serving `balinyaar.ir` (client) and `api.balinyaar.ir` (server). The database is **not**
containerised; it is a remote SQL Server. Full runbook: [DEPLOY.md](DEPLOY.md).
`archive/` holds the executed build history, the former `docs/` (engineering rules, API contracts, the
per-flow test atlas, status/backlog) and the former `product/` (business requirements, data model, research)
— consolidated there on 2026-08-02 so the live tree stays focused on MVP work. **Anything in it is a record,
not an instruction.**
---
## Where the rules live
Three tiers. Open the `CLAUDE.md` for the side you are editing, then **one** reference file for the area you
are touching.
| Tier | Where | What |
| --- | --- | --- |
| **Hard rules** | this file · [client/CLAUDE.md](client/CLAUDE.md) · [server/CLAUDE.md](server/CLAUDE.md) | Constraints whose violation breaks the build, the gate, or a business invariant |
| **Reference** (archived) | [`archive/docs/rules/`](archive/docs/rules/index.md) | The *how* and the *why*, as of 2026-08-02 — 3 shared files, 8 client, 6 server, plus the documentation convention. Not actively maintained; read it on demand, don't expect it to track later changes |
| **Procedure** | `.claude/skills/` | Playbooks: **frontend-designer** (the design contract for `client/` UI), **backend-feature** (adding a server feature), **flow-testing** (walking a flow end to end) |
Start at [archive/docs/rules/index.md](archive/docs/rules/index.md) — it maps "working on X" to the one file
to open.
**Precedence when two sources disagree:** `archive/product/` (business truth) → the relevant `CLAUDE.md`
(engineering truth) → `archive/docs/rules/` (the reasoning behind it) → the task in front of you. **Never
silently guess on money, auth, tenancy, or clinical-data rules** — do the safe thing, and say so.
---
## Working agreements (apply to both projects)
1. **Stay within one project per change** unless the task explicitly spans both.
2. **Match the surrounding style.** Mirror existing patterns; don't introduce new ones. Each
project documents its conventions in its own `CLAUDE.md`.
2. **Match the surrounding style.** Mirror existing patterns; don't introduce new ones. Each project documents
its conventions in its own `CLAUDE.md`.
3. **Run that project's own checks before declaring work done:**
- client: `npm run check` (type + lint), plus `npm run test:ci` if you touched a tested component.
- server: `dotnet build Baya.sln` and `dotnet test Baya.sln`.
4. **Read the product docs before changing behavior.** Business rules are decisions, not guesses.
5. **Don't reintroduce template/starter scaffolding.** Both projects were derived from open-source
starters; their branding, demo/showcase pages, and `_TITLE_`/`_DESCRIPTION_` placeholders were
intentionally removed. Don't add them back.
6. **Never commit secrets.** Use `.env` (client) and `appsettings.*.json` / user-secrets (server).
Real connection strings, keys, and tokens never enter git.
7. **Keep docs honest, and keep the architecture map current.** If you change how something works,
update the `CLAUDE.md` that describes it in the same change. Each level documents its architecture
in one canonical place — **this file's "Repository layout"** (repo), **client/CLAUDE.md "Project
Structure"** (frontend), **server/CLAUDE.md "Project map"** (backend). When a change alters that
structure — adds, removes, or renames a project, layer, route group, provider, or major folder, or
changes a cross-project / cross-layer boundary — update the matching architecture section in the
same change. Stale instructions are worse than none.
- client: `cd client && npm run check` (type + lint + copy), plus `npm run test:ci` if you touched a tested
component.
- server: `cd server && dotnet build Baya.sln` (**zero new warnings**) and `dotnet test Baya.sln`.
- What "done" means in full: [archive/docs/rules/shared/git-and-gates.md](archive/docs/rules/shared/git-and-gates.md).
4. **Read [`mvp/`](mvp/README.md) (and `archive/product/` for depth) before changing behavior.** Business rules are decisions, not guesses.
5. **Don't reintroduce template/starter scaffolding.** Both projects were derived from open-source starters;
their branding, demo/showcase pages, and `_TITLE_`/`_DESCRIPTION_` placeholders were intentionally removed.
Don't add them back.
6. **Configuration lives in files, not a secret store.** `dotnet user-secrets` is **not used** — the
`<UserSecretsId>` was removed from `Baya.Web.Api.csproj`, so that store **is not even read**. Any
instruction anywhere to set a value with it is stale. Server config (including keys) lives in
`appsettings.*.json`; client config in `.env.development` / `.env.production`; the deployment's
container-specific overrides in `docker-compose.yml`.
This is a deliberate pre-launch trade for a demo deployment — **the repo therefore contains live
credentials.** Before onboarding real users, rotate them and move the secret half out of git (see
[DEPLOY.md](DEPLOY.md) "Going to Production"). **One value is load-bearing and must never change:**
`Seams:FieldEncryption:Key` / `:HashKey` decrypt all existing PII and derive the phone-lookup hash.
7. **Keep docs honest, and keep the architecture map current.** If you change how something works, update the
doc that describes it in the **same** change. Each level documents its architecture in one canonical place —
**this file's "Repository layout"** (repo), **client/CLAUDE.md "Project structure"** (frontend),
**server/CLAUDE.md "Project map"** (backend). When a change alters that structure — adds, removes, or
renames a project, layer, route group, provider, or major folder, or changes a cross-project / cross-layer
boundary — update the matching section in the same change. The full anti-drift convention (what to update
when X changes, the `> Last verified:` stamp, length budgets) is
[archive/docs/rules/documentation.md](archive/docs/rules/documentation.md). **Stale instructions are worse than none.**
8. **Write clean, self-documenting code.**
- **No dead code.** Remove unused variables, imports/usings, parameters, and private members —
don't leave them behind and don't suppress the warning. The client enforces this with ESLint
(`@typescript-eslint/no-unused-vars` as an *error*); on the server they are build warnings and
the gate is zero new warnings. Per-project specifics live in each project's `CLAUDE.md` /
`CONVENTIONS.md`.
- **Comment the *why*, not the *what*.** Don't write verbose comments that restate what the code
already says. Add a comment only where a non-obvious decision, constraint, business rule, or
trade-off isn't evident from the code itself. Prefer a clearer name over a comment.
- **No dead code.** Remove unused variables, imports/usings, parameters, and private members — don't leave
them behind and don't suppress the warning. The client enforces this with ESLint
(`@typescript-eslint/no-unused-vars` as an *error*); on the server they are build warnings and the gate is
zero new warnings.
- **Comment the *why*, not the *what*.** Don't write verbose comments that restate what the code already
says. Add a comment only where a non-obvious decision, constraint, business rule, or trade-off isn't
evident from the code itself. Prefer a clearer name over a comment.
- Details and worked examples: [archive/docs/rules/shared/code-quality.md](archive/docs/rules/shared/code-quality.md).
9. **A mock is only sanctioned behind a DI-registered seam**, selected by configuration, defaulting to the
mock, and recorded in [`mvp/blockers.md`](mvp/blockers.md) (or `archive/docs/status/` for the full historical
ledger). Never an `if (mock)` branch scattered through the code.
---
## Naming
- The **server**'s C# namespaces, projects, and solution all use the `Baya*` prefix
(`Baya.Web.Api`, `Baya.sln`). Keep new server code under the `Baya.*` convention.
- The **server**'s C# namespaces, projects, and solution all use the `Baya*` prefix (`Baya.Web.Api`,
`Baya.sln`). Keep new server code under the `Baya.*` convention.
- The **client** package is `balinyaar-client`; the `@/*` import alias maps to `client/src/*`.
The product/brand name is **Balinyaar**; the server's `Baya*` prefix is a legacy code namespace —
do not rename it without explicit instruction.
The product/brand name is **Balinyaar** — «بالین‌یار» in Persian copy, with a ZWNJ, always. The server's
`Baya*` prefix is a legacy code namespace: **do not rename it without explicit instruction.** Full
conventions: [archive/docs/rules/shared/naming.md](archive/docs/rules/shared/naming.md).
---
@@ -119,5 +150,5 @@ do not rename it without explicit instruction.
cd client && npm install && npm run dev # http://localhost:3000
# Backend
cd server && dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj # https://localhost:5002/swagger
cd server && dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj # http://localhost:5002/swagger
```
+208
View File
@@ -0,0 +1,208 @@
# Deploying Balinyaar
A first, shareable deployment of the whole stack under **balinyaar.ir**, in Docker, behind an existing
Caddy reverse proxy that terminates TLS.
> **This file is the deploy *procedure*.** The runtime dependency graph — every edge, what breaks when it is
> down, and where it is configured — is [archive/docs/integration/topology.md](archive/docs/integration/topology.md)
> (archived reference, not actively maintained), and
> every configuration key on both sides is
> [archive/docs/integration/config-matrix.md](archive/docs/integration/config-matrix.md). Read those to answer
> "what talks to what" or "where is this value set"; read this one to actually ship.
| Host | Serves | Container |
| --- | --- | --- |
| `balinyaar.ir`, `www.balinyaar.ir` | Next.js web client | `balinyaar-web:3000` |
| `api.balinyaar.ir` | ASP.NET Core API | `balinyaar-api:8080` |
| *(internal only)* | Telegram OTP relay | `balinyaar-otp-relay:5010` |
The **database is not containerised** — it is the remote SQL Server already configured in
[server/src/API/Baya.Web.Api/appsettings.Development.json](server/src/API/Baya.Web.Api/appsettings.Development.json).
Nothing needs to be provisioned for it; the API just needs network reach to `87.107.152.16:1433`.
---
## Configuration model
**There is no `dotnet user-secrets` any more.** The `<UserSecretsId>` was removed from
`Baya.Web.Api.csproj`, so the API no longer reads that store at all — a stale `secrets.json` on a dev
machine is now inert and can be deleted. Every value lives in a file in the repo:
| What | Where |
| --- | --- |
| API config + secrets (DB, JWE keys, field-encryption keys, Telegram key, CORS, trusted proxies) | `server/src/API/Baya.Web.Api/appsettings.Development.json` |
| The two values that differ between a laptop and the container network | `docker-compose.yml``api.environment` |
| Client build-time config (API URL, site origin) | `client/.env.production` |
| Telegram relay config (bot token, chat ids, API key, proxy) | `docker-compose.yml``otp-relay.environment` |
The placeholder string `SET_VIA_USER_SECRETS_OR_ENV` in the base `appsettings.json` names that removed
store; the *name* is a historical artifact, kept only because it is the sentinel `StartupSecretsGuard`
rejects. **The mechanism is appsettings files and environment variables** — see
[archive/docs/integration/config-matrix.md](archive/docs/integration/config-matrix.md), which lists every key,
its default, and who reads it.
The API runs as **`ASPNETCORE_ENVIRONMENT=Development`**, so `appsettings.Development.json` is the file
that actually loads. There is **no `appsettings.Production.json` in the repo at all**, and adding one would
be ignored until the environment name changes too — put changes in the Development file, or change the
environment name first.
The relay's shared secret appears twice and the two must match: `Seams:Sms:Telegram:ApiKey` in the
appsettings file and `API_KEY` in the compose file. It was rotated away from the value in
`telegram-otp-bot/.env.example`, which is published in git and in that project's README —
`TelegramSmsSender` now refuses to authenticate with it. **If you run the relay locally**, copy the
appsettings value into your own `telegram-otp-bot/.env`.
> ⚠️ **`Seams:FieldEncryption:Key` and `:HashKey` must never change.** Every encrypted column in that
> database — phone numbers, addresses, IBANs, clinical notes — was written with those exact values, and
> `users.PhoneHash`, which every login looks up, is derived from `HashKey`. Rotating either makes the
> existing data unreadable and locks every account out. The JWE keys (`IdentitySettings:SecretKey` /
> `Encryptkey`) are safe to rotate; doing so only signs everyone out.
---
## What running as Development means
This was a deliberate choice so the demo and lifecycle seeders populate the shared database and the
screens aren't empty. It has real consequences, all of which are fine for a pre-launch demo among
people you trust, and none of which are acceptable once strangers can reach the site:
- **The developer exception page is public.** Any unhandled 500 on `api.balinyaar.ir` returns a stack
trace and configuration detail to the caller.
- **`GET /api/v1/dev/last_otp/{phone}` is live.** Anyone who knows a registered phone number can read
its login code and sign in as that user. This is the single biggest exposure.
- **Swagger is served** at `api.balinyaar.ir/swagger`.
- **The seeders re-run on every container boot** (idempotent, so this is safe — they no-op on data that
already exists) and **migrations auto-apply on boot** rather than as a separate step.
- **gRPC reflection is enabled**, and the demo `bookings/convert` payment-capture simulator is wired.
### Going to Production later
1. Set `ASPNETCORE_ENVIRONMENT: Production` in `docker-compose.yml`.
2. **Create** `appsettings.Production.json` (it does not exist) with the same content as the Development file, but with **real**
`IdentitySettings:SecretKey` / `Encryptkey``StartupSecretsGuard` rejects anything containing
`not-for-production` outside Development, so the current dev keys will refuse to boot (by design).
Keep `Seams:FieldEncryption` byte-identical.
3. Run migrations as a one-shot instead of on boot:
`docker compose run --rm api dotnet Baya.Web.Api.dll migrate`
4. Swap the OTP rail: `Seams:Sms:Provider``kavenegar`, with `Seams:Sms:ApiKey`/`Sender` filled in.
The Telegram relay broadcasts every code to a fixed recipient list, which stops being acceptable the
moment someone outside that list can request one.
---
## First deploy
### 1. Confirm the Caddy network exists
The compose file joins `caddy_net` as an **external** network — it does not create it.
```bash
docker network ls | grep caddy_net
```
### 2. Add the Balinyaar block to your Caddyfile
Copy from [deploy/Caddyfile](deploy/Caddyfile) into the Caddyfile your Caddy container already loads:
```caddyfile
balinyaar.ir, www.balinyaar.ir {
encode zstd gzip
reverse_proxy balinyaar-web:3000
}
api.balinyaar.ir {
encode zstd gzip
reverse_proxy balinyaar-api:8080
}
```
Caddy obtains and renews the certificates for both hostnames itself. Reload it:
```bash
docker exec <caddy-container> caddy reload --config /etc/caddy/Caddyfile
```
### 3. Point DNS at the host
`balinyaar.ir`, `www.balinyaar.ir` and `api.balinyaar.ir` all need an A record on the server's public IP
**before** Caddy can complete the ACME challenge.
### 4. Confirm the proxy container is up
The relay's hop to `api.telegram.org` is filtered in Iran and goes out through the proxy already on
`caddy_net`, configured as `TELEGRAM_PROXY_URL: http://hysteria-client:8081`. If that container has a
different name or port, change it in `docker-compose.yml` — a wrong value fails the relay at boot with a
clear message rather than silently per-OTP.
### 5. Build and start
```bash
docker compose up -d --build
docker compose ps
docker compose logs -f api
```
The API's first boot applies any pending migrations and runs the seeders against the remote database, so
it takes noticeably longer than later ones.
---
## Verifying
```bash
curl https://api.balinyaar.ir/healthz/live # process is up
curl https://api.balinyaar.ir/healthz/ready # + database and object storage reachable
curl -I https://balinyaar.ir # the public landing page
docker compose logs otp-relay | head # should print the bot's @username and the proxy label
```
A full login round-trip is the real check: request an OTP from the site and confirm the code arrives in
the Telegram chat. If it doesn't, `docker compose logs otp-relay` names the failing hop — a proxy error
and a Telegram API rejection look different.
---
## Redeploying
```bash
git pull
docker compose up -d --build
```
Rebuild the client whenever a `NEXT_PUBLIC_*` value in `client/.env.production` changes — those are
compiled into the browser bundle, so restarting the container alone changes nothing.
## Known wrinkle: the client lockfile is Windows-generated
`client/package-lock.json` is produced on Windows, where npm filters out wasm32-only optional packages
and therefore never records their transitive dependencies (`@emnapi/core`, `@emnapi/runtime`). On Linux
npm *does* want them, so a bare `npm ci` fails with:
```
npm error `npm ci` can only install packages when your package.json and package-lock.json ... are in sync.
npm error Missing: @emnapi/runtime@1.11.3 from lock file
```
The client Dockerfile works around this by completing the lock inside the image before installing. To fix
it permanently, regenerate the lock **on Linux** once and commit the result:
```bash
cd client
docker run --rm -v "$PWD:/app" -w /app node:24-alpine npm install --package-lock-only --no-audit --no-fund
```
Then drop the `npm install --package-lock-only` line from `client/Dockerfile`, leaving just `npm ci`.
Note `--omit=optional` is **not** a valid shortcut here: Turbopack resolves `@parcel/watcher`'s native
binary through `optionalDependencies`, so omitting them breaks `next build` with
`No prebuild or local build of @parcel/watcher found`.
## Persisted state
Two named volumes survive rebuilds. Uploaded verification documents live in the first one; losing it
means the admin verification queue shows broken documents.
| Volume | Holds |
| --- | --- |
| `api-object-storage` | Uploaded verification documents (local-disk `IObjectStorage` seam) |
| `api-logs` | Serilog JSON file sink |
View File
+18
View File
@@ -0,0 +1,18 @@
node_modules
.next
out
coverage
.swc
graphify-out
*.tsbuildinfo
# Local-only env files — .env.production IS copied, it is the deployed build's input.
.env
.env.local
.env.*.local
Dockerfile
.dockerignore
CLAUDE.md
AGENTS.md
README.md
+1 -1
View File
@@ -17,6 +17,6 @@ NEXT_PUBLIC_PUBLIC_URL = http://localhost:3000
# API/Backend basic URL (the Baya server)
NEXT_PUBLIC_API_URL = https://localhost:5002
NEXT_PUBLIC_API_URL = http://localhost:5002
# NEXT_PUBLIC_API_URL = https://dev-api.domain.com
# NEXT_PUBLIC_API_URL = https://api.domain.com
+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
+7 -1
View File
@@ -19,4 +19,10 @@ NEXT_PUBLIC_PUBLIC_URL = http://localhost:3000
# API/Backend basic URL (the Baya server)
NEXT_PUBLIC_API_URL = https://localhost:5002
# 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
The canonical agent guide for the frontend is **[CLAUDE.md](CLAUDE.md)** (same folder). It is the
engineering contract: stack, commands, lint/type gates, routing, providers, data fetching, theming,
i18n, cookies, and the rules every change must follow.
The canonical agent guide for the frontend is **[CLAUDE.md](CLAUDE.md)** (same folder): stack,
commands, the quality gates, the project structure, and the hard rules every change must follow.
- Current product truth (what to test, what's blocking, what's missing) → [../mvp/](../mvp/README.md)
- Reference rules, archived, read on demand per area → [../archive/docs/rules/client/](../archive/docs/rules/client/)
(structure · theme · components · forms · i18n · services · auth · testing)
- Repo-wide context → [../CLAUDE.md](../CLAUDE.md)
- Business rules in depth (archived) → [../archive/product/](../archive/product/index.md)
- Human setup/run instructions → [README.md](README.md)
- UI/design work → the **frontend-designer** skill
+145 -641
View File
@@ -1,30 +1,31 @@
# Balinyaar Client — Claude Code Guidelines
# Balinyaar Client
The web frontend of **Balinyaar**, a trust-first home-nursing marketplace in Iran. This file is the
**engineering contract** for everything under `client/`: providers, routing, data fetching, theming,
i18n, cookies, and the rules every change must follow.
The web frontend of **Balinyaar**, a trust-first home-nursing marketplace in Iran. Families search for,
book, pay for and review home nursing; nurses list configurable services and run their day from the same
app. Four actors share one mobile shell: family, nurse, admin, partner centre.
- Repo-wide context and the backend → root [CLAUDE.md](../CLAUDE.md).
- Product/domain rules (what to build) → [`product/`](../product/) — read the relevant doc before
designing a feature; don't infer business rules from code.
- Visual/design work (brand palette, tokens, component look-and-feel) → the **frontend-designer**
skill. It is the *design* contract and defers to this file for *engineering* rules. Don't restate
this file there.
> Last verified: 2026-07-30 against commit `d3ec723`.
- Repo-wide context and the backend → root [CLAUDE.md](../CLAUDE.md)
- Current product truth (what to test, what's blocking, what's missing) → [`mvp/`](../mvp/README.md)
- Business rules in depth (archived reference, not actively maintained) → [`archive/product/`](../archive/product/index.md).
**Read the relevant doc before designing a feature** — don't infer a business rule from code.
- Visual/design work → the **frontend-designer** skill. It is the *design* contract and defers to this file
and [`archive/docs/rules/client/`](../archive/docs/rules/client/) for engineering rules.
---
## Stack
- **Next.js 16** — App Router, Turbopack, React Server Components. **Not a static export** — the app
relies on server components, middleware, and server-side cookies. (`next.config.mjs` only wires the
next-intl plugin + `reactStrictMode`.)
- **React 19** + **TypeScript** (`strict`).
- **MUI v9** (`@mui/material`) for components and theming; **Emotion** underneath (RTL via
`stylis-plugin-rtl`).
- **next-intl v4** for i18n — locales `fa` (default, RTL) and `en`.
- **TanStack Query v5** for server state; a small **AuthContext** (React context + reducer,
`src/context/auth/`, seeded with server-read auth state) for auth/session state.
- **notistack** for toasts; **js-cookie** (wrapped) for client cookies.
- **Jest** + **Testing Library** for unit tests.
- Quality gates: **tsc**, **ESLint 9** (flat config), **Prettier**.
- **Next.js 16** — App Router, Turbopack, React Server Components. **Not a static export.**
- **React 19** + **TypeScript** (`strict`)
- **MUI v9** (`@mui/material`) with **Emotion** underneath; RTL via `stylis-plugin-rtl`
- **Lucide** (`lucide-react`) for icons, behind the `AppIcon` name registry
- **next-intl v4** — locales `fa` (default, RTL) and `en`
- **TanStack Query v5** for server state; a small **AuthContext** (`src/context/auth/`) for session state
- **react-hook-form v7** for every form with more than one field
- **notistack** for toasts; **js-cookie** (wrapped) for client cookies
- **Jest** + **Testing Library**; **ESLint 9** (flat config) + **Prettier**
## Commands
@@ -32,643 +33,146 @@ i18n, cookies, and the rules every change must follow.
| --- | --- |
| Dev server | `npm run dev` |
| Production build | `npm run build` |
| Type-check | `npm run type` |
| Lint | `npm run lint` |
| Lint + autofix | `npm run lint:fix` |
| **Type + lint (the gate)** | `npm run check` |
| Format (Prettier) | `npm run format` |
| Test (watch) | `npm test` |
| Test (CI, once) | `npm run test:ci` |
| **Type + lint + copy (the gate)** | `npm run check` |
| Type-check only | `npm run type` |
| Lint only / autofix | `npm run lint` · `npm run lint:fix` |
| Persian copy lint | `npm run lint:copy` |
| Format | `npm run format` |
| Test (watch / CI) | `npm test` · `npm run test:ci` |
**Always run `npm run check` before declaring work done.** Run `npm run test:ci` as well when you
touch a component that has a co-located `*.test.tsx`.
## Quality gates
## Quality gates: lint & type (how they work)
```
npm run check # tsc --noEmit → eslint . → scripts/check-copy.mjs
npm run test:ci # also required when you touched a component with a co-located *.test.tsx
```
Both gates are plain CLI tools. **There is no `next lint`** — it was removed in Next 16; calling it
silently does nothing.
Both must be green, and `en.json`/`fa.json` must be in sync, before work is done. There is **no
`next lint`** — it was removed in Next 16 and calling it silently does nothing.
- `npm run type``tsc --noEmit`. Config in `tsconfig.json`: `strict` on, `noEmit`, `@/*``src/*`.
- `npm run lint``eslint .` driven by **flat config** in `eslint.config.mjs`. That config spreads
`eslint-config-next` (core-web-vitals + typescript + react + react-hooks + jsx-a11y + import) and
applies `eslint-config-prettier` last so ESLint never fights Prettier on formatting.
- `npm run check` runs type then lint. Keep it green.
---
Rules for this project:
- **This project is flat-config only.** Do not add `.eslintrc*` files — put any rule changes in
`eslint.config.mjs`.
- **ESLint owns correctness, Prettier owns formatting.** Don't add stylistic ESLint rules.
- **No unused variables or imports.** `@typescript-eslint/no-unused-vars` is raised from
eslint-config-next's default `warn` to **`error`** (in `eslint.config.mjs`), so dead code fails
`npm run check`. Delete unused code rather than disabling the rule; prefix a deliberately-unused
binding with `_` (e.g. `_event`, `catch (_err)`) to opt out.
- **Prefer fixing code over silencing the linter.** When a disable is genuinely correct — e.g. a
deliberate browser-only read after mount that trips `react-hooks/set-state-in-effect` — use a
scoped `// eslint-disable-next-line <rule>` with a one-line reason, never a file-wide disable.
- **Pin to ESLint 9.** ESLint 10 currently crashes with this Next 16 toolchain
(`scopeManager.addGlobals is not a function`). `import/no-cycle` is also disabled — its TS resolver
has an interface mismatch here (see the note in `eslint.config.mjs`).
## Hard rules
## Golden rules (the short list)
A change is "done" only if it respects all of these — each has a full section below.
1. **Never add a layout above `[locale]`.** `src/app/[locale]/layout.tsx` is the root layout (it
renders `<html>`/`<body>`). A layout above it freezes `lang`/`dir`/messages on the default locale.
1. **Never add a layout above `[locale]`.** `src/app/[locale]/layout.tsx` **is** the root layout — it renders
`<html>`/`<body>`. A layout above it freezes `lang`/`dir`/messages on the default locale for every route.
2. **Respect the server/client boundary.** Never import `next/headers`, `next-intl/server`, or
`@/lib/cookies/server` from a client component; never import `@/lib/cookies/client` from an RSC.
3. **No hard-coded UI strings.** Every user-visible string is a key in **both** `messages/en.json`
and `messages/fa.json`.
4. **Fetch only through `clientFetch`/`serverFetch`** (`@/lib/api`) — never raw `fetch()`. Domain
calls live in `src/services/{domain}/apis/`.
5. **Cookies only through the cookie manager** (`@/lib/cookies/*`) — never `document.cookie`,
`js-cookie`, `localStorage`, or `sessionStorage` for app/auth state.
6. **Colors come from `tokens.css`** (`var(--…)`), never hard-coded in `sx`. Use the pre-built
`APP_THEME_LTR`/`APP_THEME_RTL`; never call `createTheme()` in a component.
7. **MUI v9 API only.** Use `sx={{ mb: 4 }}`, not `mb={4}` as a direct prop. No MUI-v5/v6-only props
(`useFlexGap`, `flexWrap` on `Stack`, `storageWindow`, `InitColorSchemeScript`, …).
8. **Shared components get a co-located `*.test.tsx`.** (A component imported from >1 place.)
9. **Magic strings become named constants** (`src/constants/` or a co-located `constants.ts`).
10. **`npm run check` is green** and translations stay in sync before you finish.
11. **No dead code; comment the *why*, not the *what*.** Unused vars/imports are lint errors — remove
them. Don't add comments that restate the code; comment only a non-obvious decision, constraint, or
trade-off. See **Comments & dead code** below.
3. **No hard-coded UI strings.** Every user-visible string is a key in **both** `messages/en.json` and
`messages/fa.json`. The one exception is `app/global-error.tsx`, which cannot use next-intl.
4. **Fetch only through `clientFetch`/`serverFetch`** (`@/lib/api`) — never raw `fetch()`. Domain calls live in
`src/services/{domain}/apis/`.
5. **Cookies only through the cookie manager** (`@/lib/cookies/*`) — never `document.cookie`, `js-cookie`
directly, `localStorage`, or `sessionStorage` for app or auth state.
6. **Colors come from `tokens.css` (`var(--bal-*)`) or MUI palette keys** — never a hex or rgb literal in `sx`
or `styled`. Use the pre-built `APP_THEME_LTR`/`APP_THEME_RTL`; **never call `createTheme()` in a
component.**
7. **MUI v9 API only.** `sx={{ mb: 4 }}`, not `mb={4}`. No v5/v6-only props (`useFlexGap`, `flexWrap` on
`Stack`, `storageWindow`, `InitColorSchemeScript`).
8. **RTL-safe.** Never `marginLeft`, `left:`, or `textAlign: 'left'` for layout flow — use logical or
MUI-flipped properties. `fa` is the default locale and it is RTL.
9. **Never add a pre-paint color-scheme script.** The no-flash boot is CSS-only; extend the `tokens.css`
media-query fallback instead.
10. **`prefers-reduced-motion` has exactly one gate**, in `src/app/globals.css`. Never add a second,
component-local branch.
11. **One layout: a phone.** `AppFrame` caps every screen at 480px at every viewport. Never add a `≥md` branch
that widens a shell, restores a sidebar, or goes multi-column. Wide content scrolls inside its own
container.
12. **Navigation is the bottom bar; there is no drawer.** Add a destination by adding a tab or a hub row, never
by forking `MobileShell`. Active state comes from the shared `matchActivePath`, never a hand-rolled
`pathname.startsWith`.
13. **All chrome navigation goes through `@/i18n/navigation`** — never a raw `next/link`, never a manual
`` `/${locale}` `` prefix.
14. **Icons come from the `AppIcon` registry by lowercase name.** `@mui/icons-material` was removed — never
reintroduce it, and never pass a raw icon component where a name is expected.
15. **Any form with more than one field uses react-hook-form**, bound through the
`@/components/common/form` wrappers. Never call `register`/`useController` at a call site.
16. **Never append `ROUTES.HOME` (`'/'`) to `PUBLIC_PATHS`** — it is matched with `startsWith`, so that would
silently make every route public.
17. **The middleware auth check is UX-only, not a security boundary** — it does not verify the JWT signature.
Never gate real authorization on it, on `isTokenAlive`, or on `useAdminCapabilities`.
18. **The client displays money; it never computes it.** IRR digit strings parsed with integer-safe `BigInt`
helpers, never a float. Never compute a rate, an aggregate, a payout date, or a holiday shift. A
server-frozen deadline is rendered, never recomputed. A signed balance is never clamped.
19. **Never leak clinical data.** The customer never fires the care-instructions query; the nurse's care-record
access is append-only; access-denied is gated *before* any clinical fetch; `is_internal` is never modelled
in user-app types. Clinical text is never logged, stored in `localStorage`, or put in a query string.
20. **Every shared component has a co-located `*.test.tsx`** (shared = imported from more than one place).
Don't mock MUI — test the rendered DOM.
21. **No dead code.** `@typescript-eslint/no-unused-vars` is an **error**, so it fails the gate. Delete it;
prefix a deliberately-unused binding with `_`. Comment the *why*, never the *what*.
22. **Every mutation needs an `onError` toast** unless the failure is already surfaced inline. But never toast
401/403/5xx in a hook — `clientFetch` already does.
23. **Magic strings become named constants** (`src/constants/`, or a co-located `constants.ts`).
24. **Don't reintroduce starter scaffolding** or `_TITLE_`/`_DESCRIPTION_` placeholders.
25. **When you change the structure, update "Project structure" below in the same change.**
## Project Structure
---
**This section is the canonical description of the client's architecture.** When a change adds, removes,
or renames a route group, provider, or top-level `src/` folder, update this tree in the same change
(root `CLAUDE.md` working agreement #7).
## Project structure
The canonical map of the frontend's architecture. Expanded, with the reasoning, in
[`docs/rules/client/structure.md`](../archive/docs/rules/client/structure.md).
```
client/
├── messages/ # Translation files (add keys to BOTH files)
│ ├── en.json
│ └── fa.json
├── middleware.ts # next-intl routing middleware (locale detection + redirect)
├── next.config.mjs # createNextIntlPlugin wires i18n into Next.js
├── messages/{en,fa}.json translations — add every key to BOTH
├── middleware.ts i18n routing → guest front door → auth gate
├── next.config.mjs next-intl plugin + reactStrictMode, nothing else
└── src/
├── app/
│ ├── globals.css
│ ├── fonts/ # Local font files (woff2) — Mikhak for fa
├── app/ the App Router tree
│ ├── global-error.tsx above [locale]: renders its own <html>, cannot use next-intl
│ ├── robots.ts · sitemap.ts the public surface, both locales
│ ├── fonts/ Mikhak woff2 (next/font/local resolves relative to the caller)
│ └── [locale]/
│ ├── layout.tsx # ROOT RSC: renders <html lang/dir> + fonts + setRequestLocale + NextIntlClientProvider + ThemeProvider + AuthProvider (seeded via getServerAuthState)
├── (private-routes)/
│ ├── layout.tsx # 'use client' — wraps PrivateLayout; mounts useSessionRoleSync (hydrates AuthContext roles from /me)
│ ├── select-role/page.tsx # /select-role — first-use role picker (no public role yet); role router lands here
│ │ ├── (customer)/ # Customer (family) app — mobile-first, bottom-tab nav; no URL segment
│ │ │ ├── layout.tsx # 'use client' — wraps CustomerLayout
│ │ │ ├── page.tsx # / (A5 home — 'use client'; greeting+avatar, search bar, data-driven category grid, first-login onboarding gate + record/profile nudges)
│ │ │ ├── search/page.tsx # /search — DEFERRED→f6 stub (PlaceholderScreen; Home search bar + category tiles land here carrying q/category_id)
│ │ │ ├── onboarding/page.tsx # /onboarding — A3→A4 wizard (relation → first patient)
│ │ │ ├── bookings/page.tsx # /bookings
│ │ │ ├── patients/page.tsx # /patients — E1 list/CRUD (add/edit dialog reusing PatientForm, soft-archive)
│ │ ├── addresses/page.tsx # /addresses — F3 address book (cascading region dropdowns + map-pin picker, set-primary)
│ │ │ ├── wallet/page.tsx # /wallet
│ │ │ ── profile/page.tsx # /profile — customer profile + emergency contact (no national-ID)
├── nurse/ # Nurse app (/nurse/…) — sidebar shell
│ │ │ ├── layout.tsx # 'use client' — wraps NurseLayout
│ │ │ ├── page.tsx # /nurse (dashboard)
│ │ │ ├── profile/page.tsx # /nurse/profile — B7 profile bootstrap (avatar+bio+years; unverified placeholder)
│ │ │ ├── services/ # /nurse/services — B7 services half: offerings list ↔ variant builder (page.tsx switches mode; MyServicesList + VariantBuilder co-located)
│ │ ├── coverage/page.tsx # /nurse/coverage — F3 coverage-area editor (whole-city/district areas, dup-blocked)
│ │ │ ├── bank/page.tsx # /nurse/bank — payout IBAN + ownership states (pending/verified/mismatch)
├── verification/page.tsx # /nurse/verification
│ │ │ └── visits/page.tsx # /nurse/visits (EVV)
│ │ └── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell
│ │ ├── layout.tsx # 'use client' — wraps AdminLayout
│ │ ├── page.tsx # /admin (overview)
│ │ ├── users/page.tsx # /admin/users
│ │ └── notifications/page.tsx # /admin/notifications
│ └── (public-routes)/
│ ├── layout.tsx # 'use client' — wraps PublicLayout
│ └── login/page.tsx # /login — phone-OTP login (A1/A2 customer, B1/B2 nurse switch)
├── components/ # Shared UI components (each with .test.tsx if imported >1 place)
│ ├── PlaceholderScreen/ # Empty-state scaffold for not-yet-built screens
│ ├── OtpInput/ # OTP code input (auto-advance, paste, RTL-safe)
│ ├── PhoneNumberField/ # Iranian mobile field (digit-normalizing, LTR-in-RTL, maskIranMobile)
│ ├── StepperHeader/ # Progress header for onboarding/verification flows
│ ├── StatusChip/ # Semantic status chip (verified/pending/rejected/…) off --bal-* tokens
│ ├── GenderToggle/ # Required male/female toggle (never defaulted) — drives same-gender matching
│ ├── ConditionChips/ # Multi-select patient-condition chips (stable codes, translated labels)
│ ├── RelationSelect/ # Single-select relation radio cards (parent/spouse/child/self)
│ ├── PatientForm/ # A4 patient form (name/age/gender/conditions/relation) — reused create+edit
│ ├── PatientCard/ # E1 patient summary card + edit/archive actions
│ ├── BankStatusPanel/ # Nurse bank-account ownership state (pending/verified/mismatch), masked IBAN
│ ├── CategoryTile/ # f4 tappable service-category tile (icon+label; `selected` state for the builder) — Home grid + builder step 1 (tested)
│ ├── PriceDisplay/ # f4 price renderer: money-util Toman + i18n unit label + unit-aware estimated total (never a total from price alone) (tested)
│ ├── VariantCard/ # f4 nurse offering card: display_name, PriceDisplay, active/deactivated distinction, edit/deactivate (no delete) (tested)
│ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested)
│ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown
├── i18n/
│ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa'
│ └── request.ts # getRequestConfig — loads messages/${locale}.json
├── layout/
│ ├── PrivateLayout.tsx # authenticated wrapper (passthrough today); actor chrome lives in the shells below
│ ├── CustomerLayout.tsx # 'use client' — customer shell: TopBar + BottomBar (5-tab); useTranslations('nav')
│ ├── NurseLayout.tsx # 'use client' — nurse shell via TopBarAndSideBarLayout; useTranslations('nav')
│ ├── AdminLayout.tsx # 'use client' — admin shell via TopBarAndSideBarLayout (persistent sidebar)
│ ├── PublicLayout.tsx # unauthenticated shell
│ ├── TopBarAndSideBarLayout.tsx # 'use client' — TopBar + SideBar composition (nurse/admin engine)
│ ├── config.ts
│ ├── index.ts
│ └── components/
│ ├── TopBar.tsx
│ ├── SideBar.tsx
│ ├── SideBarNavList.tsx
│ ├── SideBarNavItem.tsx
│ ├── DarkModeButton.tsx # 'use client' — only subscriber to useColorScheme()
│ └── index.tsx
├── lib/
│ ├── api/
│ │ ├── client.ts # clientFetch<T> — throws ApiError on error; use in hooks/client components; silent-refreshes + retries once on 401
│ │ ├── server.ts # serverFetch<T> — throws ApiError on error; use in RSCs/Server Actions
│ │ ├── types.ts # ApiEnvelope<T> + unwrap(), Paginated<T>, PageParams — shared wire types
│ │ ├── refresh.ts # attemptTokenRefresh — single-flight silent refresh used by clientFetch's 401 branch
│ │ └── errors.ts # ApiError class (status, message, code)
│ ├── auth/
│ │ ├── token.ts # decodeJwtPayload / isTokenAlive — edge-safe, shared with middleware (no next/headers)
│ │ ├── session.ts # persistAuthTokens / clearAuthTokens — client token-cookie writers (shared by auth hooks + fetch refresh)
│ │ └── server.ts # getServerAuthState — access-token cookie → AuthState for AuthProvider
│ ├── query/
│ │ ├── queryClient.ts # makeQueryClient factory + getQueryClient() SSR-safe singleton
│ │ └── QueryProvider.tsx # 'use client' — QueryClientProvider + ReactQueryDevtools
│ └── cookies/ # Cookie manager — strict server/client separation
│ ├── constants.ts # COOKIE_NAMES, CookieOptions, AUTH_*_COOKIE_OPTIONS
│ ├── server.ts # getServerCookie, getThemeMode, setServerCookie
│ ├── client.ts # getClientCookie, setClientCookie, deleteClientCookie
│ └── index.ts # Re-exports constants ONLY (never server/client)
├── services/ # Domain services — no top-level barrel; import directly from the file
│ ├── auth/ # Phone-OTP auth: requestOtp/verifyOtp/refresh/logout/me/selectRole + role router (routing.ts) + useSessionRoleSync
│ ├── patients/ # Care-recipient CRUD (b3 PatientDto + client-augmented relation/conditions), soft-archive; age.ts helper
│ ├── profiles/ # Customer + nurse profile get/upsert + avatar (behind the ProfilesApi seam)
│ ├── nurse/ # Nurse payout bank accounts + IBAN(Sheba) util (iban.ts) + ownership-inquiry states
│ ├── geography/ # F3 cached province→city→district reference lookups (Infinity staleTime, shared geographyKeys; reused by addresses, coverage & later search)
│ ├── addresses/ # F3 customer address book CRUD + set-primary (single-primary invariant; invalidate-on-mutation)
│ ├── serviceAreas/ # F3 nurse coverage areas add/remove (areaExists dup-guard; districtId=null = whole city)
│ ├── catalog/ # F4 catalog skeleton + nurse pricing variants (b5). Reference data (categories, category option groups) cached session-long like geography (Infinity staleTime); myVariants invalidated on mutation. useServiceCategories/useCategoryOptionGroups/useMyVariants/useCreateVariant/useUpdateVariant/useSetVariantActive; seam+mock+client; names.ts locale-label helper
│ └── {domain}/
│ ├── types.ts # Request/response types + the domain's Api interface (the seam)
│ ├── keys.ts # React Query key factory (hierarchical)
│ ├── constants.ts # Mock toggle + staleTime (when the domain has a mock)
│ ├── apis/
│ │ ├── clientApi.ts # Real impl wrapping clientFetch (unwraps ApiEnvelope via unwrap())
│ │ ├── mockApi.ts # In-memory impl behind the same interface (until the endpoint lands)
│ │ ├── serverApi.ts # serverFetch calls (only when an RSC needs it)
│ │ └── index.ts # Selects real vs mock by config — the seam hooks import
│ └── hooks/
│ └── use{Action}.ts # One hook per file — useQuery (deliberate staleTime) or useMutation (invalidates)
├── context/ # React context providers
│ └── auth/ # AuthContext — AuthProvider (server-seeded) + reducer + useAuth
├── theme/
│ ├── ThemeProvider.tsx # MuiThemeProvider wrapper (RTL cache) + ColorSchemeCookieSync
│ ├── colors.ts # BRAND, LIGHT_PALETTE, DARK_PALETTE
│ ├── light.ts / dark.ts # LIGHT_THEME / DARK_THEME ThemeOptions (consumed by theme.ts)
│ ├── direction.ts # getDirection(locale) → 'ltr' | 'rtl'
│ ├── theme.ts # APP_THEME_LTR / APP_THEME_RTL (static, created once)
│ ├── tokens.css # CSS custom properties — [data-mui-color-scheme] selectors
│ ├── typography.ts # TYPOGRAPHY_LTR (Space Grotesk) / TYPOGRAPHY_RTL (Mikhak)
│ └── index.ts # Public re-exports (ThemeProvider, getDirection, APP_THEME_*) — note: no ColorSchemeScript is exported/rendered today (doc drift below)
├── constants/ # App-wide constants (routes.ts w/ actor paths, roles.ts, headers.ts)
├── hooks/ # incl. auth.ts → useIsAuthenticated / useActorRole (role-aware chrome)
├── utils/ # incl. money.ts (IRR/Toman, integer-safe) + date.ts (Shamsi display) + toEnglishDigits
└── config.ts
│ ├── layout.tsx THE ROOT LAYOUT — <html lang dir>, fonts, setRequestLocale,
│ providers, generateMetadata + metadataBase
├── error.tsx · not-found.tsx · [...rest]/page.tsx
├── (private-routes)/ layout mounts useSessionRoleSync
│ │ ├── _chrome/ shared content skeleton (private, not a route)
│ │ ├── select-role/ first-use role picker, in FocusedLayout
│ │ ├── (customer)/ the family app — no URL segment
│ │ ├── (customer-focused)/ chrome-free, same URL space (onboarding)
│ │ ├── nurse/ the nurse app
│ │ ├── admin/ the backoffice (+ _hub/ shared group-root body)
│ │ └── partner/ the partner-centre portal — a SEPARATE authz scope
└── (public-routes)/ login · terms · privacy · welcome
├── components/ common/ primitives + one folder per domain composite family
── constants/ routes.ts · roles.ts · headers.ts · policy.ts
├── context/auth/ AuthContext (provider + reducer + useAuth)
├── hooks/ auth.ts · capabilities.ts · layout.ts · useAdminListState.ts
├── i18n/ routing.ts · request.ts · navigation.ts
├── layout/ AppFrame · MobileShell · the 4 actor layouts · chrome · config.ts
├── lib/ api/ · auth/ · cookies/ · query/ · toast/
├── services/ 22 domain services — the data layer
├── theme/ colors.ts · tokens.css · typography.ts · theme.ts · ThemeProvider.tsx
├── utils/ money · date · number · text · toCsv · navigation
└── config.ts API_URL · SITE_URL · NESHAN_WEB_KEY · IS_DEBUG
```
---
Route groups (parenthesised) add no URL segment; `_`-prefixed folders are private, not routes. Each private
group's layout is `'use client'` and wraps `RoleGuard` → that actor's layout.
## Server / Client Component Boundaries
**There is NO `src/app/layout.tsx`.** `src/app/[locale]/layout.tsx` is the application's **root layout** — it renders `<html>` and `<body>`. This is intentional and load-bearing (see below); do not re-introduce a layout above the `[locale]` segment.
**Root / locale layout** (`src/app/[locale]/layout.tsx`) is an RSC that owns the document shell, all i18n, and theme context. It:
- Sources the locale from the **URL param** (`params.locale`), validated against `routing.locales` (falls back to `defaultLocale`). No header reads.
- Renders `<html lang dir>` (`dir` from `getDirection(locale)`) plus `data-mui-color-scheme` from `getThemeMode()`.
- Loads the Mikhak font and attaches its CSS-variable class to `<html>` **only for `fa`** (see Fonts).
- Calls `setRequestLocale(locale)` so server components deeper in the tree can call `getLocale()` / `getTranslations()` reliably.
- Calls `getMessages({ locale })` with the locale passed **explicitly** so `getRequestConfig` receives it via `Promise.resolve(locale)` (not through the React.cache read), avoiding any cache-ordering race.
- Wraps children with `NextIntlClientProvider`, `AuthProvider` (seeded with server-read auth state), and `ThemeProvider`.
- Exports `generateStaticParams` so Next.js can enumerate locale routes at build time.
**WHY `<html>` MUST live in `[locale]/layout.tsx` and not a layout above it**: a layout above the `[locale]` segment is *shared* between `/fa` and `/en`. Next.js statically caches it at build time with `defaultLocale` ('fa') and never re-renders it on a client-side locale switch (the segment doesn't change). Its `lang`/`dir`/messages therefore freeze on 'fa'/'rtl' for every route, including `/en`. The `[locale]` layout is the lowest boundary keyed on the locale param, so it is the only place where `<html lang dir>` reliably tracks the active locale.
**Route-group layouts** (`(private-routes)/layout.tsx`, `(public-routes)/layout.tsx`) are `'use client'` — they only wrap a layout component and need no server capabilities.
**Never** import from `next/headers`, `next-intl/server`, or `@/lib/cookies/server` in a client component. The build will fail.
**Every screen is a thin RSC `page.tsx` (exporting `generateMetadata`) plus a co-located `'use client'`
`<PageName>Screen.tsx`.** `page.tsx` never renders `<title>` and never touches `document.title`.
---
## i18n (next-intl v4)
**Adding translations:**
1. Add the key to `messages/en.json` AND `messages/fa.json`. Both files must always be in sync.
2. Top-level keys are namespaces: `"nav"`, `"common"`, etc.
**Using translations in client components:**
```tsx
import { useTranslations } from 'next-intl';
function MyComponent() {
const t = useTranslations('nav'); // namespace
return <span>{t('home')}</span>; // key
}
```
**Using translations in Server Components:**
```tsx
import { getTranslations } from 'next-intl/server';
async function MyServerComponent() {
const t = await getTranslations('nav');
return <span>{t('home')}</span>;
}
```
**Established namespaces and where they're used:**
- `'nav'` — the actor shells (`CustomerLayout`/`NurseLayout`/`AdminLayout`) build their nav from here
- `'common'``DarkModeButton.tsx` (dark/light labels), shared words (loading, retry, currency_toman, …)
- `'shell'` — actor-shell titles + the not-yet-built placeholder body
- `'patients'` — the E1 patient list/CRUD (list, card, add/edit dialog, archive)
- `'onboarding'` — the A3→A4 wizard + the shared enum labels (relation/condition/gender codes → labels)
- `'home'` — the A5 family home (greeting + avatar, search bar, category grid, record/profile nudges)
- `'profile'` — the customer profile + emergency contact
- `'nurseProfile'` — the nurse B7 profile bootstrap (photo/bio/years + unverified placeholder)
- `'bank'` — the nurse payout bank settings (IBAN form + the three ownership states)
- `'geo'` — the shared cascading province→city→district dropdowns (`CascadingRegionSelect`: level labels, "whole city", cascade hints)
- `'address'` — the customer address book + add/edit form (title/street, map-pin helper, set-primary, empty/delete states) + the profile-hub link
- `'coverage'` — the nurse coverage-area editor (whole-city/specific-district scope, chips, duplicate + "won't appear in search" warnings)
- `'catalog'`**shared** catalog vocabulary: the five `price_unit` labels + count nouns + the estimated-total label (read by `PriceDisplay`; f6 reuses it customer-side)
- `'services'` — the f4 nurse Services & prices surface (offerings list, the variant builder steps/fields/validation, the duplicate-listing warning, deactivate confirm)
- `'search'` — the f4 deferred `/search` placeholder (title + "arrives next phase" + query/category echo); f6 fills it out
- `'auth'` — the phone-OTP login flow, role router, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark)
**Namespace conventions for the phases to come** (seed each when its feature lands, in both locale
files): `onboarding`, `verification`, `search`, `booking`, `payment`, `bnpl`, `reviews`,
`notifications`, `admin`. Keep top-level keys as namespaces and both files in sync.
**Never hard-code UI strings in English.** Any user-visible text must have a translation key in both locale files.
---
## Cookie Manager
The cookie manager in `src/lib/cookies/` is split into three files to prevent cross-environment bundling:
| File | Use from | Purpose |
|------|----------|---------|
| `constants.ts` | anywhere | `COOKIE_NAMES`, `CookieOptions`, `COLOR_SCHEME_COOKIE_OPTIONS` |
| `server.ts` | Server Components, Server Actions, Route Handlers only | `getServerCookie`, `getThemeMode`, `setServerCookie` |
| `client.ts` | client components / `useEffect` only | `getClientCookie`, `setClientCookie`, `deleteClientCookie` |
| `index.ts` | anywhere | Re-exports `constants.ts` only — safe barrel |
**Rules:**
- Import constants via the barrel: `import { COOKIE_NAMES } from '@/lib/cookies'`
- Import server utils directly: `import { getThemeMode } from '@/lib/cookies/server'`
- Import client utils directly: `import { setClientCookie } from '@/lib/cookies/client'`
- Never import `server.ts` in a client component; never import `client.ts` in an RSC.
- `COOKIE_NAMES.COLOR_SCHEME = 'color-scheme'` — the single source of truth for the theme cookie name. Do not redeclare it anywhere.
---
## Constants
**Rule: every magic string or configurable value must be a named constant — never inline.**
A value is "magic" if its meaning isn't obvious from the literal alone: cookie names, event names, localStorage keys, route paths, query-param names, numeric timeouts, API endpoint slugs.
Where to define:
- **Cookie names / options**: `src/lib/cookies/constants.ts`
- **Feature-scope constants**: co-locate in a `constants.ts` next to that feature's files
- **App-wide constants** (used across multiple features): `src/constants/` — one file per concern (`routes.ts`, `events.ts`, etc.)
Rules:
1. Import the constant; never copy-paste the string value.
2. When renaming, update the constant definition — the rest of the codebase follows automatically.
---
## Theme System
### How it works (end-to-end, no-flash)
1. **Request arrives**`getThemeMode()` reads `'color-scheme'` cookie → returns `{ colorScheme, defaultMode }`
2. **Root layout** sets `data-mui-color-scheme={colorScheme}` on `<html>` server-side
3. **`<ColorSchemeScript />`** in `<head>` runs before any paint:
- Reads the same cookie, sets `data-mui-color-scheme` (handles edge cases where server attr might differ)
- Patches `Storage.prototype` — routes MUI's `localStorage` writes for key `'mode'` to our cookie; reads return `null` so MUI always trusts the `defaultMode` prop
4. **`<MuiThemeProvider defaultMode={defaultMode}>`** mounts — uses the server-derived mode, not localStorage
5. **`ColorSchemeCookieSync`** in ThemeProvider writes the cookie via `useColorScheme().colorScheme` on mount (safety net for first-visit system mode)
### Critical MUI v9 rules
**`colorSchemeSelector` must be the explicit attribute name:**
```ts
// theme.ts
cssVariables: {
colorSchemeSelector: 'data-mui-color-scheme', // CORRECT
// colorSchemeSelector: 'data', // WRONG — produces boolean data-dark/data-light
},
```
The shorthand `'data'` in MUI v9 generates `[data-%s]``data-dark=""` / `data-light=""` (boolean attributes). Our `tokens.css` uses `[data-mui-color-scheme="dark"]` which never matches boolean attributes. Always use the explicit attribute name.
**Never use `storageWindow={null}`:**
In MUI v9's `localStorageManager`, the check is `if (!storageWindow && typeof window !== 'undefined')``null` is falsy, so it silently overrides to `window`. This prop is a no-op in browsers. The `Storage.prototype` patch in `ColorSchemeScript` is the correct intercept.
**Never use MUI's `InitColorSchemeScript`:**
It reads from localStorage which diverges from our cookie (especially in 'system' mode). Use `ColorSchemeScript` from `@/theme` instead.
**MUI v9 localStorage key defaults (different from v5/v6):**
- Mode key: `'mode'` (was `'mui-mode'`)
- Color scheme key: `'color-scheme'` (was `'mui-color-scheme'`)
- HTML attribute: `'data-color-scheme'` (was `'data-mui-color-scheme'`)
We override all of these via `colorSchemeSelector: 'data-mui-color-scheme'` in the theme and the Storage.prototype patch.
### Color tokens
All theme-aware colors live in `src/theme/tokens.css` under `[data-mui-color-scheme]` selectors. Do not add color values to inline `sx` props or component styles — add a CSS variable to `tokens.css` and reference it via `var(--my-token)`.
This includes feedback colors: `--bal-success`, `--bal-error`, `--bal-warning`, `--bal-info` (each with a `*-contrast` text token). These drive the toast variants (see Toast Notifications) and are the place to source any success/error/warning/info color — the MUI palette does **not** define semantic colors, so prefer these tokens over MUI's defaults for brand consistency.
### Pre-built theme objects
`APP_THEME_LTR` and `APP_THEME_RTL` are created once at module load. Never call `createTheme()` inside a component or hook — pass the appropriate pre-built theme to `MuiThemeProvider`.
### Toggle components
`DarkModeToggleButton` and `DarkModeFormSwitch` in `src/layout/components/DarkModeButton.tsx` are the **only** components that subscribe to `useColorScheme()`. When the user toggles:
1. `setMode('dark')` is called
2. `Storage.prototype.setItem` intercept fires → writes `'color-scheme'='dark'` cookie synchronously
3. MUI sets `data-mui-color-scheme="dark"` on `<html>`
4. CSS variables resolve → browser repaints. No React re-render above the button.
Use `colorScheme` (not `mode`) for the `isDark` check — `mode` can be `'system'` even when dark is active.
---
## Direction (RTL / LTR)
Derived from locale via `getDirection(locale)` in `src/theme/direction.ts`:
- RTL locales: `fa`, `ar`, `he`, `ur`
- All others: `ltr`
`ThemeProvider` accepts a `dir` prop and selects the matching pre-built theme (`APP_THEME_RTL` for RTL). The RTL Emotion cache uses `stylis-plugin-rtl` to mirror all generated CSS.
`src/app/[locale]/layout.tsx` sets `dir={dir}` on `<html>` and passes `dir` to `ThemeProvider`. Because that layout is keyed on the `[locale]` URL param, changing locale re-renders it with a fresh `dir` — on both hard and soft navigation, no client-side state. **Do not** move the `<html dir>` render to a layout above `[locale]`; such a layout is shared across locales, gets statically cached with the default locale, and `dir` freezes on 'rtl' for `/en`.
**Default locale is `fa` (RTL).** The middleware redirects bare `/` to `/fa/`. English is explicitly accessed at `/en/`.
---
## Fonts
Fonts are loaded **per locale** — the Persian face is never shipped to English pages:
| Locale | Font | CSS variable | Source | Loaded when |
|--------|------|--------------|--------|-------------|
| `fa` (RTL) | **Mikhak** | `--font-mikhak` | `next/font/local` — woff2 files in `src/app/fonts/` | only on `fa` routes |
| `en` (LTR) | **Space Grotesk** | `--font-space-grotesk` | (not currently wired — falls back to the system stack) | — |
**Typography exports:**
- `TYPOGRAPHY_LTR` — Space Grotesk headings, system font body (used by `APP_THEME_LTR`)
- `TYPOGRAPHY_RTL` — Mikhak for all text including body (used by `APP_THEME_RTL`, ensures full Persian glyph coverage)
- `TYPOGRAPHY` — alias for `TYPOGRAPHY_LTR` (deprecated, prefer the explicit exports)
**Rules:**
- Mikhak is declared with `preload: false`, and its `.variable` class is attached to `<html>` **only when `locale === 'fa'`**. Both are required: a `next/font` loader called in the root layout would otherwise preload on every route (including `/en`), and `preload: false` ensures the woff2 only downloads when Persian text actually renders.
- Font files live in `src/app/fonts/` (not `public/`). next/font/local resolves paths relative to the calling file (`src/app/[locale]/layout.tsx`) at build time.
- Never load fonts inside components — all font loading lives in `src/app/[locale]/layout.tsx`.
- To add a new font, add woff2 files to `src/app/fonts/`, declare via `localFont`/`localFont`-equivalent in `src/app/[locale]/layout.tsx`, attach its `.variable` class conditionally on the matching locale, and update `BRAND_FONT_VARIABLE_*` constants in `typography.ts`.
---
## Unit Testing
**Rule: every shared component must have a co-located test file.**
A component is "shared" if it is imported from more than one place (page, layout, or other component).
Coverage baseline for shared components:
1. It renders without crashing.
2. Every documented prop produces the correct HTML attribute or CSS class.
3. User interactions (click, change) call the expected callbacks.
Test location: `src/components/ComponentName/ComponentName.test.tsx` next to the component.
Test wrapper: wrap with `<ThemeProvider>` if the component uses MUI theming.
Do NOT mock MUI components — test against the rendered DOM.
Enforcement: before removing or renaming a shared component, check whether `src/**/*.test.{ts,tsx}` files import it. If so, update or delete those tests too.
---
## Comments & dead code
- **No dead code.** Unused variables, imports, parameters, and private members are lint errors
(`@typescript-eslint/no-unused-vars`, raised to `error` — see *Quality gates*). Delete them; don't
comment them out and don't silence the rule. Prefix a deliberately-unused binding with `_` to opt out.
- **Comment the *why*, never the *what*.** Code should read for itself — a comment that restates what
the code already says is noise. Don't write `// set the access token` above `setClientCookie(...)`, or
JSDoc that just echoes a function's name.
- **Do** add a tight comment when a decision is genuinely non-obvious from the code: a workaround for a
framework quirk, a business rule, an ordering or security constraint, a deliberate deviation. Explain
*why it is this way*. The comments in `src/app/[locale]/layout.tsx` (why `<html>` lives in the
`[locale]` layout) and `src/lib/auth/token.ts` (why the JWT `exp` check is UX-only, never a security
boundary) are the model to follow.
- Prefer a clearer name or a small helper over a comment whenever that removes the need for it.
## Anti-patterns (do not do these)
- **Do not** read `localStorage` or `document.cookie` in render functions — use `useEffect` or server-side `cookies()` from `next/headers`.
- **Do not** call `createTheme()` inside a component or hook — use `APP_THEME_LTR` / `APP_THEME_RTL`.
- **Do not** use `storageWindow={null}` on `MuiThemeProvider` — it is silently ignored in MUI v9.
- **Do not** use `InitColorSchemeScript` from MUI — use `ColorSchemeScript` from `@/theme`.
- **Do not** set `colorSchemeSelector: 'data'` — use `'data-mui-color-scheme'`.
- **Do not** check `mode === 'dark'` for "is dark active" — use `colorScheme === 'dark'`.
- **Do not** hard-code UI strings — add translation keys to both `messages/en.json` and `messages/fa.json`.
- **Do not** add a `src/app/layout.tsx` or any layout above the `[locale]` segment. Such a layout is shared across locales, gets statically cached at build time with `defaultLocale` ('fa'), and never re-renders on a locale switch — so `<html lang/dir>`, messages, providers, and fonts placed there freeze on 'fa'/'rtl' for `/en`. `src/app/[locale]/layout.tsx` is the root layout (it renders `<html>`/`<body>`) precisely because it is the lowest boundary keyed on the locale param.
- **Do not** call `getMessages()` without passing `{ locale }` explicitly — `getMessages({ locale })` passes the locale directly to `getRequestConfig` via `Promise.resolve(locale)`, bypassing potential React.cache ordering issues.
- **Do not** remove `setRequestLocale(locale)` from `src/app/[locale]/layout.tsx` — without it, `getLocale()` called by deeper server components always returns `defaultLocale`.
- **Do not** add `notFound()` to `src/app/[locale]/layout.tsx` — unknown locale URLs are handled by middleware (redirect to defaultLocale); a hard 404 here breaks fallback behavior.
- **Do not** import `TYPOGRAPHY` — use `TYPOGRAPHY_LTR` or `TYPOGRAPHY_RTL` explicitly.
- **Do not** load fonts inside components or pages — all next/font declarations belong in `src/app/[locale]/layout.tsx`, with the `.variable` class attached conditionally per locale (Mikhak only for `fa`).
- **Do not** import `@/lib/cookies/server` in client components or `@/lib/cookies/client` in RSCs.
- **Do not** call `fetch()` directly in components or services — use `serverFetch` (RSC/Server Actions) or `clientFetch` (hooks/Client Components) from `@/lib/api`.
- **Do not** create a top-level barrel at `src/services/index.ts` — imports should make the domain origin clear (e.g. `import { useLogin } from '@/services/auth'`, not `import { useLogin } from '@/services'`).
- Each domain **does** have an `index.ts` that re-exports its hooks (e.g. `src/services/auth/index.ts`). Do not export `types`, `keys`, or `apis/*` from this barrel — only hooks.
- **Do not** mix `clientFetch` and `serverFetch` in the same file — keep `clientApi.ts` and `serverApi.ts` separate; Next.js enforces the environment boundary at build time.
- **Do not** toast inside hooks for 401/403/5xx — those are already toasted by `clientFetch`. Only toast in `onError` for domain-specific 4xx messages.
- **Do not** call `js-cookie` (`Cookies.*`) directly — use the central client cookie manager (`@/lib/cookies/client`).
- **Do not** read or write `document.cookie` directly — use the central client cookie manager.
- **Do not** store auth tokens in `sessionStorage` or `localStorage` — use cookies via `@/lib/cookies/client`.
- **Do not** pass `flexWrap` or `useFlexGap` as direct props to MUI `Stack` — these are not valid Stack props in MUI v9 and cause a TypeScript overload error. Use `sx={{ flexWrap: 'wrap' }}` instead. `useFlexGap` was a MUI v5 opt-in and does not exist in v9.
- **Do not** use mui old api which cause errors
---
## API Fetch Services
Central fetch primitives live in `src/lib/api/`:
| File | Use from | Purpose |
|------|----------|---------|
| `client.ts` | hooks, client components | `clientFetch<T>` — throws `ApiError` on error |
| `server.ts` | RSCs, Server Actions only | `serverFetch<T>` — throws `ApiError` on error |
| `errors.ts` | anywhere | `ApiError` class (`status`, `message`, `code`) |
**Error contract — `clientFetch`:**
- **401** — toast "session expired", clear cookies, redirect to login (no throw; page navigates away)
- **403** — toast "forbidden", throw `ApiError`
- **5xx** — toast "server error", throw `ApiError`
- **Other 4xx** — throw `ApiError`, no toast; the calling hook owns the user-facing message
- **Network failure** — toast "network error", throw `ApiError`
**Error contract — `serverFetch`:**
- All errors throw `ApiError` (no toast — server can't fire browser events)
- RSC callers decide whether to `notFound()`, `redirect()`, or let the error propagate to an error boundary
**Domain API calls** live in `src/services/{domain}/apis/clientApi.ts` (or `serverApi.ts`). Never call raw `fetch()` directly.
### The `services/{domain}` reference pattern (copy `auth` / `patients`)
Every domain follows the same shape: `types.ts` (wire types + the domain's `Api` interface), `keys.ts`
(hierarchical React Query key factory), `apis/` (implementations + a selecting `index.ts`), `hooks/`
(one hook per file), and a barrel `index.ts` that re-exports **hooks only** (never `types`/`keys`/`apis`).
- **Caching is deliberate:** set a `staleTime` on reads so revisiting a screen doesn't refetch; mutations
**invalidate** the affected list key (`queryClient.invalidateQueries`) or `setQueryData` — never leave the
cache stale. See `services/patients/hooks/*`.
- **Reference data is cached for the whole session:** rarely-changing lookups (the geo province→city→district
hierarchy) use an **Infinite `staleTime`** + a shared, hierarchical key factory (`geographyKeys`) so each
level is fetched **once** and served from cache across every consumer (the address form, the coverage editor,
and later search) — never refetched on a dropdown open. Contrast with mutable lists (addresses, coverage
areas) which invalidate on every mutation. See `services/geography/*`. Reuse this pattern for future
reference data; do not reinvent per-consumer fetching. **`services/catalog` (f4) is the second long-lived
cached reference domain:** admin-seeded categories + a category's option groups/values use the same Infinite
`staleTime`/`gcTime` (`CATALOG_REFERENCE_*`) so the Home grid and every builder step read them from cache;
the nurse's own **variant list** is the mutable side — mutations invalidate `catalogKeys.myVariantsLists()`.
- **Mock behind a seam:** when the backend endpoint isn't live, implement the domain's `Api` interface
twice — a real `clientApi.ts` and an in-memory `mockApi.ts` — and select in `apis/index.ts` by a config
flag (`USE_{DOMAIN}_MOCK`). Hooks import the selected `api`; the swap is one line. Record every mock in
`dev/shared-working-context/reports/mocks-registry.md`.
- **The wire envelope:** the server wraps responses in `ApiEnvelope<T>` (`{ isSuccess, statusCode,
message, requestId, data }`, camelCase — see `lib/api/types.ts`). `clientFetch` returns the raw body, so
a real `clientApi` reads the payload via `unwrap()`. Types are derived from `dev/contracts/` +
`dev/contracts/openapi/swagger.v1.json`, mirroring the wire exactly.
- **Money & dates:** format via `@/utils` — `formatIrrToToman`/`formatIrr`/`parseIrr` (IRR strings, integer-safe
BigInt) and `formatShamsiDate`/`formatShamsiDateTime` (UTC ISO → Persian calendar). Money is never a float.
---
## Auth Cookies & session state
| Cookie | Constant | TTL | Set by |
|--------|----------|-----|--------|
| `access_token` | `COOKIE_NAMES.ACCESS_TOKEN` | 15 min | `persistAuthTokens` (`src/lib/auth/session.ts`) — via `useVerifyOtp`, `useRefresh`, `useSelectRole`, and the fetch-layer silent refresh |
| `refresh_token` | `COOKIE_NAMES.REFRESH_TOKEN` | 7 days | same as above |
**The credential is phone-OTP** — there is no username/password anywhere; email is never a login key.
The login flow lives in `src/components/auth/` (`LoginFlow` → `PhoneStep`/`OtpStep`) at `/login`, over the
`services/auth` domain (`requestOtp`/`verifyOtp`/`refresh`/`logout`/`getMe`/`selectRole`).
**Role router:** after a successful verify, `RoleRouter` (`src/components/auth/`) reads `/me` and navigates —
customer→family app, nurse→nurse app, empty roles→`/select-role`, admin→admin console — showing the branded
splash while `/me` loads so the wrong shell never flashes. The routing decision is the **pure**
`resolveRoleDestination(me, intendedRole)` in `src/services/auth/routing.ts` (unit-tested). The middleware
still owns the auth gate; the router only decides *which app*.
**Session state lives in `AuthContext`** (`src/context/auth/`), now carrying `SessionUser { id?, phone,
roles: AppRole[] }`. The root layout resolves the session on the server with `getServerAuthState()`
(`src/lib/auth/server.ts`) — which reads the `access_token` cookie and checks the JWT `exp` via the shared
`isTokenAlive` (`src/lib/auth/token.ts`) — and passes it to `<AuthProvider initialState={…}>`, so the first
render already knows whether the user is authenticated. **Roles are not derivable from the opaque JWE token
server-side**, so the server seeds `isAuthenticated` only; `useSessionRoleSync()` (mounted in the
private-routes layout) hydrates `currentUser.roles` from `/me` — the single source the shells read via
`useActorRole()`. `invalidateQueries(authKeys.me())` runs on login; `removeQueries(authKeys.all)` on logout.
**Lifecycle:**
- Written by `persistAuthTokens` after verify/refresh/select-role, which also dispatch `LOG_IN` to keep
`AuthContext` in sync without a reload.
- Deleted by `useLogout()` (`src/services/auth/hooks/useLogout.ts`) — the single logout path: revoke the
server session, clear both cookies, `LOG_OUT`, drop the `/me` cache, redirect — and by `clientFetch` when a
401 can't be recovered by a refresh.
- Read on the server by `serverFetch` / `getServerAuthState` via `getServerCookie`.
- Read on the client by `clientFetch` via `getClientCookie` (to attach `Authorization: Bearer`).
**Silent refresh:** `clientFetch` attempts one single-flight `attemptTokenRefresh` (`src/lib/api/refresh.ts`)
on a 401 and retries the request once; a failed refresh (unknown/expired/reused token → the server revokes
the session) clears tokens and redirects to `/login`. The refresh/OTP endpoints are excluded from this retry.
**Middleware** (`middleware.ts`) gates private routes with the same `isTokenAlive` helper before render.
**Security posture — current limits and best-practice follow-ups.** The flow above is the intended
client design, but some hardening needs *server* coordination — don't silently "fix" it client-only:
- **Tokens are non-httpOnly cookies** (JS-readable) so `clientFetch` can attach the bearer header — this
trades XSS-hardening for the bearer pattern. Real hardening (httpOnly cookies set by the server + a
same-origin proxy) spans the server.
- **The middleware check is UX-only, not a security boundary:** it decodes the JWT and checks `exp` but
does **not** verify the signature. The API is the only authority; never gate real authorization on the
middleware or `isTokenAlive`.
- **Role gating is coarse:** the shells pick chrome from `currentUser.roles`, but cross-actor route access
isn't hard-guarded client-side yet (the server authorizes each call). Add route guards when a phase needs
them.
- **Refresh-token rotation is wired** client-side (fetch-layer silent refresh + `useRefresh`), matching the
server's rotation + reuse-detection. The `refresh_token` cookie TTL (7d) is shorter than the server session
default (30d) — a follow-up can align the cookie `maxAge` to `refreshExpiresAt`.
---
## Toast Notifications (notistack)
`<SnackbarProvider>` wraps all children inside `ThemeProvider` in `src/app/[locale]/layout.tsx`.
**In React components/hooks** — use notistack directly:
```tsx
import { useSnackbar } from 'notistack'
const { enqueueSnackbar } = useSnackbar()
enqueueSnackbar('Saved!', { variant: 'success' })
```
**Outside React** (plain functions, fetch services) — use the event bridge:
```ts
import { dispatchToast } from '@/lib/toast'
dispatchToast('Something went wrong', 'error')
```
`dispatchToast` fires a `window` CustomEvent (`app:toast`). `ToastBridge` (a zero-UI `'use client'` component inside `SnackbarProvider`) listens and calls `enqueueSnackbar`.
`ToastBridge` is already rendered in `[locale]/layout.tsx` — do not add another instance.
**Toast colors follow the theme.** `NotistackProvider` maps every notistack variant to a `styled(MaterialDesignContent)` whose `backgroundColor`/`color` come from the `--bal-{success,error,warning,info}` (+ `*-contrast`) tokens in `tokens.css`. Because those tokens are defined on `<html>`, they cascade into notistack's Portal and switch with the color scheme automatically. Never hard-code a toast color — adjust the tokens instead.
**Direction is inherited, not passed.** notistack's Portal mounts under `<body>`, so it inherits `dir` from `<html dir>` (set per-locale in the root layout). Do **not** pass a `dir` prop to `SnackbarProvider` — it is not a valid prop (TS error) and is unnecessary:
```tsx
<NotistackProvider>{children}</NotistackProvider>
```
---
## Route Constants
Named path constants live in `src/constants/routes.ts`:
```ts
ROUTES.LOGIN = '/login'
ROUTES.HOME = '/'
PUBLIC_PATHS = [ROUTES.LOGIN, ...] // paths that bypass middleware auth check
```
Import from the barrel: `import { ROUTES, PUBLIC_PATHS } from '@/constants'`.
To add a new public route, append it to `PUBLIC_PATHS` — the middleware picks it up automatically.
---
## Client Cookie Manager (js-cookie)
`src/lib/cookies/client.ts` uses `js-cookie` internally. The exported API is unchanged:
| Function | Purpose |
|----------|---------|
| `getClientCookie(name)` | Read a cookie by name |
| `setClientCookie(name, value, options?)` | Write a cookie; `options` is `CookieOptions` with `maxAge` in **seconds** |
| `deleteClientCookie(name, path?)` | Delete a cookie |
| `getColorSchemeCookie()` | Typed helper for the theme cookie |
`CookieOptions` type is defined in `src/lib/cookies/constants.ts` — `maxAge` is in seconds (converted to `expires: Date` internally when calling js-cookie).
- **Do not** `document.title = title` in the render body of any component — it causes `ReferenceError: document is not defined` during build-time prerendering.
## Where to read more
Open **one** of these for the area you are touching.
| Working on… | Read |
| --- | --- |
| Routes, layouts, the RSC boundary, page metadata | [docs/rules/client/structure.md](../archive/docs/rules/client/structure.md) |
| Colors, tokens, dark mode, RTL, fonts, motion | [docs/rules/client/theme.md](../archive/docs/rules/client/theme.md) |
| The `App*` library, shells, navigation, icons, constants | [docs/rules/client/components.md](../archive/docs/rules/client/components.md) |
| Any form | [docs/rules/client/forms.md](../archive/docs/rules/client/forms.md) |
| Copy, translations, Persian orthography | [docs/rules/client/i18n.md](../archive/docs/rules/client/i18n.md) |
| Fetching, TanStack Query, `services/{domain}`, money display, cookies | [docs/rules/client/services.md](../archive/docs/rules/client/services.md) |
| Sessions, refresh, `RoleGuard`, middleware, security posture | [docs/rules/client/auth.md](../archive/docs/rules/client/auth.md) |
| Tests, ESLint, the type gate | [docs/rules/client/testing.md](../archive/docs/rules/client/testing.md) |
| The wire contract — envelope, status codes, enums, pagination | [docs/integration/](../archive/docs/integration/index.md) |
| What is built, what is mocked, what is next | [docs/status/](../archive/docs/status/index.md) |
| Brand, look and feel, turning a design into a screen | the **frontend-designer** skill |
| Cross-project rules — naming, gates, code quality | [docs/rules/shared/](../archive/docs/rules/shared/) |
+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"]
+20 -2
View File
@@ -24,5 +24,23 @@ const customJestConfig: Config = {
// },
};
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig);
// next-intl (and its use-intl dependency) ship ESM-only builds. next/jest's own
// `transformIgnorePatterns` already broadly matches all of node_modules (its negative-lookahead
// allowlist only carves out a couple of Next.js-internal packages), and Jest's array semantics
// are OR-based — a file is ignored if ANY pattern matches, so appending a more permissive pattern
// can never "un-ignore" a package an earlier pattern already caught. The array has to be replaced
// outright (post-processing the async config next/jest returns), not merged, to let a real
// (unmocked) `import ... from 'next-intl'` be transformed instead of failing to parse. Without
// this, any shared component that imports next-intl (even just for a sensible default) would
// force every test file that transitively imports it via the `@/components` barrel to
// `jest.mock('next-intl', …)`, even when that test never touches translations itself.
const withTransformableIntl = async () => {
const config = await createJestConfig(customJestConfig)();
config.transformIgnorePatterns = [
'/node_modules/(?!(geist|next/dist/client|next/dist/shared/lib|next/src/client|next/src/shared/lib|next-intl|use-intl|@formatjs|intl-messageformat)/)',
'^.+\\.module\\.(css|sass|scss)$',
];
return config;
};
module.exports = withTransformableIntl;
+5
View File
@@ -5,6 +5,11 @@
// Learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
// next/jest loads .env / .env.test but not .env.development, so NEXT_PUBLIC_API_URL (required by
// `@/config`) is absent in tests. Any test that renders a component using a `services/{domain}` hook pulls
// the real `clientApi` → `@/config` at import time, which throws without this. Provide a harmless default.
process.env.NEXT_PUBLIC_API_URL = process.env.NEXT_PUBLIC_API_URL || 'https://localhost:5002';
// To get 'next/router' working with tests
jest.mock('next/router', () => require('next-router-mock'));
+1861 -48
View File
File diff suppressed because it is too large Load Diff
+1876 -63
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 { COOKIE_NAMES } from './src/lib/cookies';
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);
@@ -17,24 +17,43 @@ export default function middleware(request: NextRequest) {
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)
const locale = routing.locales.find(
(l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`),
) ?? 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);
requestHeaders.set(HEADER_NAMES.LOCALE, locale);
@@ -49,6 +68,11 @@ export default function middleware(request: NextRequest) {
}
export const config = {
// Match all pathnames except internal Next.js paths, API routes, and static files
matcher: ['/((?!_next|_vercel|api|.*\\..*).*)'],
// Match all pathnames except internal Next.js paths, API routes, and static files. The bare
// 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|.*\\..*).*)'],
};
+6
View File
@@ -5,6 +5,12 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
turbopack: {
root: '.'
},
// Emits .next/standalone — a self-contained server bundling only the traced runtime dependencies, so
// the Docker image carries no node_modules tree. Harmless for `npm run dev`/`npm run build` locally.
output: 'standalone'
};
export default withNextIntl(nextConfig);
+65 -31
View File
@@ -12,7 +12,6 @@
"@emotion/react": "^11.14.0",
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.1.1",
"@mui/material": "^9.1.1",
"@mui/material-nextjs": "^9.1.1",
"@tanstack/react-query": "^5.101.0",
@@ -20,12 +19,16 @@
"@testing-library/dom": "^10.4.1",
"clsx": "latest",
"copy-to-clipboard": "latest",
"jalaali-js": "^2.0.0",
"js-cookie": "^3.0.8",
"leaflet": "^1.9.4",
"lucide-react": "^1.27.0",
"next": "^16.2.9",
"next-intl": "^4.13.0",
"notistack": "^3.0.2",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-hook-form": "^7.83.0",
"stylis-plugin-rtl": "^2.1.1"
},
"devDependencies": {
@@ -35,6 +38,7 @@
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/js-cookie": "^3.0.6",
"@types/leaflet": "^1.9.21",
"@types/node": "^25.9.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
@@ -556,9 +560,9 @@
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -1994,38 +1998,11 @@
"url": "https://opencollective.com/mui-org"
}
},
"node_modules/@mui/icons-material": {
"version": "9.1.1",
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-9.1.1.tgz",
"integrity": "sha512-OXhm9DajemStb58AumM06DuPhHTa3XD36TFD4yf6WtJyNRO5DfEZbbnHlBg/US2Y2oOXwM/XurMTBOD6L/YYZw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@mui/material": "^9.1.1",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/material": {
"version": "9.1.1",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-9.1.1.tgz",
"integrity": "sha512-Wv+gInjrpf99l1Q0oHe0eOWGTnlbkzs5nowClX65KCT/2fyPMwcbFEEkUsOHdpcHhB5UAbz/d7jlwt5ajWVvlA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.29.2",
"@mui/core-downloads-tracker": "^9.1.1",
@@ -3360,6 +3337,13 @@
"dev": true,
"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": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
@@ -3656,6 +3640,16 @@
"dev": true,
"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": {
"version": "25.9.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
@@ -8035,6 +8029,15 @@
"node": ">= 0.4"
}
},
"node_modules/jalaali-js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/jalaali-js/-/jalaali-js-2.0.0.tgz",
"integrity": "sha512-HkWlwO3KxuYwERP1jsn+5M+QA+EKpIJ+zGesLee5VJNn2d2Melue0uQIvd9C/0QYFR7XVWvfF/uiNEz9Jbr9Hw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz",
@@ -9118,6 +9121,12 @@
"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": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -9193,6 +9202,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.27.0.tgz",
"integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@@ -10255,6 +10273,22 @@
"react": "^19.2.7"
}
},
"node_modules/react-hook-form": {
"version": "7.83.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.83.0.tgz",
"integrity": "sha512-AXt8cMCmx5a7u4uvpb2uRFVrWQhllI4pV+LSykxIac/hjt44TnQkmX9BKuQi2i+LDC62esmiLpilkav+kjVf/A==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+7 -2
View File
@@ -9,18 +9,18 @@
"format": "prettier ./ --write",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"lint:copy": "node scripts/check-copy.mjs",
"start": "next start",
"test": "jest --watch",
"test:ci": "jest --ci",
"type": "tsc --noEmit",
"check": "npm run type && npm run lint"
"check": "npm run type && npm run lint && npm run lint:copy"
},
"dependencies": {
"@emotion/cache": "^11.14.0",
"@emotion/react": "^11.14.0",
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.1.1",
"@mui/material": "^9.1.1",
"@mui/material-nextjs": "^9.1.1",
"@tanstack/react-query": "^5.101.0",
@@ -28,12 +28,16 @@
"@testing-library/dom": "^10.4.1",
"clsx": "latest",
"copy-to-clipboard": "latest",
"jalaali-js": "^2.0.0",
"js-cookie": "^3.0.8",
"leaflet": "^1.9.4",
"lucide-react": "^1.27.0",
"next": "^16.2.9",
"next-intl": "^4.13.0",
"notistack": "^3.0.2",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-hook-form": "^7.83.0",
"stylis-plugin-rtl": "^2.1.1"
},
"devDependencies": {
@@ -43,6 +47,7 @@
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/js-cookie": "^3.0.6",
"@types/leaflet": "^1.9.21",
"@types/node": "^25.9.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 479 B

After

Width:  |  Height:  |  Size: 426 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 923 B

After

Width:  |  Height:  |  Size: 764 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 17 KiB

+8 -2
View File
@@ -1,2 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#D99E82" d="M35.222 33.598c-.647-2.101-1.705-6.059-2.325-7.566-.501-1.216-.969-2.438-1.544-3.014-.575-.575-1.553-.53-2.143.058 0 0-2.469 1.675-3.354 2.783-1.108.882-2.785 3.357-2.785 3.357-.59.59-.635 1.567-.06 2.143.576.575 1.798 1.043 3.015 1.544 1.506.62 5.465 1.676 7.566 2.325.359.11 1.74-1.271 1.63-1.63z"/><path fill="#EA596E" d="M13.643 5.308c1.151 1.151 1.151 3.016 0 4.167l-4.167 4.168c-1.151 1.15-3.018 1.15-4.167 0L1.141 9.475c-1.15-1.151-1.15-3.016 0-4.167l4.167-4.167c1.15-1.151 3.016-1.151 4.167 0l4.168 4.167z"/><path fill="#FFCC4D" d="M31.353 23.018l-4.17 4.17-4.163 4.165L7.392 15.726l8.335-8.334 15.626 15.626z"/><path fill="#292F33" d="M32.078 34.763s2.709 1.489 3.441.757c.732-.732-.765-3.435-.765-3.435s-2.566.048-2.676 2.678z"/><path fill="#CCD6DD" d="M2.183 10.517l8.335-8.335 5.208 5.209-8.334 8.335z"/><path fill="#99AAB5" d="M3.225 11.558l8.334-8.334 1.042 1.042L4.267 12.6zm2.083 2.086l8.335-8.335 1.042 1.042-8.335 8.334z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
<rect width="40" height="40" rx="10" fill="#1d4a40" />
<path fill="#f3efe9" fill-rule="evenodd" clip-rule="evenodd"
d="M21,17 A7.5,7.5 0 1,0 21,32 A7.5,7.5 0 1,0 21,17 Z
M21,20.8 A3.7,3.7 0 1,0 21,28.2 A3.7,3.7 0 1,0 21,20.8 Z" />
<rect x="12.9" y="9" width="4.8" height="22" rx="2.4" fill="#f3efe9" />
<circle cx="28.5" cy="10.5" r="2.6" fill="#d98c6a" />
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 444 B

-5
View File
@@ -1,5 +0,0 @@
User-agent: *
Disallow: /private/
User-agent: *
Allow: /
+10 -13
View File
@@ -1,22 +1,19 @@
{
"name": "Balinyaar",
"short_name": "Balinyaar",
"description": "Balinyaar web application",
"description": "Balinyaar — trust-first home nursing marketplace",
"start_url": ".",
"theme_color": "#000000",
"background_color": "#ffffff",
"theme_color": "#1d4a40",
"background_color": "#faf9f5",
"display": "standalone",
"orientation": "portrait",
"icons": [
{
"src": "favicon.ico?v=1.0",
"sizes": "48x48 32x32 16x16",
"type": "image/x-icon"
},
{ "src": "img/favicon/16x16.png?v=1.0", "sizes": "16x16", "type": "image/png" },
{ "src": "img/favicon/32x32.png?v=1.0", "sizes": "32x32", "type": "image/png" },
{ "src": "img/favicon/180x180.png?v=1.0", "sizes": "180x180", "type": "image/png" },
{ "src": "img/favicon/192x192.png?v=1.0", "sizes": "192x192", "type": "image/png" },
{ "src": "img/favicon/512x512.png?v=1.0", "sizes": "512x512", "type": "image/png" }
{ "src": "favicon.ico?v=2", "sizes": "48x48 32x32 16x16", "type": "image/x-icon" },
{ "src": "img/favicon/16x16.png?v=2", "sizes": "16x16", "type": "image/png" },
{ "src": "img/favicon/32x32.png?v=2", "sizes": "32x32", "type": "image/png" },
{ "src": "img/favicon/48x48.png?v=2", "sizes": "48x48", "type": "image/png" },
{ "src": "img/favicon/180x180.png?v=2", "sizes": "180x180", "type": "image/png" },
{ "src": "img/favicon/192x192.png?v=2", "sizes": "192x192", "type": "image/png" },
{ "src": "img/favicon/512x512.png?v=2", "sizes": "512x512", "type": "image/png" }
]
}
+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();
@@ -0,0 +1,336 @@
'use client';
import { FunctionComponent, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Avatar, Box, ButtonBase, Paper, Skeleton, Stack, Typography } from '@mui/material';
import {
AppButton,
AppIcon,
AppIconButton,
AppLoading,
CategoryTile,
EmptyState,
ErrorState,
SurfaceCard,
} from '@/components';
import { ROUTES } from '@/constants';
import { useMe } from '@/services/auth';
import { usePatients } from '@/services/patients';
import { useServiceCategories } from '@/services/catalog';
import { pickCatalogName } from '@/services/catalog/names';
import { useBookingDetail, useBookingList } from '@/services/bookings';
import type { BookingListItemDto } from '@/services/bookings/types';
interface NudgeCardProps {
icon: string;
title: string;
body: string;
ctaLabel: 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, onDismiss, dismissLabel }) => (
<Paper
elevation={0}
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', display: 'flex', gap: 2, position: 'relative' }}
>
<AppIcon icon={icon} size={28} color="var(--bal-primary)" />
<Stack sx={{ gap: 1, flexGrow: 1, minWidth: 0 }}>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, pr: onDismiss ? 4 : 0 }}>
{title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{body}
</Typography>
</Stack>
<AppButton color="primary" variant="outlined" to={to} sx={{ alignSelf: 'flex-start' }}>
{ctaLabel}
</AppButton>
</Stack>
{onDismiss ? (
<AppIconButton
icon="close"
title={dismissLabel}
onClick={onDismiss}
size="small"
sx={{ position: 'absolute', insetInlineEnd: 8, insetBlockStart: 8 }}
/>
) : null}
</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, a compact ambient trust strip, a
* tappable search entry point (routes to C1 — see `HomeSearchBar`), the **data-driven** service-category
* grid (from the cached `services/catalog` reference data), a completeness-gated patient-record nudge,
* 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
* a settled list so a post-create refetch never bounces the user back to onboarding.
*/
export default function HomeScreen() {
const t = useTranslations('home');
const tc = useTranslations('common');
const router = useRouter();
const locale = useLocale();
const { data: me } = useMe();
const { data, isError, refetch } = usePatients();
const [nudgeDismissed, setNudgeDismissed] = useState(patientNudgeDismissedInSession);
const isEmpty = data?.total === 0;
useEffect(() => {
if (isEmpty) router.replace(`/${locale}${ROUTES.ONBOARDING}`);
}, [isEmpty, router, locale]);
if (isError) {
return <ErrorState message={t('patients_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />;
}
if (data == null || isEmpty) {
return <AppLoading />;
}
const href = (path: string) => `/${locale}${path}`;
const profileComplete = me?.hasCustomerProfile ?? false;
const firstName = me?.firstName?.trim() || null;
const greeting = firstName ? t('greeting_named', { name: firstName }) : t('greeting_plain');
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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<Avatar sx={{ width: 48, height: 48, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}>
{avatarInitial ?? <AppIcon icon="account" size={28} color="var(--bal-primary)" />}
</Avatar>
<Box>
<Typography variant="h5" component="h1">
{greeting}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
</Stack>
<TrustStrip />
<HomeSearchBar />
<CategoryGrid onSelect={(categoryId) => router.push(`${href(ROUTES.SEARCH)}?category_id=${categoryId}`)} />
<RebookRow />
{showPatientNudge ? (
<NudgeCard
icon="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 ? (
<NudgeCard
icon="profile"
title={t('nudge_profile_title')}
body={t('nudge_profile_body')}
ctaLabel={t('nudge_profile_cta')}
to={href(ROUTES.PROFILE)}
/>
) : null}
</Box>
);
}
/**
* Quiet, one-line ambient reassurance under the greeting — not a hero. Three icon+label items: escrow
* payment, verified nurses, support. Purely presentational; tokens only.
*/
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 t = useTranslations('home');
const router = useRouter();
const locale = useLocale();
return (
<ButtonBase
onClick={() => router.push(`/${locale}${ROUTES.SEARCH}`)}
aria-label={t('search_action')}
sx={{
justifyContent: 'flex-start',
gap: 1,
width: '100%',
px: 2,
py: 1.5,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
color: 'text.secondary',
}}
>
<AppIcon icon="search" size={22} color="var(--bal-text-secondary)" />
<Typography variant="body1">{t('search_placeholder')}</Typography>
</ButtonBase>
);
};
/** The data-driven service-category grid — one tile per `service_category`, with all four states. */
const CategoryGrid: FunctionComponent<{ onSelect: (categoryId: number) => void }> = ({ onSelect }) => {
const t = useTranslations('home');
const tc = useTranslations('common');
const locale = useLocale();
const { data, isLoading, isError, refetch } = useServiceCategories();
const categories = data?.items ?? [];
return (
<Stack sx={{ gap: 1.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('categories_title')}
</Typography>
{isLoading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
))}
</Box>
) : isError ? (
<ErrorState message={t('categories_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />
) : categories.length === 0 ? (
<EmptyState title={t('categories_empty')} />
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
{categories.map((category) => (
<CategoryTile
key={category.id}
label={pickCatalogName(category, locale)}
iconKey={category.iconKey}
onClick={() => onSelect(category.id)}
/>
))}
</Box>
)}
</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,18 +2,8 @@
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Paper,
Skeleton,
Stack,
Typography,
} from '@mui/material';
import { AppButton, AppIcon } from '@/components';
import { Box, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, ConfirmDialog, EmptyState, ErrorState, FormDialogShell } from '@/components';
import { AddressCard, AddressForm } from '@/components/geography';
import {
useAddresses,
@@ -26,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
* addresses with add/edit (the cascading dropdowns + map pin in a dialog), soft-delete (confirm),
* and set-primary (exactly one badge). Loading skeleton + empty state both handled. The chosen
* address later feeds the f7 booking request.
* addresses with add/edit (the cascading dropdowns + map pin in a full-screen-on-mobile dialog),
* soft-delete (confirm), and set-primary (exactly one badge). Loading skeleton, error (with
* retry), and empty states are all handled. The chosen address later feeds the f7 booking request.
*/
export default function AddressesPage() {
const t = useTranslations('address');
@@ -36,13 +26,14 @@ export default function AddressesPage() {
const locale = useLocale();
const { enqueueSnackbar } = useSnackbar();
const { data, isLoading } = useAddresses();
const { data, isLoading, isError, refetch } = useAddresses();
const createAddress = useCreateAddress();
const updateAddress = useUpdateAddress();
const deleteAddress = useDeleteAddress();
const setPrimary = useSetPrimaryAddress();
const [formOpen, setFormOpen] = useState(false);
const [formDirty, setFormDirty] = useState(false);
const [editing, setEditing] = useState<CustomerAddress | null>(null);
const [deleteTarget, setDeleteTarget] = useState<CustomerAddress | null>(null);
@@ -88,7 +79,7 @@ export default function AddressesPage() {
};
const addresses = data?.items ?? [];
const isEmpty = !isLoading && addresses.length === 0;
const isEmpty = !isLoading && !isError && addresses.length === 0;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
@@ -101,8 +92,8 @@ export default function AddressesPage() {
{t('subtitle')}
</Typography>
</Box>
{!isEmpty ? (
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ m: 0, flexShrink: 0 }}>
{!isEmpty && !isError ? (
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ flexShrink: 0 }}>
{t('add')}
</AppButton>
) : null}
@@ -114,32 +105,19 @@ export default function AddressesPage() {
<Skeleton key={key} variant="rounded" height={104} />
))}
</Stack>
) : isError ? (
<ErrorState message={t('load_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />
) : isEmpty ? (
<Paper
elevation={0}
sx={{
p: 4,
textAlign: 'center',
border: '1px dashed',
borderColor: 'divider',
borderRadius: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1.5,
}}
>
<AppIcon icon="location" size={40} color="var(--bal-primary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('empty_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('empty_body')}
</Typography>
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ mt: 1 }}>
{t('add')}
</AppButton>
</Paper>
<EmptyState
icon="location"
title={t('empty_title')}
body={t('empty_body')}
action={
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ mt: 1 }}>
{t('add')}
</AppButton>
}
/>
) : (
<Stack sx={{ gap: 1.5 }}>
{addresses.map((address) => (
@@ -150,6 +128,9 @@ export default function AddressesPage() {
addressLine={address.addressLine}
isPrimary={address.isPrimary}
primaryLabel={t('primary')}
hasPin={address.latitude != null && address.longitude != null}
pinSetLabel={t('pin_set')}
pinMissingLabel={t('pin_missing')}
onEdit={() => openEdit(address)}
onDelete={() => setDeleteTarget(address)}
onSetPrimary={() =>
@@ -167,50 +148,52 @@ export default function AddressesPage() {
</Stack>
)}
<Dialog open={formOpen} onClose={closeForm} fullWidth maxWidth="sm">
<DialogTitle>{editing ? t('edit_title') : t('add_title')}</DialogTitle>
<DialogContent>
<Box sx={{ pt: 1 }}>
<AddressForm
key={editing?.id ?? 'new'}
initial={
editing
? {
title: editing.title,
provinceId: editing.provinceId,
cityId: editing.cityId,
districtId: editing.districtId,
addressLine: editing.addressLine,
latitude: editing.latitude,
longitude: editing.longitude,
isPrimary: editing.isPrimary,
}
: undefined
}
submitting={createAddress.isPending || updateAddress.isPending}
onSubmit={handleSubmit}
onCancel={closeForm}
/>
</Box>
</DialogContent>
</Dialog>
<FormDialogShell
open={formOpen}
title={editing ? t('edit_title') : t('add_title')}
dirty={formDirty}
onClose={closeForm}
closeLabel={tc('close')}
discardTitle={tc('discard_title')}
discardBody={tc('discard_body')}
discardConfirmLabel={tc('discard_confirm')}
discardCancelLabel={tc('cancel')}
>
<AddressForm
key={editing?.id ?? 'new'}
initial={
editing
? {
title: editing.title,
provinceId: editing.provinceId,
cityId: editing.cityId,
districtId: editing.districtId,
addressLine: editing.addressLine,
latitude: editing.latitude,
longitude: editing.longitude,
isPrimary: editing.isPrimary,
recipientName: editing.recipientName,
recipientPhone: editing.recipientPhone,
}
: undefined
}
submitting={createAddress.isPending || updateAddress.isPending}
onSubmit={handleSubmit}
onCancel={closeForm}
onDirtyChange={setFormDirty}
/>
</FormDialogShell>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
<DialogTitle>{t('delete_title')}</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('delete_body')}
</Typography>
</DialogContent>
<DialogActions>
<AppButton variant="text" onClick={() => setDeleteTarget(null)}>
{tc('cancel')}
</AppButton>
<AppButton color="error" variant="contained" onClick={confirmDelete}>
{t('delete_confirm')}
</AppButton>
</DialogActions>
</Dialog>
<ConfirmDialog
open={Boolean(deleteTarget)}
title={t('delete_title')}
body={t('delete_body')}
confirmLabel={t('delete_confirm')}
cancelLabel={tc('cancel')}
confirmColor="error"
onClose={() => setDeleteTarget(null)}
onConfirm={confirmDelete}
/>
</Box>
);
}
@@ -0,0 +1,328 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Badge, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
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 { bookingReviewPath, ROUTES } from '@/constants';
import { formatShamsiDate, localeTag } from '@/utils';
import { useBookingList } from '@/services/bookings';
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 lifecycle home. Three tabs so a money-adjacent pending request is never orphaned
* once the user leaves C5: **در انتظار پاسخ** wires the exported-but-previously-unused
* `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() {
const t = useTranslations('booking');
const router = useRouter();
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 (
<Stack sx={{ gap: 3 }}>
<Stack>
<Typography variant="h5" component="h1">
{t('list_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('list_subtitle')}
</Typography>
</Stack>
<Tabs value={tab} onChange={(_event, value: BookingsTab) => setTab(value)} variant="fullWidth">
<Tab
value="pending"
data-tab="pending"
label={
pendingItems.length > 0 ? (
<Badge badgeContent={pendingItems.length} color="secondary" sx={{ '& .MuiBadge-badge': { insetInlineEnd: -12 } }}>
{t('tab_pending')}
</Badge>
) : (
t('tab_pending')
)
}
/>
<Tab value="active" data-tab="active" label={t('tab_active')} />
<Tab value="past" data-tab="past" label={t('tab_past')} />
</Tabs>
{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 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 kind = BOOKING_STATUS_KIND[item.status];
const isCompleted = item.status === 'completed' || item.status === 'closed';
return (
<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 direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', 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' }}>
{formatShamsiDate(item.scheduledDate, locale)} · {t('session_count', { count: item.sessionCount })}
</Typography>
</Stack>
<StatusChip status={kind} label={t(`bstatus_${item.status}`)} />
</Stack>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('list_total')}: <Money amountIrr={item.amountIrr} size="sm" sx={{ fontWeight: 700 }} />
</Typography>
{isCompleted ? <CompletedReviewStrip bookingId={item.id} enabled={isCompleted} /> : null}
</Stack>
</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>
);
}
@@ -0,0 +1,272 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { Checkbox, FormControlLabel, MenuItem, Paper, Stack, Typography } from '@mui/material';
import AppButton from '@/components/common/AppButton';
import AppAlert from '@/components/common/AppAlert';
import AppLoading from '@/components/common/AppLoading';
import Money from '@/components/common/Money';
import { RhfControlGroup, RhfTextField } from '@/components/common/form';
import StepperHeader from '@/components/StepperHeader';
import CancellationPolicyDisclosure from '@/components/CancellationPolicyDisclosure';
import { ContactSupportDialog } from '@/components/messaging';
import type { TicketCategory } from '@/services/tickets/types';
import { ApiError } from '@/lib/api/errors';
import { bookingRefundStatusPath, ROUTES } from '@/constants';
import { useCancelBooking, useCancellationPolicyPreview } from '@/services/refunds';
import type { CancelReasonCategory } from '@/services/refunds/types';
const REASON_CATEGORIES: CancelReasonCategory[] = [
'changed_mind',
'schedule_conflict',
'found_other_care',
'other',
];
/** Maps the cancel mutation's `409` code to a user-facing message; anything else is the generic failure. */
function cancelErrorKey(error: unknown): string {
if (error instanceof ApiError) {
if (error.code === 'not_cancellable') return 'err_not_cancellable';
if (error.code === 'nothing_refundable' || error.code === 'session_not_refundable') {
return 'err_nothing_refundable';
}
}
return 'err_generic';
}
interface CancelFormValues {
reasonCategory: CancelReasonCategory | '';
reasonNotes: string;
acknowledged: boolean;
}
/**
* Cancellation flow (f10) — the trust-first exit. Step 1 **discloses** the resolved policy tier, the
* refund % + fee %, and the concrete Toman amounts (refunded vs kept) **before** anything is submitted;
* the confirm button is gated behind an explicit acknowledgement. Step 2 restates the numbers and submits
* via `useCancelBooking` (which invalidates the booking + primes the refund cache), then routes to the
* refund status. Refunds are admin-approved — the copy makes clear the request is *submitted* and
* *processed by the team*, never self-issued.
*/
export default function CancelBookingPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
const locale = useLocale();
const t = useTranslations('refunds');
const tc = useTranslations('common');
const rawId = Number(params.id);
const bookingId = Number.isInteger(rawId) && rawId > 0 ? rawId : undefined;
const { data: preview, isLoading, isError } = useCancellationPolicyPreview(bookingId);
const cancel = useCancelBooking();
const [step, setStep] = useState<0 | 1>(0);
const [supportDialogCategory, setSupportDialogCategory] = useState<TicketCategory | null>(null);
// `reasonCategory` is never pre-defaulted (that would make the reason analytics lie) — the continue
// CTA stays disabled until it and the acknowledgement are both set.
const form = useForm<CancelFormValues>({
mode: 'onTouched',
defaultValues: { reasonCategory: '', reasonNotes: '', acknowledged: false },
});
const { control, getValues } = form;
const reasonCategory = useWatch({ control, name: 'reasonCategory' });
const acknowledged = useWatch({ control, name: 'acknowledged' });
const bookingHref = `/${locale}${ROUTES.BOOKINGS}/${bookingId}`;
if (isLoading) return <AppLoading />;
if (isError || !preview || bookingId == null) {
return (
<Stack sx={{ gap: 2, maxWidth: 640, mx: 'auto', width: '100%', py: 2 }}>
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('error_title')}
</Typography>
<Typography variant="body2">{t('error_body')}</Typography>
</Stack>
</AppAlert>
</Stack>
);
}
if (!preview.cancellable) {
return (
<Stack sx={{ gap: 2, maxWidth: 640, mx: 'auto', width: '100%', py: 2 }}>
<AppAlert severity="info" variant="outlined" sx={{ marginY: 0 }}>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('not_cancellable_title')}
</Typography>
<Typography variant="body2">{t('not_cancellable_body')}</Typography>
</Stack>
</AppAlert>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton
variant="contained"
color="primary"
onClick={() => router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`)}
>
{t('view_refund_status')}
</AppButton>
<AppButton variant="text" color="inherit" onClick={() => router.push(bookingHref)}>
{t('back_to_booking')}
</AppButton>
</Stack>
</Stack>
);
}
const submit = () => {
const values = getValues();
cancel.mutate(
{
bookingId,
sessionIds: preview.refundableSessionIds,
// Guaranteed non-empty: step 1 is only reachable once a reason is chosen (the continue CTA gate).
reasonCategory: values.reasonCategory as CancelReasonCategory,
reasonNotes: values.reasonNotes.trim() || undefined,
},
{ onSuccess: () => router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`) },
);
};
return (
<FormProvider {...form}>
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%', py: 2 }}>
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
{t('cancel_title')}
</Typography>
<StepperHeader steps={[t('step_review'), t('step_confirm')]} activeStep={step} />
{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} />
<RhfTextField<CancelFormValues> name="reasonCategory" select label={t('reason_field_label')} fullWidth>
<MenuItem value="" disabled>
{t('reason_placeholder')}
</MenuItem>
{REASON_CATEGORIES.map((category) => (
<MenuItem key={category} value={category}>
{t(`reason_cat_${category}`)}
</MenuItem>
))}
</RhfTextField>
<RhfTextField<CancelFormValues>
name="reasonNotes"
label={t('reason_notes_label')}
multiline
minRows={2}
fullWidth
/>
<RhfControlGroup<CancelFormValues> name="acknowledged">
{({ field }) => (
<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' }}>
<AppButton variant="text" color="inherit" onClick={() => router.push(bookingHref)}>
{t('back_to_booking')}
</AppButton>
<AppButton
variant="contained"
color="primary"
disabled={!acknowledged || reasonCategory === ''}
onClick={() => setStep(1)}
>
{t('continue_cta')}
</AppButton>
</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: 'var(--bal-radius-md)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1 }}>
{t('confirm_title')}
</Typography>
<Typography variant="body2" component="div">
{t.rich('confirm_restate', {
refund: () => (
<Money amountIrr={preview.refundAmountIrr} size="sm" sx={{ fontWeight: 700 }} />
),
fee: () => <Money amountIrr={preview.feeAmountIrr} size="sm" sx={{ fontWeight: 700 }} />,
})}
</Typography>
</Paper>
{cancel.isError && (
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
{t(cancelErrorKey(cancel.error))}
</AppAlert>
)}
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', flexWrap: 'wrap' }}>
<AppButton
variant="text"
color="inherit"
onClick={() => setStep(0)}
disabled={cancel.isPending}
>
{tc('back')}
</AppButton>
<AppButton
variant="contained"
color="error"
onClick={submit}
disabled={cancel.isPending}
>
{cancel.isPending ? t('submitting') : t('confirm_cta')}
</AppButton>
</Stack>
</>
)}
</Stack>
</FormProvider>
);
}
@@ -0,0 +1,287 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import { Box, Divider, GlobalStyles, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, PriceBreakdown, StatusChip, type StatusKind } from '@/components';
import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors';
import { formatShamsiDate, localeTag, parseIrr } from '@/utils';
import { useInvoice } from '@/services/payment';
import { useBookingDetail } from '@/services/bookings';
import { useCustomerProfile } from '@/services/profiles';
import type { MoadianStatus } from '@/services/payment/types';
/** The printable region — everything else is hidden by the print rules below. */
const PRINT_AREA_CLASS = 'invoice-print-area';
// مودیان registration is a backend concern — surfaced strictly read-only (contract exposes the state).
const MOADIAN_KIND: Record<MoadianStatus, StatusKind> = {
pending: 'pending',
submitted: 'info',
registered: 'verified',
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
* issue date), a buyer/service/visit-date recap (composed client-side from the customer's own profile +
* the booking detail read — a UI join, not money math), the reconciling lines with the **VAT-on-commission**
* line explicitly labelled (product rule: VAT is on Balinyaar's commission — the taxable supply — never the
* nurse's earnings), the payment method + transaction reference, a seller fiscal-identity block, and the
* مودیان 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() {
const t = useTranslations('payment');
const tc = useTranslations('common');
const locale = useLocale();
const router = useRouter();
const params = useParams<{ id: string }>();
const bookingId = Number(params.id);
const validId = Number.isInteger(bookingId) && bookingId > 0;
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`).
if (!validId) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon="error" size={44} color="var(--bal-error)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('error_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('invalid_link_body')}
</Typography>
<AppButton variant="contained" onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}`)}>
{t('view_booking')}
</AppButton>
</Stack>
</Paper>
);
}
if (isLoading) {
return (
<Stack sx={{ gap: 2 }}>
<Skeleton variant="text" width="40%" height={36} />
<Skeleton variant="rounded" height={120} />
<Skeleton variant="rounded" height={200} />
</Stack>
);
}
if (!invoice) {
const notIssued = error instanceof ApiError && error.status === 404;
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={notIssued ? 'document' : 'error'} size={44} color={notIssued ? 'var(--bal-warning)' : 'var(--bal-error)'} />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{notIssued ? t('invoice_not_issued_title') : t('error_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{notIssued ? t('invoice_not_issued_body') : t('error_body')}
</Typography>
{notIssued ? (
<AppButton variant="contained" onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${bookingId}`)}>
{t('view_booking')}
</AppButton>
) : (
<AppButton variant="contained" onClick={() => refetch()}>
{tc('retry')}
</AppButton>
)}
</Stack>
</Paper>
);
}
// The receipt must print on paper colors: the tokens are attribute-driven, so a dark-scheme user
// would otherwise print cream-on-dark. Flip to the light tokens for the print dialog and restore
// after it closes (afterprint) — no hard-coded colors, the same token system does the work.
const handlePrint = () => {
const root = document.documentElement;
const previous = root.getAttribute('data-mui-color-scheme');
if (previous === 'dark') {
const restore = () => {
root.setAttribute('data-mui-color-scheme', previous);
window.removeEventListener('afterprint', restore);
};
window.addEventListener('afterprint', restore);
root.setAttribute('data-mui-color-scheme', 'light');
}
window.print();
};
// Display rows from served amounts only, integer-safe: the service line is the exact remainder, so
// service + commission + VAT reconciles to the gross total by construction.
const serviceIrr = (
parseIrr(invoice.grossIrr) - parseIrr(invoice.platformCommissionIrr) - parseIrr(invoice.vatIrr)
).toString();
// maximumFractionDigits: the default (0) would silently round a fractional served rate (e.g. 9.5%).
const vatPercent = new Intl.NumberFormat(localeTag(locale), {
style: 'percent',
maximumFractionDigits: 2,
}).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 (
<Stack sx={{ gap: 2 }}>
<GlobalStyles
styles={{
'@page': { size: 'A4', margin: '16mm' },
'@media print': {
'body *': { visibility: 'hidden' },
[`.${PRINT_AREA_CLASS}, .${PRINT_AREA_CLASS} *`]: { visibility: 'visible' },
[`.${PRINT_AREA_CLASS}`]: { position: 'absolute', top: 0, insetInlineStart: 0, width: '100%' },
},
}}
/>
<Paper
elevation={0}
className={PRINT_AREA_CLASS}
sx={{ p: 3, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
>
<Stack sx={{ gap: 2 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
<Typography variant="h6" component="h1">
{t('invoice_title')}
</Typography>
<Typography variant="subtitle2" sx={{ color: 'var(--bal-primary)', fontWeight: 700 }}>
{t('issuer_platform')}
</Typography>
</Stack>
<Divider />
<Stack sx={{ gap: 0.75 }}>
<MetaRow label={t('invoice_number_label')} value={invoice.invoiceNumber} ltr />
<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>
<PriceBreakdown
rows={[
{ key: 'service_cost', label: t('row_service_cost'), amountIrr: serviceIrr },
{ key: 'commission', label: t('row_commission'), amountIrr: invoice.platformCommissionIrr },
{
key: 'vat',
label: `${t('invoice_vat_on_commission')} (${vatPercent})`,
amountIrr: invoice.vatIrr,
},
]}
totalLabel={t('row_total')}
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 ? (
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('moadian_label')}
</Typography>
<StatusChip status={MOADIAN_KIND[invoice.moadianStatus]} label={t(`moadian_${invoice.moadianStatus}`)} />
</Stack>
) : 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>
</Paper>
<Stack sx={{ gap: 1 }}>
{invoice.pdfUrl ? (
<AppButton color="primary" variant="contained" startIcon="document" href={invoice.pdfUrl} openInNewTab>
{t('download_invoice')}
</AppButton>
) : (
<AppButton color="primary" variant="contained" startIcon="document" onClick={handlePrint}>
{t('print_invoice')}
</AppButton>
)}
<AppButton variant="text" onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${bookingId}`)}>
{t('view_booking')}
</AppButton>
</Stack>
</Stack>
);
}
function MetaRow({ label, value, ltr }: { label: string; value: string; ltr?: boolean }) {
return (
<Stack direction="row" sx={{ justifyContent: 'space-between', gap: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{label}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }} dir={ltr ? 'ltr' : undefined}>
{value}
</Typography>
</Stack>
);
}
@@ -0,0 +1,124 @@
'use client';
import { useParams, useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Stack, Typography } from '@mui/material';
import { BookingDetailView } from '@/components/booking';
import { BookingSupportEntry } from '@/components/messaging';
import RefundStatusCard from '@/components/RefundStatusCard';
import AppButton from '@/components/common/AppButton';
import { bookingCancelPath, bookingRefundStatusPath, bookingReviewPath } from '@/constants';
import { useBookingDetail } from '@/services/bookings';
import { useRefundStatus } from '@/services/refunds';
import { isBookingCancellable } from '@/services/refunds/types';
import { useMyReviewForBooking } from '@/services/reviews';
/**
* Customer booking detail (`/bookings/{id}`) — the read-only both-roles view in the **customer** shell:
* server-truth status timeline, session schedule, and money summary. Care instructions are gated to the
* assigned nurse, so the customer sees the "visible to your nurse only" affordance (the query never fires).
*
* f10 hangs the cancellation/refund entry off this screen: a **Cancel booking** CTA while the booking is
* cancellable, or the **refund status** section once it's cancelled — both page-only glue (the booking
* domain stays decoupled from refunds).
*/
export default function CustomerBookingDetailPage() {
const params = useParams<{ id: string }>();
const id = Number(params.id);
const bookingId = Number.isInteger(id) && id > 0 ? id : -1;
return (
<Stack sx={{ gap: 3 }}>
<BookingDetailView bookingId={bookingId} viewerRole="customer" />
{bookingId > 0 && <CustomerBookingActions bookingId={bookingId} />}
{bookingId > 0 && <LeaveReviewCta bookingId={bookingId} />}
{bookingId > 0 && <BookingSupportEntry bookingId={bookingId} role="customer" />}
</Stack>
);
}
/**
* The f13 leave-a-review entry — reads the already-cached booking detail (no extra fetch) and only offers the
* CTA once the booking is completed/closed. If the customer has already reviewed it, the CTA becomes a passive
* "under review" affordance (the review is `pending_moderation` and never shown publicly here). The my-review
* read is enabled only for a review-eligible booking, so an active booking triggers no reviews query.
*/
function LeaveReviewCta({ bookingId }: { bookingId: number }) {
const router = useRouter();
const locale = useLocale();
const t = useTranslations('reviews');
const { data: booking } = useBookingDetail(bookingId, 'customer');
const reviewable = booking?.status === 'completed' || booking?.status === 'closed';
const { data: myReview } = useMyReviewForBooking(bookingId, { enabled: reviewable });
if (!booking || !reviewable) return null;
const alreadyReviewed = Boolean(myReview && myReview.status !== 'none');
const underReview = myReview?.status === 'pending_moderation';
return (
<Stack sx={{ maxWidth: 640, mx: 'auto', width: '100%' }}>
<AppButton
variant={alreadyReviewed ? 'outlined' : 'contained'}
color="primary"
startIcon="star"
onClick={() => router.push(`/${locale}${bookingReviewPath(bookingId)}`)}
>
{alreadyReviewed ? (underReview ? t('cta_under_review') : t('cta_view_review')) : t('cta_leave')}
</AppButton>
</Stack>
);
}
/**
* The customer's cancel/refund entry — reads the already-cached booking detail (same query key as
* `BookingDetailView`, so no extra fetch) to decide between the Cancel CTA and the refund section. The
* refund read is enabled only once the booking is cancelled, so an active booking triggers no refund query.
*/
function CustomerBookingActions({ bookingId }: { bookingId: number }) {
const router = useRouter();
const locale = useLocale();
const t = useTranslations('refunds');
const { data: booking } = useBookingDetail(bookingId, 'customer');
const isCancelled = booking?.status === 'cancelled';
const { data: refund } = useRefundStatus(bookingId, { enabled: isCancelled });
if (!booking) return null;
if (isBookingCancellable(booking.status)) {
return (
<Stack sx={{ maxWidth: 640, mx: 'auto', width: '100%' }}>
<AppButton
variant="outlined"
color="error"
startIcon="rejected"
onClick={() => router.push(`/${locale}${bookingCancelPath(bookingId)}`)}
>
{t('cancel_booking_cta')}
</AppButton>
</Stack>
);
}
if (isCancelled && refund) {
return (
<Stack sx={{ gap: 1.5, maxWidth: 640, mx: 'auto', width: '100%' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('refund_section_title')}
</Typography>
<RefundStatusCard refund={refund} />
<AppButton
variant="text"
color="primary"
onClick={() => router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`)}
sx={{ alignSelf: 'flex-start' }}
>
{t('view_refund_status')}
</AppButton>
</Stack>
);
}
return null;
}
@@ -0,0 +1,75 @@
'use client';
import { useParams, useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Stack, Typography } from '@mui/material';
import AppButton from '@/components/common/AppButton';
import AppAlert from '@/components/common/AppAlert';
import AppLoading from '@/components/common/AppLoading';
import RefundStatusCard from '@/components/RefundStatusCard';
import { ROUTES } from '@/constants';
import { useRefundStatus } from '@/services/refunds';
/**
* Customer refund status (f10) — read-only. Renders the three-step progress (pending → on-its-way →
* completed), the refunded amount, the honest per-channel ETA (BNPL's ~710-day window), and — where the
* backend serves it — the fee-leg split. `failed` shows a needs-attention / contact-support state, never a
* retry (retry is admin-only, DEFERRED to f15). Polling runs only while the refund is non-terminal (see
* `useRefundStatus`). An empty state renders when the booking has no refund (e.g. it wasn't cancelled).
*/
export default function RefundStatusPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
const locale = useLocale();
const t = useTranslations('refunds');
const rawId = Number(params.id);
const bookingId = Number.isInteger(rawId) && rawId > 0 ? rawId : undefined;
const { data: refund, isLoading, isError } = useRefundStatus(bookingId);
return (
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%', py: 2 }}>
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
{t('status_title')}
</Typography>
{isLoading ? (
<AppLoading />
) : isError ? (
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('error_title')}
</Typography>
<Typography variant="body2">{t('error_body')}</Typography>
</Stack>
</AppAlert>
) : refund ? (
<>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('status_subtitle')}
</Typography>
<RefundStatusCard refund={refund} />
</>
) : (
<AppAlert severity="info" variant="outlined" sx={{ marginY: 0 }}>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('no_refund_title')}
</Typography>
<Typography variant="body2">{t('no_refund_body')}</Typography>
</Stack>
</AppAlert>
)}
<AppButton
variant="text"
color="inherit"
onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${bookingId}`)}
sx={{ alignSelf: 'flex-start' }}
>
{t('back_to_booking')}
</AppButton>
</Stack>
);
}
@@ -0,0 +1,265 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import { Avatar, Paper, Skeleton, Stack, Typography } from '@mui/material';
import {
AppButton,
AppIcon,
EmptyState,
RatingInput,
ReviewTagSelector,
RhfControlGroup,
RhfTextField,
StatusChip,
SurfaceCard,
} from '@/components';
import type { StatusKind } from '@/components';
import { formatShamsiDate } from '@/utils';
import { useBookingDetail } from '@/services/bookings';
import type { BookingDetailDto } from '@/services/bookings/types';
import { useReviewEligibility, useMyReviewForBooking, useCreateReview } from '@/services/reviews';
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;
interface ReviewFormValues {
rating: number;
body: string;
tagCodes: string[];
}
/** moderationStatus → StatusChip kind (published=success, pending=warning, rejected=error, hidden=neutral). */
const STATUS_KIND: Record<ModerationStatus, StatusKind> = {
pending_moderation: 'pending',
published: 'verified',
hidden: 'neutral',
rejected: 'rejected',
};
/**
* f13 — Leave a review («ثبت نظر»): the customer's one moderated review for a completed booking. The form is
* shown only when the server says the booking can be reviewed AND it is not already reviewed; on submit it
* flips to the persistent "under review" state (the review is `pending_moderation` and never appears publicly
* here). One review per booking (1:1) — a returning customer sees their review's state, never a second form.
*/
export default function LeaveReviewPage() {
const t = useTranslations('reviews');
const tc = useTranslations('common');
const locale = useLocale();
const router = useRouter();
const { enqueueSnackbar } = useSnackbar();
const params = useParams<{ id: string }>();
const rawId = Number(params.id);
const bookingId = Number.isInteger(rawId) && rawId > 0 ? rawId : -1;
const { data: booking } = useBookingDetail(bookingId, 'customer');
const reviewable = booking?.status === 'completed' || booking?.status === 'closed';
const eligibility = useReviewEligibility(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 form = useForm<ReviewFormValues>({ mode: 'onTouched', defaultValues: { rating: 0, body: '', tagCodes: [] } });
const { control, handleSubmit } = form;
const rating = useWatch({ control, name: 'rating' });
const body = useWatch({ control, name: 'body' });
const tagCodes = useWatch({ control, name: 'tagCodes' });
const nurseName = booking?.nurseName?.trim();
const submit = (values: ReviewFormValues) =>
createReview.mutate(
{ bookingId, body: { rating: values.rating, body: values.body.trim() || null, tagCodes: values.tagCodes } },
{ onError: () => enqueueSnackbar(t('error_submit'), { variant: 'error' }) },
);
// ── Already reviewed → the persistent under-review / published state (never a second form) ───────────────
const existing = myReview.data;
const submittedThisSession = createReview.isSuccess;
if ((existing && existing.status !== 'none') || submittedThisSession) {
const status: ModerationStatus =
existing && existing.status !== 'none' ? existing.status : 'pending_moderation';
const shownRating = existing && existing.status !== 'none' ? (existing.rating ?? rating) : rating;
const shownBody = existing && existing.status !== 'none' ? existing.body : body.trim() || null;
const shownTags = existing && existing.status !== 'none' ? existing.tagCodes : tagCodes;
return (
<Stack sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
<PageHeading title={t('my_review_title')} subtitle={nurseName ? t('for_nurse', { name: nurseName }) : undefined} />
{booking ? <ReviewContextRecap booking={booking} locale={locale} /> : null}
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<StatusChip status={STATUS_KIND[status]} label={t(`status_${status}`)} />
{existing?.createdAt ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{formatShamsiDate(existing.createdAt, locale)}
</Typography>
) : null}
</Stack>
{status === 'pending_moderation' ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('under_review_body')}
</Typography>
) : null}
<RatingInput value={shownRating} readOnly size={22} ariaLabel={t('rating_label')} />
{shownBody ? <Typography variant="body2">{shownBody}</Typography> : null}
{shownTags.length > 0 ? (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{shownTags.map((code) => (
<StatusChip key={code} status="info" label={t.has(`tag_${code}`) ? t(`tag_${code}`) : code} />
))}
</Stack>
) : null}
</Stack>
</Paper>
<AppButton variant="outlined" color="primary" onClick={() => router.back()} sx={{ alignSelf: 'flex-start' }}>
{tc('back')}
</AppButton>
</Stack>
);
}
if (eligibility.isLoading || myReview.isLoading) {
return (
<Stack sx={{ gap: 2, maxWidth: 560, mx: 'auto', width: '100%' }}>
<Skeleton variant="text" width="50%" height={36} />
<Skeleton variant="rounded" height={56} />
<Skeleton variant="rounded" height={120} />
<Skeleton variant="rounded" height={72} />
</Stack>
);
}
// ── Not eligible → a clear, non-leaking reason (no form) ─────────────────────────────────────────────────
if (!eligibility.data?.canReview) {
const reason = eligibility.data?.reason ?? 'not_completed';
return (
<Stack sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
<PageHeading title={t('not_eligible_title')} />
<EmptyState title={t(`reason_${reason}`)} />
<AppButton variant="outlined" color="primary" onClick={() => router.back()} sx={{ alignSelf: 'flex-start' }}>
{tc('back')}
</AppButton>
</Stack>
);
}
// ── Eligible → the review form ───────────────────────────────────────────────────────────────────────────
return (
<FormProvider {...form}>
<Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
<PageHeading title={t('title')} subtitle={nurseName ? t('for_nurse', { name: nurseName }) : t('subtitle')} />
{booking ? <ReviewContextRecap booking={booking} locale={locale} /> : null}
{/* Moderation expectation, up front — not only after submit. */}
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', p: 1.5, borderRadius: 'var(--bal-radius-md)', bgcolor: 'var(--bal-info-soft)' }}>
<AppIcon icon="info" size={18} color="var(--bal-info)" />
<Typography variant="body2" sx={{ color: 'var(--bal-info)' }}>
{t('moderation_note')}
</Typography>
</Stack>
<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')}
placeholder={t('body_placeholder')}
transform={(raw) => raw.slice(0, REVIEW_BODY_MAX)}
multiline
minRows={3}
fullWidth
/>
<RhfControlGroup<ReviewFormValues> name="tagCodes" label={t('tags_label')}>
{({ field }) => (
<ReviewTagSelector
codes={REVIEW_TAG_CODES}
selected={(field.value as string[]) ?? []}
onChange={field.onChange}
labelFor={(code) => (t.has(`tag_${code}`) ? t(`tag_${code}`) : code)}
disabled={createReview.isPending}
/>
)}
</RhfControlGroup>
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
<AppButton variant="text" color="primary" onClick={() => router.back()} disabled={createReview.isPending}>
{tc('cancel')}
</AppButton>
<AppButton
type="submit"
variant="contained"
color="primary"
disabled={rating < 1 || createReview.isPending}
startIcon="star"
>
{createReview.isPending ? t('submitting') : t('submit')}
</AppButton>
</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>
);
}
function PageHeading({ title, subtitle }: { title: string; subtitle?: string }) {
return (
<Stack sx={{ gap: 0.5 }}>
<Typography variant="h5" component="h1">
{title}
</Typography>
{subtitle ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{subtitle}
</Typography>
) : null}
</Stack>
);
}
@@ -0,0 +1,237 @@
'use client';
import { FunctionComponent } from 'react';
import { useTranslations } from 'next-intl';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { Checkbox, CircularProgress, FormControlLabel, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, Money, PhoneNumberField, RhfControlGroup, RhfTextField } from '@/components';
import { digitsOnly } from '@/utils';
import { useCheckEligibility } from '@/services/bnpl';
import { NATIONAL_ID_LENGTH, NATIONAL_ID_PATTERN } from '@/services/bnpl/constants';
import type { BnplEligibilityResult, ProviderCode } from '@/services/bnpl/types';
interface EligibilityFormValues {
nationalId: string;
consent: boolean;
}
interface EligibilityStepProps {
bookingRequestId: number;
providerCode: ProviderCode;
/** Mobile prefilled from the session (may be empty if unknown). */
sessionMobile: string;
/** A prior approval to re-show on back-navigation from D4 (so the approved panel survives, not the form). */
initialResult?: BnplEligibilityResult | null;
onApproved: (result: BnplEligibilityResult) => void;
onPayWithCard: () => void;
}
/**
* D3 · اعتبارسنجی — the provider credit check. کد ملی (client-side format only — the real check is the
* provider's), موبایل (prefilled from the session, read-only), and a consent checkbox that **gates** the
* submit. On approval → the credit ceiling + «تایید و ادامه» → D4. On decline / ceiling-exceeded → the
* declined panel + a card fall-back (never a dead end). The verdict is surfaced, never pre-judged.
*/
const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
bookingRequestId,
providerCode,
sessionMobile,
initialResult,
onApproved,
onPayWithCard,
}) => {
const t = useTranslations('bnpl');
const tc = useTranslations('common');
const form = useForm<EligibilityFormValues>({ mode: 'onTouched', defaultValues: { nationalId: '', consent: false } });
const { control, handleSubmit } = form;
const consent = useWatch({ control, name: 'consent' });
const check = useCheckEligibility();
// A fresh check wins; otherwise re-show a prior approval carried back from D4.
const result = check.data ?? initialResult ?? undefined;
const providerName = t(`provider_${providerCode}`);
const submit = (values: EligibilityFormValues) =>
check.mutate({
bookingRequestId,
providerCode,
nationalId: values.nationalId,
mobile: sessionMobile,
consent: values.consent,
});
// Approved — show the ceiling + advance.
if (result?.isEligible) {
return (
<Stack sx={{ gap: 2 }}>
<Paper
elevation={0}
sx={{
p: 2.5,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'var(--bal-success)',
backgroundColor: 'var(--bal-primary-soft)',
textAlign: 'center',
}}
>
<Stack sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="verified" size={36} color="var(--bal-success)" />
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'var(--bal-success)' }}>
{t('approved_title')}
</Typography>
{result.creditCeilingIrr ? (
<>
<Typography variant="caption" sx={{ color: 'text.secondary', mt: 0.5 }}>
{t('credit_ceiling_label')}
</Typography>
<Money amountIrr={result.creditCeilingIrr} tone="emphasis" size="lg" />
</>
) : null}
</Stack>
</Paper>
<AppButton color="secondary" variant="contained" size="large" onClick={() => onApproved(result)}>
{t('approve_continue')}
</AppButton>
</Stack>
);
}
// Declined (not_eligible / ceiling_exceeded) — a clear panel + the card fall-back.
if (result && !result.isEligible) {
const ceiling = result.eligibilityStatus === 'ceiling_exceeded';
return (
<DeclinedPanel
title={ceiling ? t('declined_ceiling_title') : t('declined_not_eligible_title')}
body={ceiling ? t('declined_ceiling_body') : t('declined_not_eligible_body')}
cardLabel={t('pay_with_card')}
onPayWithCard={onPayWithCard}
/>
);
}
// Error / timeout — retry or fall back to card.
if (check.isError) {
return (
<DeclinedPanel
title={t('eligibility_title')}
body={t('eligibility_error')}
cardLabel={t('pay_with_card')}
onPayWithCard={onPayWithCard}
onRetry={handleSubmit(submit)}
retryLabel={tc('retry')}
/>
);
}
return (
<FormProvider {...form}>
<Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('eligibility_title')}
</Typography>
<RhfTextField<EligibilityFormValues>
name="nationalId"
label={t('national_id_label')}
placeholder={t('national_id_placeholder')}
transform={(raw) => digitsOnly(raw).slice(0, NATIONAL_ID_LENGTH)}
rules={{ validate: (value) => NATIONAL_ID_PATTERN.test(String(value ?? '')) || t('national_id_invalid') }}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', maxLength: NATIONAL_ID_LENGTH, style: { textAlign: 'start' } } }}
fullWidth
/>
<PhoneNumberField
label={t('mobile_label')}
value={sessionMobile}
onChange={() => undefined}
slotProps={{ input: { readOnly: true } }}
fullWidth
/>
<RhfControlGroup<EligibilityFormValues> name="consent">
{({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={Boolean(field.value)}
onChange={(event) => field.onChange(event.target.checked)}
color="secondary"
/>
}
label={
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('consent_label', { provider: providerName })}
</Typography>
}
sx={{ alignItems: 'flex-start', m: 0 }}
/>
)}
</RhfControlGroup>
<Stack sx={{ gap: 1 }}>
<AppButton
type="submit"
color="secondary"
variant="contained"
size="large"
disabled={!consent || check.isPending}
startIcon={check.isPending ? <CircularProgress size={18} color="inherit" /> : undefined}
>
{check.isPending ? t('checking_eligibility') : t('check_eligibility')}
</AppButton>
<AppButton variant="text" color="primary" onClick={onPayWithCard}>
{t('pay_with_card')}
</AppButton>
</Stack>
</Stack>
</FormProvider>
);
};
function DeclinedPanel({
title,
body,
cardLabel,
onPayWithCard,
onRetry,
retryLabel,
}: {
title: string;
body: string;
cardLabel: string;
onPayWithCard: () => void;
onRetry?: () => void;
retryLabel?: string;
}) {
return (
<Stack sx={{ gap: 2 }}>
<Paper
elevation={0}
sx={{ p: 3, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'var(--bal-error)', textAlign: 'center' }}
>
<Stack sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="rejected" size={36} color="var(--bal-error)" />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{body}
</Typography>
</Stack>
</Paper>
<Stack sx={{ gap: 1 }}>
{onRetry && retryLabel ? (
<AppButton variant="outlined" color="secondary" onClick={onRetry}>
{retryLabel}
</AppButton>
) : null}
<AppButton color="primary" variant="contained" size="large" onClick={onPayWithCard}>
{cardLabel}
</AppButton>
</Stack>
</Stack>
);
}
export default EligibilityStep;
@@ -0,0 +1,169 @@
'use client';
import { FunctionComponent } from 'react';
import { useTranslations } from 'next-intl';
import { Box, ButtonBase, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, BnplProviderLogo, EmptyState, Money } from '@/components';
import type { BnplOptions, BnplProvider, ProviderCode } from '@/services/bnpl/types';
interface MethodStepProps {
options: BnplOptions;
selectedProvider: ProviderCode | null;
onSelectProvider: (code: ProviderCode) => void;
onContinue: () => void;
onPayWithCard: () => void;
}
/**
* 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
* hardcoded). Primary action «ادامه با {provider}». Empty provider set → only the card option.
*/
const MethodStep: FunctionComponent<MethodStepProps> = ({
options,
selectedProvider,
onSelectProvider,
onContinue,
onPayWithCard,
}) => {
const t = useTranslations('bnpl');
const hasProviders = options.providers.length > 0;
return (
<Stack sx={{ gap: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('method_title')}
</Typography>
<Paper
elevation={0}
sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', textAlign: 'center' }}
>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('payable_amount')}
</Typography>
<Money amountIrr={options.orderAmountIrr} tone="emphasis" size="lg" sx={{ mt: 0.5 }} />
</Paper>
{/* Full-card option — selecting it continues the f9 card flow (C6), which this phase does not rebuild. */}
<ButtonBase
onClick={onPayWithCard}
sx={{
display: 'block',
width: '100%',
textAlign: 'start',
borderRadius: 'var(--bal-radius-md)',
p: 1.75,
border: '1px solid',
borderColor: 'divider',
}}
>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1.5 }}>
<AppIcon icon="payment" size={24} color="var(--bal-primary)" />
<Stack sx={{ flex: 1, gap: 0.25 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('method_card')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('method_card_hint')}
</Typography>
</Stack>
</Stack>
</ButtonBase>
{hasProviders ? (
<>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'var(--bal-secondary-dark)' }}>
{t('installments_heading')}
</Typography>
{/* Ownership disclosure at the point of choice: the provider finances & owns the repayment. */}
<Typography variant="caption" sx={{ color: 'text.secondary', lineHeight: 1.8 }}>
{t('ownership_note')}
</Typography>
<Stack sx={{ gap: 1 }}>
{options.providers.map((provider) => (
<ProviderOption
key={provider.providerCode}
provider={provider}
selected={selectedProvider === provider.providerCode}
onSelect={() => onSelectProvider(provider.providerCode)}
/>
))}
</Stack>
<AppButton
color="secondary"
variant="contained"
size="large"
disabled={selectedProvider == null}
onClick={onContinue}
>
{selectedProvider
? t('continue_with', { provider: t(`provider_${selectedProvider}`) })
: t('continue')}
</AppButton>
</>
) : (
<EmptyState title={t('no_providers_title')} body={t('no_providers_body')} />
)}
</Stack>
);
};
function ProviderOption({
provider,
selected,
onSelect,
}: {
provider: BnplProvider;
selected: boolean;
onSelect: () => void;
}) {
const t = useTranslations('bnpl');
return (
<ButtonBase
data-provider={provider.providerCode}
data-selected={selected}
aria-pressed={selected}
onClick={onSelect}
sx={{
display: 'block',
width: '100%',
textAlign: 'start',
borderRadius: 'var(--bal-radius-md)',
p: 1.5,
border: '1px solid',
borderColor: selected ? 'var(--bal-secondary)' : 'divider',
borderWidth: selected ? 2 : 1,
backgroundColor: selected ? 'var(--bal-secondary-soft)' : 'transparent',
}}
>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1.5 }}>
<BnplProviderLogo providerCode={provider.providerCode} size={40} />
<Stack sx={{ flex: 1, gap: 0.25 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t(`provider_${provider.providerCode}`)}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t(`provider_tagline_${provider.providerCode}`)}
</Typography>
</Stack>
{/* Decorative radio indicator — the whole card is the ButtonBase; a real <input> here would nest
interactive content inside a <button> (invalid HTML) and warn on checked-without-onChange. */}
<Box
aria-hidden
sx={{
width: 18,
height: 18,
borderRadius: '50%',
flex: 'none',
border: '2px solid',
borderColor: selected ? 'var(--bal-secondary)' : 'var(--bal-divider)',
backgroundColor: selected ? 'var(--bal-secondary)' : 'transparent',
boxShadow: selected ? 'inset 0 0 0 3px var(--bal-bg-paper)' : 'none',
}}
/>
</Stack>
</ButtonBase>
);
}
export default MethodStep;
@@ -0,0 +1,126 @@
'use client';
import { FunctionComponent } from 'react';
import { useTranslations } from 'next-intl';
import { Paper, Stack, Typography } from '@mui/material';
import { AppButton, BnplPlanCard, EmptyState, Money } from '@/components';
import { parseIrr } from '@/utils';
import type { BnplPlanOption, ProviderCode } from '@/services/bnpl/types';
interface PlanStepProps {
providerCode: ProviderCode;
/** D1's payable gross — the interest-free baseline `BnplPlanCard`'s fee delta compares against. */
orderAmountIrr: string;
plans: BnplPlanOption[];
selectedPlanId: string | null;
onSelectPlan: (planId: string) => void;
onContinue: () => 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
* options the contract returned (monthly amount + down-payment %) as a single-select terracotta card group.
* Every amount comes through the money util from served IRR strings — the client computes nothing about
* money. Empty plans → back to D1.
*/
const PlanStep: FunctionComponent<PlanStepProps> = ({
providerCode,
orderAmountIrr,
plans,
selectedPlanId,
onSelectPlan,
onContinue,
onBack,
}) => {
const t = useTranslations('bnpl');
const tc = useTranslations('common');
if (plans.length === 0) {
return (
<Stack sx={{ gap: 2 }}>
<EmptyState title={t('no_plans_title')} body={t('no_plans_body')} />
<AppButton variant="outlined" color="primary" onClick={onBack}>
{t('back_to_providers')}
</AppButton>
</Stack>
);
}
// The plan total is a per-plan served figure (interest-free plans = order gross; fee plans add the fee).
// No default fallback to plans[0] — the header only shows a total once a plan is actually selected, so
// 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 (
<Stack sx={{ gap: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('plan_title', { provider: t(`provider_${providerCode}`) })}
</Typography>
{shownPlan ? (
<Paper
elevation={0}
sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}
>
<Stack sx={{ gap: 0.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('total_amount_named', { plan: termLabelFor(shownPlan, t) })}
</Typography>
<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 }}>
{plans.map((plan) => (
<BnplPlanCard
key={plan.planId}
plan={plan}
orderAmountIrr={orderAmountIrr}
selected={selectedPlanId === plan.planId}
onSelect={onSelectPlan}
/>
))}
</Stack>
<Stack sx={{ gap: 1 }}>
<AppButton
color="secondary"
variant="contained"
size="large"
disabled={selectedPlanId == null}
onClick={onContinue}
>
{t('continue')}
</AppButton>
<AppButton variant="text" color="primary" onClick={onBack}>
{tc('back')}
</AppButton>
</Stack>
</Stack>
);
};
export default PlanStep;
@@ -0,0 +1,174 @@
'use client';
import { FunctionComponent, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Checkbox, CircularProgress, FormControlLabel, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, InstallmentScheduleRow } from '@/components';
import AppAlert from '@/components/common/AppAlert';
import { ApiError } from '@/lib/api/errors';
import { useBnplSchedule, useIssueBnplToken } from '@/services/bnpl';
import type { IssueBnplTokenResult, ProviderCode } from '@/services/bnpl/types';
interface ScheduleStepProps {
bookingRequestId: number;
providerCode: ProviderCode;
planId: string;
onBack: () => void;
/** The provider handoff — the page follows `redirectUrl`. */
onIssued: (result: IssueBnplTokenResult) => void;
/** A `409` (already paid / in progress / window lapsed) — the page converges by reading the order. */
onConverged: () => void;
}
/**
* D4 · تایید طرح و قرارداد — the repayment schedule + contract acceptance. Renders the **served** repayment
* rows (پیش‌پرداخت today + قسط ۱…N with Shamsi due dates + amounts), the ownership-truth note (the
* agreement is customer ↔ provider; Balinyaar is paid in full), and a contract-acceptance checkbox that
* **gates** the final action. «تایید نهایی و پرداخت پیش‌پرداخت» issues the provider token and hands off
* (the page follows the redirect); on success the booking confirms exactly as the card path.
*/
const ScheduleStep: FunctionComponent<ScheduleStepProps> = ({
bookingRequestId,
providerCode,
planId,
onBack,
onIssued,
onConverged,
}) => {
const t = useTranslations('bnpl');
const tc = useTranslations('common');
const { data: schedule, isLoading, isError, refetch } = useBnplSchedule(bookingRequestId, providerCode, planId);
const issue = useIssueBnplToken();
const [accepted, setAccepted] = useState(false);
// One idempotency key per handoff attempt, reused across retries of that attempt (mirrors C6).
const attemptKeyRef = useRef<string | null>(null);
const providerName = t(`provider_${providerCode}`);
const busy = issue.isPending || issue.isSuccess;
const handleConfirm = () => {
attemptKeyRef.current ??= crypto.randomUUID();
issue.mutate(
{ bookingRequestId, providerCode, planId, idempotencyKey: attemptKeyRef.current },
{
onSuccess: (result) => onIssued(result),
onError: (error) => {
if (error instanceof ApiError && error.status === 409) onConverged();
},
},
);
};
// Handoff in progress — the provider redirect is being followed.
if (busy) {
return (
<Paper elevation={0} sx={{ p: 4, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', textAlign: 'center' }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<CircularProgress color="secondary" size="2.5rem" />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('redirecting', { provider: providerName })}
</Typography>
</Stack>
</Paper>
);
}
if (isLoading) return <ScheduleSkeleton />;
if (isError || !schedule) {
return (
<Stack sx={{ gap: 2 }}>
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
{t('error_body')}
</AppAlert>
<AppButton variant="outlined" color="secondary" onClick={() => refetch()}>
{tc('retry')}
</AppButton>
</Stack>
);
}
const inlineError =
issue.error && !(issue.error instanceof ApiError && issue.error.status === 409) ? t('settle_failed_body') : null;
return (
<Stack sx={{ gap: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('schedule_title')}
</Typography>
<Stack sx={{ gap: 1 }}>
{schedule.rows.map((row) => (
<InstallmentScheduleRow key={`${row.kind}-${row.sequence}`} row={row} />
))}
</Stack>
{/* The ownership truth: the installment agreement is customer ↔ provider; Balinyaar is paid in full. */}
<Paper
elevation={0}
sx={{
p: 1.75,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'var(--bal-secondary)',
backgroundColor: 'var(--bal-secondary-soft)',
}}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<AppIcon icon="info" size={18} color="var(--bal-secondary-dark)" />
<Typography variant="caption" sx={{ color: 'var(--bal-secondary-dark)', lineHeight: 1.9 }}>
{t('contract_note', { provider: providerName })}
</Typography>
</Stack>
</Paper>
<FormControlLabel
control={<Checkbox checked={accepted} onChange={(e) => setAccepted(e.target.checked)} color="secondary" />}
label={
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('contract_consent')}
</Typography>
}
sx={{ alignItems: 'flex-start', m: 0 }}
/>
{inlineError ? (
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
{inlineError}
</AppAlert>
) : null}
<Stack sx={{ gap: 1 }}>
<AppButton
color="secondary"
variant="contained"
size="large"
disabled={!accepted}
onClick={handleConfirm}
sx={{ py: 1.25 }}
>
{t('pay_down_payment')}
</AppButton>
<AppButton variant="text" color="primary" onClick={onBack}>
{tc('back')}
</AppButton>
</Stack>
</Stack>
);
};
function ScheduleSkeleton() {
return (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="text" width="40%" height={28} />
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} variant="rounded" height={56} />
))}
<Skeleton variant="rounded" height={64} />
<Skeleton variant="rounded" height={48} />
</Stack>
);
}
export default ScheduleStep;
@@ -0,0 +1,77 @@
'use client';
import { Suspense } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { notFound, useRouter, useSearchParams } from 'next/navigation';
import { Stack } from '@mui/material';
import { AppButton, AppLoading, EmptyState } from '@/components';
import { ROUTES } from '@/constants';
import {
BNPL_QUERY_OUTCOME,
BNPL_QUERY_PLAN,
BNPL_QUERY_PROVIDER,
BNPL_QUERY_REQUEST_ID,
BNPL_QUERY_TRANSACTION_ID,
} from '@/services/bnpl/constants';
import type { BnplHandoffOutcome, ProviderCode } from '@/services/bnpl/types';
/**
* Dev provider-handoff harness — a **test harness, not a product feature**. It stands in for the BNPL
* provider so the initiate → redirect → return round-trip is exercisable without a real provider: the
* mock's `redirectUrl` points here, and the pay/cancel buttons drive both outcome branches of the return
* surface (a real provider redirects back after the customer completes or abandons the agreement). On the
* real path the `redirectUrl` is the provider's absolute URL and this page is never reached.
*/
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 (
<Suspense fallback={<AppLoading />}>
<BnplGatewayScreen />
</Suspense>
);
}
function BnplGatewayScreen() {
const t = useTranslations('bnpl');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const requestId = params.get(BNPL_QUERY_REQUEST_ID) ?? '';
const transactionId = params.get(BNPL_QUERY_TRANSACTION_ID) ?? '';
const provider = (params.get(BNPL_QUERY_PROVIDER) ?? '') as ProviderCode | '';
const providerName = provider ? t(`provider_${provider}`) : t('installments_heading');
const returnWith = (outcome: BnplHandoffOutcome) => {
const query = new URLSearchParams({
[BNPL_QUERY_REQUEST_ID]: requestId,
[BNPL_QUERY_TRANSACTION_ID]: transactionId,
[BNPL_QUERY_PROVIDER]: provider,
[BNPL_QUERY_PLAN]: params.get(BNPL_QUERY_PLAN) ?? '',
[BNPL_QUERY_OUTCOME]: outcome,
});
router.replace(`/${locale}${ROUTES.CHECKOUT_BNPL_RETURN}?${query.toString()}`);
};
return (
<EmptyState
icon="installments"
title={t('handoff_title')}
body={t('handoff_body', { provider: providerName })}
action={
<Stack sx={{ gap: 1.5, alignItems: 'center', width: '100%' }}>
<AppButton color="secondary" variant="contained" size="large" onClick={() => returnWith('success')}>
{t('handoff_pay_success')}
</AppButton>
<AppButton variant="text" color="error" onClick={() => returnWith('failure')}>
{t('handoff_pay_fail')}
</AppButton>
</Stack>
}
/>
);
}
@@ -0,0 +1,199 @@
'use client';
import { Suspense, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppLoading, PaymentStateCard, StepperHeader } from '@/components';
import { ROUTES } from '@/constants';
import { useAuth } from '@/context/auth';
import { useBnplOptions } from '@/services/bnpl';
import { BNPL_QUERY_REQUEST_ID, BNPL_QUERY_TRANSACTION_ID } from '@/services/bnpl/constants';
import { CHECKOUT_QUERY_REQUEST_ID } from '@/services/payment/constants';
import type { BnplEligibilityResult, IssueBnplTokenResult, ProviderCode } from '@/services/bnpl/types';
import MethodStep from './MethodStep';
import PlanStep from './PlanStep';
import EligibilityStep from './EligibilityStep';
import ScheduleStep from './ScheduleStep';
type WizardStep = 'provider' | 'plan' | 'eligibility' | 'schedule';
const STEP_ORDER: WizardStep[] = ['provider', 'plan', 'eligibility', 'schedule'];
/**
* BNPL installment checkout (D1D4) — the alternate branch off C6. A single stateful wizard: D1 method /
* provider → D2 plan → D3 eligibility → D4 schedule + contract, then the provider handoff. On a cleared
* down-payment the return surface routes to the **reused f9 confirmation** (the booking confirms exactly
* as the card path). Reached with `?request_id=`; `useSearchParams` needs a Suspense boundary.
*/
export default function BnplCheckoutPage() {
return (
<Suspense fallback={<AppLoading />}>
<BnplCheckoutScreen />
</Suspense>
);
}
function BnplCheckoutScreen() {
const t = useTranslations('bnpl');
const tb = useTranslations('booking');
const tc = useTranslations('common');
const tp = useTranslations('payment');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const [{ currentUser }] = useAuth();
const requestId = Number(params.get(BNPL_QUERY_REQUEST_ID));
const validId = Number.isInteger(requestId) && requestId > 0;
const { data: options, isLoading, isError, refetch } = useBnplOptions(validId ? requestId : undefined);
const [step, setStep] = useState<WizardStep>('provider');
const [providerCode, setProviderCode] = useState<ProviderCode | null>(null);
const [planId, setPlanId] = useState<string | null>(null);
const [eligibility, setEligibility] = useState<BnplEligibilityResult | null>(null);
const toCard = () => router.replace(`/${locale}${ROUTES.CHECKOUT}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`);
const toBookings = () => router.replace(`/${locale}${ROUTES.BOOKINGS}`);
const toRequest = () => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`);
if (!validId) {
return (
<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) {
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 />;
// Only an accepted, awaiting-payment request is payable — converge/explain otherwise (mirrors C6).
if (options.requestStatus === 'converted') {
return (
<PaymentStateCard icon="verified" tone="var(--bal-success)" title={tp('already_paid_title')} body={tp('already_paid_body')}>
<AppButton variant="contained" color="primary" onClick={toRequest}>
{tb('converted_cta')}
</AppButton>
</PaymentStateCard>
);
}
if (options.requestStatus !== 'accepted_awaiting_payment') {
const expired = options.requestStatus === 'payment_deadline_expired';
return (
<PaymentStateCard
icon="pending"
tone="var(--bal-warning)"
title={expired ? tp('window_expired_title') : tp('not_payable_title')}
body={expired ? tp('window_expired_body') : undefined}
>
<AppButton variant="contained" color="primary" onClick={toCard}>
{t('pay_with_card')}
</AppButton>
</PaymentStateCard>
);
}
const returnUrl = (extra?: Record<string, string>) => {
const query = new URLSearchParams({ [BNPL_QUERY_REQUEST_ID]: String(requestId), ...extra });
return `/${locale}${ROUTES.CHECKOUT_BNPL_RETURN}?${query.toString()}`;
};
const onIssued = (result: IssueBnplTokenResult) => {
if (!result.redirectUrl) {
// Nothing to hand off to — read the order directly on the return surface.
router.push(returnUrl({ [BNPL_QUERY_TRANSACTION_ID]: String(result.bnplTransactionId) }));
return;
}
if (/^https?:\/\//i.test(result.redirectUrl)) {
// The real provider page — a full navigation outside the app router.
window.location.assign(result.redirectUrl);
return;
}
router.push(`/${locale}${result.redirectUrl}`);
};
const activeProvider = options.providers.find((p) => p.providerCode === providerCode);
return (
<Stack sx={{ gap: 2 }}>
<Stack sx={{ gap: 0.25, alignItems: 'center', textAlign: 'center' }}>
<Typography variant="h6" component="h1">
{t('title')}
</Typography>
</Stack>
<StepperHeader
activeStep={STEP_ORDER.indexOf(step)}
steps={[t('step_provider'), t('step_plan'), t('step_eligibility'), t('step_schedule')]}
/>
{step === 'provider' ? (
<MethodStep
options={options}
selectedProvider={providerCode}
onSelectProvider={setProviderCode}
onContinue={() => setStep('plan')}
onPayWithCard={toCard}
/>
) : null}
{step === 'plan' && providerCode && activeProvider ? (
<PlanStep
providerCode={providerCode}
orderAmountIrr={options.orderAmountIrr}
plans={activeProvider.plans}
selectedPlanId={planId}
onSelectPlan={setPlanId}
onContinue={() => setStep('eligibility')}
onBack={() => setStep('provider')}
/>
) : null}
{step === 'eligibility' && providerCode ? (
<EligibilityStep
bookingRequestId={requestId}
providerCode={providerCode}
sessionMobile={currentUser?.phone ?? ''}
initialResult={eligibility}
onApproved={(result) => {
setEligibility(result);
setStep('schedule');
}}
onPayWithCard={toCard}
/>
) : null}
{step === 'schedule' && providerCode && planId ? (
<ScheduleStep
bookingRequestId={requestId}
providerCode={providerCode}
planId={planId}
onBack={() => setStep('eligibility')}
onIssued={onIssued}
onConverged={() => router.replace(returnUrl())}
/>
) : null}
</Stack>
);
}
function WizardSkeleton() {
return (
<Stack sx={{ gap: 2 }}>
<Skeleton variant="text" width="50%" height={32} sx={{ mx: 'auto' }} />
<Skeleton variant="rounded" height={48} />
<Skeleton variant="rounded" height={72} />
<Skeleton variant="rounded" height={64} />
<Skeleton variant="rounded" height={64} />
<Skeleton variant="rounded" height={48} />
</Stack>
);
}
@@ -0,0 +1,156 @@
'use client';
import { Suspense, useEffect, useRef } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query';
import { CircularProgress } from '@mui/material';
import { AppButton, AppLoading, PaymentStateCard } from '@/components';
import { ROUTES } from '@/constants';
import { useAcceptBnplSchedule, useBnplOrder } from '@/services/bnpl';
import { invalidateAfterBnplSettlement } from '@/services/bnpl/invalidations';
import { isBnplSettlementSuccess } from '@/services/bnpl/types';
import {
BNPL_QUERY_OUTCOME,
BNPL_QUERY_PROVIDER,
BNPL_QUERY_REQUEST_ID,
BNPL_QUERY_TRANSACTION_ID,
CHECKOUT_METHOD_BNPL,
CHECKOUT_QUERY_METHOD,
} from '@/services/bnpl/constants';
import {
CHECKOUT_QUERY_BOOKING_ID,
CHECKOUT_QUERY_REQUEST_ID,
} from '@/services/payment/constants';
/**
* Return-from-provider surface — drives the tail of the BNPL checkout: report the return
* (`useAcceptBnplSchedule`; the settle trigger in the mock, an order read on the real path), then a brief
* settle-pending state backed by the bounded order poll until terminal. On settlement: hand off to the
* **reused f9 confirmation** marked «paid via installments» (the booking confirmed exactly as the card
* path); on decline: a retry (a fresh D4 = a new attempt) or the card fall-back; on window-lapse: back to
* the request.
*/
export default function BnplReturnPage() {
return (
<Suspense fallback={<AppLoading />}>
<BnplReturnScreen />
</Suspense>
);
}
function BnplReturnScreen() {
const t = useTranslations('bnpl');
const tp = useTranslations('payment');
const tb = useTranslations('booking');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const queryClient = useQueryClient();
const requestId = Number(params.get(BNPL_QUERY_REQUEST_ID));
const validId = Number.isInteger(requestId) && requestId > 0;
const transactionIdParam = params.get(BNPL_QUERY_TRANSACTION_ID);
const transactionId = transactionIdParam ? Number(transactionIdParam) : null;
const provider = params.get(BNPL_QUERY_PROVIDER) ?? '';
const outcome = params.get(BNPL_QUERY_OUTCOME) === 'failure' ? ('failure' as const) : ('success' as const);
const accept = useAcceptBnplSchedule();
const { mutate: acceptMutate } = accept;
// Fire the settle report exactly once per mount — a refresh replays it (idempotent convergence).
const firedRef = useRef(false);
useEffect(() => {
if (firedRef.current || !validId) return;
firedRef.current = true;
acceptMutate({ bookingRequestId: requestId, bnplTransactionId: transactionId, outcome });
}, [acceptMutate, validId, requestId, transactionId, outcome]);
const settled = accept.isSuccess || accept.isError;
const acceptSawSuccess = accept.data ? isBnplSettlementSuccess(accept.data) : false;
// Poll only for the late-settle case: when the accept result is ALREADY a settlement success (the mock
// down-payment-cleared path), the navigation effect hands off immediately — reading the order would be a
// needless fetch. Poll only when accept resolved without a settlement (real provider callback still in flight).
const orderQuery = useBnplOrder(validId ? requestId : undefined, { enabled: settled && !acceptSawSuccess });
const order = settled ? orderQuery.data : undefined;
const succeeded = acceptSawSuccess || order?.status === 'settled';
const windowExpired = accept.data?.requestStatus === 'payment_deadline_expired';
const failed = !succeeded && !windowExpired && (accept.data?.status === 'failed' || order?.status === 'failed');
// Hand off to the confirmation exactly once. The accept mutation already invalidated on immediate
// success; a success that arrived later through the poll invalidates here instead (never twice).
const navigatedRef = useRef(false);
const bookingId = accept.data?.bookingId ?? order?.bookingId ?? null;
useEffect(() => {
if (!succeeded || navigatedRef.current) return;
navigatedRef.current = true;
if (!acceptSawSuccess) {
invalidateAfterBnplSettlement(queryClient, requestId, bookingId);
}
const query = new URLSearchParams({
[CHECKOUT_QUERY_REQUEST_ID]: String(requestId),
[CHECKOUT_QUERY_METHOD]: CHECKOUT_METHOD_BNPL,
});
if (bookingId != null) query.set(CHECKOUT_QUERY_BOOKING_ID, String(bookingId));
if (provider) query.set(BNPL_QUERY_PROVIDER, provider);
router.replace(`/${locale}${ROUTES.CHECKOUT_CONFIRMATION}?${query.toString()}`);
}, [succeeded, acceptSawSuccess, bookingId, queryClient, requestId, provider, router, locale]);
if (!validId) {
return (
<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}`)}>
{tb('bd_my_bookings')}
</AppButton>
</PaymentStateCard>
);
}
if (windowExpired) {
// 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.
return (
<PaymentStateCard icon="pending" tone="var(--bal-warning)" title={tp('window_expired_title')} body={tp('window_expired_body')}>
<AppButton
variant="contained"
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
>
{tp('back_to_request')}
</AppButton>
</PaymentStateCard>
);
}
if (failed) {
return (
<PaymentStateCard icon="error" tone="var(--bal-error)" title={t('settle_failed_title')} body={t('settle_failed_body')}>
<AppButton
color="secondary"
variant="contained"
size="large"
onClick={() => router.replace(`/${locale}${ROUTES.CHECKOUT_BNPL}?${BNPL_QUERY_REQUEST_ID}=${requestId}`)}
>
{t('retry_installments')}
</AppButton>
<AppButton
variant="text"
onClick={() => router.replace(`/${locale}${ROUTES.CHECKOUT}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)}
>
{t('pay_with_card')}
</AppButton>
</PaymentStateCard>
);
}
// Settle-pending (and the brief succeeded → confirmation hand-off): a calm waiting state.
return (
<PaymentStateCard icon="installments" tone="var(--bal-secondary)" title={t('settling_title')} body={t('settling_body')}>
<CircularProgress color="secondary" size="2.5rem" />
<AppButton variant="text" disabled={orderQuery.isFetching} onClick={() => orderQuery.refetch()}>
{t('check_again')}
</AppButton>
</PaymentStateCard>
);
}
@@ -0,0 +1,213 @@
'use client';
import { Suspense, type ReactNode } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { useSnackbar } from 'notistack';
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 { formatShamsiDateTime } from '@/utils';
import { useCheckoutSummary, usePaymentOutcome } from '@/services/payment';
import { CHECKOUT_QUERY_BOOKING_ID, CHECKOUT_QUERY_REQUEST_ID } from '@/services/payment/constants';
import { useBnplOrder } from '@/services/bnpl';
import {
BNPL_QUERY_PROVIDER,
CHECKOUT_METHOD_BNPL,
CHECKOUT_QUERY_METHOD,
} from '@/services/bnpl/constants';
/**
* Post-payment confirmation — a screenshot-worthy receipt (Iranian users screenshot payment receipts): the
* paid total, a copyable LTR کد پیگیری, the Shamsi payment date-time, the payment method, the booking
* reference, the escrow reassurance, and a "what happens next" 2-step strip. The booking is now
* **confirmed** (flipped by cache invalidation on the return surface, never a blanket refetch). Reused by
* both the f9 card flow and the f11 BNPL branch: reached with `?method=bnpl` it reads the settled BNPL
* 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() {
return (
<Suspense fallback={<AppLoading />}>
<ConfirmationScreen />
</Suspense>
);
}
function ConfirmationScreen() {
const t = useTranslations('payment');
const tc = useTranslations('common');
const tBnpl = useTranslations('bnpl');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const { enqueueSnackbar } = useSnackbar();
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 bookingId = bookingIdParam ? Number(bookingIdParam) : null;
const isBnpl = params.get(CHECKOUT_QUERY_METHOD) === CHECKOUT_METHOD_BNPL;
const bnplProvider = params.get(BNPL_QUERY_PROVIDER) ?? '';
const {
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 (
<Stack sx={{ gap: 3, alignItems: 'center', textAlign: 'center' }}>
<AppIcon icon="verified" size={64} color="var(--bal-success)" />
<Stack sx={{ gap: 0.5 }}>
<Typography variant="h6" component="h1">
{t('confirm_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('confirm_subtitle')}
</Typography>
</Stack>
{isLoading ? (
<ReceiptSkeleton />
) : isError || !summary ? (
<ErrorState message={t('error_body')} retryLabel={tc('retry')} onRetry={() => refetch()} />
) : (
<SurfaceCard sx={{ width: '100%' }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('total_paid_label')}
</Typography>
<Money amountIrr={summary.totalIrr} tone="emphasis" size="xl" sx={{ fontWeight: 800 }} />
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{summary.variantLabel} · {summary.nurseName}
</Typography>
<Divider sx={{ width: '100%', my: 0.5 }} />
<Stack sx={{ width: '100%', gap: 1 }}>
{trackingCode ? (
<ReceiptRow label={t('receipt_tracking_code_label')}>
<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>
</SurfaceCard>
)}
<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%' }}>
<AppButton
color="primary"
variant="contained"
size="large"
onClick={() =>
router.push(`/${locale}${bookingId != null ? `${ROUTES.BOOKINGS}/${bookingId}` : ROUTES.BOOKINGS}`)
}
>
{t('view_booking')}
</AppButton>
{bookingId != null ? (
<AppButton
variant="outlined"
color="primary"
startIcon="document"
onClick={() => router.push(`/${locale}${bookingInvoicePath(bookingId)}`)}
>
{t('download_invoice')}
</AppButton>
) : null}
</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>
);
}
@@ -0,0 +1,355 @@
'use client';
import { Suspense, useRef } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { Avatar, Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import {
AppButton,
AppIcon,
AppLoading,
CountdownTimer,
EscrowExplainer,
Money,
PaymentStateCard,
PriceBreakdown,
StatusChip,
TrustBadge,
} from '@/components';
import AppAlert from '@/components/common/AppAlert';
import StickyActionBar from '@/components/common/StickyActionBar';
import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors';
import { formatShamsiDate, localeTag } from '@/utils';
import { useCheckoutSummary, useInitiatePayment } from '@/services/payment';
import {
BNPL_ENABLED,
CHECKOUT_QUERY_REQUEST_ID,
CHECKOUT_QUERY_TRANSACTION_ID,
} from '@/services/payment/constants';
import type { CheckoutSummaryDto } from '@/services/payment/types';
/**
* C6 — خلاصه و پرداخت (summary & pay). The acceptance badge, the identity moment (nurse avatar + verified
* badge), a prominent total, the served & reconciling service-cost / commission / VAT / total breakdown,
* the load-bearing escrow trust notice, and a safe-area-aware sticky pay bar that initiates the card
* payment and follows the gateway redirect. Reached from C5's accept CTA with `?request_id=`.
* `useSearchParams` needs a Suspense boundary.
*/
export default function CheckoutPage() {
return (
<Suspense fallback={<AppLoading />}>
<CheckoutScreen />
</Suspense>
);
}
function CheckoutScreen() {
const t = useTranslations('payment');
const tb = useTranslations('booking');
const tc = useTranslations('common');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const requestId = Number(params.get(CHECKOUT_QUERY_REQUEST_ID));
const validId = Number.isInteger(requestId) && requestId > 0;
const { data: summary, isLoading, isError, refetch } = useCheckoutSummary(validId ? requestId : undefined);
const initiate = useInitiatePayment();
// One idempotency key per payment ATTEMPT: created lazily on the first tap and reused across retries
// of the same attempt (double-tap, transient network error). A new attempt — after a failed outcome the
// user comes back to a fresh mount of this screen — gets a new key.
const attemptKeyRef = useRef<string | null>(null);
// A malformed/missing request_id can never load — navigation, not a retry that would fire
// `checkout_summary/undefined` (a manual refetch() bypasses the query's `enabled` gate).
if (!validId) {
return (
<PaymentStateCard icon="error" tone="var(--bal-error)" title={t('error_title')} body={t('invalid_link_body')}>
<AppButton variant="contained" color="primary" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}>
{tb('bd_my_bookings')}
</AppButton>
</PaymentStateCard>
);
}
if (isError) {
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 || !summary) return <CheckoutSkeleton />;
const returnUrl = (extra?: Record<string, string>) => {
const query = new URLSearchParams({ [CHECKOUT_QUERY_REQUEST_ID]: String(requestId), ...extra });
return `/${locale}${ROUTES.CHECKOUT_RETURN}?${query.toString()}`;
};
// Anything other than "awaiting payment" cannot show a pay CTA — converge or explain instead.
if (summary.requestStatus === 'converted') {
return (
<PaymentStateCard
icon="verified"
tone="var(--bal-success)"
title={t('already_paid_title')}
body={t('already_paid_body')}
>
<AppButton variant="contained" color="primary" onClick={() => router.replace(returnUrl())}>
{tb('converted_cta')}
</AppButton>
</PaymentStateCard>
);
}
if (summary.requestStatus !== 'accepted_awaiting_payment') {
const expired = summary.requestStatus === 'payment_deadline_expired';
return (
<PaymentStateCard
icon="pending"
tone="var(--bal-warning)"
title={expired ? t('window_expired_title') : t('not_payable_title')}
body={expired ? t('window_expired_body') : undefined}
>
<AppButton
variant="contained"
color="primary"
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
>
{t('back_to_request')}
</AppButton>
</PaymentStateCard>
);
}
const handlePay = () => {
attemptKeyRef.current ??= crypto.randomUUID();
initiate.mutate(
{ bookingRequestId: requestId, idempotencyKey: attemptKeyRef.current },
{
onSuccess: (result) => {
if (!result.redirectUrl) {
// No gateway hop to make — read the outcome directly.
router.push(returnUrl({ [CHECKOUT_QUERY_TRANSACTION_ID]: String(result.transactionId) }));
return;
}
if (/^https?:\/\//i.test(result.redirectUrl)) {
// The real PSP page — a full navigation, outside the app router.
window.location.assign(result.redirectUrl);
return;
}
router.push(`/${locale}${result.redirectUrl}`);
},
onError: (error) => {
if (error instanceof ApiError && error.status === 409) {
// "Already paid / already in progress / window lapsed" — benign convergence, never a toast:
// the return surface reads the actual outcome and routes accordingly.
router.replace(returnUrl());
}
},
},
);
};
const busy = initiate.isPending || initiate.isSuccess;
// ApiError.message is a raw server/network string, never localized — always show the i18n copy.
const inlineError =
initiate.error && !(initiate.error instanceof ApiError && initiate.error.status === 409)
? t('initiate_failed')
: 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 (
<Stack sx={{ gap: 3 }}>
<Stack sx={{ gap: 1, alignItems: 'center', textAlign: 'center' }}>
<StatusChip status="verified" label={tb('accepted_badge')} />
<Typography variant="h6" component="h1">
{t('title_checkout')}
</Typography>
<AppButton
variant="text"
color="primary"
size="small"
startIcon="chevron_start"
onClick={() => router.push(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
sx={{ px: 0.5 }}
>
{t('back_to_request')}
</AppButton>
</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>
);
}
/** 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 }) {
const start = new Date(`${summary.requestedDate}T${summary.requestedTimeStart}`);
const end = new Date(`${summary.requestedDate}T${summary.requestedTimeEnd}`);
const timeFmt = new Intl.DateTimeFormat(localeTag(locale), {
hour: '2-digit',
minute: '2-digit',
});
return (
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<Avatar
src={summary.nurseAvatarUrl ?? undefined}
sx={{ width: 48, height: 48, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}
>
{summary.nurseName.trim().charAt(0)}
</Avatar>
<Stack sx={{ gap: 0.5, flex: 1, minWidth: 0 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<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>
</Paper>
);
}
function CheckoutSkeleton() {
return (
<Stack sx={{ gap: 3 }}>
<Stack sx={{ gap: 1, alignItems: 'center' }}>
<Skeleton variant="rounded" width={140} height={24} />
<Skeleton variant="text" width="50%" height={32} />
</Stack>
<Skeleton variant="rounded" height={96} />
<Skeleton variant="text" width="40%" height={48} sx={{ mx: 'auto' }} />
<Skeleton variant="rounded" height={64} />
<Skeleton variant="rounded" height={160} />
<Skeleton variant="rounded" height={56} />
<Skeleton variant="rounded" height={48} />
</Stack>
);
}
@@ -0,0 +1,169 @@
'use client';
import { Suspense, useEffect, useRef } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query';
import { Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppLoading, PaymentStateCard, StatusTimeline, type TimelineNode } from '@/components';
import { ROUTES } from '@/constants';
import { useConfirmGatewayReturn, usePaymentOutcome } from '@/services/payment';
import { invalidateAfterPaymentSuccess } from '@/services/payment/invalidations';
import {
CHECKOUT_QUERY_BOOKING_ID,
CHECKOUT_QUERY_OUTCOME,
CHECKOUT_QUERY_REQUEST_ID,
CHECKOUT_QUERY_TRANSACTION_ID,
} from '@/services/payment/constants';
/**
* 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
* **pending-callback** state — a staged 2-node progress («بازگشت از درگاه ✓» → «در انتظار تایید بانک»,
* the calm animated `StatusTimeline` `current` pulse) with an expected-duration hint, backed by the
* backoff poll ("PSP received ≠ cash in bank" — pending is normal, reflected calmly) — until a terminal
* 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() {
return (
<Suspense fallback={<AppLoading />}>
<ReturnScreen />
</Suspense>
);
}
const PENDING_NODES = (returnedLabel: string, confirmingLabel: string): TimelineNode[] => [
{ key: 'returned', label: returnedLabel, state: 'completed' },
{ key: 'confirming', label: confirmingLabel, state: 'current' },
];
function ReturnScreen() {
const t = useTranslations('payment');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const queryClient = useQueryClient();
const requestId = Number(params.get(CHECKOUT_QUERY_REQUEST_ID));
const validId = Number.isInteger(requestId) && requestId > 0;
const transactionIdParam = params.get(CHECKOUT_QUERY_TRANSACTION_ID);
const transactionId = transactionIdParam ? Number(transactionIdParam) : null;
const gatewayOutcome = params.get(CHECKOUT_QUERY_OUTCOME) === 'failure' ? ('failure' as const) : ('success' as const);
const confirm = useConfirmGatewayReturn();
const { mutate: confirmMutate } = confirm;
// Fire the return report exactly once per mount — a browser refresh on this page replays it, which the
// backend/mock treat as a duplicate (idempotent convergence, per the webhook-dedup rule).
const confirmFiredRef = useRef(false);
useEffect(() => {
if (confirmFiredRef.current || !validId) return;
confirmFiredRef.current = true;
confirmMutate({ bookingRequestId: requestId, transactionId, outcome: gatewayOutcome });
}, [confirmMutate, validId, requestId, transactionId, gatewayOutcome]);
// The poll takes over once the return report settles (its result is primed into the outcome key).
const confirmSettled = confirm.isSuccess || confirm.isError;
const outcomeQuery = usePaymentOutcome(validId ? requestId : undefined, {
enabled: confirmSettled,
});
// Trust the outcome cache only after THIS mount's report settled — a previous attempt's failed outcome
// survives in the cache and would otherwise flash a false "payment failed" (with a live retry CTA)
// while the current attempt's capture is still in flight.
const outcome = confirmSettled ? outcomeQuery.data : undefined;
const succeeded = outcome?.transactionStatus === 'succeeded' || outcome?.requestStatus === 'converted';
const windowExpired = outcome?.requestStatus === 'payment_deadline_expired';
const failed = !succeeded && !windowExpired && outcome?.transactionStatus === 'failed';
// Hand off to the confirmation exactly once. The confirm mutation already invalidated on an immediate
// success; a success that arrived later through the poll invalidates here instead (never twice).
const navigatedRef = useRef(false);
const confirmSawSuccess = confirm.data?.transactionStatus === 'succeeded';
useEffect(() => {
if (!succeeded || navigatedRef.current || !outcome) return;
navigatedRef.current = true;
if (!confirmSawSuccess) {
invalidateAfterPaymentSuccess(queryClient, outcome.bookingRequestId, outcome.bookingId);
}
const query = new URLSearchParams({ [CHECKOUT_QUERY_REQUEST_ID]: String(outcome.bookingRequestId) });
if (outcome.bookingId != null) query.set(CHECKOUT_QUERY_BOOKING_ID, String(outcome.bookingId));
router.replace(`/${locale}${ROUTES.CHECKOUT_CONFIRMATION}?${query.toString()}`);
}, [succeeded, outcome, confirmSawSuccess, queryClient, router, locale]);
if (!validId) {
return (
<PaymentStateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
<AppButton variant="contained" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}>
{t('view_booking')}
</AppButton>
</PaymentStateCard>
);
}
if (windowExpired) {
return (
<PaymentStateCard
icon="pending"
tone="var(--bal-warning)"
title={t('window_expired_title')}
body={t('window_expired_body')}
>
<AppButton
variant="contained"
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
>
{t('back_to_request')}
</AppButton>
</PaymentStateCard>
);
}
if (failed) {
return (
<PaymentStateCard icon="error" tone="var(--bal-error)" title={t('state_failed_title')} body={t('state_failed_hint')}>
<Stack sx={{ gap: 1, width: '100%' }}>
<AppButton
color="secondary"
variant="contained"
size="large"
onClick={() =>
router.replace(`/${locale}${ROUTES.CHECKOUT}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)
}
>
{t('retry_payment')}
</AppButton>
<AppButton
variant="text"
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
>
{t('back_to_request')}
</AppButton>
</Stack>
</PaymentStateCard>
);
}
// Pending-callback (and the brief succeeded → confirmation hand-off): a staged 2-node progress instead
// of a bare spinner+chip+title stack — the flow's calmest, most designed wait state.
return (
<Paper elevation={0} sx={{ p: 4, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 2.5, alignItems: 'center' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, textAlign: 'center' }}>
{t('state_pending_title')}
</Typography>
<Stack sx={{ alignSelf: 'stretch', maxWidth: 320, mx: 'auto' }}>
<StatusTimeline nodes={PENDING_NODES(t('stage_returned'), t('stage_confirming'))} />
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}>
{t('state_pending_duration_hint')}
</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>
</Paper>
);
}
@@ -1,8 +1,13 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { PlaceholderScreen } from '@/components';
import BookingsScreen from './BookingsScreen';
export default async function BookingsPage() {
const t = await getTranslations('nav');
const tShell = await getTranslations('shell');
return <PlaceholderScreen icon="bookings" title={t('bookings')} description={tShell('placeholder_body')} />;
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'booking' });
return { title: t('list_title') };
}
export default function Page() {
return <BookingsScreen />;
}
@@ -0,0 +1,368 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import { Paper, Skeleton, Stack, Typography } from '@mui/material';
import {
AppButton,
AppIcon,
BookingRequestSummaryCard,
ConfirmDialog,
CountdownTimer,
StatusChip,
StepperHeader,
} from '@/components';
import { ROUTES } from '@/constants';
import { useBookingRequest, useCancelBookingRequest } from '@/services/bookingRequests';
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
* request (`useBookingRequest`, stopping at a terminal status) so the accept / reject / expire transition
* surfaces without a manual refresh. It renders the shared summary card, the 3-step tracker, and a
* countdown driven by the **server-frozen** deadline: the response window while pending, then the 30-min
* payment window once accepted (with the hand-off to checkout). Terminal states show their own card.
*/
export default function BookingRequestStatusPage() {
const t = useTranslations('booking');
const locale = useLocale();
const router = useRouter();
const params = useParams<{ id: string }>();
const id = Number(params.id);
const { data: request, isLoading, isError, refetch } = useBookingRequest(
Number.isInteger(id) && id > 0 ? id : undefined,
'customer',
);
const cancelRequest = useCancelBookingRequest();
const [confirmCancel, setConfirmCancel] = useState(false);
if (isLoading) return <StatusSkeleton />;
if (isError || !request) {
return (
<TerminalCard
icon="error"
tone="var(--bal-error)"
title={t('error_title')}
primary={{ label: t('retry'), onClick: () => refetch() }}
/>
);
}
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 summary = (
<BookingRequestSummaryCard
nurseName={request.nurseName}
nurseRating={request.nurseRating}
patientName={request.patientName}
variantLabel={request.variantLabel}
variantPrice={request.variantPrice}
variantPriceUnit={request.variantPriceUnit}
addressLabel={addressLabel}
requestedDate={request.requestedDate}
requestedTimeStart={request.requestedTimeStart}
requestedTimeEnd={request.requestedTimeEnd}
/>
);
// Terminal states — each is its own card with a re-request path back into discovery (or booking).
if (request.status === 'rejected_by_nurse') {
const canRetrySameNurse = rejectionAllowsSameNurseRetry(request.nurseRejectionReason);
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard
icon="rejected"
tone="var(--bal-error)"
title={t('rejected_title')}
body={request.nurseRejectionReason ? `${t('rejected_reason_label')}: ${request.nurseRejectionReason}` : undefined}
primary={
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>
);
}
if (request.status === 'expired_no_response') {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<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>
);
}
if (request.status === 'payment_deadline_expired') {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard
icon="pending"
tone="var(--bal-warning)"
title={t('payment_expired_title')}
primary={{ label: t('terminal_rerequest'), onClick: goToSearch }}
/>
</Stack>
);
}
if (request.status === 'cancelled_by_customer') {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard
icon="rejected"
tone="var(--bal-text-secondary)"
title={t('cancelled_title')}
primary={{ label: t('terminal_rerequest'), onClick: goToSearch }}
/>
</Stack>
);
}
if (request.status === 'converted') {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard
icon="verified"
tone="var(--bal-success)"
title={t('converted_title')}
primary={{
label: t('converted_cta'),
// Deep-link the booking when the id is known (client-augmented, REQ-017); list fallback otherwise.
onClick: () =>
router.push(
`/${locale}${request.bookingId != null ? `${ROUTES.BOOKINGS}/${request.bookingId}` : ROUTES.BOOKINGS}`,
),
}}
/>
</Stack>
);
}
const accepted = request.status === 'accepted_awaiting_payment';
const activeStep = accepted ? 2 : 1;
return (
<Stack sx={{ gap: 3 }}>
<Stack sx={{ gap: 0.5, alignItems: 'center', textAlign: 'center' }}>
<AppIcon icon="pending" size={40} color="var(--bal-primary)" />
<Typography variant="h6" component="h1">
{t('awaiting_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('awaiting_subtitle')}
</Typography>
</Stack>
<StepperHeader
steps={[t('step_submitted'), t('step_awaiting'), t('step_payment')]}
activeStep={activeStep}
/>
{summary}
{accepted ? (
<Paper
elevation={0}
sx={{
p: 2.5,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStartColor: 'var(--bal-secondary)',
}}
>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<StatusChip status="verified" label={t('accepted_badge')} />
<Typography variant="body2" sx={{ color: 'text.secondary', textAlign: 'center' }}>
{t('accepted_body')}
</Typography>
{request.paymentDeadlineAt ? (
<CountdownTimer
deadlineIso={request.paymentDeadlineAt}
label={t('payment_countdown_label')}
elapsedText={t('payment_elapsed')}
urgent
onElapsed={() => refetch()}
/>
) : null}
<AppButton
color="secondary"
variant="contained"
size="large"
endIcon="forward"
onClick={() => router.push(`/${locale}${ROUTES.CHECKOUT}?request_id=${request.id}`)}
sx={{ py: 1.25 }}
>
{t('continue_payment')}
</AppButton>
</Stack>
</Paper>
) : (
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
<Stack sx={{ gap: 1, alignItems: 'center' }}>
<CountdownTimer
deadlineIso={request.nurseResponseDeadlineAt}
windowStart={request.createdAt}
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>
)}
<AppButton
variant="text"
color="error"
disabled={cancelRequest.isPending}
onClick={() => setConfirmCancel(true)}
sx={{ alignSelf: 'center' }}
>
{cancelRequest.isPending ? t('cancelling') : t('cancel_request')}
</AppButton>
<ConfirmDialog
open={confirmCancel}
title={t('cancel_confirm_title')}
body={t('cancel_confirm_body')}
cancelLabel={t('cancel_confirm_keep')}
confirmLabel={t('cancel_confirm_destructive')}
confirmColor="error"
loading={cancelRequest.isPending}
onClose={() => setConfirmCancel(false)}
onConfirm={() => {
setConfirmCancel(false);
cancelRequest.mutate(request.id);
}}
/>
</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. */
function customerAddressLabel(request: BookingRequestDto, locale: string, wholeCityLabel: string): string {
const city = locale === 'en' ? request.cityNameEn : request.cityNameFa;
const district =
request.districtId == null ? wholeCityLabel : locale === 'en' ? request.districtNameEn : request.districtNameFa;
return `${request.addressTitle} · ${city} · ${district}`;
}
interface TerminalAction {
label: string;
onClick: () => void;
}
function TerminalCard({
icon,
tone,
title,
body,
primary,
secondary,
}: {
icon: string;
tone: string;
title: string;
body?: string;
primary: TerminalAction;
secondary?: TerminalAction;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: body ? 0.5 : 2 }}>
{title}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{body}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 1, justifyContent: 'center', flexWrap: 'wrap' }}>
<AppButton variant="contained" color="primary" onClick={primary.onClick}>
{primary.label}
</AppButton>
{secondary ? (
<AppButton variant="outlined" color="primary" onClick={secondary.onClick}>
{secondary.label}
</AppButton>
) : null}
</Stack>
</Paper>
);
}
function StatusSkeleton() {
return (
<Stack sx={{ gap: 3 }}>
<Stack sx={{ gap: 1, alignItems: 'center' }}>
<Skeleton variant="circular" width={44} height={44} />
<Skeleton variant="text" width="60%" height={28} />
</Stack>
<Skeleton variant="rounded" height={72} />
<Skeleton variant="rounded" height={160} />
<Skeleton variant="rounded" height={96} />
</Stack>
);
}
@@ -0,0 +1,727 @@
'use client';
import { Suspense, useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import {
Avatar,
Box,
Chip,
MenuItem,
Skeleton,
Stack,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
} from '@mui/material';
import {
AppButton,
AppIcon,
AppLoading,
EmptyState,
JalaliDateIntentPicker,
PriceDisplay,
RhfControlGroup,
RhfTextField,
StepperHeader,
TrustBadge,
} from '@/components';
import { todayIso } from '@/components/common/JalaliDatePicker';
import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors';
import { usePatients } from '@/services/patients';
import { useAddresses } from '@/services/addresses';
import { useNurseProfile } from '@/services/search';
import type { NurseProfile } from '@/services/search/types';
import { useCreateBookingRequest } from '@/services/bookingRequests';
import { CUSTOMER_NOTES_MAX_LENGTH } from '@/services/bookingRequests/constants';
import { formatNumber } from '@/utils';
import type {
BookingRequestDisplayContext,
RequiredCaregiverGender,
} from '@/services/bookingRequests/types';
import type { CustomerAddress } from '@/services/addresses/types';
import type { Patient } from '@/services/patients/types';
const GENDER_OPTIONS: RequiredCaregiverGender[] = ['female', 'male', 'any'];
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
* `nurse_id`, an optional `variant_id`, and the same-gender `required_gender` intent from search). The
* family picks a patient (f2), one of the nurse's service variants (f4/search profile), a saved address
* (f3), a future date + time window, the **first-class caregiver-gender** preference, and stage-1 notes,
* then sends the request → lands on C5. Money-free: no price breakdown, no booking row (that's f9/b9).
* `useSearchParams` requires a Suspense boundary.
*/
export default function BookingRequestFormPage() {
return (
<Suspense fallback={<AppLoading />}>
<BookingRequestForm />
</Suspense>
);
}
/**
* Resolves the URL hand-off and waits for every list the form defaults off before mounting it.
*
* The wait is load-bearing rather than cosmetic: the variant and address fields default to "the one
* carried in the URL, else the nurse's first service / the primary address", and those defaults can
* only be computed once the lists exist. Previously the form mounted immediately and re-derived the
* effective value on every render (`variantSel !== '' ? variantSel : firstVariantId`), which meant the
* *stored* value and the *shown* value could disagree, and neither field could carry a plain required
* rule. Mounting once with real `defaultValues` makes the stored value the only value.
*/
function BookingRequestForm() {
const t = useTranslations('booking');
const locale = useLocale();
const router = useRouter();
const query = useSearchParams();
const nurseId = Number(query.get('nurse_id'));
const hasNurse = Number.isInteger(nurseId) && nurseId > 0;
const variantIdParam = Number(query.get('variant_id')) || null;
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 patientsQuery = usePatients();
const addressesQuery = useAddresses();
if (!hasNurse) {
return (
<EmptyState
icon="search"
title={t('missing_nurse_title')}
body={t('missing_nurse_body')}
action={
<AppButton
variant="contained"
color="primary"
onClick={() => router.push(`/${locale}${ROUTES.SEARCH}`)}
sx={{ m: 0 }}
>
{t('missing_nurse_cta')}
</AppButton>
}
/>
);
}
if (profileQuery.isLoading || patientsQuery.isLoading || addressesQuery.isLoading) return <FormSkeleton />;
return (
<RequestForm
nurseId={nurseId}
profile={profileQuery.data}
patients={patientsQuery.data?.items ?? []}
addresses={addressesQuery.data?.items ?? []}
carried={{
variantId: variantIdParam,
patientId: patientIdParam,
addressId: addressIdParam,
gender: genderParam === 'male' || genderParam === 'female' ? genderParam : null,
}}
/>
);
}
function RequestForm({
nurseId,
profile,
patients,
addresses,
carried,
}: {
nurseId: number;
profile: NurseProfile | undefined;
patients: Patient[];
addresses: CustomerAddress[];
carried: {
variantId: number | null;
patientId: number | null;
addressId: number | null;
gender: RequiredCaregiverGender | null;
};
}) {
const t = useTranslations('booking');
const locale = useLocale();
const router = useRouter();
const createRequest = useCreateBookingRequest();
const services = useMemo(() => profile?.services ?? [], [profile]);
const [addressEditing, setAddressEditing] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const primaryAddressId: number | '' =
addresses.length > 0 ? (addresses.find((address) => address.isPrimary)?.id ?? addresses[0].id) : '';
const form = useForm<RequestFormValues>({
mode: 'onTouched',
defaultValues: {
patientId: carried.patientId ?? '',
variantId: carried.variantId ?? (services.length > 0 ? services[0].variantId : ''),
addressId: carried.addressId ?? primaryAddressId,
gender: carried.gender ?? '',
date: '',
window: null,
timeStart: '',
timeEnd: '',
notes: '',
},
});
const { control, handleSubmit, setValue, getValues } = form;
const values = useWatch({ control });
const patientId = values.patientId ?? '';
const variantId = values.variantId ?? '';
const addressId = values.addressId ?? '';
const gender = values.gender ?? '';
const notes = values.notes ?? '';
const windowSel = values.window ?? null;
const selectedVariant = services.find((service) => service.variantId === variantId);
const selectedAddress = addresses.find((address) => address.id === addressId);
const selectedPatient = patients.find((patient) => patient.id === patientId);
// A concrete gender that contradicts the (single) nurse's gender is a same-gender mismatch (400) —
// block it inline before the round-trip; the server re-validates and is authoritative.
const genderMismatch = gender !== '' && gender !== 'any' && profile != null && gender !== profile.nurseGender;
const missingFieldLabels: string[] = [];
if (patientId === '') missingFieldLabels.push(t('cta_missing_patient'));
if (variantId === '') missingFieldLabels.push(t('cta_missing_service'));
if (addressId === '') missingFieldLabels.push(t('cta_missing_address'));
if (values.date === '') missingFieldLabels.push(t('cta_missing_date'));
if (values.timeStart === '' || values.timeEnd === '') missingFieldLabels.push(t('cta_missing_time'));
if (gender === '') missingFieldLabels.push(t('cta_missing_gender'));
const requiredChosen = missingFieldLabels.length === 0;
const regionLabel = (address: CustomerAddress): string => {
const city = locale === 'en' ? address.cityNameEn : address.cityNameFa;
const district =
address.districtId == null
? t('address_whole_city')
: locale === 'en'
? address.districtNameEn
: address.districtNameFa;
return `${address.title} · ${city} · ${district}`;
};
const selectWindow = (option: TimeWindowOption) => {
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);
if (genderMismatch) return;
const context: BookingRequestDisplayContext | undefined =
profile && selectedVariant && selectedAddress && selectedPatient
? {
nurseName: profile.nurseName,
nurseRating: profile.averageRating,
nurseTotalReviews: profile.totalReviews,
patientName: selectedPatient.displayName,
variantLabel: selectedVariant.displayName,
variantPriceUnit: selectedVariant.priceUnit,
variantPrice: selectedVariant.priceIrr,
addressTitle: selectedAddress.title,
cityId: selectedAddress.cityId,
cityNameFa: selectedAddress.cityNameFa,
cityNameEn: selectedAddress.cityNameEn,
districtId: selectedAddress.districtId,
districtNameFa: selectedAddress.districtNameFa,
districtNameEn: selectedAddress.districtNameEn,
addressLine: selectedAddress.addressLine,
postalCode: selectedAddress.postalCode,
recipientName: selectedAddress.recipientName,
recipientPhone: selectedAddress.recipientPhone,
}
: undefined;
createRequest.mutate(
{
payload: {
nurseId,
variantId: formValues.variantId as number,
patientId: formValues.patientId as number,
customerAddressId: formValues.addressId as number,
requestedDate: formValues.date,
requestedTimeStart: `${formValues.timeStart}:00`,
requestedTimeEnd: `${formValues.timeEnd}:00`,
requiredCaregiverGender: formValues.gender as RequiredCaregiverGender,
customerNotes: formValues.notes.trim() || null,
},
context,
},
{
onSuccess: (dto) => router.push(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${dto.id}`),
onError: (error) => setFormError(mapCreateError(error, t)),
},
);
};
return (
<FormProvider {...form}>
<Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 3 }}>
{profile ? <NurseIdentityBar profile={profile} /> : null}
<Box>
<Typography variant="h5" component="h1">
{t('request_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('form_subtitle')}
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>
{t('whathappens_title')}
</Typography>
<StepperHeader steps={[t('step_submitted'), t('step_awaiting'), t('step_payment')]} activeStep={0} />
</Box>
{/* 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 }}>
{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
label={t('address_label')}
rules={{ validate: (value) => value !== '' || t('error_address_required') }}
// Collapses back to the compact summary row once the menu closes — picking a different
// address is the normal exit, and dismissing without picking leaves the current one shown.
slotProps={{ select: { onClose: () => setAddressEditing(false) } }}
fullWidth
>
<MenuItem value="" disabled>
{t('address_placeholder')}
</MenuItem>
{addresses.map((address) => (
<MenuItem key={address.id} value={address.id}>
{address.title} · {locale === 'en' ? address.cityNameEn : address.cityNameFa}
</MenuItem>
))}
</RhfTextField>
) : (
<Stack
direction="row"
sx={{
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>
{selectedAddress.addressLine ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }} noWrap>
{selectedAddress.addressLine}
</Typography>
) : null}
</Stack>
<AppButton variant="text" size="small" onClick={() => setAddressEditing(true)} sx={{ flexShrink: 0 }}>
{t('address_change_cta')}
</AppButton>
</Stack>
)}
{/* Date — the past-date guard is a cross-field rule against the chosen start time. */}
<RhfControlGroup<RequestFormValues>
name="date"
label={t('date_label')}
rules={{
validate: {
chosen: (value) => value !== '' || t('error_date_required'),
future: (value, all) =>
!all.timeStart || Date.parse(`${value}T${all.timeStart}`) >= Date.now() || t('error_past_date'),
},
}}
>
{({ field }) => (
<JalaliDateIntentPicker
value={(field.value as string) ?? ''}
onChange={field.onChange}
min={todayIso()}
todayLabel={t('date_today')}
tomorrowLabel={t('date_tomorrow')}
pickOtherLabel={t('date_pick_other')}
/>
)}
</RhfControlGroup>
{/* 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>
<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 ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_gender_mismatch')}
</Typography>
) : 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>
</FormProvider>
);
}
/** The sticky "who you're inviting home" identity summary — avatar, name, rating, trust badge, gender. */
function NurseIdentityBar({ profile }: { profile: NurseProfile }) {
const t = useTranslations('booking');
const locale = useLocale();
const name = profile.nurseName.trim() || t('unnamed_nurse');
const ratingLabel = formatNumber(profile.averageRating, locale, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
return (
<Stack
direction="row"
data-nurse-identity-bar
sx={{
position: 'sticky',
// Sticks just below the shell's pinned header rather than behind it (AppFrame publishes the
// height); `0px` in a chrome-free shell.
top: 'var(--bal-chrome-top, 0px)',
zIndex: 2,
gap: 1.5,
alignItems: 'center',
p: 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 }}
>
{name.charAt(0)}
</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>
);
}
/** Map a create `ApiError` (domain 400/404 codes) to a translated, user-facing message. */
function mapCreateError(error: unknown, t: (key: string) => string): string {
if (error instanceof ApiError) {
switch (error.code) {
case 'gender_required':
return t('error_gender_required');
case 'invalid_time_range':
return t('error_time_range');
case 'past_date':
return t('error_past_date');
case 'notes_too_long':
return t('error_notes_long');
case 'gender_mismatch':
return t('error_gender_mismatch');
case 'inactive_variant':
case 'not_bookable':
return t('error_not_bookable');
case 'not_found':
return t('error_tenancy');
default:
break;
}
if (error.status === 404) return t('error_tenancy');
}
return t('error_generic');
}
function FieldEmpty({
label,
message,
ctaLabel,
onCta,
}: {
label: string;
message: string;
ctaLabel: string;
onCta: () => void;
}) {
return (
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{label}
</Typography>
<EmptyState
title={message}
action={
<AppButton variant="outlined" color="primary" startIcon="add" onClick={onCta}>
{ctaLabel}
</AppButton>
}
/>
</Stack>
);
}
function FormSkeleton() {
return (
<Stack sx={{ gap: 2.5 }}>
<Skeleton variant="rounded" height={76} />
<Skeleton variant="text" width="50%" height={36} />
<Skeleton variant="rounded" height={48} />
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={56} />
))}
<Skeleton variant="rounded" height={96} />
</Stack>
);
}
@@ -1,12 +1,20 @@
'use client';
import type { ReactNode } from 'react';
import { CustomerLayout } from '@/layout';
import { RoleGuard } from '@/components/auth';
import { APP_ROLES } from '@/constants';
/*
* Customer (family) route group — the primary mobile-first experience with the
* 5-tab bottom nav. A route group `(customer)` adds chrome without adding a URL
* segment, so these screens live at the app root (/, /bookings, /patients, …).
* RoleGuard gates it on a resolved customer role: a pure nurse lands on /nurse,
* a role-less user on /select-role — never the family app as a loading stand-in.
*/
export default function CustomerRouteLayout({ children }: { children: ReactNode }) {
return <CustomerLayout>{children}</CustomerLayout>;
return (
<RoleGuard expected={APP_ROLES.CUSTOMER}>
<CustomerLayout>{children}</CustomerLayout>
</RoleGuard>
);
}
@@ -0,0 +1,37 @@
import Skeleton from '@mui/material/Skeleton';
import Stack from '@mui/material/Stack';
import Box from '@mui/material/Box';
import SurfaceCard from '@/components/common/SurfaceCard';
import { CONTENT_MAX_WIDTH } from '@/components/config';
/**
* The (customer) home shell shape: greeting/avatar + search bar + a category-tile row + a short card
* stack — mirrors A5 (`(customer)/page.tsx`) closely enough that switching from skeleton to real content
* doesn't jump. `CustomerLayout` (top bar + bottom tabs) is already rendered by the enclosing layout.
*/
export default function Loading() {
return (
<Box sx={{ maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<Stack sx={{ gap: 2.5 }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<Skeleton variant="circular" width={48} height={48} />
<Skeleton variant="text" width="45%" height={28} />
</Stack>
<Skeleton variant="rounded" height={48} sx={{ borderRadius: 'var(--bal-radius-sm)' }} />
<Stack direction="row" sx={{ gap: 1.5, flexWrap: 'wrap' }}>
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" width={92} height={92} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
))}
</Stack>
{[0, 1].map((key) => (
<SurfaceCard key={key}>
<Stack sx={{ gap: 0.75 }}>
<Skeleton variant="text" width="50%" height={22} />
<Skeleton variant="text" width="80%" height={18} />
</Stack>
</SurfaceCard>
))}
</Stack>
</Box>
);
}
@@ -0,0 +1,7 @@
'use client';
import { NotificationCenter } from '@/components/notifications';
/** /notifications — the customer notification center (f14). The bell deep-links here. */
export default function CustomerNotificationsPage() {
return <NotificationCenter role="customer" />;
}
@@ -1,210 +1,13 @@
'use client';
import { FormEvent, FunctionComponent, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Avatar, Box, InputAdornment, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, CategoryTile } from '@/components';
import { ROUTES } from '@/constants';
import { useMe } from '@/services/auth';
import { usePatients } from '@/services/patients';
import { useServiceCategories } from '@/services/catalog';
import { pickCatalogName } from '@/services/catalog/names';
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import HomeScreen from './HomeScreen';
interface NudgeCardProps {
icon: string;
title: string;
body: string;
ctaLabel: string;
to: string;
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'shell' });
return { title: t('customer_app') };
}
const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLabel, to }) => (
<Paper
elevation={0}
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', gap: 2 }}
>
<AppIcon icon={icon} size={28} color="var(--bal-primary)" />
<Stack sx={{ gap: 1, flexGrow: 1 }}>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{body}
</Typography>
</Stack>
<AppButton color="primary" variant="outlined" to={to} sx={{ m: 0, alignSelf: 'flex-start' }}>
{ctaLabel}
</AppButton>
</Stack>
</Paper>
);
/**
* A5 — the family Home: the front door of the app. Greeting + avatar, the search bar (which hands a
* query / chosen `service_category_id` toward the f6 search flow — results are not built here), the
* **data-driven** service-category grid (from the cached `services/catalog` reference data), and the
* complete-patient-record nudge (derived from the f2 patient cache — no extra fetch).
*
* 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.
*/
export default function CustomerHomePage() {
const t = useTranslations('home');
const router = useRouter();
const locale = useLocale();
const { data: me } = useMe();
const { data } = usePatients();
const isEmpty = data?.total === 0;
useEffect(() => {
if (isEmpty) router.replace(`/${locale}${ROUTES.ONBOARDING}`);
}, [isEmpty, router, locale]);
if (data == null || isEmpty) {
return <AppLoading />;
}
const href = (path: string) => `/${locale}${path}`;
const profileComplete = me?.hasCustomerProfile ?? false;
const firstName = me?.firstName?.trim() || null;
const greeting = firstName ? t('greeting_named', { name: firstName }) : t('greeting_plain');
const avatarInitial = firstName ? firstName.charAt(0).toUpperCase() : null;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<Avatar sx={{ width: 48, height: 48, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}>
{avatarInitial ?? <AppIcon icon="account" size={28} color="var(--bal-primary)" />}
</Avatar>
<Box>
<Typography variant="h5" component="h1">
{greeting}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
</Stack>
<HomeSearchBar />
<CategoryGrid onSelect={(categoryId) => router.push(`${href(ROUTES.SEARCH)}?category_id=${categoryId}`)} />
<NudgeCard
icon="patients"
title={t('nudge_patient_title')}
body={t('nudge_patient_body')}
ctaLabel={t('nudge_patient_cta')}
to={href(ROUTES.PATIENTS)}
/>
{!profileComplete ? (
<NudgeCard
icon="profile"
title={t('nudge_profile_title')}
body={t('nudge_profile_body')}
ctaLabel={t('nudge_profile_cta')}
to={href(ROUTES.PROFILE)}
/>
) : null}
</Box>
);
export default function Page() {
return <HomeScreen />;
}
/**
* The Home search field. Rendering + query capture live here; **execution is f6** — submitting
* navigates toward the (future) search route carrying the typed query. Results/filters are DEFERRED
* → frontend-phase-6-b7.
*/
const HomeSearchBar: FunctionComponent = () => {
const t = useTranslations('home');
const router = useRouter();
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 (
<Box component="form" onSubmit={submit} role="search">
<TextField
fullWidth
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('search_placeholder')}
aria-label={t('search_action')}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<AppIcon icon="search" size={22} color="var(--bal-text-secondary)" />
</InputAdornment>
),
},
}}
/>
</Box>
);
};
/** The data-driven service-category grid — one tile per `service_category`, with all four states. */
const CategoryGrid: FunctionComponent<{ onSelect: (categoryId: number) => void }> = ({ onSelect }) => {
const t = useTranslations('home');
const tc = useTranslations('common');
const locale = useLocale();
const { data, isLoading, isError, refetch } = useServiceCategories();
const categories = data?.items ?? [];
return (
<Stack sx={{ gap: 1.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('categories_title')}
</Typography>
{isLoading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
))}
</Box>
) : isError ? (
<Paper
elevation={0}
sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}
>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1 }}>
{t('categories_error')}
</Typography>
<AppButton variant="outlined" color="primary" onClick={() => refetch()} sx={{ m: 0 }}>
{tc('retry')}
</AppButton>
</Paper>
) : categories.length === 0 ? (
<Paper
elevation={0}
sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}
>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('categories_empty')}
</Typography>
</Paper>
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
{categories.map((category) => (
<CategoryTile
key={category.id}
label={pickCatalogName(category, locale)}
iconKey={category.iconKey}
onClick={() => onSelect(category.id)}
/>
))}
</Box>
)}
</Stack>
);
};
@@ -0,0 +1,995 @@
'use client';
import { FunctionComponent, ReactNode, useEffect, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import {
Box,
Checkbox,
Dialog,
Drawer,
FormControlLabel,
MenuItem,
Paper,
Skeleton,
Stack,
Tab,
Tabs,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
useMediaQuery,
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import {
AppButton,
AppIcon,
ConfirmDialog,
EmptyState,
PatientHeader,
RhfControlGroup,
RhfTextField,
VisitNoteCard,
} from '@/components';
import { ROUTES, bookingDetailPath } from '@/constants';
import { formatShamsiDate, formatShamsiMonthYear } from '@/utils';
import { bookingKeys } from '@/services/bookings/keys';
import { usePatient } from '@/services/patients';
import { birthDateToAge } from '@/services/patients/age';
import { useRecordAccess, usePatientCareRecord, usePatientHistory, useUpdateCareRecord } from '@/services/patientRecords';
import { CARE_RECORD_TABS, DOSE_UNITS, FREQUENCY_PRESETS, TIME_OF_DAY_CODES } from '@/services/patientRecords/types';
import type {
CareRecordTab,
CareTask,
DoseUnit,
FamilyCareRecord,
FrequencyPreset,
Medication,
RoutineItem,
TimeOfDayCode,
VisitNote,
} from '@/services/patientRecords/types';
/**
* E2 — Patient care-record viewer (پروندهٔ مراقبت). The **family-owned, patient-scoped** record with four
* tabs: داروها / روتین / سوابق / وظایف. The customer owns and edits medications/routine/tasks via per-item
* bottom sheets (never a whole-list edit mode — switching tabs mid-edit loses nothing by construction); the
* **سوابق** (nurse visit notes) are read-only to everyone, grouped into a Shamsi month-by-month timeline. A
* persistent ownership banner states the record belongs to the family. The clinical-access check gates the
* whole screen (a `403` → a clear, non-leaking access-denied card — never partial clinical data).
*/
export default function PatientRecordPage() {
const t = useTranslations('records');
// Shared enum labels are reused from the onboarding/patients namespaces — never re-keyed.
const to = useTranslations('onboarding');
const tp = useTranslations('patients');
const params = useParams<{ id: string }>();
const rawId = Number(params.id);
const patientId = Number.isInteger(rawId) && rawId > 0 ? rawId : -1;
const access = useRecordAccess(patientId);
const canView = access.data?.canView ?? false;
const patient = usePatient(patientId, { enabled: canView });
const [tab, setTab] = useState<CareRecordTab>('medications');
if (access.isLoading) return <RecordSkeleton />;
// Access-denied — a clear, non-leaking card (never any clinical data).
if (access.data && !access.data.canView) {
return (
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%' }}>
<EmptyState icon="lock" title={t('access_denied_title')} body={t('access_denied_body')} />
<BackToPatients />
</Stack>
);
}
if (patient.isLoading) return <RecordSkeleton />;
if (patient.isError || !patient.data) {
return (
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%' }}>
<EmptyState title={t('not_found_title')} body={t('not_found_body')} />
<BackToPatients />
</Stack>
);
}
const p = patient.data;
const age = birthDateToAge(p.birthDate);
return (
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%' }}>
<PatientHeader
displayName={p.displayName}
relationLabel={p.relation ? to(`relation_${p.relation}`) : undefined}
genderLabel={to(`gender_${p.gender}`)}
ageLabel={age == null ? undefined : tp('age_years', { age })}
conditionLabels={p.conditions.map((code) => to(`condition_${code}`))}
noConditionsLabel={tp('conditions_none')}
/>
<Paper
elevation={0}
sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', backgroundColor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
>
<AppIcon icon="family" size={22} color="var(--bal-primary)" />
<Typography variant="body2" sx={{ color: 'var(--bal-primary)', fontWeight: 500 }}>
{t('ownership_banner')}
</Typography>
</Paper>
<Tabs
value={tab}
onChange={(_, next: CareRecordTab) => setTab(next)}
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
sx={{ borderBottom: 1, borderColor: 'divider' }}
>
{CARE_RECORD_TABS.map((value) => (
<Tab key={value} value={value} label={t(`tab_${value}`)} sx={{ textTransform: 'none', fontWeight: 700 }} />
))}
</Tabs>
{tab === 'history' ? (
<HistoryTab patientId={patientId} />
) : (
<EditableTabs patientId={patientId} tab={tab} canEdit={access.data?.canEdit ?? false} />
)}
</Stack>
);
}
/** The three customer-editable tabs (medications/routine/tasks) share one record read + one save mutation. */
function EditableTabs({ patientId, tab, canEdit }: { patientId: number; tab: CareRecordTab; canEdit: boolean }) {
const t = useTranslations('records');
const { enqueueSnackbar } = useSnackbar();
const record = usePatientCareRecord(patientId);
const update = useUpdateCareRecord(patientId);
if (record.isLoading) {
return (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="rounded" height={72} />
<Skeleton variant="rounded" height={72} />
</Stack>
);
}
if (record.isError || !record.data) {
return (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('load_error')}
</Typography>
);
}
const data = record.data;
// The real endpoint fully replaces all three lists from the request (no server-side merge), so every save
// must resend the whole current plan — not just the tab being edited — or the other two tabs get wiped.
const save = (patch: Partial<Pick<FamilyCareRecord, 'medications' | 'routine' | 'tasks'>>, onDone: () => void) => {
update.mutate(
{ medications: data.medications, routine: data.routine, tasks: data.tasks, ...patch },
{
onSuccess: () => {
enqueueSnackbar(t('saved'), { variant: 'success' });
onDone();
},
onError: () => enqueueSnackbar(t('save_error'), { variant: 'error' }),
},
);
};
if (tab === 'medications') {
return (
<MedicationsTab
data={data.medications}
canEdit={canEdit}
saving={update.isPending}
onSave={(meds, done) => save({ medications: meds }, done)}
/>
);
}
if (tab === 'routine') {
return (
<RoutineTab data={data.routine} canEdit={canEdit} saving={update.isPending} onSave={(items, done) => save({ routine: items }, done)} />
);
}
return <TasksTab data={data.tasks} canEdit={canEdit} saving={update.isPending} onSave={(tasks, done) => save({ tasks }, done)} />;
}
// ── The responsive per-item sheet: Drawer(bottom) on mobile, Dialog on desktop; dirty-gated close ─────────
function RecordItemSheet({
open,
title,
dirty,
onClose,
children,
}: {
open: boolean;
title: string;
dirty: boolean;
onClose: () => void;
children: ReactNode;
}) {
const t = useTranslations('common');
const theme = useTheme();
const mobile = useMediaQuery(theme.breakpoints.down('sm'));
const [discardOpen, setDiscardOpen] = useState(false);
const requestClose = () => {
if (dirty) setDiscardOpen(true);
else onClose();
};
const body = (
<Box sx={{ p: 2.5, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Stack direction="row" sx={{ alignItems: 'center' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, flexGrow: 1 }}>
{title}
</Typography>
<AppButton variant="text" color="inherit" onClick={requestClose} aria-label={t('close')} sx={{ minWidth: 0, p: 1 }}>
<AppIcon icon="close" size={20} />
</AppButton>
</Stack>
{children}
</Box>
);
return (
<>
{mobile ? (
<Drawer
anchor="bottom"
open={open}
onClose={requestClose}
slotProps={{ paper: { sx: { borderTopLeftRadius: 'var(--bal-radius-lg)', borderTopRightRadius: 'var(--bal-radius-lg)', maxHeight: '88vh' } } }}
>
{body}
</Drawer>
) : (
<Dialog open={open} onClose={requestClose} fullWidth maxWidth="xs">
{body}
</Dialog>
)}
<ConfirmDialog
open={discardOpen}
title={t('discard_title')}
body={t('discard_body')}
confirmLabel={t('discard_confirm')}
cancelLabel={t('cancel')}
confirmColor="error"
onConfirm={() => {
setDiscardOpen(false);
onClose();
}}
onClose={() => setDiscardOpen(false)}
/>
</>
);
}
/**
* Client-side id for a row that has never been saved. Module scope on purpose: `Date.now()` is impure
* and the lint rule can't tell that a submit callback only ever runs from an event, so keeping the
* call out of the component body states the same thing structurally.
*/
function newTempId(): string {
return `new-${Date.now()}`;
}
/** Save submits the enclosing `<form>`, so each sheet body owns its submit handler rather than a callback. */
function SheetActions({
onCancel,
onDelete,
saving,
canSave,
}: {
onCancel: () => void;
onDelete?: () => void;
saving: boolean;
canSave: boolean;
}) {
const t = useTranslations('records');
const tc = useTranslations('common');
return (
<Stack direction="row" sx={{ gap: 1, justifyContent: onDelete ? 'space-between' : 'flex-end', alignItems: 'center' }}>
{onDelete ? (
<AppButton variant="text" color="error" onClick={onDelete} disabled={saving}>
{t('remove')}
</AppButton>
) : null}
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton variant="text" onClick={onCancel} disabled={saving}>
{tc('cancel')}
</AppButton>
<AppButton type="submit" variant="contained" color="primary" disabled={saving || !canSave}>
{saving ? tc('saving') : tc('save')}
</AppButton>
</Stack>
</Stack>
);
}
// A tappable row surface shared by the three editable tabs — mirrors PatientCard's press affordance.
function RowCard({ onOpen, children }: { onOpen?: () => void; children: ReactNode }) {
return (
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', overflow: 'hidden' }}>
{onOpen ? (
<AppButton
variant="text"
color="inherit"
onClick={onOpen}
sx={{ width: '100%', p: 1.5, justifyContent: 'flex-start', textAlign: 'start', borderRadius: 0, '&:hover': { bgcolor: 'action.hover' } }}
>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1.5, width: '100%' }}>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>{children}</Box>
<AppIcon icon="forward" size={18} color="var(--bal-text-secondary)" />
</Stack>
</AppButton>
) : (
<Box sx={{ p: 1.5 }}>{children}</Box>
)}
</Paper>
);
}
function TimeOfDayChipRow({
value,
onChange,
disabled,
}: {
value: TimeOfDayCode[];
onChange: (next: TimeOfDayCode[]) => void;
disabled?: boolean;
}) {
const t = useTranslations('records');
return (
<ToggleButtonGroup
value={value}
onChange={(_, next: TimeOfDayCode[]) => onChange(next)}
disabled={disabled}
sx={{ flexWrap: 'wrap', gap: 1, '& .MuiToggleButtonGroup-grouped': { border: '1px solid', borderColor: 'divider !important', borderRadius: '999px !important', mx: 0 } }}
>
{TIME_OF_DAY_CODES.map((code) => (
<ToggleButton key={code} value={code} size="small" sx={{ textTransform: 'none', px: 1.5, borderRadius: '999px' }}>
{t(`time_${code}`)}
</ToggleButton>
))}
</ToggleButtonGroup>
);
}
// ── Medications ───────────────────────────────────────────────────────────────────────────────────────────
function medicationSummary(m: Medication, t: ReturnType<typeof useTranslations>): string {
const dose = m.doseAmount ? `${m.doseAmount} ${m.doseUnit ? t(`dose_unit_${m.doseUnit}`) : ''}`.trim() : null;
const frequency = m.frequencyCode ? t(`frequency_${m.frequencyCode}`) : m.frequencyText;
return [dose, frequency].filter(Boolean).join(' · ');
}
function MedicationsTab({
data,
canEdit,
saving,
onSave,
}: {
data: Medication[];
canEdit: boolean;
saving: boolean;
onSave: (medications: Medication[], onDone: () => void) => void;
}) {
const t = useTranslations('records');
const [sheetItem, setSheetItem] = useState<Medication | 'new' | null>(null);
const [sheetDirty, setSheetDirty] = useState(false);
const close = () => setSheetItem(null);
const handleSave = (item: Medication) => {
const next = sheetItem === 'new' ? [...data, item] : data.map((m) => (m.id === item.id ? item : m));
onSave(next, close);
};
const handleDelete = (id: string) => onSave(data.filter((m) => m.id !== id), close);
return (
<Stack sx={{ gap: 1.5 }}>
{data.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('medications_empty')}
</Typography>
) : (
<Stack sx={{ gap: 1 }}>
{data.map((m) => (
<RowCard key={m.id} onOpen={canEdit ? () => setSheetItem(m) : undefined}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
<AppIcon icon="medication" size={20} color="var(--bal-primary)" />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{m.name}
</Typography>
</Stack>
{medicationSummary(m, t) ? (
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
{medicationSummary(m, t)}
</Typography>
) : null}
{m.timeOfDay.length > 0 ? (
<Stack direction="row" sx={{ gap: 0.5, mt: 0.5, flexWrap: 'wrap' }}>
{m.timeOfDay.map((code) => (
<Typography
key={code}
variant="caption"
sx={{ px: 1, py: 0.25, borderRadius: '999px', bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)' }}
>
{t(`time_${code}`)}
</Typography>
))}
</Stack>
) : null}
</RowCard>
))}
</Stack>
)}
{canEdit ? (
<AppButton variant="outlined" color="primary" startIcon="add" onClick={() => setSheetItem('new')} sx={{ alignSelf: 'flex-start' }}>
{t('add_medication')}
</AppButton>
) : null}
<RecordItemSheet open={sheetItem != null} title={sheetItem === 'new' ? t('add_medication') : t('edit_medication')} dirty={sheetDirty} onClose={close}>
{sheetItem != null ? (
<MedicationSheetBody
initial={sheetItem === 'new' ? null : sheetItem}
saving={saving}
onCancel={close}
onSave={handleSave}
onDelete={sheetItem !== 'new' ? () => handleDelete((sheetItem as Medication).id) : undefined}
onDirtyChange={setSheetDirty}
/>
) : null}
</RecordItemSheet>
</Stack>
);
}
interface MedicationFormValues {
name: string;
doseAmount: string;
doseUnit: DoseUnit | '';
frequencyCode: FrequencyPreset | null;
frequencyText: string;
timeOfDay: TimeOfDayCode[];
timingNote: string;
}
function MedicationSheetBody({
initial,
saving,
onCancel,
onSave,
onDelete,
onDirtyChange,
}: {
initial: Medication | null;
saving: boolean;
onCancel: () => void;
onSave: (item: Medication) => void;
onDelete?: () => void;
onDirtyChange: (dirty: boolean) => void;
}) {
const t = useTranslations('records');
const form = useForm<MedicationFormValues>({
mode: 'onTouched',
defaultValues: {
name: initial?.name ?? '',
doseAmount: initial?.doseAmount ?? '',
doseUnit: initial?.doseUnit ?? '',
frequencyCode: initial?.frequencyCode ?? null,
frequencyText: initial?.frequencyText ?? '',
timeOfDay: initial?.timeOfDay ?? [],
timingNote: initial?.timingNote ?? '',
},
});
const { control, formState, handleSubmit, setValue } = form;
const { isDirty } = formState;
const name = useWatch({ control, name: 'name' });
const frequencyCode = useWatch({ control, name: 'frequencyCode' });
useEffect(() => {
onDirtyChange(isDirty);
}, [isDirty, onDirtyChange]);
const submit = (values: MedicationFormValues) => {
onSave({
id: initial?.id ?? newTempId(),
name: values.name.trim(),
doseAmount: values.doseAmount.trim() || null,
doseUnit: values.doseUnit || null,
frequencyCode: values.frequencyCode,
frequencyText: values.frequencyCode ? null : values.frequencyText.trim() || null,
timeOfDay: values.timeOfDay,
timingNote: values.timingNote.trim() || null,
});
};
return (
<FormProvider {...form}>
<Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 2 }}>
<RhfTextField<MedicationFormValues> name="name" label={t('med_name')} fullWidth required autoFocus />
<Stack direction="row" sx={{ gap: 1.5 }}>
<RhfTextField<MedicationFormValues> name="doseAmount" label={t('med_dose_amount')} sx={{ flex: 1 }} />
<RhfTextField<MedicationFormValues> name="doseUnit" select label={t('med_dose_unit')} sx={{ flex: 1 }}>
<MenuItem value="">{t('med_dose_unit_none')}</MenuItem>
{DOSE_UNITS.map((unit) => (
<MenuItem key={unit} value={unit}>
{t(`dose_unit_${unit}`)}
</MenuItem>
))}
</RhfTextField>
</Stack>
<Stack sx={{ gap: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('med_frequency')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{FREQUENCY_PRESETS.map((preset) => (
<AppButton
key={preset}
variant={frequencyCode === preset ? 'contained' : 'outlined'}
color="primary"
size="small"
onClick={() => {
const next = frequencyCode === preset ? null : preset;
setValue('frequencyCode', next, { shouldDirty: true });
// A preset and the free-text alternative are mutually exclusive by design.
if (next) setValue('frequencyText', '', { shouldDirty: true });
}}
sx={{ borderRadius: '999px' }}
>
{t(`frequency_${preset}`)}
</AppButton>
))}
</Stack>
{!frequencyCode ? (
<RhfTextField<MedicationFormValues>
name="frequencyText"
label={t('med_frequency_text')}
fullWidth
size="small"
/>
) : null}
</Stack>
<RhfControlGroup<MedicationFormValues> name="timeOfDay" hint={t('time_of_day')}>
{({ field }) => <TimeOfDayChipRow value={(field.value as TimeOfDayCode[]) ?? []} onChange={field.onChange} />}
</RhfControlGroup>
<RhfTextField<MedicationFormValues> name="timingNote" label={t('med_timing')} fullWidth size="small" />
<SheetActions onCancel={onCancel} onDelete={onDelete} saving={saving} canSave={name.trim().length > 0} />
</Stack>
</FormProvider>
);
}
// ── Routine ───────────────────────────────────────────────────────────────────────────────────────────────
function RoutineTab({
data,
canEdit,
saving,
onSave,
}: {
data: RoutineItem[];
canEdit: boolean;
saving: boolean;
onSave: (routine: RoutineItem[], onDone: () => void) => void;
}) {
const t = useTranslations('records');
const [sheetItem, setSheetItem] = useState<RoutineItem | 'new' | null>(null);
const [sheetDirty, setSheetDirty] = useState(false);
const close = () => setSheetItem(null);
const handleSave = (item: RoutineItem) => {
const next = sheetItem === 'new' ? [...data, item] : data.map((r) => (r.id === item.id ? item : r));
onSave(next, close);
};
const handleDelete = (id: string) => onSave(data.filter((r) => r.id !== id), close);
return (
<Stack sx={{ gap: 1.5 }}>
{data.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('routine_empty')}
</Typography>
) : (
<Stack sx={{ gap: 1 }}>
{data.map((r) => (
<RowCard key={r.id} onOpen={canEdit ? () => setSheetItem(r) : undefined}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
<AppIcon icon="routine" size={20} color="var(--bal-primary)" />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{r.label}
</Typography>
</Stack>
{r.timeOfDay.length > 0 ? (
<Stack direction="row" sx={{ gap: 0.5, mt: 0.5, flexWrap: 'wrap' }}>
{r.timeOfDay.map((code) => (
<Typography
key={code}
variant="caption"
sx={{ px: 1, py: 0.25, borderRadius: '999px', bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)' }}
>
{t(`time_${code}`)}
</Typography>
))}
</Stack>
) : null}
{r.note ? (
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
{r.note}
</Typography>
) : null}
</RowCard>
))}
</Stack>
)}
{canEdit ? (
<AppButton variant="outlined" color="primary" startIcon="add" onClick={() => setSheetItem('new')} sx={{ alignSelf: 'flex-start' }}>
{t('add_routine')}
</AppButton>
) : null}
<RecordItemSheet open={sheetItem != null} title={sheetItem === 'new' ? t('add_routine') : t('edit_routine')} dirty={sheetDirty} onClose={close}>
{sheetItem != null ? (
<RoutineSheetBody
initial={sheetItem === 'new' ? null : sheetItem}
saving={saving}
onCancel={close}
onSave={handleSave}
onDelete={sheetItem !== 'new' ? () => handleDelete((sheetItem as RoutineItem).id) : undefined}
onDirtyChange={setSheetDirty}
/>
) : null}
</RecordItemSheet>
</Stack>
);
}
function RoutineSheetBody({
initial,
saving,
onCancel,
onSave,
onDelete,
onDirtyChange,
}: {
initial: RoutineItem | null;
saving: boolean;
onCancel: () => void;
onSave: (item: RoutineItem) => void;
onDelete?: () => void;
onDirtyChange: (dirty: boolean) => void;
}) {
const t = useTranslations('records');
const form = useForm<{ label: string; timeOfDay: TimeOfDayCode[]; note: string }>({
mode: 'onTouched',
defaultValues: {
label: initial?.label ?? '',
timeOfDay: initial?.timeOfDay ?? [],
note: initial?.note ?? '',
},
});
const { control, formState, handleSubmit } = form;
const { isDirty } = formState;
const label = useWatch({ control, name: 'label' });
useEffect(() => {
onDirtyChange(isDirty);
}, [isDirty, onDirtyChange]);
return (
<FormProvider {...form}>
<Stack
component="form"
noValidate
onSubmit={handleSubmit((values) =>
onSave({
id: initial?.id ?? newTempId(),
label: values.label.trim(),
timeOfDay: values.timeOfDay,
note: values.note.trim() || null,
}),
)}
sx={{ gap: 2 }}
>
<RhfTextField name="label" label={t('routine_label')} fullWidth required autoFocus />
<RhfControlGroup name="timeOfDay" hint={t('time_of_day')}>
{({ field }) => <TimeOfDayChipRow value={(field.value as TimeOfDayCode[]) ?? []} onChange={field.onChange} />}
</RhfControlGroup>
<RhfTextField name="note" label={t('routine_note')} fullWidth size="small" />
<SheetActions onCancel={onCancel} onDelete={onDelete} saving={saving} canSave={label.trim().length > 0} />
</Stack>
</FormProvider>
);
}
// ── Tasks ─────────────────────────────────────────────────────────────────────────────────────────────────
function TasksTab({
data,
canEdit,
saving,
onSave,
}: {
data: CareTask[];
canEdit: boolean;
saving: boolean;
onSave: (tasks: CareTask[], onDone: () => void) => void;
}) {
const t = useTranslations('records');
const [sheetItem, setSheetItem] = useState<CareTask | 'new' | null>(null);
const [sheetDirty, setSheetDirty] = useState(false);
const close = () => setSheetItem(null);
const handleSave = (item: CareTask) => {
const next = sheetItem === 'new' ? [...data, item] : data.map((task) => (task.id === item.id ? item : task));
onSave(next, close);
};
const handleDelete = (id: string) => onSave(data.filter((task) => task.id !== id), close);
const toggleDone = (task: CareTask) => onSave(data.map((t2) => (t2.id === task.id ? { ...t2, done: !t2.done } : t2)), () => {});
return (
<Stack sx={{ gap: 1.5 }}>
{data.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('tasks_empty')}
</Typography>
) : (
<Stack sx={{ gap: 1 }}>
{data.map((task) => (
<Paper key={task.id} elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', p: 1 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
<FormControlLabel
control={<Checkbox checked={task.done} onChange={() => (canEdit ? toggleDone(task) : undefined)} disabled={!canEdit || saving} />}
label=""
sx={{ m: 0 }}
aria-label={t('task_done')}
/>
<AppButton
variant="text"
color="inherit"
disabled={!canEdit}
onClick={() => setSheetItem(task)}
sx={{ flexGrow: 1, justifyContent: 'flex-start', textAlign: 'start', minWidth: 0 }}
>
<Typography variant="body2" sx={{ color: task.done ? 'text.secondary' : undefined }}>
{task.label}
</Typography>
</AppButton>
{canEdit ? <AppIcon icon="forward" size={16} color="var(--bal-text-secondary)" /> : null}
</Stack>
</Paper>
))}
</Stack>
)}
{canEdit ? (
<AppButton variant="outlined" color="primary" startIcon="add" onClick={() => setSheetItem('new')} sx={{ alignSelf: 'flex-start' }}>
{t('add_task')}
</AppButton>
) : null}
<RecordItemSheet open={sheetItem != null} title={sheetItem === 'new' ? t('add_task') : t('edit_task')} dirty={sheetDirty} onClose={close}>
{sheetItem != null ? (
<TaskSheetBody
initial={sheetItem === 'new' ? null : sheetItem}
saving={saving}
onCancel={close}
onSave={handleSave}
onDelete={sheetItem !== 'new' ? () => handleDelete((sheetItem as CareTask).id) : undefined}
onDirtyChange={setSheetDirty}
/>
) : null}
</RecordItemSheet>
</Stack>
);
}
function TaskSheetBody({
initial,
saving,
onCancel,
onSave,
onDelete,
onDirtyChange,
}: {
initial: CareTask | null;
saving: boolean;
onCancel: () => void;
onSave: (item: CareTask) => void;
onDelete?: () => void;
onDirtyChange: (dirty: boolean) => void;
}) {
const t = useTranslations('records');
const form = useForm<{ label: string; done: boolean }>({
mode: 'onTouched',
defaultValues: { label: initial?.label ?? '', done: initial?.done ?? false },
});
const { control, formState, handleSubmit } = form;
const { isDirty } = formState;
const label = useWatch({ control, name: 'label' });
useEffect(() => {
onDirtyChange(isDirty);
}, [isDirty, onDirtyChange]);
return (
<FormProvider {...form}>
<Stack
component="form"
noValidate
onSubmit={handleSubmit((values) =>
onSave({ id: initial?.id ?? newTempId(), label: values.label.trim(), done: values.done }),
)}
sx={{ gap: 2 }}
>
<RhfTextField name="label" label={t('task_label')} fullWidth required autoFocus />
<RhfControlGroup name="done">
{({ field }) => (
<FormControlLabel
control={
<Checkbox checked={Boolean(field.value)} onChange={(event) => field.onChange(event.target.checked)} />
}
label={t('task_done')}
/>
)}
</RhfControlGroup>
<SheetActions onCancel={onCancel} onDelete={onDelete} saving={saving} canSave={label.trim().length > 0} />
</Stack>
</FormProvider>
);
}
// ── History (سوابق) — patient-scoped, read-only, month-grouped, paged ─────────────────────────────────────
// Best-effort read of the frozen variant display name from a cached booking snapshot — mirrors
// BookingDetailView's helper; never fetched (would be an N+1 against the booking detail per note).
function variantName(snapshotJson: string): string | null {
try {
const parsed = JSON.parse(snapshotJson) as { displayName?: string };
return parsed?.displayName ?? null;
} catch {
return null;
}
}
function HistoryTab({ patientId }: { patientId: number }) {
const t = useTranslations('records');
const locale = useLocale();
const router = useRouter();
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const history = usePatientHistory(patientId, page);
if (history.isLoading) {
return (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="rounded" height={96} />
<Skeleton variant="rounded" height={96} />
</Stack>
);
}
if (history.isError || !history.data) {
return (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('load_error')}
</Typography>
);
}
const { items, total, pageSize } = history.data;
if (items.length === 0) {
return (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('history_empty')}
</Typography>
);
}
const totalPages = Math.max(1, Math.ceil(total / pageSize));
// Group this page's notes by Shamsi month (notes already arrive newest-first from the server).
const groups: Array<{ month: string; notes: VisitNote[] }> = [];
for (const note of items) {
const month = formatShamsiMonthYear(note.recordedAt, locale);
const lastGroup = groups[groups.length - 1];
if (lastGroup && lastGroup.month === month) lastGroup.notes.push(note);
else groups.push({ month, notes: [note] });
}
return (
<Stack sx={{ gap: 2 }}>
{groups.map((group) => (
<Stack key={group.month} direction="row" sx={{ gap: 1.5 }}>
<Stack sx={{ alignItems: 'center', pt: 0.5 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: 'var(--bal-primary)' }} />
<Box sx={{ width: '1px', flexGrow: 1, bgcolor: 'divider', mt: 0.5 }} />
</Stack>
<Stack sx={{ gap: 1.5, pb: 1, flexGrow: 1, minWidth: 0 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'text.secondary' }}>
{group.month}
</Typography>
{group.notes.map((note) => {
const doneCount = note.taskResults.filter((task) => task.done).length;
const booking = note.bookingId != null ? queryClient.getQueryData(bookingKeys.bookingDetail(note.bookingId)) : undefined;
const service =
booking && typeof booking === 'object' && 'variantSnapshotJson' in booking
? variantName((booking as { variantSnapshotJson: string }).variantSnapshotJson)
: undefined;
return (
<VisitNoteCard
key={note.id}
note={note}
dateLabel={formatShamsiDate(note.recordedAt, locale)}
authorFallback={t('author_fallback')}
taskSummaryLabel={note.taskResults.length > 0 ? t('task_summary', { done: doneCount, total: note.taskResults.length }) : undefined}
serviceLabel={service ?? undefined}
bookingLinkLabel={note.bookingId != null ? t('view_booking') : undefined}
onOpenBooking={note.bookingId != null ? () => router.push(`/${locale}${bookingDetailPath(note.bookingId as number)}`) : undefined}
/>
);
})}
</Stack>
</Stack>
))}
{totalPages > 1 ? (
<Stack direction="row" sx={{ gap: 1, justifyContent: 'center', alignItems: 'center' }}>
<AppButton variant="text" color="primary" disabled={page <= 1 || history.isFetching} onClick={() => setPage((n) => Math.max(1, n - 1))}>
{t('prev')}
</AppButton>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('page_of', { page, total: totalPages })}
</Typography>
<AppButton variant="text" color="primary" disabled={page >= totalPages || history.isFetching} onClick={() => setPage((n) => n + 1)}>
{t('next')}
</AppButton>
</Stack>
) : null}
</Stack>
);
}
// ── Small shared bits ─────────────────────────────────────────────────────────────────────────────────────
function BackToPatients() {
const t = useTranslations('records');
const router = useRouter();
const locale = useLocale();
return (
<AppButton
variant="outlined"
color="primary"
onClick={() => router.push(`/${locale}${ROUTES.PATIENTS}`)}
sx={{ alignSelf: 'flex-start' }}
>
{t('back_to_patients')}
</AppButton>
);
}
function RecordSkeleton() {
return (
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%' }}>
<Stack sx={{ gap: 1 }}>
<Skeleton variant="text" width="50%" height={32} />
<Skeleton variant="text" width="30%" />
</Stack>
<Skeleton variant="rounded" height={56} />
<Skeleton variant="rounded" height={48} />
<Skeleton variant="rounded" height={96} />
</Stack>
);
}
@@ -1,40 +1,35 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { useSnackbar } from 'notistack';
import {
Box,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Paper,
Skeleton,
Stack,
Typography,
} from '@mui/material';
import { AppButton, AppIcon, PatientCard, PatientForm } from '@/components';
import { Box, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, ConfirmDialog, EmptyState, ErrorState, FormDialogShell, PatientCard, PatientForm } from '@/components';
import { patientRecordPath } from '@/constants';
import { usePatients, useCreatePatient, useUpdatePatient, useArchivePatient } from '@/services/patients';
import { birthDateToAge } from '@/services/patients/age';
import type { CreatePatientInput, Patient } from '@/services/patients/types';
/**
* E1 the Patients tab: a cached, invalidate-on-mutation list of the customer's patients
* with add/edit (the A4 form reused in a dialog) and soft archive (confirm). Loading skeleton
* and an empty state with the add CTA are both handled.
* E1 the care-circle tab («حلقه مراقبت»): a cached, invalidate-on-mutation list of the people the
* customer arranges care for, with add/edit (the A4 form in a full-screen-on-mobile dialog) and soft
* archive (confirm). Loading skeleton and an empty state with the add CTA are both handled.
*/
export default function PatientsPage() {
const t = useTranslations('patients');
const to = useTranslations('onboarding');
const tc = useTranslations('common');
const router = useRouter();
const locale = useLocale();
const { enqueueSnackbar } = useSnackbar();
const { data, isLoading } = usePatients();
const { data, isLoading, isError, refetch } = usePatients();
const createPatient = useCreatePatient();
const updatePatient = useUpdatePatient();
const archivePatient = useArchivePatient();
const [formOpen, setFormOpen] = useState(false);
const [formDirty, setFormDirty] = useState(false);
const [editing, setEditing] = useState<Patient | null>(null);
const [archiveTarget, setArchiveTarget] = useState<Patient | null>(null);
@@ -76,7 +71,7 @@ export default function PatientsPage() {
};
const patients = data?.items ?? [];
const isEmpty = !isLoading && patients.length === 0;
const isEmpty = !isLoading && !isError && patients.length === 0;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
@@ -89,8 +84,8 @@ export default function PatientsPage() {
{t('subtitle')}
</Typography>
</Box>
{!isEmpty ? (
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ m: 0, flexShrink: 0 }}>
{!isEmpty && !isError ? (
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ flexShrink: 0 }}>
{t('add')}
</AppButton>
) : null}
@@ -102,32 +97,19 @@ export default function PatientsPage() {
<Skeleton key={key} variant="rounded" height={96} />
))}
</Stack>
) : isError ? (
<ErrorState message={t('load_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />
) : isEmpty ? (
<Paper
elevation={0}
sx={{
p: 4,
textAlign: 'center',
border: '1px dashed',
borderColor: 'divider',
borderRadius: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1.5,
}}
>
<AppIcon icon="patients" size={40} color="var(--bal-primary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('empty_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('empty_body')}
</Typography>
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ mt: 1 }}>
{t('add')}
</AppButton>
</Paper>
<EmptyState
icon="patients"
title={t('empty_title')}
body={t('empty_body')}
action={
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd}>
{t('add')}
</AppButton>
}
/>
) : (
<Stack sx={{ gap: 1.5 }}>
{patients.map((patient) => {
@@ -141,6 +123,8 @@ export default function PatientsPage() {
ageLabel={age == null ? undefined : t('age_years', { age })}
conditionLabels={patient.conditions.map((code) => to(`condition_${code}`))}
noConditionsLabel={t('conditions_none')}
onOpen={() => router.push(`/${locale}${patientRecordPath(patient.id)}`)}
openLabel={t('open_record', { name: patient.displayName })}
onEdit={() => openEdit(patient)}
onArchive={() => setArchiveTarget(patient)}
editLabel={t('edit')}
@@ -151,50 +135,52 @@ export default function PatientsPage() {
</Stack>
)}
<Dialog open={formOpen} onClose={closeForm} fullWidth maxWidth="sm">
<DialogTitle>{editing ? t('edit_title') : t('add_title')}</DialogTitle>
<DialogContent>
<Box sx={{ pt: 1 }}>
<PatientForm
key={editing?.id ?? 'new'}
initial={
editing
? {
displayName: editing.displayName,
birthDate: editing.birthDate,
gender: editing.gender,
conditions: editing.conditions,
relation: editing.relation,
}
: undefined
}
showRelation
submitLabel={tc('save')}
submitting={createPatient.isPending || updatePatient.isPending}
onSubmit={handleSubmit}
onCancel={closeForm}
cancelLabel={tc('cancel')}
/>
</Box>
</DialogContent>
</Dialog>
<FormDialogShell
open={formOpen}
title={editing ? t('edit_title') : t('add_title')}
dirty={formDirty}
onClose={closeForm}
closeLabel={tc('close')}
discardTitle={tc('discard_title')}
discardBody={tc('discard_body')}
discardConfirmLabel={tc('discard_confirm')}
discardCancelLabel={tc('cancel')}
>
<PatientForm
key={editing?.id ?? 'new'}
initial={
editing
? {
displayName: editing.displayName,
firstName: editing.firstName,
lastName: editing.lastName,
birthDate: editing.birthDate,
gender: editing.gender,
conditions: editing.conditions,
relation: editing.relation,
}
: undefined
}
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)}>
<DialogTitle>{t('archive_title')}</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('archive_body')}
</Typography>
</DialogContent>
<DialogActions>
<AppButton variant="text" onClick={() => setArchiveTarget(null)}>
{tc('cancel')}
</AppButton>
<AppButton color="error" variant="contained" onClick={confirmArchive}>
{t('archive_confirm')}
</AppButton>
</DialogActions>
</Dialog>
<ConfirmDialog
open={Boolean(archiveTarget)}
title={t('archive_title')}
body={t('archive_body')}
confirmLabel={t('archive_confirm')}
cancelLabel={tc('cancel')}
confirmColor="error"
onClose={() => setArchiveTarget(null)}
onConfirm={confirmArchive}
/>
</Box>
);
}
@@ -1,158 +1,388 @@
'use client';
import { FunctionComponent, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import { Box, Divider, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, PhoneNumberField } from '@/components';
import { Box, Divider, MenuItem, Skeleton, Stack, Typography } from '@mui/material';
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 { ROUTES } from '@/constants';
import { digitsOnly } from '@/utils';
import { ActorSwitcher } from '@/layout';
import { useCustomerProfile, useUpsertCustomerProfile } from '@/services/profiles';
import { useMe, useLogout } from '@/services/auth';
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() {
const { data: profile, isLoading } = useCustomerProfile();
if (isLoading) return <AppLoading />;
return <CustomerProfileForm initial={profile ?? null} />;
const t = useTranslations('profile');
const tc = useTranslations('common');
const { data: profile, isLoading, isError, refetch } = useCustomerProfile();
const { data: me, isLoading: meLoading } = useMe();
if (isLoading || meLoading) return <ProfileSkeleton />;
// The form must never render on a failed fetch — it would otherwise show blank/undefined fields
// whose save could overwrite server truth.
if (isError) return <ErrorState message={t('load_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />;
return (
<AccountHub
initial={profile ?? null}
nameFallback={{ firstName: me?.firstName ?? null, lastName: me?.lastName ?? null }}
phone={me?.phone}
/>
);
}
const CustomerProfileForm: FunctionComponent<{ initial: CustomerProfile | null }> = ({ initial }) => {
interface AccountFormValues {
firstName: string;
lastName: string;
language: string;
emergencyName: string;
emergencyPhone: string;
}
const AccountHub: FunctionComponent<{
initial: CustomerProfile | null;
nameFallback: { firstName: string | null; lastName: string | null };
phone?: string;
}> = ({ initial, nameFallback, phone }) => {
const t = useTranslations('profile');
const ta = useTranslations('address');
const tc = useTranslations('common');
const locale = useLocale();
const router = useRouter();
const { enqueueSnackbar } = useSnackbar();
const upsert = useUpsertCustomerProfile();
const logout = useLogout();
const [firstName, setFirstName] = useState(initial?.firstName ?? '');
const [lastName, setLastName] = useState(initial?.lastName ?? '');
const [language, setLanguage] = useState(initial?.preferredLanguage ?? 'fa');
const [emergencyName, setEmergencyName] = useState(initial?.defaultEmergencyContactName ?? '');
const [emergencyPhone, setEmergencyPhone] = useState(digitsOnly(initial?.defaultEmergencyContactPhone ?? ''));
const [nameError, setNameError] = useState(false);
const [phoneError, setPhoneError] = useState(false);
const [personalSheetOpen, setPersonalSheetOpen] = useState(false);
const [languageSheetOpen, setLanguageSheetOpen] = useState(false);
const [emergencySheetOpen, setEmergencySheetOpen] = useState(false);
const [signOutOpen, setSignOutOpen] = 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 nameInvalid = emergencyName.trim().length === 0;
const phoneInvalid = !isIranianMobile(emergencyPhone);
setNameError(nameInvalid);
setPhoneError(phoneInvalid);
if (nameInvalid || phoneInvalid) return;
const displayName = [watched.firstName, watched.lastName].filter(Boolean).join(' ').trim() || phone || '';
const emergencyComplete = Boolean(watched.emergencyName?.trim() && watched.emergencyPhone);
const save = (onDone: () => void) => {
const values = getValues();
upsert.mutate(
{
defaultEmergencyContactName: emergencyName.trim(),
defaultEmergencyContactPhone: emergencyPhone,
firstName: firstName.trim() || null,
lastName: lastName.trim() || null,
preferredLanguage: language,
defaultEmergencyContactName: values.emergencyName.trim(),
defaultEmergencyContactPhone: values.emergencyPhone,
firstName: values.firstName.trim() || null,
lastName: values.lastName.trim() || null,
preferredLanguage: values.language,
},
{
onSuccess: () => {
enqueueSnackbar(t('saved'), { variant: 'success' });
// Re-baseline so the saved slice stops counting as unsaved work in its sheet's discard guard.
reset(getValues());
onDone();
},
},
{ onSuccess: () => enqueueSnackbar(t('saved'), { variant: 'success' }) },
);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 520 }}>
<Box>
<Typography variant="h5" component="h1">
{t('title')}
</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>
const savePersonal = () => save(() => setPersonalSheetOpen(false));
const saveLanguage = () => save(() => setLanguageSheetOpen(false));
const saveEmergency = async () => {
if (!(await trigger(['emergencyName', 'emergencyPhone']))) return;
save(() => setEmergencySheetOpen(false));
};
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField label={t('first_name')} value={firstName} onChange={(e) => setFirstName(e.target.value)} fullWidth />
<TextField label={t('last_name')} value={lastName} onChange={(e) => setLastName(e.target.value)} fullWidth />
const personalDirty = Boolean(dirtyFields.firstName || dirtyFields.lastName);
const languageDirty = Boolean(dirtyFields.language);
const emergencyDirty = Boolean(dirtyFields.emergencyName || dirtyFields.emergencyPhone);
const goTo = (path: string) => router.push(`/${locale}${path}`);
// 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>
<TextField
select
label={t('language')}
value={language}
onChange={(e) => setLanguage(e.target.value)}
sx={{ maxWidth: 220 }}
{/* Renders nothing for a single-role session — see ActorSwitcher's own doc. */}
<ActorSwitcher target="nurse" />
<Stack sx={{ gap: 1 }}>
<Divider sx={{ my: 0.5 }} />
<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>
<MenuItem value="en">{t('language_en')}</MenuItem>
</TextField>
<Stack sx={{ gap: 2.5 }}>
<RhfTextField<AccountFormValues> name="firstName" label={t('first_name')} fullWidth />
<RhfTextField<AccountFormValues> name="lastName" label={t('last_name')} fullWidth />
<SheetActions onCancel={() => setPersonalSheetOpen(false)} onSave={savePersonal} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
</Stack>
</FormDialogShell>
<Divider />
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('emergency_section')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('emergency_hint')}
</Typography>
</Box>
<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={{ m: 0, alignSelf: 'flex-start' }}
{/* زبان — the row owns the server-stored preference; the actual UI locale switch is phase 2's LocaleSwitcher, reused verbatim. */}
<FormDialogShell
open={languageSheetOpen}
title={t('row_language')}
dirty={languageDirty}
onClose={() => setLanguageSheetOpen(false)}
closeLabel={closeLabel}
discardTitle={discardTitle}
discardBody={discardBody}
discardConfirmLabel={discardConfirmLabel}
discardCancelLabel={cancelLabel}
>
{upsert.isPending ? tc('saving') : t('save')}
</AppButton>
<Divider />
{/* 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')}
<Stack sx={{ gap: 2.5 }}>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('app_language')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{ta('manage_body')}
{t('app_language_hint')}
</Typography>
</Box>
<AppButton
color="primary"
variant="outlined"
startIcon="location"
to={`/${locale}${ROUTES.ADDRESSES}`}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{ta('manage_cta')}
</AppButton>
<LocaleSwitcher />
</Stack>
<Divider />
<RhfTextField<AccountFormValues> name="language" select label={t('language')} helperText={t('language_hint')}>
<MenuItem value="fa">{t('language_fa')}</MenuItem>
<MenuItem value="en">{t('language_en')}</MenuItem>
</RhfTextField>
<SheetActions onCancel={() => setLanguageSheetOpen(false)} onSave={saveLanguage} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
</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>
);
};
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>
);
}
@@ -0,0 +1,236 @@
'use client';
import { Suspense, type FunctionComponent, type ReactNode } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Box, InputAdornment, Skeleton, Stack, TextField, Typography } from '@mui/material';
import {
AppButton,
AppLoading,
CategoryTile,
ErrorState,
GenderToggle,
JalaliDateIntentPicker,
StickyActionBar,
} from '@/components';
import CascadingRegionSelect from '@/components/geography/CascadingRegionSelect';
import { todayIso } from '@/components/common/JalaliDatePicker';
import { ROUTES } from '@/constants';
import { useServiceCategories } from '@/services/catalog';
import { pickCatalogName } from '@/services/catalog/names';
import { useNurseSearch } from '@/services/search';
import { filtersToSearchParams } from '@/services/search/filterParams';
import { useSearchFilters } from './useSearchFilters';
/**
* 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),
* the **prominent same-gender facet** (the shared `GenderToggle`, `allowAny`), a Jalali date-intent chip
* strip, and an optional Toman price range; a live result count drives the sticky "مشاهده N پرستار" CTA
* 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() {
return (
<Suspense fallback={<AppLoading />}>
<SearchFilterScreen />
</Suspense>
);
}
function SearchFilterScreen() {
const t = useTranslations('search');
const router = useRouter();
const locale = useLocale();
const params = useSearchParams();
const controller = useSearchFilters(params);
const { data, isFetching } = useNurseSearch(controller.filters);
const count = data?.total;
const goToResults = () => {
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);
router.push(`/${locale}${ROUTES.SEARCH_RESULTS}?${query.toString()}`);
};
const zeroResults = controller.isReady && !isFetching && count === 0;
return (
<Stack sx={{ gap: 3 }}>
<Box>
<Typography variant="h5" component="h1">
{t('title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
<CategorySelect selectedId={controller.categoryId} onSelect={controller.setCategoryId} />
<FilterSection title={t('section_location')}>
<CascadingRegionSelect value={controller.region} onChange={controller.setRegion} includeDistrict />
</FilterSection>
<FilterSection title={t('section_gender')} hint={t('gender_hint')}>
<GenderToggle
allowAny
value={controller.gender ?? 'any'}
onChange={(value) => controller.setGender(value === 'any' ? undefined : value)}
maleLabel={t('gender_male')}
femaleLabel={t('gender_female')}
anyLabel={t('gender_any')}
ariaLabel={t('section_gender')}
/>
</FilterSection>
<FilterSection title={t('section_date')} hint={t('date_hint')}>
<DateIntentFilter value={controller.dateIntent} onChange={controller.setDateIntent} />
</FilterSection>
<FilterSection title={t('section_price')} hint={t('price_hint')}>
<Stack direction="row" sx={{ gap: 2 }}>
<PriceField
label={t('price_min')}
value={controller.priceMinToman}
onChange={controller.setPriceMinToman}
adornment={t('toman')}
/>
<PriceField
label={t('price_max')}
value={controller.priceMaxToman}
onChange={controller.setPriceMaxToman}
adornment={t('toman')}
/>
</Stack>
</FilterSection>
<StickyActionBar>
{zeroResults ? (
<Stack sx={{ gap: 0.25 }} data-search-cta="zero">
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('cta_zero_title')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('cta_zero_hint')}
</Typography>
</Stack>
) : (
<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>
);
}
const FilterSection: FunctionComponent<{ title: string; hint?: string; children: ReactNode }> = ({
title,
hint,
children,
}) => (
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{hint ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{hint}
</Typography>
) : null}
{children}
</Stack>
);
const PriceField: FunctionComponent<{
label: string;
value: string;
onChange: (value: string) => void;
adornment: string;
}> = ({ label, value, onChange, adornment }) => (
<TextField
label={label}
value={value}
onChange={(event) => onChange(event.target.value)}
inputMode="numeric"
fullWidth
slotProps={{
input: { endAdornment: <InputAdornment position="end">{adornment}</InputAdornment> },
}}
/>
);
/**
* 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. */
const CategorySelect: FunctionComponent<{ selectedId: number | null; onSelect: (id: number) => void }> = ({
selectedId,
onSelect,
}) => {
const t = useTranslations('search');
const locale = useLocale();
const { data, isLoading, isError, refetch } = useServiceCategories();
const categories = data?.items ?? [];
return (
<FilterSection title={t('section_category')}>
{isLoading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
))}
</Box>
) : isError ? (
<ErrorState message={t('categories_error')} retryLabel={t('retry')} onRetry={() => refetch()} />
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
{categories.map((category) => (
<CategoryTile
key={category.id}
label={pickCatalogName(category, locale)}
iconKey={category.iconKey}
selected={category.id === selectedId}
onClick={() => onSelect(category.id)}
/>
))}
</Box>
)}
</FilterSection>
);
};
@@ -0,0 +1,395 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { Avatar, Box, Chip, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
import {
AppButton,
AppIcon,
EmptyState,
ErrorState,
PriceDisplay,
RatingInput,
ServicePriceRow,
StickyActionBar,
SurfaceCard,
TrustBadge,
VerificationPanel,
} from '@/components';
import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors';
import { formatNumber, formatShamsiDate } from '@/utils';
import { useNurseProfile } from '@/services/search';
import type { NurseProfile, NurseProfileServiceRow } from '@/services/search/types';
import { useNurseReviews } from '@/services/reviews';
import type { ReviewListItem } from '@/services/reviews/types';
import { useNurseTrustBadge } from '@/services/verification';
type ProfileTab = 'services' | 'reviews';
/**
* C3 Nurse profile (پروفایل پرستار): the trust dossier identity header (completed visits + rating),
* a tappable تاییدشده badge + «نظام پرستاری» chip, the shared `VerificationPanel` (what Balinyaar
* verified, fed by the public trust-badge read), attribute chips, and a **tabbed** body «خدمات» (the
* 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() {
const t = useTranslations('search');
const router = useRouter();
const locale = useLocale();
const routeParams = useParams<{ nurseId: string }>();
const query = useSearchParams();
const nurseId = Number(routeParams.nurseId);
const { data: profile, isLoading, isError, error, refetch } = useNurseProfile(
Number.isInteger(nurseId) && nurseId > 0 ? nurseId : undefined,
);
const [tab, setTab] = useState<ProfileTab>('services');
if (isLoading) return <ProfileSkeleton />;
if (isError) {
const notFound = error instanceof ApiError && error.status === 404;
return notFound ? (
<EmptyState
title={t('profile_not_found_title')}
body={t('profile_not_found_body')}
action={
<AppButton variant="outlined" color="primary" onClick={() => router.push(`/${locale}${ROUTES.SEARCH}`)}>
{t('profile_not_found_cta')}
</AppButton>
}
/>
) : (
<ErrorState message={t('profile_error_body')} retryLabel={t('retry')} onRetry={() => refetch()} />
);
}
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 variantId = carriedVariant ?? String(profile.services[0]?.variantId ?? '');
const params = new URLSearchParams();
params.set('nurse_id', String(profile.nurseId));
if (variantId) params.set('variant_id', variantId);
// The same-gender intent chosen on C1, carried BEFORE booking (becomes required_caregiver_gender in f7/b8).
const requiredGender = query.get('required_gender');
if (requiredGender) params.set('required_gender', requiredGender);
const cityId = query.get('city_id');
if (cityId) params.set('city_id', cityId);
const categoryId = query.get('service_category_id');
if (categoryId) params.set('service_category_id', categoryId);
const date = query.get('date');
if (date) params.set('date', date);
router.push(`/${locale}${ROUTES.BOOKING_REQUEST}?${params.toString()}`);
};
return (
<Stack sx={{ gap: 3 }}>
<ProfileHeader profile={profile} />
<AttributeChips profile={profile} />
<VerificationSection nurseId={profile.nurseId} />
<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="reviews" label={t('tab_reviews')} sx={{ textTransform: 'none', fontWeight: 700 }} />
</Tabs>
{tab === 'services' ? <ServicesSection profile={profile} /> : <ReviewsPanel nurseId={profile.nurseId} />}
<StickyActionBar>
<Stack direction="row" sx={{ gap: 2, alignItems: 'center', justifyContent: 'space-between' }}>
{primaryService ? (
<Box>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('price_from')}
</Typography>
<PriceDisplay price={primaryService.priceIrr} priceUnit={primaryService.priceUnit} align="start" />
</Box>
) : 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>
);
}
function ProfileHeader({ profile }: { profile: NurseProfile }) {
const t = useTranslations('search');
const locale = useLocale();
const name = profile.nurseName.trim() || t('unnamed_nurse');
const rating = formatNumber(profile.averageRating, locale, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
// Reached by direct URL, not gated by the search-index verified-only invariant — unlike the result card
// (NurseResultCard), an unverified nurse's profile CAN be opened this way, so the badge must reflect
// `profile.isVerified` (already correctly fetched by `getNurseProfile`), never an assumed-verified literal.
return (
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}>
<Avatar
src={profile.avatarUrl ?? undefined}
sx={{ width: 72, height: 72, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700, fontSize: 28 }}
>
{name.charAt(0)}
</Avatar>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="h5" component="h1">
{name}
</Typography>
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="star" size={18} color="var(--bal-rating)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{rating}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('reviews_count', { count: profile.totalReviews })}
</Typography>
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('completed_visits', { count: formatNumber(profile.totalCompletedBookings, locale) })}
</Typography>
</Stack>
</Stack>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<TrustBadge state={profile.isVerified ? 'verified' : 'unverified'} nurseId={profile.nurseId} />
{profile.inoMembership ? (
<Chip
icon={<AppIcon icon="license" size={16} color="var(--bal-primary)" />}
label={t('badge_ino')}
sx={{ backgroundColor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}
/>
) : null}
</Stack>
{profile.bio ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{profile.bio}
</Typography>
) : null}
</Stack>
);
}
/** "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 }) {
const t = useTranslations('search');
const locale = useLocale();
const chips: string[] = [];
if (profile.yearsExperience != null && profile.yearsExperience > 0) {
const years = formatNumber(profile.yearsExperience, locale);
chips.push(t('years_experience', { years }));
}
for (const code of profile.attributeChips) {
chips.push(t.has(`specialty_${code}`) ? t(`specialty_${code}`) : code);
}
if (chips.length === 0) return null;
return (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{chips.map((label) => (
<Chip key={label} label={label} variant="outlined" />
))}
</Stack>
);
}
function ServicesSection({ profile }: { profile: NurseProfile }) {
const t = useTranslations('search');
return (
<Stack sx={{ gap: 2 }}>
{profile.services.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('services_empty')}
</Typography>
) : (
<Box>
{profile.services.map((service) => (
<ServicePriceRow
key={service.variantId}
displayName={service.displayName}
priceIrr={service.priceIrr}
priceUnit={service.priceUnit}
sessionCount={service.sessionCount}
/>
))}
</Box>
)}
{profile.latestReview ? <LatestReviewSnippet review={profile.latestReview} /> : null}
</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
* requests or renders `pending_moderation`/`hidden`/`rejected` content; the aggregate is the server's
* recomputed value, not a client sum.
*/
function ReviewsPanel({ nurseId }: { nurseId: number }) {
const t = useTranslations('reviews');
const locale = useLocale();
const { data, isLoading, isError, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } = useNurseReviews(nurseId);
if (isLoading) {
return (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="text" width="40%" />
<Skeleton variant="rounded" height={88} />
<Skeleton variant="rounded" height={88} />
</Stack>
);
}
if (isError) {
return <ErrorState message={t('load_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
}
const aggregate = data?.pages[0]?.aggregate;
const items = data?.pages.flatMap((page) => page.reviews.items) ?? [];
const publishedCount = aggregate?.publishedCount ?? 0;
if (publishedCount === 0) {
return (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('reviews_empty')}
</Typography>
);
}
const average = formatNumber(aggregate?.averageRating ?? 0, locale, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
return (
<Stack sx={{ gap: 2 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<RatingInput value={aggregate?.averageRating ?? 0} readOnly size={20} ariaLabel={t('rating_label')} />
<Typography variant="h6" component="p" sx={{ fontWeight: 700 }}>
{average}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('count', { count: publishedCount })}
</Typography>
</Stack>
<Stack sx={{ gap: 1.5 }}>
{items.map((review) => (
<ReviewCard key={review.id} review={review} />
))}
</Stack>
{hasNextPage ? (
<AppButton
variant="text"
color="primary"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
sx={{ alignSelf: 'center' }}
>
{isFetchingNextPage ? t('loading') : t('load_more')}
</AppButton>
) : null}
</Stack>
);
}
function ReviewCard({ review }: { review: ReviewListItem }) {
const t = useTranslations('reviews');
const locale = useLocale();
return (
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', mb: 0.5, flexWrap: 'wrap' }}>
<RatingInput value={review.rating} readOnly size={16} ariaLabel={t('rating_label')} />
<Typography variant="caption" sx={{ color: 'text.secondary', marginInlineStart: 'auto' }}>
{t('author_masked')} · {formatShamsiDate(review.createdAt, locale)}
</Typography>
</Stack>
{review.body ? <Typography variant="body2">{review.body}</Typography> : null}
{review.tagCodes.length > 0 ? (
<Stack direction="row" sx={{ gap: 0.5, flexWrap: 'wrap', mt: 1 }}>
{review.tagCodes.map((code) => (
<Chip key={code} size="small" variant="outlined" label={t.has(`tag_${code}`) ? t(`tag_${code}`) : code} />
))}
</Stack>
) : null}
</Paper>
);
}
function ProfileSkeleton() {
return (
<Stack sx={{ gap: 3 }}>
<Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}>
<Skeleton variant="circular" width={72} height={72} />
<Stack sx={{ gap: 1, flexGrow: 1 }}>
<Skeleton variant="text" width="60%" height={32} />
<Skeleton variant="text" width="40%" />
</Stack>
</Stack>
<Skeleton variant="rounded" height={72} />
<Skeleton variant="rounded" height={120} />
<Skeleton variant="rounded" height={96} />
</Stack>
);
}
@@ -1,35 +1,13 @@
'use client';
import { Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { AppLoading, PlaceholderScreen } from '@/components';
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import SearchScreen from './SearchScreen';
/**
* Search landing **DEFERRED frontend-phase-6-b7**. The A5 Home search bar and category tiles
* navigate here carrying a `q` / `category_id`; f6 builds the actual results, filters, and nurse
* cards. This placeholder just acknowledges the intent so the Home CTAs don't dead-end. `useSearchParams`
* needs a Suspense boundary under static rendering.
*/
export default function SearchPage() {
return (
<Suspense fallback={<AppLoading />}>
<SearchDeferred />
</Suspense>
);
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'search' });
return { title: t('title') };
}
function SearchDeferred() {
const t = useTranslations('search');
const params = useSearchParams();
const query = params.get('q');
const categoryId = params.get('category_id');
const echo = query ? t('query_echo', { query }) : categoryId ? t('category_echo') : undefined;
return (
<PlaceholderScreen
icon="search"
title={t('title')}
description={[t('deferred'), echo].filter(Boolean).join(' ')}
/>
);
export default function Page() {
return <SearchScreen />;
}
@@ -0,0 +1,201 @@
'use client';
import { Suspense, useCallback, useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Chip, Stack, Typography } from '@mui/material';
import type { SxProps, Theme } from '@mui/material';
import { AppButton, AppLoading, EmptyState, ErrorState, NurseResultCard } from '@/components';
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 { searchParamsToFilters } from '@/services/search/filterParams';
import { SEARCH_PAGE_SIZE } from '@/services/search/constants';
import { formatIrrToToman } from '@/utils';
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
* 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` +
* `keepPreviousData`). A tappable **filter-recap chip row** (category · region · gender · price) deep-
* 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() {
return (
<Suspense fallback={<AppLoading />}>
<ResultsScreen />
</Suspense>
);
}
function ResultsScreen() {
const t = useTranslations('search');
const tc = useTranslations('common');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const [pageSize, setPageSize] = useState(SEARCH_PAGE_SIZE);
// 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 dateIntent = params.get('date') ?? undefined;
const provinceIdParam = params.get('province_id');
const { data, isLoading, isError, isFetching, refetch } = useNurseSearch(filters);
const items = data?.items ?? [];
const total = data?.total ?? 0;
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(
(nurse: NurseSearchResult) => {
const query = new URLSearchParams();
query.set('variant_id', String(nurse.variantId));
query.set('service_category_id', String(filters.serviceCategoryId));
query.set('city_id', String(filters.cityId));
if (filters.nurseGender) query.set('required_gender', filters.nurseGender);
if (dateIntent) query.set('date', dateIntent);
router.push(`/${locale}${ROUTES.SEARCH_NURSE}/${nurse.nurseId}?${query.toString()}`);
},
[router, locale, filters.serviceCategoryId, filters.cityId, filters.nurseGender, dateIntent],
);
return (
<Stack sx={{ gap: 2 }}>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="h6" component="h1">
{isLoading ? t('results_loading_title') : t('results_count', { count: total })}
</Typography>
{/* Rating is the only MVP sort a static caption, not a dead-interactive dropdown. Other
sorts are DEFERRED until the API grows them. */}
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{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>
{isLoading ? (
<Box sx={RESULTS_GRID_SX}>
{[0, 1, 2, 3].map((key) => (
<NurseResultCard.Skeleton key={key} />
))}
</Box>
) : isError ? (
<ErrorState message={t('results_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />
) : items.length === 0 ? (
<RelaxFiltersEmptyState onRelax={backToFilters} />
) : (
// 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) => (
<NurseResultCard
key={`${nurse.nurseId}-${nurse.variantId}`}
nurse={nurse}
serviceLabel={categoryLabelById.get(nurse.serviceCategoryId) ?? t('unnamed_service')}
onSelect={openProfile}
/>
))}
{hasMore ? (
<AppButton
variant="outlined"
color="primary"
onClick={() => setPageSize((size) => size + SEARCH_PAGE_SIZE)}
disabled={isFetching}
sx={{ alignSelf: 'center', gridColumn: '1 / -1' }}
>
{t('load_more')}
</AppButton>
) : null}
</Box>
)}
</Stack>
);
}
/** The "no nurses match → relax your filters" state with concrete, product-aligned suggestions. */
function RelaxFiltersEmptyState({ onRelax }: { onRelax: () => void }) {
const t = useTranslations('search');
return (
<EmptyState
icon="search"
title={t('empty_title')}
body={
<Stack sx={{ gap: 0.5 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('empty_suggest_gender')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('empty_suggest_district')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('empty_suggest_date')}
</Typography>
</Stack>
}
action={
<AppButton variant="contained" color="primary" onClick={onRelax} startIcon="tune">
{t('empty_cta')}
</AppButton>
}
/>
);
}
@@ -0,0 +1,94 @@
import { useMemo, useState } from 'react';
import { toEnglishDigits, tomanToRial } from '@/utils';
import { rialToToman } from '@/utils/money';
import { useDebouncedValue } from '@/services/search';
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 { CascadingRegionValue } from '@/components/geography/CascadingRegionSelect';
/** 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. */
function tomanInputToIrr(toman: string): string | undefined {
const digits = toEnglishDigits(toman).trim();
if (!/^\d+$/.test(digits)) return undefined;
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,
* 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
* 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.
*
* `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(params: ParamReader) {
const [categoryId, setCategoryId] = useState<number | null>(() => {
const raw = searchParamsToFilters(params).serviceCategoryId;
return raw > 0 ? raw : null;
});
const [region, setRegion] = useState<CascadingRegionValue>(() => {
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 debouncedMax = useDebouncedValue(priceMaxToman, SEARCH_FILTER_DEBOUNCE_MS);
const filters: NurseSearchFilters = useMemo(
() => ({
serviceCategoryId: categoryId ?? 0,
cityId: region.cityId ?? 0,
districtId: region.districtId ?? undefined,
nurseGender: gender,
priceMin: tomanInputToIrr(debouncedMin),
priceMax: tomanInputToIrr(debouncedMax),
sort: 'rating',
page: 1,
pageSize: SEARCH_PAGE_SIZE,
}),
[categoryId, region.cityId, region.districtId, gender, debouncedMin, debouncedMax],
);
const isReady = filters.serviceCategoryId > 0 && filters.cityId > 0;
return {
categoryId,
setCategoryId,
region,
setRegion,
gender,
setGender,
priceMinToman,
setPriceMinToman,
priceMaxToman,
setPriceMaxToman,
dateIntent,
setDateIntent,
filters,
isReady,
};
}
@@ -0,0 +1,11 @@
'use client';
import { useParams } from 'next/navigation';
import { TicketThreadScreen } from '@/components/messaging';
/** /support/tickets/[id] — the customer ticket thread (f14). */
export default function CustomerTicketThreadPage() {
const params = useParams<{ id: string }>();
const id = Number(params.id);
const ticketId = Number.isInteger(id) && id > 0 ? id : -1;
return <TicketThreadScreen role="customer" ticketId={ticketId} />;
}
@@ -0,0 +1,7 @@
'use client';
import { TicketInboxScreen } from '@/components/messaging';
/** /support/tickets — the customer "My Tickets" inbox (f14). Shares the screen with the nurse shell. */
export default function CustomerTicketsPage() {
return <TicketInboxScreen role="customer" />;
}
@@ -0,0 +1,129 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, InstallmentScheduleRow, Money, PlaceholderScreen } from '@/components';
import { formatShamsiDate } from '@/utils';
import { useWalletInstallments } from '@/services/bnpl';
import type { WalletInstallmentPlan } from '@/services/bnpl/types';
/**
* D5 · پیگیری اقساط the Wallet «اقساط» section (active installment plans). It reads
* `useWalletInstallments` and renders **provider-reported** status: an outstanding-balance card
* (terracotta), the next-installment date + a provider hand-off «پرداخت زودهنگام» (early-pay is a
* *provider* action, never a Balinyaar transaction), the per-installment due list with status chips, and
* the ownership note (Balinyaar displays, it does not manage, this schedule). Section body only the
* page-level heading + tab strip live in `WalletScreen`.
*/
const WalletInstallments: FunctionComponent = () => {
const t = useTranslations('bnpl');
const tc = useTranslations('common');
const { data: plans, isLoading, isError, refetch } = useWalletInstallments();
return (
<Stack sx={{ gap: 2, width: '100%' }}>
{isLoading ? (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="rounded" height={128} />
<Skeleton variant="rounded" height={56} />
<Skeleton variant="rounded" height={56} />
</Stack>
) : isError ? (
<Paper elevation={0} sx={{ p: 3, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', textAlign: 'center' }}>
<Stack sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="warning" size={36} color="var(--bal-warning)" />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('wallet_error_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('wallet_error_body')}
</Typography>
<AppButton variant="outlined" color="secondary" onClick={() => refetch()} sx={{ mt: 1 }}>
{tc('retry')}
</AppButton>
</Stack>
</Paper>
) : !plans || plans.length === 0 ? (
<PlaceholderScreen icon="installments" title={t('wallet_empty_title')} description={t('wallet_empty_body')} />
) : (
<Stack sx={{ gap: 3 }}>
{plans.map((plan) => (
<InstallmentPlanSection key={plan.bnplTransactionId} plan={plan} />
))}
</Stack>
)}
</Stack>
);
};
function InstallmentPlanSection({ plan }: { plan: WalletInstallmentPlan }) {
const t = useTranslations('bnpl');
const locale = useLocale();
const providerName = t(`provider_${plan.providerCode}`);
const handleEarlyPay = () => {
// Early-pay is a PROVIDER action — hand off to the provider, never a Balinyaar payment.
if (plan.earlyPayUrl) window.open(plan.earlyPayUrl, '_blank', 'noopener,noreferrer');
};
return (
<Stack sx={{ gap: 1.5 }}>
{/* Outstanding-balance card — terracotta financial accent; contrast text is scheme-stable. */}
<Paper
elevation={0}
sx={{ p: 2.25, borderRadius: 'var(--bal-radius-lg)', backgroundColor: 'var(--bal-secondary)', color: 'var(--bal-secondary-contrast)' }}
>
<Typography variant="caption" sx={{ opacity: 0.85 }}>
{t('outstanding_balance')}
</Typography>
<Money amountIrr={plan.outstandingBalanceIrr} size="lg" sx={{ fontWeight: 800, mt: 0.25 }} />
<Typography variant="caption" sx={{ opacity: 0.85, display: 'block', mt: 0.5 }}>
{plan.serviceLabel}
</Typography>
{plan.nextDueDate ? (
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-end', mt: 1.5, gap: 1.5 }}>
<Box>
<Typography variant="caption" sx={{ opacity: 0.85 }}>
{t('next_installment')} · {formatShamsiDate(`${plan.nextDueDate}T00:00:00`, locale)}
</Typography>
{plan.nextAmountIrr ? (
<Money amountIrr={plan.nextAmountIrr} size="sm" sx={{ fontWeight: 800 }} />
) : null}
</Box>
{plan.earlyPayUrl ? (
<AppButton
color="primary"
variant="contained"
size="small"
onClick={handleEarlyPay}
sx={{ flex: 'none' }}
>
{t('early_pay')}
</AppButton>
) : null}
</Stack>
) : null}
</Paper>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('due_dates')}
</Typography>
<Stack sx={{ gap: 1 }}>
{plan.installments.map((row) => (
<InstallmentScheduleRow key={`${row.kind}-${row.sequence}`} row={row} showStatus />
))}
</Stack>
{/* Ownership note: Balinyaar displays, it does not manage, this provider-owned schedule. */}
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'flex-start', px: 0.5 }}>
<AppIcon icon="info" size={16} color="var(--bal-text-secondary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('provider_owned_note', { provider: providerName })}
</Typography>
</Stack>
</Stack>
);
}
export default WalletInstallments;
@@ -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,8 +1,9 @@
import { getTranslations } from 'next-intl/server';
import { PlaceholderScreen } from '@/components';
import WalletScreen from './WalletScreen';
export default async function WalletPage() {
const t = await getTranslations('nav');
const tShell = await getTranslations('shell');
return <PlaceholderScreen icon="wallet" title={t('wallet')} description={tShell('placeholder_body')} />;
/**
* /wallet — the customer money hub (ui-phase-6): پرداخت‌ها / اقساط / استردادها / رسیدها. Thin route shell;
* the tabbed body is a client component (TanStack Query).
*/
export default function WalletPage() {
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 { useSnackbar } from 'notistack';
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 { useCreatePatient } from '@/services/patients';
import { RELATION_CODES } from '@/services/patients/constants';
@@ -12,12 +13,26 @@ import type { CreatePatientInput, Relation } from '@/services/patients/types';
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
* chosen relation pre-shapes the patient (it is hidden on the A4 form since it's already
* chosen here). On save it creates the patient and lands on Home (A5).
* The chrome-free A3 A4 first-run journey: a one-screen welcome moment, then pick who care is
* for, then register the first patient. `FocusedLayout` (the route group above this) strips the
* 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 tc = useTranslations('common');
const router = useRouter();
@@ -25,10 +40,14 @@ export default function OnboardingPage() {
const { enqueueSnackbar } = useSnackbar();
const createPatient = useCreatePatient();
const [step, setStep] = useState(0);
const [phase, setPhase] = useState<Phase>('welcome');
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) => {
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 (
<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: 0.5 }}>
<Typography variant="h6" component="h1">
@@ -66,8 +106,8 @@ export default function OnboardingPage() {
variant="contained"
fullWidth
disabled={!relation}
onClick={() => setStep(1)}
sx={{ m: 0 }}
onClick={() => setPhase('patient')}
endIcon={<AppIcon icon="forward" size={18} aria-hidden="true" />}
>
{t('continue')}
</AppButton>
@@ -87,7 +127,7 @@ export default function OnboardingPage() {
submitLabel={t('save_continue')}
submitting={createPatient.isPending}
onSubmit={handleCreate}
onCancel={() => setStep(0)}
onCancel={() => setPhase('relation')}
cancelLabel={tc('back')}
/>
</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 />;
}
@@ -0,0 +1,26 @@
import Skeleton from '@mui/material/Skeleton';
import Stack from '@mui/material/Stack';
import SurfaceCard from '@/components/common/SurfaceCard';
/**
* The shared loading skeleton for the nurse/admin/partner route groups. The `MobileShell` chrome
* (top bar + bottom nav) is already rendered by the enclosing `layout.tsx` by the time this shows,
* so this only shapes the content area: a heading line + a short stack of generic worklist cards.
* A private (`_`-prefixed) folder not a route.
*/
export default function ShellContentSkeleton() {
return (
<Stack sx={{ gap: 2 }}>
<Skeleton variant="text" width={220} height={32} />
{[0, 1, 2].map((key) => (
<SurfaceCard key={key}>
<Stack sx={{ gap: 0.75 }}>
<Skeleton variant="text" width="40%" height={22} />
<Skeleton variant="text" width="70%" height={18} />
<Skeleton variant="text" width="55%" height={18} />
</Stack>
</SurfaceCard>
))}
</Stack>
);
}
@@ -0,0 +1,64 @@
'use client';
import { useTranslations } from 'next-intl';
import { Stack } from '@mui/material';
import { NavHubList, PageHeader } from '@/components';
import type { NavHubItem } from '@/components';
import { useAdminCapabilities } from '@/hooks';
import { ROUTES } from '@/constants';
interface ConsoleEntry extends NavHubItem {
/** Section this console belongs to — the same four groups the bottom nav carries. */
section: 'trust' | 'finance' | 'support' | 'system';
enabled: boolean;
}
/**
* Admin overview landing (f15) the backoffice index. Every worklist the current principal may
* act on, grouped by the same four sections as the bottom nav, so the overview and the tabs agree
* on where a console lives. Gating comes from `useAdminCapabilities()` (a UI hint; the server
* still enforces every command's role scope): a `support` admin sees verification/tickets/alerts,
* a `finance` admin sees payouts/config, only a `super_admin` sees roles.
*
* The old 3-column card grid is gone inside a phone-width frame it collapsed to a single column
* of oversized tiles carrying nothing but an icon and one word each.
*/
export default function AdminOverviewScreen() {
const t = useTranslations('admin');
const th = useTranslations('hub');
const tNav = useTranslations('nav');
const caps = useAdminCapabilities();
const consoles: ConsoleEntry[] = [
{ section: 'trust', title: tNav('verification'), subtitle: th('admin_verification_sub'), path: ROUTES.ADMIN_VERIFICATION, icon: 'verification', enabled: caps.canVerify },
{ section: 'trust', title: tNav('reviews'), subtitle: th('admin_reviews_sub'), path: ROUTES.ADMIN_REVIEWS, icon: 'moderation', enabled: caps.canModerate },
{ section: 'finance', title: tNav('payouts'), subtitle: th('admin_payouts_sub'), path: ROUTES.ADMIN_PAYOUTS, icon: 'earnings', enabled: caps.canPayout },
{ section: 'support', title: tNav('tickets'), subtitle: th('admin_tickets_sub'), path: ROUTES.ADMIN_TICKETS, icon: 'support', enabled: caps.canManageTickets },
{ section: 'support', title: tNav('alerts'), subtitle: th('admin_alerts_sub'), path: ROUTES.ADMIN_ALERTS, icon: 'alerts', enabled: caps.canManageAlerts },
{ section: 'system', title: tNav('config'), subtitle: th('admin_config_sub'), path: ROUTES.ADMIN_CONFIG, icon: 'config', enabled: caps.canConfig },
{ section: 'system', title: tNav('catalog'), subtitle: th('admin_catalog_sub'), path: ROUTES.ADMIN_CATALOG, icon: 'category', enabled: caps.canManageCatalog },
{ section: 'system', title: tNav('holidays'), subtitle: th('admin_holidays_sub'), path: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', enabled: caps.canConfig },
{ section: 'system', title: tNav('audit'), subtitle: th('admin_audit_sub'), path: ROUTES.ADMIN_AUDIT, icon: 'audit', enabled: caps.canViewAudit },
{ section: 'system', title: tNav('partners'), subtitle: th('admin_partners_sub'), path: ROUTES.ADMIN_PARTNERS, icon: 'partners', enabled: caps.canManagePartners },
{ section: 'system', title: tNav('users'), subtitle: th('admin_users_sub'), path: ROUTES.ADMIN_USERS, icon: 'users', enabled: caps.canManageRoles },
{ section: 'system', title: tNav('roles'), subtitle: th('admin_roles_sub'), path: ROUTES.ADMIN_ROLES, icon: 'roles', enabled: caps.canManageRoles },
];
const sections: Array<{ key: ConsoleEntry['section']; label: string }> = [
{ key: 'trust', label: tNav('group_trust') },
{ key: 'finance', label: tNav('group_finance') },
{ key: 'support', label: tNav('group_support') },
{ key: 'system', label: tNav('group_system') },
];
return (
<Stack sx={{ gap: 2.5 }}>
<PageHeader title={t('overview_title')} subtitle={t('overview_subtitle')} />
{sections.map((section) => {
const items = consoles.filter((entry) => entry.section === section.key && entry.enabled);
if (items.length === 0) return null;
return <NavHubList key={section.key} title={section.label} items={items} />;
})}
</Stack>
);
}
@@ -0,0 +1,45 @@
'use client';
import { FunctionComponent, ReactNode } from 'react';
import { Stack } from '@mui/material';
import { useTranslations } from 'next-intl';
import { EmptyState, NavHubList, PageHeader } from '@/components';
import type { NavHubItem } from '@/components';
export interface AdminGroupConsole extends NavHubItem {
/** Capability gate for this console — a hidden row is one the current admin role can't act on. */
enabled: boolean;
}
interface Props {
title: string;
subtitle?: string;
consoles: Array<AdminGroupConsole>;
/** Rendered below the console list (the system group's settings + sign-out). */
children?: ReactNode;
}
/**
* The body every admin group-root page shares: a header, the capability-filtered consoles in that
* group, and an optional tail. Gating stays per-console and is still only a UI hint the server
* authorizes every command regardless of what the nav shows.
* A private (`_`-prefixed) folder, so this is not itself a route.
* @component AdminGroupHub
*/
const AdminGroupHub: FunctionComponent<Props> = ({ title, subtitle, consoles, children }) => {
const t = useTranslations('hub');
const permitted = consoles.filter((console_) => console_.enabled);
return (
<Stack sx={{ gap: 2 }}>
<PageHeader title={title} subtitle={subtitle} />
{permitted.length > 0 ? (
<NavHubList items={permitted} />
) : (
<EmptyState icon="lock" title={t('admin_group_empty_title')} body={t('admin_group_empty_body')} />
)}
{children}
</Stack>
);
};
export default AdminGroupHub;
@@ -0,0 +1,197 @@
'use client';
import { Suspense, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Box, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
import { AppLoading } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfirmDialog, SupportAlertCard } from '@/components/admin';
import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { useAuth } from '@/context/auth';
import { ADMIN_PAGE_SIZE } from '@/services/admin/constants';
import type { SupportAlert, SupportAlertStatus, SupportAlertType } from '@/services/admin/types';
import { useSupportAlerts, useAssignSupportAlert, useResolveSupportAlert } from '@/services/admin';
interface AlertFilters {
status?: SupportAlertStatus;
type?: SupportAlertType;
}
const STATUSES: readonly SupportAlertStatus[] = ['open', 'assigned', 'resolved'];
const TYPES: readonly SupportAlertType[] = [
'low_rating',
'evv_no_show',
'evv_location_mismatch',
'verification_expired',
'shared_sim',
'payment_anomaly',
'fraud_signal',
'nurse_clawback',
'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
* 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.
*/
export default function AdminAlertsPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminAlertsPageInner />
</Suspense>
);
}
function AdminAlertsPageInner() {
const t = useTranslations('admin');
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const [authState] = useAuth();
const meId = authState.currentUser?.id;
const listState = useAdminListState<AlertFilters>({ parse: parseFilters, serialize: serializeFilters, empty: EMPTY });
const { applied: filters, page } = listState;
const [resolving, setResolving] = useState<SupportAlert | null>(null);
const alerts = useSupportAlerts(filters, page);
const assign = useAssignSupportAlert();
const resolve = useResolveSupportAlert();
const items = alerts.data?.items ?? [];
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) => {
if (meId == null) return;
assign.mutate(
{ alertId: alert.id, ownerUserId: meId },
{ onSuccess: () => enqueueSnackbar(t('alert_assigned'), { variant: 'success' }) },
);
};
const onResolveConfirm = (note?: string) => {
if (!resolving) return;
resolve.mutate(
{ alertId: resolving.id, note: note ?? '' },
{
onSuccess: () => {
enqueueSnackbar(t('alert_resolved'), { variant: 'success' });
setResolving(null);
},
},
);
};
const setStatusFilter = (value: SupportAlertStatus | '') =>
listState.applyFilters({ ...filters, status: value || undefined });
const setTypeFilter = (value: SupportAlertType | '') =>
listState.applyFilters({ ...filters, type: value || undefined });
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
title={t('alert_title')}
subtitle={t('alert_subtitle')}
actions={
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<TextField
select
size="small"
label={t('alert_col_status')}
value={filters.status ?? ''}
onChange={(e) => setStatusFilter(e.target.value as SupportAlertStatus | '')}
sx={{ minWidth: 140 }}
>
<MenuItem value="">{t('filter_all')}</MenuItem>
{STATUSES.map((s) => (
<MenuItem key={s} value={s}>
{t(`astatus_${s}`)}
</MenuItem>
))}
</TextField>
<TextField
select
size="small"
label={t('alert_col_type')}
value={filters.type ?? ''}
onChange={(e) => setTypeFilter(e.target.value as SupportAlertType | '')}
sx={{ minWidth: 180 }}
>
<MenuItem value="">{t('filter_all')}</MenuItem>
{TYPES.map((ty) => (
<MenuItem key={ty} value={ty}>
{t(`atype_${ty}`)}
</MenuItem>
))}
</TextField>
</Stack>
}
/>
{alerts.isLoading ? (
<Stack sx={{ gap: 2 }}>{[0, 1, 2].map((k) => <Skeleton key={k} variant="rounded" height={110} />)}</Stack>
) : alerts.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => alerts.refetch()} />
) : items.length === 0 ? (
<AdminEmptyState icon="alerts" title={t('alert_empty')} />
) : (
<Stack sx={{ gap: 2 }}>
{items.map((alert) => (
<SupportAlertCard
key={alert.id}
alert={alert}
canAct={caps.canManageAlerts}
onAssignSelf={onAssignSelf}
onResolve={setResolving}
assignSelfDisabled={meId == null}
assignSelfDisabledTitle={t('assign_me_loading')}
/>
))}
</Stack>
)}
<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 })}
/>
<ConfirmDialog
open={resolving != null}
title={t('alert_resolve_title')}
confirmLabel={t('alert_resolve')}
cancelLabel={t('cancel')}
onConfirm={onResolveConfirm}
onClose={() => setResolving(null)}
loading={resolve.isPending}
requireReason
reasonLabel={t('note_label')}
reasonPlaceholder={t('alert_resolve_note_ph')}
/>
</Box>
);
}
@@ -0,0 +1,142 @@
'use client';
import { Suspense, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Box, Skeleton, Stack, TextField } from '@mui/material';
import { AppButton, AppLoading, JalaliDateField } from '@/components';
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 type { AuditFilters } from '@/services/admin/types';
import { useAuditLogs, useUserLookup } from '@/services/admin';
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,
* each row expandable to its `changed_fields` diff. There is **no** edit/delete affordance (phase §5). The
* filter draft is committed to the query only on Apply, so typing never refetches; the applied filters +
* page are the cache key, so switching filters/pages never refetches data already held.
*/
export default function AdminAuditPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminAuditPageInner />
</Suspense>
);
}
function AdminAuditPageInner() {
const t = useTranslations('admin');
const listState = useAdminListState<AuditFilters>({ parse: parseFilters, serialize: serializeFilters, empty: EMPTY });
const { draft, setDraft, applied, page } = listState;
const audit = useAuditLogs(applied, page);
const items = audit.data?.items ?? [];
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;
// Batch id→name resolve (3.2/3.6) — one request for every actor rendered on this page, never one per row.
const actorIds = useMemo(
() => [...new Set((audit.data?.items ?? []).map((e) => e.actorUserId).filter((id): id is number => id != null))].sort(
(a, b) => a - b,
),
[audit.data?.items],
);
const userLookup = useUserLookup(actorIds);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('audit_title')} subtitle={t('audit_subtitle')} />
<Stack
direction="row"
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
>
<TextField
size="small"
label={t('audit_col_entity')}
placeholder={t('audit_entity_type_ph')}
value={draft.entityType ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, entityType: e.target.value || undefined }))}
sx={{ minWidth: 200 }}
/>
<TextField
size="small"
label="ID"
placeholder={t('audit_entity_id_ph')}
value={draft.entityId ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, entityId: e.target.value || undefined }))}
sx={{ minWidth: 120 }}
/>
<JalaliDateField
label={t('audit_from')}
value={draft.from ?? null}
onChange={(iso) => setDraft((d) => ({ ...d, from: iso }))}
max={draft.to}
sx={{ minWidth: 160 }}
/>
<JalaliDateField
label={t('audit_to')}
value={draft.to ?? null}
onChange={(iso) => setDraft((d) => ({ ...d, to: iso }))}
min={draft.from}
sx={{ minWidth: 160 }}
/>
<AppButton variant="contained" color="primary" onClick={listState.apply}>
{t('apply')}
</AppButton>
<AppButton variant="text" color="inherit" onClick={listState.clear}>
{t('clear')}
</AppButton>
</Stack>
{audit.isLoading ? (
<Stack sx={{ gap: 1 }}>{[0, 1, 2, 3].map((k) => <Skeleton key={k} variant="rounded" height={56} />)}</Stack>
) : audit.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => audit.refetch()} />
) : items.length === 0 ? (
<AdminEmptyState icon="audit" title={t('audit_empty')} />
) : (
<Stack sx={{ gap: 1 }}>
{items.map((entry) => (
<AuditLogRow key={entry.id} entry={entry} actorLabel={actorLabelFrom(userLookup.data, entry.actorUserId)} />
))}
</Stack>
)}
{items.length > 0 ? (
<Box sx={{ typography: 'caption', color: 'text.secondary' }}>{t('showing_range', { from, to, total })}</Box>
) : null}
<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 })}
/>
</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>
);
}
@@ -0,0 +1,262 @@
'use client';
import { Suspense, useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Drawer,
FormControlLabel,
Skeleton,
Stack,
Switch,
TextField,
Typography,
} from '@mui/material';
import { AppButton, AppIcon, AppLoading } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfigRow } from '@/components/admin';
import { formatShamsiDateTime } from '@/utils';
import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { CONFIG_GROUPS, RATE_CONFIG_KEYS } from '@/services/admin/constants';
import type { PlatformConfig } from '@/services/admin/types';
import { usePlatformConfigs, useUpdatePlatformConfig, useConfigChangeHistory } from '@/services/admin';
const GROUP_ORDER = ['fees', 'deadlines', 'evv', 'bnpl', 'cancellation', 'other'] as const;
type GroupKey = (typeof GROUP_ORDER)[number];
/** Validate a candidate value against a config's `data_type` (+ the 01 rate rule). Returns an i18n key or null. */
function validate(config: PlatformConfig, value: string): string | null {
const trimmed = value.trim();
if (trimmed.length === 0) return 'cfg_empty_error';
if (config.dataType === 'int') {
if (!/^-?\d+$/.test(trimmed)) return 'cfg_int_error';
}
if (config.dataType === 'int' || config.dataType === 'decimal') {
const n = Number(trimmed);
if (Number.isNaN(n)) return 'cfg_int_error';
if (RATE_CONFIG_KEYS.includes(config.key) && (n < 0 || n > 1)) return 'cfg_range_error';
}
if (config.dataType === 'json') {
try {
JSON.parse(trimmed);
} catch {
return 'cfg_json_error';
}
}
return null;
}
/**
* Platform config editor (f15) every `platform_configs` row grouped by concern, each with a typed input
* by `data_type` and boundary validation (a rate is 01). Saving is audited server-side and takes effect
* immediately without re-pricing already-computed rows the save dialog says so. The change-history drawer
* proves the value in effect at any past moment. The client never re-parses config beyond rendering by
* `data_type` (phase §5).
*/
export default function AdminConfigPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminConfigPageInner />
</Suspense>
);
}
function AdminConfigPageInner() {
const t = useTranslations('admin');
const caps = useAdminCapabilities();
const listState = useAdminListState<Record<string, never>>({ parse: () => ({}), serialize: () => ({}), empty: {} });
const configs = usePlatformConfigs(listState.page);
const [editing, setEditing] = useState<PlatformConfig | null>(null);
const [historyKey, setHistoryKey] = useState<string | null>(null);
const grouped = useMemo(() => {
const items = configs.data?.items ?? [];
const byKey = new Map(Object.entries(CONFIG_GROUPS).flatMap(([g, keys]) => keys.map((k) => [k, g as GroupKey])));
const result: Record<GroupKey, PlatformConfig[]> = { fees: [], deadlines: [], evv: [], bnpl: [], cancellation: [], other: [] };
for (const c of items) result[byKey.get(c.key) ?? 'other'].push(c);
return result;
}, [configs.data]);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('cfg_title')} subtitle={t('cfg_subtitle')} />
{configs.isLoading ? (
<Stack sx={{ gap: 2 }}>{[0, 1, 2].map((k) => <Skeleton key={k} variant="rounded" height={96} />)}</Stack>
) : configs.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => configs.refetch()} />
) : (configs.data?.items.length ?? 0) === 0 ? (
<AdminEmptyState icon="config" title={t('cfg_title')} />
) : (
GROUP_ORDER.filter((g) => grouped[g].length > 0).map((g) => (
<Stack key={g} sx={{ gap: 1.5 }}>
<Typography variant="overline" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t(`cfg_group_${g}`)}
</Typography>
{grouped[g].map((config) => (
<ConfigRow
key={config.key}
config={config}
canEdit={caps.canConfig}
onEdit={setEditing}
onHistory={(c) => setHistoryKey(c.key)}
/>
))}
</Stack>
))
)}
<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}
<ConfigHistoryDrawer configKey={historyKey} onClose={() => setHistoryKey(null)} />
</Box>
);
}
/** The typed, validated, audited edit dialog for one config row. */
function ConfigEditDialog({ config, onClose }: { config: PlatformConfig; onClose: () => void }) {
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const update = useUpdatePlatformConfig();
const [value, setValue] = useState(config.value);
const errorKey = validate(config, value);
const isBool = config.dataType === 'bool';
const onSave = () => {
if (errorKey) return;
update.mutate(
{ key: config.key, value: isBool ? value : value.trim() },
{
onSuccess: () => {
enqueueSnackbar(t('cfg_saved'), { variant: 'success' });
onClose();
},
},
);
};
return (
<Dialog open onClose={update.isPending ? undefined : onClose} fullWidth maxWidth="sm">
<DialogTitle sx={{ fontWeight: 800, fontFamily: 'monospace' }}>{config.key}</DialogTitle>
<DialogContent>
{config.description ? (
<DialogContentText sx={{ mb: 2 }}>{config.description}</DialogContentText>
) : null}
{isBool ? (
<FormControlLabel
control={<Switch checked={value === 'true'} onChange={(e) => setValue(e.target.checked ? 'true' : 'false')} />}
label={t(`dtype_bool`)}
/>
) : (
<TextField
fullWidth
autoFocus
multiline={config.dataType === 'json'}
minRows={config.dataType === 'json' ? 4 : 1}
value={value}
onChange={(e) => setValue(e.target.value)}
label={t('cfg_col_value')}
error={!!errorKey}
helperText={errorKey ? t(errorKey) : undefined}
slotProps={{ input: { sx: config.dataType === 'json' ? { fontFamily: 'monospace' } : undefined } }}
/>
)}
<DialogContentText sx={{ mt: 2, color: 'var(--bal-warning)', fontWeight: 500 }}>
{t('cfg_save_confirm_body')}
</DialogContentText>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={update.isPending}>
{t('cancel')}
</AppButton>
<AppButton variant="contained" color="primary" onClick={onSave} disabled={!!errorKey || update.isPending}>
{update.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</Dialog>
);
}
/** The change-history drawer for one config key. */
function ConfigHistoryDrawer({ configKey, onClose }: { configKey: string | null; onClose: () => void }) {
const t = useTranslations('admin');
const locale = useLocale();
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).
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 (
<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 }}>
<Typography variant="h6">{configKey ? t('cfg_history_title', { key: configKey }) : ''}</Typography>
<AppButton variant="text" color="inherit" onClick={close} sx={{ minWidth: 0 }}>
<AppIcon icon="close" />
</AppButton>
</Stack>
{history.isLoading ? (
<Stack sx={{ gap: 1 }}>{[0, 1].map((k) => <Skeleton key={k} variant="rounded" height={64} />)}</Stack>
) : (history.data?.items.length ?? 0) === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('cfg_history_empty')}
</Typography>
) : (
<Stack sx={{ gap: 1.5 }}>
{history.data?.items.map((change) => (
<Box key={change.id} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', p: 1.5 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 700 }}>
{t('cfg_history_change_old', { old: change.oldValue ?? '—' })}
</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' }}>
{formatShamsiDateTime(change.occurredAt, locale)}
{change.actorUserId != null ? ` · #${change.actorUserId}` : ''}
</Typography>
</Box>
))}
</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>
);
}
@@ -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,
},
]}
/>
);
}
@@ -0,0 +1,217 @@
'use client';
import { Suspense, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { FormProvider, useForm } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import {
Box,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
MenuItem,
Skeleton,
Stack,
Switch,
} from '@mui/material';
import { AppButton, AppLoading, RhfControlGroup, RhfJalaliDateField, RhfTextField } from '@/components';
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, type AdminTableColumn } from '@/components/admin';
import { formatShamsiDate } from '@/utils';
import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { ADMIN_PAGE_SIZE } from '@/services/admin/constants';
import type { Holiday, HolidayInput, HolidayType } from '@/services/admin/types';
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'];
/**
* Iranian-holiday calendar manager (f15). Lists `iranian_holidays`, each with its Shamsi date, name, type,
* and an `is_bank_closed` flag the flag that shifts payout scheduling (the copy surfaces that consequence).
* The client only maintains the calendar the **server** uses for the next-business-day shift; it never
* computes the shift itself (phase §5).
*/
export default function AdminHolidaysPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminHolidaysPageInner />
</Suspense>
);
}
function AdminHolidaysPageInner() {
const t = useTranslations('admin');
const locale = useLocale();
const caps = useAdminCapabilities();
const listState = useAdminListState<Record<string, never>>({ parse: () => ({}), serialize: () => ({}), empty: {} });
const holidays = useHolidays({}, listState.page);
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>[] = [
{ key: 'date', header: t('hol_col_date'), render: (h) => formatShamsiDate(h.holidayDate, locale) },
{ key: 'name', header: t('hol_col_name'), render: (h) => h.nameFa },
{ key: 'type', header: t('hol_col_type'), render: (h) => <Chip size="small" variant="outlined" label={t(`htype_${h.type}`)} /> },
{
key: 'bank',
header: t('hol_col_bank'),
render: (h) => (
<Chip
size="small"
label={h.isBankClosed ? t('yes') : t('no')}
sx={{
bgcolor: h.isBankClosed ? 'var(--bal-warning)' : 'var(--bal-divider)',
color: h.isBankClosed ? 'var(--bal-warning-contrast)' : 'var(--bal-text-secondary)',
fontWeight: 700,
}}
/>
),
},
...(caps.canConfig
? [
{
key: 'actions',
header: '',
render: (h: Holiday) => (
<AppButton variant="text" color="primary" startIcon="edit" onClick={() => setEditing(h)}>
{t('cfg_edit')}
</AppButton>
),
} as AdminTableColumn<Holiday>,
]
: []),
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
title={t('hol_title')}
subtitle={t('hol_subtitle')}
actions={
caps.canConfig ? (
<AppButton variant="contained" color="primary" startIcon="add" onClick={() => setEditing('new')}>
{t('hol_add')}
</AppButton>
) : undefined
}
/>
{holidays.isLoading ? (
<Skeleton variant="rounded" height={200} />
) : holidays.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => holidays.refetch()} />
) : (holidays.data?.items.length ?? 0) === 0 ? (
<AdminEmptyState icon="calendar" title={t('hol_empty')} />
) : (
<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 ? (
<HolidayDialog holiday={editing === 'new' ? null : editing} onClose={() => setEditing(null)} />
) : null}
</Box>
);
}
function HolidayDialog({ holiday, onClose }: { holiday: Holiday | null; onClose: () => void }) {
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const upsert = useUpsertHoliday();
const form = useForm<HolidayInput>({
mode: 'onTouched',
defaultValues: {
holidayDate: holiday?.holidayDate?.slice(0, 10) ?? todayLocalIso(),
nameFa: holiday?.nameFa ?? '',
type: holiday?.type ?? 'official',
isBankClosed: holiday?.isBankClosed ?? true,
},
});
const { handleSubmit, formState } = form;
const onSave = (values: HolidayInput) =>
upsert.mutate(
{ ...values, nameFa: values.nameFa.trim() },
{
onSuccess: () => {
enqueueSnackbar(t('hol_saved'), { variant: 'success' });
onClose();
},
},
);
return (
<Dialog open onClose={upsert.isPending ? undefined : onClose} fullWidth maxWidth="xs">
<DialogTitle sx={{ fontWeight: 800 }}>{holiday ? t('hol_edit') : t('hol_add')}</DialogTitle>
<FormProvider {...form}>
<DialogContent>
{/* A real <form> so Enter submits from any field; the footer button (outside DialogContent)
calls the same handler directly rather than relying on cross-element form association. */}
<Stack component="form" noValidate onSubmit={handleSubmit(onSave)} sx={{ gap: 2, mt: 1 }}>
<RhfJalaliDateField<HolidayInput>
name="holidayDate"
label={t('hol_col_date')}
rules={{ validate: (value) => String(value ?? '').length > 0 }}
disabled={!!holiday}
/>
<RhfTextField<HolidayInput>
name="nameFa"
label={t('hol_name_fa')}
rules={{ validate: (value) => String(value ?? '').trim().length > 0 }}
/>
<RhfTextField<HolidayInput> name="type" select label={t('hol_col_type')}>
{HOLIDAY_TYPES.map((ty) => (
<MenuItem key={ty} value={ty}>
{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}
>
{upsert.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</FormProvider>
</Dialog>
);
}
@@ -1,11 +1,18 @@
'use client';
import type { ReactNode } from 'react';
import { AdminLayout } from '@/layout';
import { RoleGuard } from '@/components/auth';
import { APP_ROLES } from '@/constants';
/*
* Admin / backoffice route group (/admin/) desktop-oriented ops console (f15)
* with a persistent sidebar.
* with a persistent sidebar. RoleGuard gates the shell on the (collapsed) admin actor
* role; the per-console fine-grained gating stays with useAdminCapabilities inside.
*/
export default function AdminRouteLayout({ children }: { children: ReactNode }) {
return <AdminLayout>{children}</AdminLayout>;
return (
<RoleGuard expected={APP_ROLES.ADMIN}>
<AdminLayout>{children}</AdminLayout>
</RoleGuard>
);
}
@@ -0,0 +1,5 @@
import ShellContentSkeleton from '../_chrome/ShellContentSkeleton';
export default function Loading() {
return <ShellContentSkeleton />;
}
@@ -1,8 +1,13 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { PlaceholderScreen } from '@/components';
import AdminOverviewScreen from './AdminOverviewScreen';
export default async function AdminOverviewPage() {
const t = await getTranslations('nav');
const tShell = await getTranslations('shell');
return <PlaceholderScreen icon="admin" title={t('overview')} description={tShell('placeholder_body')} />;
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'admin' });
return { title: t('overview_title') };
}
export default function Page() {
return <AdminOverviewScreen />;
}
@@ -0,0 +1,262 @@
'use client';
import { ReactNode, useState } from 'react';
import { useParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Box, Divider, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, PageHeader, StatusChip, TrustBadge } from '@/components';
import { AdminEmptyState, AdminErrorState, ConfirmDialog, NursePicker } from '@/components/admin';
import { useAdminCapabilities, useAdminBackToList } from '@/hooks';
import { ROUTES } from '@/constants';
import type { AdminUserSummary } from '@/services/admin/types';
import {
usePartnerCenter,
useCenterSponsoredNurses,
useVerifyPartnerCenter,
useSetPartnerCenterActive,
useAssignNurseToPartnerCenter,
} from '@/services/partnerCenter';
import { CENTER_STATE_KIND, PartnerCenterFormDialog } from '../page';
/**
* Partner-center admin detail (f15) the licensing/settlement record for one center, its lifecycle actions,
* and its sponsored-nurse roster. Admins with `canManagePartners` may verify & activate a center (records
* licensing approval), suspend/reactivate it, edit it, and add/remove sponsored nurses. The settlement IBAN
* is only ever shown masked (last-4); it is never rendered in plaintext (write-then-masked). The server
* enforces every command's scope the capability flag only hides controls.
*/
export default function AdminPartnerCenterDetailPage() {
const t = useTranslations('admin');
const locale = useLocale();
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const goBack = useAdminBackToList(`/${locale}${ROUTES.ADMIN_PARTNERS}`);
const params = useParams<{ id: string }>();
const parsed = Number(params?.id);
const centerId = Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
const center = usePartnerCenter(centerId || null);
const roster = useCenterSponsoredNurses(centerId || null);
const verify = useVerifyPartnerCenter(centerId);
const setActive = useSetPartnerCenterActive(centerId);
const assignNurse = useAssignNurseToPartnerCenter(centerId);
const [confirmVerify, setConfirmVerify] = useState(false);
const [editing, setEditing] = useState(false);
const [assignNurseUser, setAssignNurseUser] = useState<AdminUserSummary | null>(null);
const data = center.data;
const onVerifyConfirm = () => {
verify.mutate(undefined, {
onSuccess: () => {
enqueueSnackbar(t('partner_verified_toast'), { variant: 'success' });
setConfirmVerify(false);
},
});
};
const onAssign = () => {
const nurseProfileId = assignNurseUser?.nurseProfileId;
if (nurseProfileId == null) return;
assignNurse.mutate(
{ nurseProfileId, unlink: false },
{
onSuccess: () => {
enqueueSnackbar(t('partner_nurse_assigned'), { variant: 'success' });
setAssignNurseUser(null);
},
},
);
};
const onRemove = (nurseProfileId: number) => {
assignNurse.mutate(
{ nurseProfileId, unlink: true },
{ onSuccess: () => enqueueSnackbar(t('partner_nurse_assigned'), { variant: 'success' }) },
);
};
const back = <PageHeader title={t('partner_detail_title')} onBack={goBack} backLabel={t('back')} />;
if (center.isLoading) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{back}
<Skeleton variant="rounded" height={80} />
<Skeleton variant="rounded" height={220} />
<Skeleton variant="rounded" height={160} />
</Box>
);
}
if (center.isError) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{back}
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => center.refetch()} />
</Box>
);
}
if (!data) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{back}
<AdminEmptyState icon="partners" title={t('partner_empty')} />
</Box>
);
}
const commissionPercent = new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 2 }).format(
data.commissionRate,
);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<PageHeader
title={t('partner_detail_title')}
subtitle={data.name}
onBack={goBack}
backLabel={t('back')}
meta={<StatusChip status={CENTER_STATE_KIND[data.onboardingState]} label={t(`center_state_${data.onboardingState}`)} />}
/>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack divider={<Divider flexItem />} sx={{ gap: 1.25 }}>
<DetailRow label={t('partner_legal_type')}>{data.legalEntityType || '—'}</DetailRow>
<DetailRow label={t('partner_permit')}>{data.mohEstablishmentPermitNo || '—'}</DetailRow>
<DetailRow label={t('partner_tech_director')}>{data.technicalDirectorLicenseNo ?? '—'}</DetailRow>
<DetailRow label={t('partner_enamad')}>{data.enamadCode ?? '—'}</DetailRow>
<DetailRow label={t('partner_commission')}>{commissionPercent}</DetailRow>
<DetailRow label={t('partner_is_mor')}>{t(data.isMerchantOfRecord ? 'yes' : 'no')}</DetailRow>
<DetailRow label={t('partner_iban')}>
<Box component="span" dir="ltr">
{data.settlementIbanMasked ?? '—'}
</Box>
</DetailRow>
</Stack>
</Paper>
{caps.canManagePartners ? (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{data.verifiedAt == null ? (
<AppButton
variant="contained"
color="primary"
startIcon="verified"
onClick={() => setConfirmVerify(true)}
disabled={verify.isPending}
>
{t('partner_verify')}
</AppButton>
) : null}
<AppButton
variant="outlined"
color="primary"
onClick={() => setActive.mutate(!data.isActive)}
disabled={setActive.isPending}
>
{t(data.isActive ? 'partner_suspend' : 'partner_activate')}
</AppButton>
<AppButton variant="outlined" color="inherit" startIcon="edit" onClick={() => setEditing(true)}>
{t('partner_edit')}
</AppButton>
</Stack>
) : null}
<Stack sx={{ gap: 1.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('partner_roster_title')}
</Typography>
{roster.isLoading ? (
<Skeleton variant="rounded" height={120} />
) : (
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', overflow: 'hidden' }}>
<Stack divider={<Divider />}>
{(roster.data ?? []).map((nurse) => (
<Stack
key={nurse.nurseProfileId}
direction="row"
sx={{ p: 1.75, gap: 1.5, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{nurse.name}
</Typography>
<TrustBadge state={nurse.isVerified ? 'verified' : 'unverified'} />
</Stack>
{caps.canManagePartners ? (
<AppButton
variant="text"
color="error"
startIcon="delete"
onClick={() => onRemove(nurse.nurseProfileId)}
disabled={assignNurse.isPending}
>
{t('partner_unlink_nurse')}
</AppButton>
) : null}
</Stack>
))}
</Stack>
</Paper>
)}
{caps.canManagePartners ? (
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<Box sx={{ minWidth: 260 }}>
<NursePicker
value={assignNurseUser}
onChange={setAssignNurseUser}
label={t('partner_assign_nurse_ph')}
placeholder={t('user_picker_search_ph')}
noOptionsText={t('user_picker_no_options')}
loadingText={t('user_picker_loading')}
/>
</Box>
<AppButton
variant="contained"
color="primary"
startIcon="assign"
onClick={onAssign}
disabled={assignNurse.isPending || assignNurseUser?.nurseProfileId == null}
sx={{ mt: 0.25 }}
>
{t('partner_assign_nurse')}
</AppButton>
</Stack>
) : null}
</Stack>
<ConfirmDialog
open={confirmVerify}
title={t('partner_verify')}
body={t('partner_verify_confirm')}
confirmLabel={t('confirm')}
cancelLabel={t('cancel')}
onConfirm={onVerifyConfirm}
onClose={() => setConfirmVerify(false)}
loading={verify.isPending}
/>
{editing ? <PartnerCenterFormDialog center={data} onClose={() => setEditing(false)} /> : null}
</Box>
);
}
/** One label/value line in the license/settlement block. */
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return (
<Stack direction="row" sx={{ gap: 1.5, justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{label}
</Typography>
<Typography variant="body2" component="div" sx={{ fontWeight: 500, textAlign: 'end' }}>
{children}
</Typography>
</Stack>
);
}
@@ -0,0 +1,314 @@
'use client';
import { Suspense, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import {
Box,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
Skeleton,
Stack,
Switch,
Typography,
} from '@mui/material';
import { AppButton, AppLoading, RhfControlGroup, RhfTextField, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import {
AdminDataTable,
AdminEmptyState,
AdminErrorState,
AdminPageHeader,
AdminPager,
UserPicker,
type AdminTableColumn,
} from '@/components/admin';
import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { adminPartnerCenterPath } from '@/constants';
import { PARTNER_PAGE_SIZE } from '@/services/partnerCenter/constants';
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';
/** 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. */
export const CENTER_STATE_KIND: Record<CenterOnboardingState, StatusKind> = {
verified: 'verified',
pending_verification: 'pending',
suspended: 'rejected',
draft: 'neutral',
};
/**
* Partner-center admin list (f15) the licensed sponsoring centers (پروانه تأسیس + مسئول فنی + نماد
* اعتماد الکترونیکی) that may be the merchant-of-record. Each row shows whether it issues invoices, its
* sponsored-nurse count, and its onboarding state; a row opens the center detail. Admins with
* `canManagePartners` may create a new center (inactive until verified). The full IBAN is write-then-masked
* it is only ever entered here, never displayed (the list carries no IBAN at all).
*/
export default function AdminPartnersPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminPartnersPageInner />
</Suspense>
);
}
function AdminPartnersPageInner() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const caps = useAdminCapabilities();
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 items = centers.data?.items ?? [];
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>[] = [
{ key: 'name', header: t('partner_col_name'), render: (c) => c.name },
{ key: 'mor', header: t('partner_col_mor'), render: (c) => t(c.isMerchantOfRecord ? 'yes' : 'no') },
{ key: 'nurses', header: t('partner_col_nurses'), render: (c) => c.sponsoredNurseCount },
{
key: 'state',
header: t('partner_col_state'),
render: (c) => <StatusChip status={CENTER_STATE_KIND[c.onboardingState]} label={t(`center_state_${c.onboardingState}`)} />,
},
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
title={t('partner_title')}
subtitle={t('partner_subtitle')}
actions={
caps.canManagePartners ? (
<AppButton variant="contained" color="primary" startIcon="add" onClick={() => setCreating(true)}>
{t('partner_create')}
</AppButton>
) : undefined
}
/>
{centers.isLoading ? (
<Skeleton variant="rounded" height={220} />
) : centers.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => centers.refetch()} />
) : items.length === 0 ? (
<AdminEmptyState icon="partners" title={t('partner_empty')} />
) : (
<AdminDataTable
columns={columns}
rows={items}
getRowKey={(c) => c.id}
ariaLabel={t('partner_title')}
onRowClick={(c) => router.push(`/${locale}${adminPartnerCenterPath(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 ? <PartnerCenterFormDialog center={null} onClose={() => setCreating(false)} /> : null}
</Box>
);
}
/** 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 {
name: string;
legalEntityType: string;
mohEstablishmentPermitNo: string;
technicalDirectorLicenseNo: string;
enamadCode: string;
settlementIban: string;
isMerchantOfRecord: boolean;
commissionRate: string;
adminUser: AdminUserSummary | null | undefined;
}
function initialForm(center: PartnerCenter | null): CenterFormState {
return {
name: center?.name ?? '',
legalEntityType: center?.legalEntityType ?? '',
mohEstablishmentPermitNo: center?.mohEstablishmentPermitNo ?? '',
technicalDirectorLicenseNo: center?.technicalDirectorLicenseNo ?? '',
enamadCode: center?.enamadCode ?? '',
// Write-then-masked: always blank on open. On edit, a blank IBAN keeps the existing masked value.
settlementIban: '',
isMerchantOfRecord: center?.isMerchantOfRecord ?? false,
commissionRate: center != null ? String(center.commissionRate) : '',
adminUser: undefined,
};
}
/**
* The create/edit dialog for a partner center shared by the list (create, `center=null`) and the detail
* (edit, `center` prefilled). `settlementIban` is write-then-masked: the field is always blank on open and a
* blank submit on edit keeps the stored masked value. Validates name + permit non-empty, `commissionRate ∈
* [0, 1)`, and (create only) an IBAN when the center is merchant-of-record.
*/
export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCenter | null; onClose: () => void }) {
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const isEdit = center != null;
const create = useCreatePartnerCenter();
const update = useUpdatePartnerCenter(center?.id ?? 0);
const mutation = isEdit ? update : create;
const form = useForm<CenterFormState>({ mode: 'onTouched', defaultValues: initialForm(center) });
const { control, handleSubmit, formState } = form;
const isMerchantOfRecord = useWatch({ control, name: 'isMerchantOfRecord' });
const adminUser = useWatch({ control, name: 'adminUser' });
// Edit mode: the center already has an adminUserId (a plain number) — resolve it to a name so the picker
// opens pre-filled with a person, never a bare id (3.2). Derived in render (never synced into form state):
// once the admin actually picks someone, the form value wins over the resolved existing one.
const existingAdminId = center?.adminUserId ?? null;
const existingAdminLookup = useUserLookup(existingAdminId != null ? [existingAdminId] : []);
const resolvedExistingAdmin = existingAdminId != null ? (existingAdminLookup.data?.get(existingAdminId) ?? null) : null;
const displayedAdminUser = adminUser !== undefined ? adminUser : resolvedExistingAdmin;
const onSave = (values: CenterFormState) => {
const input: PartnerCenterInput = {
name: values.name.trim(),
legalEntityType: values.legalEntityType.trim(),
mohEstablishmentPermitNo: values.mohEstablishmentPermitNo.trim(),
technicalDirectorLicenseNo: values.technicalDirectorLicenseNo.trim() || null,
enamadCode: values.enamadCode.trim() || null,
settlementIban: values.settlementIban.trim() || null,
isMerchantOfRecord: values.isMerchantOfRecord,
commissionRate: Number(values.commissionRate),
adminUserId: displayedAdminUser?.id ?? null,
};
mutation.mutate(input, {
onSuccess: () => {
enqueueSnackbar(t('partner_saved'), { variant: 'success' });
onClose();
},
});
};
return (
<Dialog open onClose={mutation.isPending ? undefined : onClose} fullWidth maxWidth="sm">
<DialogTitle sx={{ fontWeight: 800 }}>{isEdit ? t('partner_edit') : t('partner_create')}</DialogTitle>
<FormProvider {...form}>
<DialogContent>
{/* A real <form> so Enter submits from any field; the footer button (outside DialogContent)
calls the same handler directly rather than relying on cross-element form association. */}
<Stack component="form" noValidate onSubmit={handleSubmit(onSave)} sx={{ gap: 2, mt: 1 }}>
<RhfTextField<CenterFormState>
name="name"
label={t('partner_name')}
rules={{ validate: (value) => String(value ?? '').trim() !== '' }}
/>
<RhfTextField<CenterFormState> name="legalEntityType" label={t('partner_legal_type')} />
<RhfTextField<CenterFormState>
name="mohEstablishmentPermitNo"
label={t('partner_permit')}
rules={{ validate: (value) => String(value ?? '').trim() !== '' }}
/>
<RhfTextField<CenterFormState>
name="technicalDirectorLicenseNo"
label={t('partner_tech_director_license')}
/>
<RhfTextField<CenterFormState> name="enamadCode" label={t('partner_enamad')} />
<RhfTextField<CenterFormState>
name="settlementIban"
label={t('partner_iban')}
helperText={t('partner_iban_write_hint')}
placeholder={center?.settlementIbanMasked ?? undefined}
// On edit a blank IBAN keeps the stored value; on create an MoR center must supply one.
rules={{ validate: (value) => !isMerchantOfRecord || isEdit || String(value ?? '').trim() !== '' }}
slotProps={{ htmlInput: { dir: 'ltr' } }}
/>
<Box>
<RhfControlGroup<CenterFormState> name="isMerchantOfRecord">
{({ field }) => (
<FormControlLabel
control={
<Switch
checked={Boolean(field.value)}
onChange={(event) => field.onChange(event.target.checked)}
/>
}
label={t('partner_is_mor')}
/>
)}
</RhfControlGroup>
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
{t('partner_is_mor_hint')}
</Typography>
</Box>
<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>
);
}
@@ -0,0 +1,301 @@
'use client';
import { FunctionComponent, ReactNode, Suspense, useState } from 'react';
import { useParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Box, Chip, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppLoading, PageHeader, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPager, ConfirmDialog } from '@/components/admin';
import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { ROUTES } from '@/constants';
import { formatIrrToToman, formatShamsiDate } from '@/utils';
import { usePayoutBatchDetail, useRecordTransferReference, useRetryPayout } from '@/services/payouts';
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> = {
draft: 'neutral',
processing: 'info',
partially_failed: 'pending',
completed: 'verified',
failed: 'rejected',
};
const PAYOUT_STATUS_KIND: Record<PayoutStatus, StatusKind> = {
pending: 'pending',
submitted: 'info',
paid: 'verified',
failed: 'rejected',
};
/**
* Admin payout-batch detail (f15) one batch expanded: its window + holiday-shifted processing date, and its
* paginated per-payout rows (money decomposition, masked IBAN, transfer reference, status). A failed payout
* can be retried (idempotency-keyed) and a reconciled bank transfer reference recorded both gated on
* `canPayout`. Money is display-only Toman; the client never recomputes amounts, eligibility, or dates.
*/
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 locale = useLocale();
const caps = useAdminCapabilities();
const params = useParams<{ batchId: string }>();
const batchId = Number(params?.batchId);
const { page, goToPage } = useAdminListState<BatchRowsFilters>({
parse: () => EMPTY_FILTERS,
serialize: () => ({}),
empty: EMPTY_FILTERS,
});
const detail = usePayoutBatchDetail(Number.isFinite(batchId) ? batchId : null, page);
const data = detail.data;
const pageCount = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<PageHeader
title={t('payout_batch_title', { id: batchId })}
backTo={`/${locale}${ROUTES.ADMIN_PAYOUTS}`}
backLabel={t('back')}
/>
{detail.isLoading ? (
<Stack sx={{ gap: 2 }}>
<Skeleton variant="rounded" height={120} />
<Skeleton variant="rounded" height={96} />
<Skeleton variant="rounded" height={96} />
</Stack>
) : detail.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => detail.refetch()} />
) : !data ? (
<AdminEmptyState icon="wallet" title={t('payout_empty')} />
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.5 }}>
<StatusChip
status={BATCH_STATUS_KIND[data.batch.status]}
label={t(`batch_status_${data.batch.status}`)}
sx={{ alignSelf: 'flex-start' }}
/>
<MetaLine label={t('payout_col_period')}>
{formatShamsiDate(data.batch.periodStart, locale)} {formatShamsiDate(data.batch.periodEnd, locale)}
</MetaLine>
<MetaLine label={t('payout_col_processing')}>
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', flexWrap: 'wrap' }}>
<span>{formatShamsiDate(data.batch.processingDate, locale)}</span>
{data.batch.holidayShifted ? (
<Chip
size="small"
label={t('payout_holiday_shift')}
sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 500 }}
/>
) : null}
</Stack>
</MetaLine>
</Stack>
</Paper>
<Stack sx={{ gap: 1.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('payout_rows_title')}
</Typography>
{data.payouts.length === 0 ? (
<AdminEmptyState icon="wallet" title={t('payout_empty')} />
) : (
data.payouts.map((row) => (
<PayoutRowCard key={row.id} row={row} batchId={batchId} canPayout={caps.canPayout} />
))
)}
</Stack>
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => goToPage(Math.max(1, page - 1))}
onNext={() => goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', { page, total: pageCount })}
/>
</>
)}
</Box>
);
}
/**
* One `nurse_payouts` row the money decomposition (`gross clawback = net`), masked IBAN + transfer
* reference, status, and (for a failed payout) the reason + an idempotency-keyed retry. Recording a
* reconciled bank transfer reference is an inline per-row action. Both writes are gated on `canPayout`.
*/
const PayoutRowCard: FunctionComponent<{ row: AdminPayoutRow; batchId: number; canPayout: boolean }> = ({
row,
batchId,
canPayout,
}) => {
const t = useTranslations('admin');
const locale = useLocale();
const { enqueueSnackbar } = useSnackbar();
const retry = useRetryPayout();
const record = useRecordTransferReference();
const [retryOpen, setRetryOpen] = useState(false);
const [reference, setReference] = useState('');
const onRetryConfirm = () => {
retry.mutate(
{ payoutId: row.id, idempotencyKey: crypto?.randomUUID?.() ?? String(Date.now()), batchId },
{
onSuccess: () => {
setRetryOpen(false);
enqueueSnackbar(t('saved'), { variant: 'success' });
},
},
);
};
const onRecord = () => {
record.mutate(
{ payoutId: row.id, reference: reference.trim(), batchId },
{
onSuccess: () => {
enqueueSnackbar(t('payout_ref_saved'), { variant: 'success' });
setReference('');
},
},
);
};
return (
<Paper 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', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}
>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
{t('payout_row_nurse')}: {row.nurseName ?? `#${row.nurseId}`}
</Typography>
<StatusChip status={PAYOUT_STATUS_KIND[row.status]} label={t(`pstatus_${row.status}`)} />
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('payout_decomp', {
gross: formatIrrToToman(row.grossEarningsIrr, locale),
clawback: formatIrrToToman(row.clawbackAppliedIrr, locale),
net: formatIrrToToman(row.netAmountIrr, locale),
})}
</Typography>
<Stack direction="row" sx={{ gap: 3, flexWrap: 'wrap' }}>
<Field label={t('masked_iban_label')}>
<Box component="span" dir="ltr">
{row.maskedIban}
</Box>
</Field>
<Field label={t('payout_row_ref')}>
<Box component="span" dir="ltr">
{row.transferReference ?? '—'}
</Box>
</Field>
</Stack>
{row.status === 'failed' ? (
<Box
sx={{
p: 1.25,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
borderInlineStart: '3px solid',
borderInlineStartColor: 'var(--bal-error)',
}}
>
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
{t('payout_failure_reason', { reason: row.failureReason ?? '—' })}
</Typography>
{canPayout ? (
<Box sx={{ mt: 1 }}>
<AppButton
variant="outlined"
color="error"
onClick={() => setRetryOpen(true)}
disabled={retry.isPending}
>
{t('payout_retry')}
</AppButton>
</Box>
) : null}
</Box>
) : null}
{canPayout ? (
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap', mt: 0.5 }}>
<TextField
size="small"
label={t('payout_record_ref')}
placeholder={t('payout_record_ref_ph')}
value={reference}
onChange={(e) => setReference(e.target.value)}
slotProps={{ htmlInput: { dir: 'ltr' } }}
sx={{ minWidth: 220 }}
/>
<AppButton
variant="contained"
color="primary"
onClick={onRecord}
disabled={reference.trim().length === 0 || record.isPending}
>
{record.isPending ? t('saving') : t('save')}
</AppButton>
</Stack>
) : null}
</Stack>
<ConfirmDialog
open={retryOpen}
title={t('payout_retry')}
body={t('payout_retry_confirm')}
confirmLabel={t('confirm')}
cancelLabel={t('cancel')}
onConfirm={onRetryConfirm}
onClose={() => setRetryOpen(false)}
loading={retry.isPending}
confirmColor="error"
/>
</Paper>
);
};
const MetaLine: FunctionComponent<{ label: string; children: ReactNode }> = ({ label, children }) => (
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{label}
</Typography>
<Box sx={{ fontWeight: 500, typography: 'body2' }}>{children}</Box>
</Stack>
);
const Field: FunctionComponent<{ label: string; children: ReactNode }> = ({ label, children }) => (
<Stack sx={{ gap: 0.25 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{label}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{children}
</Typography>
</Stack>
);
@@ -0,0 +1,402 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
MenuItem,
Skeleton,
Stack,
TextField,
Typography,
} from '@mui/material';
import { AppButton, JalaliDateField, Money, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import {
AdminDataTable,
AdminEmptyState,
AdminErrorState,
AdminPageHeader,
AdminPager,
ConfirmDialog,
} from '@/components/admin';
import type { AdminTableColumn } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks';
import { adminPayoutBatchPath } from '@/constants';
import { formatNumber, formatShamsiDate } from '@/utils';
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
import { usePayoutBatches, usePreviewPayoutBatch, useRunPayoutBatch } from '@/services/payouts';
import type { PayoutBatchStatus, PayoutBatchSummary } from '@/services/payouts/types';
/** Batch lifecycle → semantic chip color (server truth; the client only renders it). */
const BATCH_STATUS_KIND: Record<PayoutBatchStatus, StatusKind> = {
draft: 'neutral',
processing: 'info',
partially_failed: 'pending',
completed: 'verified',
failed: 'rejected',
};
const BATCH_STATUSES: readonly PayoutBatchStatus[] = [
'draft',
'processing',
'partially_failed',
'completed',
'failed',
];
/**
* 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
* entry point to previewing + running the next batch. Money is IRR digit-strings rendered as display-only
* Toman; the server owns eligibility and the holiday-shifted processing date the client never computes
* them. Running a batch moves money, so it is gated (`canPayout`), idempotency-keyed, and confirmed.
*/
export default function AdminPayoutsPage() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const caps = useAdminCapabilities();
const [status, setStatus] = useState<PayoutBatchStatus | ''>('');
const [page, setPage] = useState(1);
const [previewOpen, setPreviewOpen] = useState(false);
const filters = { status: status || undefined };
const batches = usePayoutBatches(filters, page);
const items = batches.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((batches.data?.total ?? 0) / PAYOUTS_PAGE_SIZE));
const columns: AdminTableColumn<PayoutBatchSummary>[] = [
{
key: 'period',
header: t('payout_col_period'),
render: (b) => `${formatShamsiDate(b.periodStart, locale)} ${formatShamsiDate(b.periodEnd, locale)}`,
},
{
key: 'count',
header: t('payout_col_count'),
render: (b) => b.payoutCount,
},
{
key: 'total',
header: t('payout_col_total'),
render: (b) => <Money amountIrr={b.totalAmount} size="sm" />,
},
{
key: 'status',
header: t('payout_col_status'),
render: (b) => <StatusChip status={BATCH_STATUS_KIND[b.status]} label={t(`batch_status_${b.status}`)} />,
},
{
key: 'processing',
header: t('payout_col_processing'),
render: (b) => (
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', flexWrap: 'wrap' }}>
<span>{formatShamsiDate(b.processingDate, locale)}</span>
{b.holidayShifted ? (
<Chip
size="small"
label={t('payout_holiday_shift')}
sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 500 }}
/>
) : null}
</Stack>
),
},
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
title={t('payout_title')}
subtitle={t('payout_subtitle')}
actions={
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
select
size="small"
label={t('payout_col_status')}
value={status}
onChange={(e) => {
setStatus(e.target.value as PayoutBatchStatus | '');
setPage(1);
}}
sx={{ minWidth: 160 }}
>
<MenuItem value="">{t('filter_all')}</MenuItem>
{BATCH_STATUSES.map((s) => (
<MenuItem key={s} value={s}>
{t(`batch_status_${s}`)}
</MenuItem>
))}
</TextField>
{caps.canPayout ? (
<AppButton variant="contained" color="primary" onClick={() => setPreviewOpen(true)}>
{t('payout_preview')}
</AppButton>
) : null}
</Stack>
}
/>
{batches.isLoading ? (
<Stack sx={{ gap: 2 }}>{[0, 1, 2].map((k) => <Skeleton key={k} variant="rounded" height={64} />)}</Stack>
) : batches.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => batches.refetch()} />
) : items.length === 0 ? (
<AdminEmptyState icon="wallet" title={t('payout_empty')} />
) : (
<AdminDataTable
columns={columns}
rows={items}
getRowKey={(b) => b.id}
onRowClick={(b) => router.push(`/${locale}${adminPayoutBatchPath(b.id)}`)}
ariaLabel={t('payout_title')}
/>
)}
<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 })}
/>
{previewOpen ? <PreviewBatchDialog canPayout={caps.canPayout} onClose={() => setPreviewOpen(false)} /> : null}
</Box>
);
}
/**
* The eligibility dry-run + run-batch dialog. Preview is a mutation (runs only when the admin asks), and its
* eligible/skipped breakdown + the server's holiday-shifted processing date are read straight from the
* mutation's `data`. Running is idempotency-keyed and confirmed; on success it deep-links to the new batch.
*/
function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClose: () => void }) {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const { enqueueSnackbar } = useSnackbar();
// Default the window to the last 7 days (end = today). A lazy initializer runs the `Date` read once.
const [periodStart, setPeriodStart] = useState(() =>
isoDate(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)),
);
const [periodEnd, setPeriodEnd] = useState(() => isoDate(new Date()));
const [runConfirmOpen, setRunConfirmOpen] = useState(false);
const preview = usePreviewPayoutBatch();
const run = useRunPayoutBatch();
const result = preview.data;
const onPreview = () => {
if (!periodStart || !periodEnd) return;
preview.mutate({ periodStart, periodEnd });
};
const onRunConfirm = () => {
const idempotencyKey = crypto?.randomUUID
? crypto.randomUUID()
: `batch_${periodStart}_${periodEnd}_${Date.now()}`;
run.mutate(
{ periodStart, periodEnd, idempotencyKey },
{
onSuccess: (batch) => {
setRunConfirmOpen(false);
onClose();
enqueueSnackbar(t('payout_ran'), { variant: 'success' });
router.push(`/${locale}${adminPayoutBatchPath(batch.id)}`);
},
},
);
};
return (
<Dialog open onClose={run.isPending ? undefined : onClose} fullWidth maxWidth="sm">
<DialogTitle sx={{ fontWeight: 800 }}>{t('payout_preview_title')}</DialogTitle>
<DialogContent>
<Stack direction="row" sx={{ gap: 1.5, mt: 1, flexWrap: 'wrap', alignItems: 'center' }}>
<JalaliDateField
size="small"
label={t('payout_period_start')}
value={periodStart}
onChange={setPeriodStart}
/>
<JalaliDateField size="small" label={t('payout_period_end')} value={periodEnd} onChange={setPeriodEnd} />
<AppButton
variant="outlined"
color="primary"
onClick={onPreview}
disabled={!periodStart || !periodEnd || preview.isPending}
>
{t('payout_preview')}
</AppButton>
</Stack>
{result ? (
<Stack sx={{ gap: 2, mt: 2.5 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('payout_col_processing')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{formatShamsiDate(result.processingDate, locale)}
</Typography>
{result.holidayShifted ? (
<Chip
size="small"
label={t('payout_holiday_shift')}
sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 500 }}
/>
) : null}
</Stack>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
{t('payout_eligible_nurses')}
</Typography>
{result.eligible.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
</Typography>
) : (
<Stack divider={<Divider />} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
{result.eligible.map((n) => (
<Stack key={n.nurseId} sx={{ p: 1.5, gap: 0.5 }}>
<Stack
direction="row"
sx={{ justifyContent: 'space-between', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}
>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{n.nurseName ?? `#${n.nurseId}`}
</Typography>
{!n.hasVerifiedPrimaryIban ? (
<Chip
size="small"
label={t('payout_no_iban')}
sx={{ bgcolor: 'var(--bal-error)', color: 'var(--bal-error-contrast)', fontWeight: 500 }}
/>
) : null}
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('payout_col_gross')}: <Money amountIrr={n.grossEarningsIrr} size="sm" hideUnit /> ·{' '}
{t('payout_col_clawback')}: <Money amountIrr={n.clawbackAppliedIrr} size="sm" hideUnit /> ·{' '}
{t('payout_col_net')}: <Money amountIrr={n.netAmountIrr} size="sm" />
</Typography>
</Stack>
))}
</Stack>
)}
</Stack>
{result.skipped.length > 0 ? (
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
{t('payout_skipped')}
</Typography>
<Stack divider={<Divider />} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
{result.skipped.map((n) => (
<Stack
key={n.nurseId}
direction="row"
sx={{ p: 1.5, justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}
>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{n.nurseName ?? `#${n.nurseId}`}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
{n.reason}
</Typography>
</Stack>
))}
</Stack>
</Stack>
) : null}
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('payout_eligibility_note')}
</Typography>
</Stack>
) : null}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={run.isPending}>
{t('cancel')}
</AppButton>
{canPayout ? (
<AppButton
variant="contained"
color="primary"
onClick={() => setRunConfirmOpen(true)}
disabled={!result || run.isPending}
>
{t('payout_run')}
</AppButton>
) : null}
</DialogActions>
<ConfirmDialog
open={runConfirmOpen}
title={t('payout_run_confirm_title')}
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')}
cancelLabel={t('cancel')}
onConfirm={onRunConfirm}
onClose={() => setRunConfirmOpen(false)}
loading={run.isPending}
requireTypedConfirmation={result ? ['تایید', result.totalNetIrr] : []}
typedConfirmationLabel={t('payout_run_type_to_confirm')}
/>
</Dialog>
);
}
@@ -0,0 +1,217 @@
'use client';
import { Suspense, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Box, Chip, MenuItem, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppLoading, RatingInput } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfirmDialog } from '@/components/admin';
import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { REVIEWS_PAGE_SIZE } from '@/services/reviews/constants';
import type { ModerationAction, ModerationQueueItem, ModerationStatus } from '@/services/reviews/types';
import { useModerationQueue, useModerateReview } from '@/services/reviews';
/** The moderation worklist tabs — the four `moderationStatus` values; the queue defaults to the pending backlog. */
const MODERATION_STATUSES: readonly ModerationStatus[] = ['pending_moderation', 'published', 'hidden', 'rejected'];
/** Status → MUI chip color. **Never** styles `pending_moderation` like a published review (it reads as a warning). */
const STATUS_CHIP_COLOR: Record<ModerationStatus, 'default' | 'success' | 'warning' | 'error'> = {
pending_moderation: 'warning',
published: 'success',
hidden: 'default',
rejected: 'error',
};
/**
* Review moderation queue (f15) the admin worklist over `reviews` awaiting a decision (b14). Each row carries
* moderation internals (`lowRatingAlertId`, the nurse/booking context) that are **never** rendered on a
* customer/nurse surface. A review is born `pending_moderation` and is never public / never counted until an
* admin publishes it, so the card presents pending content as an under-review item, not as a published review.
* 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.
*/
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() {
return (
<Suspense fallback={<AppLoading />}>
<AdminReviewsPageInner />
</Suspense>
);
}
function AdminReviewsPageInner() {
const t = useTranslations('admin');
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const listState = useAdminListState<{ status: ModerationStatus }>({
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 queue = useModerationQueue({ status }, page);
const moderate = useModerateReview();
const items = queue.data?.items ?? [];
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 onConfirm = (reason?: string) => {
if (!pending) return;
moderate.mutate(
{ reviewId: pending.item.id, action: pending.action, reason },
{
onSuccess: () => {
enqueueSnackbar(t('mod_done'), { variant: 'success' });
setPending(null);
},
},
);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
title={t('mod_title')}
subtitle={t('mod_subtitle')}
actions={
<TextField
select
size="small"
label={t('filter_label')}
value={status}
onChange={(e) => setStatus(e.target.value as ModerationStatus)}
sx={{ minWidth: 180 }}
>
{MODERATION_STATUSES.map((s) => (
<MenuItem key={s} value={s}>
{t(`mstatus_${s}`)}
</MenuItem>
))}
</TextField>
}
/>
{queue.isLoading ? (
<Stack sx={{ gap: 2 }}>{[0, 1, 2].map((k) => <Skeleton key={k} variant="rounded" height={150} />)}</Stack>
) : queue.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => queue.refetch()} />
) : items.length === 0 ? (
<AdminEmptyState icon="moderation" title={t('mod_empty')} />
) : (
<Stack sx={{ gap: 2 }}>
{items.map((item) => (
<ModerationCard
key={item.id}
item={item}
canModerate={caps.canModerate}
onAct={(action) => setPending({ item, action })}
/>
))}
</Stack>
)}
<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 })}
/>
<ConfirmDialog
open={pending != null}
title={pending ? t(`mod_${pending.action}`) : ''}
body={pending ? t(`mod_confirm_${pending.action}`) : undefined}
confirmLabel={pending ? t(`mod_${pending.action}`) : t('confirm')}
cancelLabel={t('cancel')}
confirmColor={pending?.action === 'reject' ? 'error' : 'primary'}
loading={moderate.isPending}
requireReason={requireReason}
reasonLabel={t('reason_label')}
onConfirm={onConfirm}
onClose={() => setPending(null)}
/>
</Box>
);
}
/** One review awaiting a decision. Presentational; the actions bubble up to the page-level confirm dialog. */
function ModerationCard({
item,
canModerate,
onAct,
}: {
item: ModerationQueueItem;
canModerate: boolean;
onAct: (action: ModerationAction) => void;
}) {
const t = useTranslations('admin');
const tReviews = useTranslations('reviews');
return (
<Paper
elevation={0}
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', display: 'flex', flexDirection: 'column', gap: 1.5 }}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<RatingInput value={item.rating} readOnly size={20} ariaLabel={t('mod_col_rating')} />
<Chip size="small" color={STATUS_CHIP_COLOR[item.moderationStatus]} label={t(`mstatus_${item.moderationStatus}`)} />
{item.lowRatingAlertId != null ? (
<Chip size="small" color="warning" variant="outlined" label={t('mod_low_rating')} />
) : null}
</Stack>
{item.body ? (
<Typography variant="body2" sx={{ color: 'text.primary' }}>
{item.body}
</Typography>
) : null}
{item.tagCodes.length > 0 ? (
<Stack direction="row" sx={{ gap: 0.5, flexWrap: 'wrap' }}>
{item.tagCodes.map((code) => (
<Chip key={code} size="small" variant="outlined" label={tReviews(`tag_${code}`)} />
))}
</Stack>
) : null}
<Stack direction="row" sx={{ gap: 2, flexWrap: 'wrap' }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('mod_nurse', { id: item.nurseProfileId })}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('mod_booking', { id: item.bookingId })}
</Typography>
</Stack>
{canModerate ? (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton variant="contained" color="primary" startIcon="publish" onClick={() => onAct('publish')}>
{t('mod_publish')}
</AppButton>
<AppButton variant="outlined" color="inherit" onClick={() => onAct('hide')}>
{t('mod_hide')}
</AppButton>
<AppButton variant="outlined" color="error" onClick={() => onAct('reject')}>
{t('mod_reject')}
</AppButton>
</Stack>
) : null}
</Paper>
);
}
@@ -0,0 +1,211 @@
'use client';
/**
* RBAC roles grid (f15) **DEFERRED-IF-MISSING.** The b15 contract does not yet expose role grant/revoke
* endpoints, so this console is served by the admin **mock** (REQ-031); an info banner says so. When the
* endpoints land, only `services/admin/apis` flips this screen is unchanged.
*
* Grants/revokes are gated on `canManageRoles` (only a `super_admin`); the server remains the authority. The
* grid lists **active** grants (revoked rows are filtered out); an audited confirm dialog fronts every
* revoke and grant.
*/
import { useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
MenuItem,
Skeleton,
Stack,
TextField,
} from '@mui/material';
import { AppAlert, AppButton } from '@/components';
import {
AdminDataTable,
AdminEmptyState,
AdminErrorState,
AdminPageHeader,
ConfirmDialog,
UserPicker,
type AdminTableColumn,
} from '@/components/admin';
import { formatShamsiDate } from '@/utils';
import { useAdminCapabilities } from '@/hooks';
import type { AdminRole, AdminUserSummary, RoleGrant } from '@/services/admin/types';
import { useAdminRoles, useGrantRole, useRevokeRole, useUserLookup } from '@/services/admin';
/** The fine-grained admin roles the grid grants (aligned with the b2 `AdminRole` enum). */
const ROLES: readonly AdminRole[] = ['super_admin', 'admin', 'support', 'finance', 'moderation'];
export default function AdminRolesPage() {
const t = useTranslations('admin');
const locale = useLocale();
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const roles = useAdminRoles();
const revoke = useRevokeRole();
const [granting, setGranting] = useState(false);
const [revoking, setRevoking] = useState<RoleGrant | null>(null);
// Only active grants — a revoked grant leaves the grid.
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>[] = [
{ key: 'user', header: t('role_col_user'), render: (g) => nameFor(g.userId) },
{
key: 'role',
header: t('role_col_role'),
render: (g) => <Chip size="small" variant="outlined" label={t(`role_${g.role}`)} />,
},
{ key: 'granted', header: t('role_col_granted'), render: (g) => formatShamsiDate(g.grantedAt, locale) },
...(caps.canManageRoles
? [
{
key: 'actions',
header: '',
align: 'right',
render: (g: RoleGrant) => (
<AppButton variant="text" color="error" startIcon="delete" onClick={() => setRevoking(g)}>
{t('role_revoke')}
</AppButton>
),
} as AdminTableColumn<RoleGrant>,
]
: []),
];
const onRevokeConfirm = () => {
if (!revoking) return;
revoke.mutate(
{ userId: revoking.userId, role: revoking.role },
{
onSuccess: () => {
enqueueSnackbar(t('role_updated'), { variant: 'success' });
setRevoking(null);
},
},
);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
title={t('role_title')}
subtitle={t('role_subtitle')}
actions={
caps.canManageRoles ? (
<AppButton variant="contained" color="primary" startIcon="add" onClick={() => setGranting(true)}>
{t('role_grant')}
</AppButton>
) : undefined
}
/>
<AppAlert severity="info" variant="outlined">
{t('role_deferred')}
</AppAlert>
{roles.isLoading ? (
<Skeleton variant="rounded" height={200} />
) : roles.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => roles.refetch()} />
) : active.length === 0 ? (
<AdminEmptyState icon="roles" title={t('role_title')} />
) : (
<AdminDataTable
columns={columns}
rows={active}
getRowKey={(g) => `${g.userId}:${g.role}`}
ariaLabel={t('role_title')}
/>
)}
{granting ? <GrantRoleDialog onClose={() => setGranting(false)} /> : null}
<ConfirmDialog
open={revoking != null}
title={t('role_revoke')}
body={revoking ? t('role_revoke_confirm', { role: t(`role_${revoking.role}`), name: nameFor(revoking.userId) }) : undefined}
confirmLabel={t('role_revoke')}
cancelLabel={t('cancel')}
confirmColor="error"
loading={revoke.isPending}
onConfirm={onRevokeConfirm}
onClose={() => setRevoking(null)}
/>
</Box>
);
}
/** Collect a target user id + a role, then grant it (inline confirm copy once both are set). */
function GrantRoleDialog({ onClose }: { onClose: () => void }) {
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const grant = useGrantRole();
const [user, setUser] = useState<AdminUserSummary | null>(null);
const [role, setRole] = useState<AdminRole>('support');
const valid = user != null;
const onGrant = () => {
if (!user) return;
grant.mutate(
{ userId: user.id, role },
{
onSuccess: () => {
enqueueSnackbar(t('role_updated'), { variant: 'success' });
onClose();
},
},
);
};
return (
<Dialog open onClose={grant.isPending ? undefined : onClose} fullWidth maxWidth="xs">
<DialogTitle sx={{ fontWeight: 800 }}>{t('role_grant')}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}>
<UserPicker
value={user}
onChange={setUser}
label={t('role_col_user')}
placeholder={t('user_picker_search_ph')}
noOptionsText={t('user_picker_no_options')}
loadingText={t('user_picker_loading')}
/>
<TextField select label={t('role_col_role')} value={role} onChange={(e) => setRole(e.target.value as AdminRole)}>
{ROLES.map((r) => (
<MenuItem key={r} value={r}>
{t(`role_${r}`)}
</MenuItem>
))}
</TextField>
{valid ? (
<DialogContentText>{t('role_grant_confirm', { role: t(`role_${role}`), name: user.displayName })}</DialogContentText>
) : null}
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={grant.isPending}>
{t('cancel')}
</AppButton>
<AppButton variant="contained" color="primary" onClick={onGrant} disabled={!valid || grant.isPending}>
{grant.isPending ? t('saving') : t('confirm')}
</AppButton>
</DialogActions>
</Dialog>
);
}
@@ -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>
);
}

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