Compare commits
68 Commits
aae056b4e5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e4d19ac4f | |||
| 71ca986dcd | |||
| 10d160358f | |||
| 184b202f00 | |||
| 66a60ce874 | |||
| 90e0cdcc34 | |||
| 9a55846df3 | |||
| e3c988e961 | |||
| 340012b2f8 | |||
| 08949a24de | |||
| dd3e39dec5 | |||
| 2cd1075286 | |||
| 42f38f5a72 | |||
| fb58ca54e1 | |||
| 72ab290da1 | |||
| 51e86a1e5f | |||
| e2db97392a | |||
| cd8144e653 | |||
| b876490246 | |||
| c841bded26 | |||
| c889c46110 | |||
| d3ec723119 | |||
| c99e3f4a6e | |||
| 96b57eb1b8 | |||
| 5885280b49 | |||
| 630c7907ec | |||
| e6a8f93a1e | |||
| baa3cc63cd | |||
| bd06ef0016 | |||
| 12ce7fa7de | |||
| d33568bf31 | |||
| 87fa4cd497 | |||
| b4b8c9ea79 | |||
| b638e25a0e | |||
| 1ef4feb911 | |||
| edc38543fd | |||
| a438edeeaa | |||
| 4c70d8e424 | |||
| 53b4e1b0a4 | |||
| 222856d600 | |||
| 370c1beefa | |||
| f1cba6cf74 | |||
| 9051bb3e18 | |||
| 70fb0a9202 | |||
| ef3024ef2f | |||
| 7edadadea1 | |||
| 70268ecc06 | |||
| d4147342da | |||
| 64f6aa45c9 | |||
| 314763f764 | |||
| 1ce36f9414 | |||
| 0b45ec51f4 | |||
| 7acecda5c4 | |||
| 850cdf3414 | |||
| a87b47bedb | |||
| 70cf00ce4a | |||
| bc51cf59b4 | |||
| 85488bc25b | |||
| 6186f54294 | |||
| 67c028562e | |||
| ccfa27aff6 | |||
| 40cc1d163b | |||
| cd6c2591a6 | |||
| 93cc5ecb98 | |||
| de53f9d8a6 | |||
| dc64472631 | |||
| 465f75c29e | |||
| 23605591eb |
@@ -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) §5–7
|
||||
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) §1–3.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -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.
|
||||
@@ -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.4–1.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')`, 3–5 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/` |
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
temp
|
||||
temp
|
||||
**/graphify-out/*
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/) |
|
||||
|
||||
@@ -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"]
|
||||
@@ -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,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'));
|
||||
|
||||
|
||||
@@ -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|.*\\..*).*)'],
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
Before Width: | Height: | Size: 479 B After Width: | Height: | Size: 426 B |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 923 B After Width: | Height: | Size: 764 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 17 KiB |
@@ -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 |
@@ -1,5 +0,0 @@
|
||||
User-agent: *
|
||||
Disallow: /private/
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 — 5–6
|
||||
* 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 ~7–10-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 (D1–D4) — 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 0–1 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 0–1). 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>
|
||||
);
|
||||
}
|
||||