ui phase 12
This commit is contained in:
+24
-2
@@ -244,6 +244,7 @@ client/
|
||||
│ │ ├── Pager/ # ui-phase-7 — the shared prev/next "page X of Y" control (common namespace i18n) replacing the near-identical inline pagers hand-rolled per list screen (nurse inbox tabs, payout history/earnings) (tested)
|
||||
│ │ ├── InitialsAvatar/ # ui-phase-9 — warm auto-colored initials for a person with no photo: deterministic name-hash → one of 6 `--bal-avatar-*` token pairs (tokens.css, both scheme blocks); `aria-hidden` (decorative next to a visible name); used by `PatientHeader` and (via `ProfileSummary`'s new `initialsFallback` prop) the customer account hub (tested)
|
||||
│ │ ├── FormDialogShell/ # ui-phase-9 — full-screen-below-`sm` form dialog (app-bar header + close) with a dirty-gated discard-confirm on close/backdrop/escape; the shared primitive behind the patient/address add-edit dialogs (the hosted form reports `dirty` via an `onDirtyChange` prop) (tested)
|
||||
│ │ ├── RouteFadeIn/ # ui-phase-12 — the one route-content fade/slide primitive (motion pass): wraps `{children}`, keyed on the locale-stripped pathname so it remounts (replays the CSS `bal-fade-in` keyframe, globals.css) on navigation but never on an in-place re-render; mounted inside the `ErrorBoundary` in all five shells (tested)
|
||||
│ │ └── index.tsx # barrel — keep next-intl-importing primitives (Money) below the presentational ones so the poisoning risk stays visible in review
|
||||
│ ├── admin/ # f15 backoffice + partner composites (import from `@/components/admin`): AdminDataTable (v2, ui-phase-11 — optional per-column server-param `sort`/`sortable` + `TableSortLabel`, `stickyHeader` scroll viewport, `minWidth`, a `footer` line), AdminPager (page/pageCount; the admin `page_indicator` i18n key regained its `{total}`), AdminPageHeader/AdminEmptyState/AdminErrorState, ConfirmDialog (thin alias), ConfigRow/AuditLogRow (ui-phase-11 — expand chevron rotates + `aria-expanded` + button semantics, new `actorLabel` prop resolved via a batch id→name lookup)/SupportAlertCard (ui-phase-11 — `assignSelfDisabled`/`assignSelfDisabledTitle` so "assign to me" never falls back to a guessed user)/PartnerSettlementRow, DocumentViewer, RefundPanel, AdminMessageBubble, and (ui-phase-11) `UserPicker`/`NursePicker` — async name/phone `Autocomplete` over the admin user directory (REQ-061, mock-backed), replacing every raw numeric-id `TextField` on an audited action; each option renders name+masked-phone+id, never a bare id
|
||||
│ ├── PlaceholderScreen/ # Empty-state scaffold for not-yet-built screens
|
||||
@@ -387,7 +388,7 @@ client/
|
||||
│ ├── tokens.css # CSS custom properties — [data-mui-color-scheme] selectors + the dark @media fallback (no-flash boot, no script — see "Theme System" below)
|
||||
│ ├── typography.ts # TYPOGRAPHY_LTR (Space Grotesk) / TYPOGRAPHY_RTL (Mikhak) — shared size scale, 500/700 weight system
|
||||
│ └── index.ts # Public re-exports (ThemeProvider, getDirection, APP_THEME_*)
|
||||
├── constants/ # App-wide constants (routes.ts w/ actor paths, roles.ts, headers.ts)
|
||||
├── constants/ # App-wide constants (routes.ts w/ actor paths, roles.ts, headers.ts, policy.ts — ui-phase-12's single-sourced trust-copy numbers pending REQ-065)
|
||||
├── hooks/ # incl. auth.ts → useIsAuthenticated / useActorRole (role-aware chrome); ui-phase-11 added `useAdminListState.ts` — URL-synced worklist state (`useSearchParams`-based; callers need a `<Suspense>` boundary) mirroring **applied** filters+page into the URL (draft-vs-applied; `apply`/`applyFilters`/`clear`/`goToPage`) + `useAdminBackToList` (a real `router.back()` with a list-route fallback), adopted by every admin/partner queue page
|
||||
├── utils/ # incl. money.ts (IRR/Toman, integer-safe) + date.ts (Shamsi display) + number.ts (localeTag/formatNumber/formatRelativeTime/formatClock — the one home for locale-ternary formatting) + toEnglishDigits + toCsv.ts (ui-phase-11 — dependency-free CSV serializer, CRLF + comma/quote escaping; backs the partner settlement CSV export, UTF-8-BOM-prefixed for Excel)
|
||||
└── config.ts
|
||||
@@ -511,6 +512,13 @@ 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.
|
||||
|
||||
**Persian orthography is a checked-in style guide, not tribal knowledge.** `client/messages/STYLE.md`
|
||||
(ui-phase-12) is the binding decision record for brand spelling (ZWNJ: «بالینیار»), تأیید's hamza form,
|
||||
the one `جستجو` spelling, ZWNJ rules, the domain glossary, the shell-naming system, the
|
||||
verification-pipeline-vs-KYC-step naming split, and the digits/policy-number-interpolation rules.
|
||||
`npm run lint:copy` (`client/scripts/check-copy.mjs`, part of `npm run check`) greps `fa.json` for the
|
||||
banned variants on every run — a regression fails the gate immediately, it doesn't need re-discovering.
|
||||
|
||||
---
|
||||
|
||||
## Cookie Manager
|
||||
@@ -542,7 +550,7 @@ A value is "magic" if its meaning isn't obvious from the literal alone: cookie n
|
||||
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.)
|
||||
- **App-wide constants** (used across multiple features): `src/constants/` — one file per concern (`routes.ts`, `events.ts`, etc.). `constants/policy.ts` (ui-phase-12) is this pattern applied to legally-sensitive numbers that trust-critical copy states in plain language (the payout dispute-window hours, the cancellation lead-time hours, the refund ETA day range) — real server config with no public/authenticated read yet (REQ-065), single-sourced here and fed into message keys as ICU params (`{hours}`, `{minDays}`/`{maxDays}`) instead of being baked into the string.
|
||||
|
||||
Rules:
|
||||
1. Import the constant; never copy-paste the string value.
|
||||
@@ -627,6 +635,20 @@ This includes feedback colors: `--bal-success`, `--bal-error`, `--bal-warning`,
|
||||
|
||||
`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`.
|
||||
|
||||
### Motion & the reduced-motion gate
|
||||
|
||||
Durations/easing live as tokens in `tokens.css` (`--bal-motion-fast/-base/-slow`, `--bal-easing-standard`);
|
||||
`theme.ts` points `MuiDialog`/`MuiDrawer`/`MuiPopover`/`MuiMenu`'s `defaultProps.transitionDuration` at the
|
||||
same numbers (in one place) instead of MUI's per-variant defaults. `RouteFadeIn`
|
||||
(`components/common/RouteFadeIn/`) is the one route-content fade/slide primitive, mounted inside the
|
||||
`ErrorBoundary` in all five shells — new pages get the motion for free, no per-page wiring.
|
||||
**`prefers-reduced-motion: reduce` has exactly one gate**, in `src/app/globals.css`: a universal
|
||||
`*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; … }`
|
||||
media-query block. This is deliberately a global CSS reset, not a token-only zeroing (`tokens.css` also
|
||||
zeroes the duration tokens, but that alone wouldn't reach MUI's own JS-driven Dialog/Drawer/Menu/Collapse
|
||||
transitions, which don't read the CSS custom properties) — never add a second, component-local
|
||||
reduced-motion branch; extend this one rule if a new motion primitive needs the same treatment.
|
||||
|
||||
### Toggle components
|
||||
|
||||
`DarkModeToggleButton` and `DarkModeFormSwitch` in `src/layout/components/DarkModeButton.tsx` are the **only** components that subscribe to `useColorScheme()`. When the user toggles:
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Persian (fa) copy style guide
|
||||
|
||||
One page, binding for `messages/fa.json`. `scripts/check-copy.mjs` (`npm run lint:copy`, part of
|
||||
`npm run check`) enforces the banned-variant rules below so these decisions cannot silently regress.
|
||||
This file does not repeat `en.json` conventions beyond what's noted in §7 — the English catalog is
|
||||
hand-written and reviewed for idiom, not linted.
|
||||
|
||||
## 1. Brand name
|
||||
|
||||
**«بالینیار» — ZWNJ (``) between بالین and یار, always.** Never a plain space («بالین یار»).
|
||||
The brand name appears in money/trust copy (login, escrow, refunds) as often as anywhere else — an
|
||||
unstable brand mark there is the worst place to be inconsistent.
|
||||
|
||||
## 2. تأیید — hamza, always
|
||||
|
||||
Write **تأیید** (with hamza) and its derived forms — **تأییدشده**, **تأییدیه**, **تأیید کردن** — every
|
||||
time, never تایید/تاییدشده/تاییدیه (hamza-less). This is the single most frequent word in a
|
||||
verification product; one spelling, no exceptions, in every namespace (booking, payment, auth,
|
||||
verification, admin, payouts, bnpl, refunds, legal — all of them).
|
||||
|
||||
## 3. جستجو — one form
|
||||
|
||||
Standard form: **جستجو** (no ZWNJ, one word). Not «جستوجو» / «جست و جو». Applies to the noun and any
|
||||
compound (`در جستجو`, `نتایج جستجو`).
|
||||
|
||||
## 4. ZWNJ (نیمفاصله) rules
|
||||
|
||||
Use ZWNJ (``) — never a plain space or no separator — in:
|
||||
- **می + verb stem**: میشود، میکند، میپردازید، میماند (never میشود/می شود).
|
||||
- **Plural ها**: مراقبها-style compounds keep the ZWNJ before ها when the base ends in a consonant that
|
||||
would otherwise misread (`شبها` not `شبها`); a plain plural on a word already ending in a vowel/silent-h
|
||||
takes the ZWNJ too (`بچهها`).
|
||||
- **Compound past-participle adjectives**: تأییدشده، لغوشده، ردشده، منتشرشده، پرداختشده — the doer/state
|
||||
compound is one ZWNJ-joined word, not two spaced words («تایید شده») and not fused with no separator.
|
||||
- Brand name itself (§1) is the other load-bearing ZWNJ case.
|
||||
|
||||
## 5. Punctuation & quotes
|
||||
|
||||
- Persian text uses «...» guillemets for quoted terms/labels in prose (as this document does), and
|
||||
Persian «،» / «؛» for commas/semicolons *inside translated sentences* where the surrounding punctuation
|
||||
is itself Persian prose (most UI strings use plain Latin `,`/`;` today for simplicity in short labels —
|
||||
don't retrofit existing short strings, but prefer «،»/«؛» in new multi-clause sentences).
|
||||
- English (`en.json`) uses **straight** apostrophes (`don't`, `couldn't`) throughout — never curly
|
||||
(`’`, `‘`). One admin-namespace holdout (curly `don't`/`couldn't`) is fixed by this phase; don't
|
||||
reintroduce curly quotes when editing English copy.
|
||||
|
||||
## 6. Domain glossary
|
||||
|
||||
- **بیمار** — the care recipient, used consistently everywhere except one parenthetical. Do not adopt
|
||||
«مددجو» — it appeared exactly once (`booking.patient_label`) and has been dropped in favor of the
|
||||
99%-majority «بیمار».
|
||||
- **پرستار** — the caregiver, always (never «مراقب» as a noun for the person — «مراقب» only survives as
|
||||
an adjective/role qualifier, e.g. `booking.gender_label` "جنسیت مراقب" meaning "the caregiver's gender").
|
||||
- **رزرو** — a confirmed, paid booking. **درخواست رزرو** — a pre-payment request. Never conflate the two;
|
||||
a `booking_request` is never called «رزرو» before it converts.
|
||||
- **ویزیت** — one scheduled visit/session within a booking.
|
||||
- **شبا** — IBAN, always («شماره شبا» for the field label, «شبا» alone elsewhere).
|
||||
|
||||
## 7. Shell naming system
|
||||
|
||||
One metaphor per audience class, not four:
|
||||
- **End-user shells** (family, nurse — the apps people book/work through day to day) → **«اپلیکیشن»**:
|
||||
«اپلیکیشن خانواده», «اپلیکیشن پرستار».
|
||||
- **Back-office shells** (staff consoles — admin, partner-center) → **«کنسول»**: «کنسول مدیریت»,
|
||||
«کنسول همکار».
|
||||
- Never «نما» (view) or «پرتال» (portal) for a whole shell name — those read as one-off inconsistent
|
||||
metaphors. (`booking.evv_nurse_view` "نمای پرستار" is a different thing — a chip labeling *whose
|
||||
perspective* a shared booking-detail screen is rendered from, not a shell name; it correctly keeps
|
||||
«نما» in that narrower sense.)
|
||||
|
||||
## 8. Verification pipeline vs. the identity step
|
||||
|
||||
**«تأیید صلاحیت»** names the whole 7-step nurse trust pipeline (nav entry, the verification hub's title,
|
||||
its start/progress/approved states, the admin queue). **«احراز هویت»** stays the name of the one KYC
|
||||
step inside it (national-ID + civil-registry + liveness selfie) — both on the nurse side
|
||||
(`verification.step_identity_kyc`) and the admin side (`admin.step_identity_kyc`). A nurse who passed the
|
||||
KYC step but still sees a pipeline titled «احراز هویت» incomplete in the nav used to read as a
|
||||
contradiction; they no longer share a name.
|
||||
|
||||
## 9. Status vocabulary — one nurse-facing form, one admin-facing form
|
||||
|
||||
For "this step/item was rejected/failed" style states that appear on both a nurse-facing screen and an
|
||||
admin-facing screen for the *same underlying concept* (a verification step's outcome):
|
||||
- **Nurse-facing**: «رد شد» (`verification.status_failed`) — a short declarative sentence-style status,
|
||||
matching the register of its sibling `status_passed` ("تأییدشده")/`status_in_review` ("در حال بررسی").
|
||||
- **Admin-facing**: «ردشده» (`admin.step_failed`, `admin.agg_rejected`, `admin.rstatus_rejected`,
|
||||
`admin.mstatus_rejected`) — the compound-adjective state form, matching the admin namespace's own
|
||||
`step_passed`/`agg_approved`/`center_state_verified` ("تأییدشده") pattern.
|
||||
|
||||
This does **not** extend to unrelated money-failure vocabulary (`payouts.pstatus_failed`,
|
||||
`refunds.rstatus_failed`, `admin.batch_status_failed` all legitimately use «ناموفق» — a transfer/payment
|
||||
*failing* is a different concept from a document being *rejected*, and conflating them would blur a real
|
||||
distinction).
|
||||
|
||||
## 10. Digits policy
|
||||
|
||||
Persian digits (۰۱۲۳۴۵۶۷۸۹) everywhere on `/fa` — both hard-coded literals (`"۲۴ ساعت"`) and
|
||||
interpolated numbers. For an interpolated `{count}`/`{hours}`/… inside an ICU message, use the ICU
|
||||
`number` sub-format (`{count, number}`) or a plain `#` inside a `plural` block — next-intl formats both
|
||||
through the active locale (`fa` → Persian digits) automatically. When a raw number is interpolated at a
|
||||
call site instead of through ICU (e.g. built into a larger string in code, not a message placeholder),
|
||||
route it through `formatNumber` (`@/utils`) — never template a raw JS number directly into Persian text.
|
||||
|
||||
## 11. Register
|
||||
|
||||
Formal شما throughout, with polite imperatives (کنید) for actions and instructions. Already consistent
|
||||
across the whole catalog — this codifies it so a future addition can't drift into informal تو/imperative
|
||||
stems (نکن, برو).
|
||||
|
||||
## 12. Policy numbers
|
||||
|
||||
Legally/financially sensitive numbers that the admin config panel can change (the dispute-window hours,
|
||||
cancellation lead-time hours, refund ETA days) are **never hard-coded into a message string**. The
|
||||
message key takes a parameter (`{hours}`, `{minDays}`/`{maxDays}`) and the call site interpolates from
|
||||
`client/src/constants/policy.ts` (single-sourced, REQ-065 tracks the eventual public config-read that
|
||||
replaces the constants file). A config edit must never again silently make the UI copy lie.
|
||||
+18
-17
@@ -446,11 +446,11 @@
|
||||
"categories_error": "Couldn't load categories.",
|
||||
"cta_choose_category_city": "Choose a category and city",
|
||||
"cta_loading": "Counting nurses…",
|
||||
"cta_view_results": "View {count, plural, =0 {no nurses} one {# nurse} other {# nurses}}",
|
||||
"cta_view_results": "{count, plural, =0 {No nurses found} one {View # nurse} other {View # nurses}}",
|
||||
"cta_zero_title": "No nurses match these filters",
|
||||
"cta_zero_hint": "Loosen a filter to see more nurses.",
|
||||
"results_loading_title": "Searching…",
|
||||
"results_count": "{count, plural, =0 {No nurses} one {# nurse} other {# nurses}}",
|
||||
"results_count": "{count, plural, =0 {No nurses found} one {# nurse} other {# nurses}}",
|
||||
"sort_static": "Sorted by rating",
|
||||
"results_error": "Something went wrong loading results.",
|
||||
"retry": "Try again",
|
||||
@@ -568,7 +568,7 @@
|
||||
"accepted_body": "Pay within the window below to confirm the booking.",
|
||||
"payment_countdown_label": "Payment window",
|
||||
"payment_elapsed": "The payment window has closed",
|
||||
"continue_payment": "Continue to payment ←",
|
||||
"continue_payment": "Continue to payment",
|
||||
"cancel_request": "Cancel request",
|
||||
"cancelling": "Cancelling…",
|
||||
"cancel_confirm_title": "Cancel this request?",
|
||||
@@ -831,12 +831,12 @@
|
||||
"customer_title": "Sign in to Balinyaar",
|
||||
"customer_subtitle": "Sign in with your mobile number",
|
||||
"nurse_title": "Nurse sign in",
|
||||
"nurse_subtitle": "For nurses with a Nursing Council licence",
|
||||
"nurse_subtitle": "For nurses with a Nursing Council license",
|
||||
"phone_label": "Mobile number",
|
||||
"phone_invalid": "Enter a valid Iranian mobile number",
|
||||
"request_code": "Get verification code",
|
||||
"nurse_switch": "Are you a nurse? Nurse sign in →",
|
||||
"customer_switch": "Are you a family? Family sign in →",
|
||||
"nurse_switch": "Are you a nurse? Nurse sign in",
|
||||
"customer_switch": "Are you a family? Family sign in",
|
||||
"rate_limited": "Too many attempts — please try again shortly",
|
||||
"otp_title": "Enter the verification code",
|
||||
"otp_sent_to": "Code sent to {phone}",
|
||||
@@ -1024,8 +1024,8 @@
|
||||
"policy_free_24h": "Free cancellation",
|
||||
"policy_partial_under_24h": "Partial refund",
|
||||
"policy_customer_no_show": "No refund",
|
||||
"lead_gt_24h": "More than 24 hours before the visit",
|
||||
"lead_lt_24h": "Less than 24 hours before the visit",
|
||||
"lead_gt_24h": "More than {hours} hours before the visit",
|
||||
"lead_lt_24h": "Less than {hours} hours before the visit",
|
||||
"lead_started": "The visit has already started",
|
||||
"refund_percent": "{percent}% refunded",
|
||||
"fee_percent": "Cancellation fee: {percent}%",
|
||||
@@ -1086,7 +1086,7 @@
|
||||
"eta_bnpl_body": "Unpaid installments are cancelled and credited back to your installment wallet; any installment you already paid is returned to your bank account.",
|
||||
"eta_manual_title": "Manual bank transfer",
|
||||
"eta_manual_body": "The amount is transferred to your bank account by our team.",
|
||||
"eta_business_days": "About 7–10 business days",
|
||||
"eta_business_days": "About {minDays}–{maxDays} business days",
|
||||
"eta_expected_label": "estimated by {date}",
|
||||
"cancel_booking_cta": "Cancel booking",
|
||||
"refund_section_title": "Refund",
|
||||
@@ -1210,7 +1210,7 @@
|
||||
"bucket_clawback": "Clawback outstanding",
|
||||
"explainer_title": "How payouts work",
|
||||
"explainer_point_1": "You're paid in weekly batches to your verified primary account.",
|
||||
"explainer_point_2": "An amount becomes eligible only after the visit is verified (check-out) and the 72-hour dispute window closes.",
|
||||
"explainer_point_2": "An amount becomes eligible only after the visit is verified (check-out) and the {hours}-hour dispute window closes.",
|
||||
"explainer_point_3": "You receive the same amount whether the family paid by card or in installments — the installment provider's fee is never deducted from you.",
|
||||
"tab_all": "All",
|
||||
"tab_pending": "Pending",
|
||||
@@ -1496,7 +1496,7 @@
|
||||
"next_page": "Next",
|
||||
"search_placeholder": "Search…",
|
||||
"refresh": "Refresh",
|
||||
"no_permission": "You don’t have access to this console.",
|
||||
"no_permission": "You don't have access to this console.",
|
||||
"role_gate_hint": "This action is limited to certain admin roles.",
|
||||
"masked_iban_label": "IBAN",
|
||||
"ver_title": "Verification queue",
|
||||
@@ -1544,7 +1544,7 @@
|
||||
"doc_loading": "Loading document…",
|
||||
"doc_expired": "This secure link expired.",
|
||||
"doc_reload": "Re-request link",
|
||||
"doc_error": "Couldn’t load the document.",
|
||||
"doc_error": "Couldn't load the document.",
|
||||
"doc_open_new": "Open in a new tab",
|
||||
"doc_file_meta": "{name} · {size}",
|
||||
"payout_title": "Payout batches",
|
||||
@@ -1568,7 +1568,7 @@
|
||||
"payout_eligibility_note": "Only completed bookings past their dispute window appear here — computed server-side.",
|
||||
"payout_run": "Run batch",
|
||||
"payout_run_confirm_title": "Run this payout batch?",
|
||||
"payout_run_confirm_body": "Money moves to nurses. This is protected by an idempotency key — a double-click can’t pay a booking twice.",
|
||||
"payout_run_confirm_body": "Money moves to nurses. This is protected by an idempotency key — a double-click can't pay a booking twice.",
|
||||
"payout_run_summary_intro": "This batch will run and the amount below will be transferred:",
|
||||
"payout_run_count_label": "Nurse count",
|
||||
"payout_run_type_to_confirm": "Type “CONFIRM” or the amount to proceed",
|
||||
@@ -1619,7 +1619,7 @@
|
||||
"mod_publish": "Publish",
|
||||
"mod_hide": "Hide",
|
||||
"mod_reject": "Reject",
|
||||
"mod_confirm_publish": "Publish this review? It becomes public and re-computes the nurse’s rating.",
|
||||
"mod_confirm_publish": "Publish this review? It becomes public and re-computes the nurse's rating.",
|
||||
"mod_confirm_hide": "Hide this review with the reason above?",
|
||||
"mod_confirm_reject": "Reject this review with the reason above?",
|
||||
"mod_done": "Review updated.",
|
||||
@@ -1642,7 +1642,8 @@
|
||||
"cfg_saved": "Configuration saved.",
|
||||
"cfg_history": "Change history",
|
||||
"cfg_history_title": "History — {key}",
|
||||
"cfg_history_change": "{old} → {new}",
|
||||
"cfg_history_change_old": "{old}",
|
||||
"cfg_history_change_new": "{new}",
|
||||
"cfg_history_empty": "No changes recorded yet.",
|
||||
"cfg_group_fees": "Fees & VAT",
|
||||
"cfg_group_deadlines": "Deadlines & windows",
|
||||
@@ -1885,7 +1886,7 @@
|
||||
"legal_type": "Legal entity type",
|
||||
"is_mor_yes": "Merchant of record",
|
||||
"is_mor_no": "Settlement runs through Balinyaar",
|
||||
"access_denied": "You don’t have access to a partner center.",
|
||||
"access_denied": "You don't have access to a partner center.",
|
||||
"nurses_title": "Sponsored nurses",
|
||||
"nurses_empty": "No nurses sponsored yet.",
|
||||
"nurses_col_name": "Nurse",
|
||||
@@ -1924,7 +1925,7 @@
|
||||
"invoice_row_vat": "VAT (on commission)",
|
||||
"invoice_row_total": "Invoice total",
|
||||
"invoice_download": "Download PDF",
|
||||
"invoice_pdf_error": "Couldn’t open the invoice PDF.",
|
||||
"invoice_pdf_error": "Couldn't open the invoice PDF.",
|
||||
"invoice_number": "Invoice #{number}",
|
||||
"settlement_iban": "Settlement IBAN",
|
||||
"settlement_export_csv": "Export CSV",
|
||||
|
||||
+101
-100
@@ -10,7 +10,7 @@
|
||||
"services": "خدمات",
|
||||
"dashboard": "داشبورد",
|
||||
"requests": "درخواستها",
|
||||
"verification": "احراز هویت",
|
||||
"verification": "تأیید صلاحیت",
|
||||
"visits": "ویزیتها",
|
||||
"earnings": "درآمدها",
|
||||
"admin": "مدیریت",
|
||||
@@ -60,7 +60,7 @@
|
||||
"close": "بستن",
|
||||
"optional": "اختیاری",
|
||||
"currency_toman": "تومان",
|
||||
"brand": "بالین یار",
|
||||
"brand": "بالینیار",
|
||||
"brand_tagline": "مراقبت مطمئن در خانه",
|
||||
"open_sidebar": "باز کردن منو",
|
||||
"switch_locale": "تغییر به {locale}",
|
||||
@@ -73,11 +73,11 @@
|
||||
},
|
||||
"shell": {
|
||||
"customer_app": "اپلیکیشن خانواده",
|
||||
"nurse_app": "نمای پرستار",
|
||||
"nurse_app": "اپلیکیشن پرستار",
|
||||
"admin_console": "کنسول مدیریت",
|
||||
"placeholder_body": "این بخش در فازهای بعدی تکمیل میشود.",
|
||||
"partner_console": "پرتال همکار",
|
||||
"switch_to_nurse": "نمای پرستار",
|
||||
"partner_console": "کنسول همکار",
|
||||
"switch_to_nurse": "اپلیکیشن پرستار",
|
||||
"switch_to_customer": "اپلیکیشن خانواده"
|
||||
},
|
||||
"home": {
|
||||
@@ -97,7 +97,7 @@
|
||||
"nudge_profile_cta": "رفتن به پروفایل",
|
||||
"patients_error": "حلقهٔ مراقبت شما بارگذاری نشد.",
|
||||
"trust_escrow": "پرداخت امن امانی",
|
||||
"trust_verified_nurses": "پرستاران تاییدشده",
|
||||
"trust_verified_nurses": "پرستاران تأییدشده",
|
||||
"trust_support": "پشتیبانی",
|
||||
"rebook_with": "رزرو دوباره با {name}"
|
||||
},
|
||||
@@ -174,7 +174,7 @@
|
||||
"app_language_hint": "زبان نمایش برنامه را انتخاب کنید.",
|
||||
"sign_out": "خروج از حساب",
|
||||
"sign_out_confirm_title": "خروج از حساب؟",
|
||||
"sign_out_confirm_body": "برای ورود دوباره باید شمارهٔ موبایل خود را دوباره تایید کنید.",
|
||||
"sign_out_confirm_body": "برای ورود دوباره باید شمارهٔ موبایل خود را دوباره تأیید کنید.",
|
||||
"row_personal": "اطلاعات شخصی",
|
||||
"row_addresses": "نشانیها",
|
||||
"row_language": "زبان",
|
||||
@@ -196,8 +196,8 @@
|
||||
"save": "ذخیره پروفایل",
|
||||
"saved": "پروفایل ذخیره شد",
|
||||
"unverified_title": "پروفایل شما هنوز فعال نیست",
|
||||
"unverified_body": "برای نمایش به خانوادهها و دریافت رزرو، احراز هویت را تکمیل کنید.",
|
||||
"unverified_cta": "تکمیل احراز هویت",
|
||||
"unverified_body": "برای نمایش به خانوادهها و دریافت رزرو، تأیید صلاحیت را تکمیل کنید.",
|
||||
"unverified_cta": "تکمیل تأیید صلاحیت",
|
||||
"deferred_services": "خدمات و قیمتهای خود را در بخش «خدمات و قیمتها» مدیریت کنید؛ روزهای کاری در مرحله بعد اضافه میشود.",
|
||||
"save_error": "ذخیرهٔ پروفایل انجام نشد. دوباره تلاش کنید.",
|
||||
"avatar_upload_error": "بارگذاری عکس انجام نشد. دوباره تلاش کنید.",
|
||||
@@ -224,7 +224,7 @@
|
||||
"preview_reviews_count": "({count, plural, =0 {بدون نظر} one {# نظر} other {# نظر}})",
|
||||
"preview_completed_visits": "{count, number} ویزیت موفق",
|
||||
"preview_years_experience": "{years} سال سابقه",
|
||||
"preview_verified_title": "بالینیار چه چیزی را تایید کرده است",
|
||||
"preview_verified_title": "بالینیار چه چیزی را تأیید کرده است",
|
||||
"preview_services_title": "خدمات",
|
||||
"preview_services_empty": "هنوز خدمت فعالی ندارید — یک خدمت در بخش «خدمات و قیمتها» اضافه کنید.",
|
||||
"preview_coverage_title": "مناطق تحت پوشش",
|
||||
@@ -248,8 +248,8 @@
|
||||
"status_pending_chip": "در حال استعلام",
|
||||
"status_pending_title": "در حال استعلام مالکیت حساب",
|
||||
"status_pending_body": "در حال استعلام صحت شبا؛ معمولاً چند دقیقه طول میکشد…",
|
||||
"status_verified_chip": "تاییدشد",
|
||||
"status_verified_title": "حساب تایید شد",
|
||||
"status_verified_chip": "تأییدشده",
|
||||
"status_verified_title": "حساب تأیید شد",
|
||||
"status_verified_body": "این حساب برای واریز درآمد شما آماده است.",
|
||||
"status_mismatch_chip": "مغایرت مالکیت",
|
||||
"status_mismatch_title": "حساب باید به نام خودتان باشد",
|
||||
@@ -324,7 +324,7 @@
|
||||
"adding": "در حال افزودن…",
|
||||
"whole_city_chip": "کل شهر",
|
||||
"empty_title": "هنوز منطقهای ثبت نشده",
|
||||
"empty_warning": "تا زمانی که حداقل یک منطقهٔ تحت پوشش اضافه نکنید، در جستوجو نمایش داده نمیشوید.",
|
||||
"empty_warning": "تا زمانی که حداقل یک منطقهٔ تحت پوشش اضافه نکنید، در جستجو نمایش داده نمیشوید.",
|
||||
"duplicate": "این منطقه از قبل تحت پوشش شماست.",
|
||||
"city_required": "شهر را انتخاب کنید",
|
||||
"added": "منطقهٔ تحت پوشش اضافه شد",
|
||||
@@ -414,7 +414,7 @@
|
||||
"row_profile": "تکمیل نمایه",
|
||||
"row_services": "حداقل یک خدمت فعال",
|
||||
"row_coverage": "محدودهٔ پوشش",
|
||||
"row_bank": "شبای تاییدشده",
|
||||
"row_bank": "شبای تأییدشده",
|
||||
"row_bank_hint": "برای دریافت درآمد — برای نمایش در جستجو لازم نیست.",
|
||||
"row_fix": "تکمیل",
|
||||
"live_title": "فعال در جستجو",
|
||||
@@ -424,7 +424,7 @@
|
||||
},
|
||||
"search": {
|
||||
"title": "یافتن پرستار",
|
||||
"subtitle": "فقط پرستاران تاییدشده و دارای صلاحیت اینجا نمایش داده میشوند.",
|
||||
"subtitle": "فقط پرستاران تأییدشده و دارای صلاحیت اینجا نمایش داده میشوند.",
|
||||
"section_category": "دستهٔ مراقبت",
|
||||
"section_location": "شهر",
|
||||
"section_gender": "جنسیت پرستار",
|
||||
@@ -446,11 +446,11 @@
|
||||
"categories_error": "بارگذاری دستهها ممکن نشد.",
|
||||
"cta_choose_category_city": "یک دسته و شهر انتخاب کنید",
|
||||
"cta_loading": "در حال شمارش پرستاران…",
|
||||
"cta_view_results": "مشاهده {count} پرستار",
|
||||
"cta_view_results": "{count, plural, =0 {پرستاری یافت نشد} one {مشاهدهٔ # پرستار} other {مشاهدهٔ # پرستار}}",
|
||||
"cta_zero_title": "پرستاری با این فیلترها یافت نشد",
|
||||
"cta_zero_hint": "فیلترها را کمی باز کنید تا پرستاران بیشتری ببینید.",
|
||||
"results_loading_title": "در حال جستجو…",
|
||||
"results_count": "{count} پرستار",
|
||||
"results_count": "{count, plural, =0 {پرستاری یافت نشد} one {# پرستار} other {# پرستار}}",
|
||||
"sort_static": "مرتبشده بر اساس امتیاز",
|
||||
"results_error": "در بارگذاری نتایج مشکلی پیش آمد.",
|
||||
"retry": "تلاش دوباره",
|
||||
@@ -463,7 +463,7 @@
|
||||
"unnamed_nurse": "پرستار",
|
||||
"unnamed_service": "خدمت",
|
||||
"completed_visits": "{count, number} ویزیت موفق",
|
||||
"reviews_count": "({count} نظر)",
|
||||
"reviews_count": "({count, plural, =0 {بدون نظر} one {# نظر} other {# نظر}})",
|
||||
"distance_km": "{km} کیلومتر",
|
||||
"price_from": "از",
|
||||
"price_chip_range": "از {min} تا {max} تومان",
|
||||
@@ -500,7 +500,7 @@
|
||||
"form_subtitle": "برای این پرستار درخواست بفرستید. هنوز پرداختی انجام نمیشود؛ ابتدا پرستار درخواست را بررسی میکند.",
|
||||
"whathappens_title": "چه اتفاقی میافتد؟",
|
||||
"missing_nurse_title": "پرستاری انتخاب نشده است",
|
||||
"missing_nurse_body": "ابتدا از جستوجو یک پرستار انتخاب کنید و سپس درخواست دهید.",
|
||||
"missing_nurse_body": "ابتدا از جستجو یک پرستار انتخاب کنید و سپس درخواست دهید.",
|
||||
"missing_nurse_cta": "یافتن پرستار",
|
||||
"patient_label": "بیمار (مددجو)",
|
||||
"patient_placeholder": "انتخاب بیمار",
|
||||
@@ -530,7 +530,7 @@
|
||||
"gender_hint": "برای مراقبتهای بدنی، همجنس بودن مراقب اهمیت دارد. انتخاب شما همراه درخواست ارسال میشود.",
|
||||
"notes_label": "توضیحات برای پرستار",
|
||||
"notes_placeholder": "آنچه پرستار پیش از پذیرش میبیند…",
|
||||
"notes_hint": "این تنها چیزی است که پرستار پیش از پذیرش میبیند؛ شرح کامل مراقبت پس از تایید ثبت میشود.",
|
||||
"notes_hint": "این تنها چیزی است که پرستار پیش از پذیرش میبیند؛ شرح کامل مراقبت پس از تأیید ثبت میشود.",
|
||||
"notes_counter": "{count}/{max}",
|
||||
"submit": "ارسال درخواست",
|
||||
"submitting": "در حال ارسال…",
|
||||
@@ -557,18 +557,18 @@
|
||||
"awaiting_title": "درخواست برای پرستار ارسال شد",
|
||||
"awaiting_subtitle": "در انتظار پاسخ پرستار",
|
||||
"step_submitted": "درخواست ثبت شد",
|
||||
"step_awaiting": "در انتظار تایید پرستار",
|
||||
"step_payment": "پرداخت و تایید نهایی",
|
||||
"step_awaiting": "در انتظار تأیید پرستار",
|
||||
"step_payment": "پرداخت و تأیید نهایی",
|
||||
"response_countdown_label": "مهلت پاسخ پرستار",
|
||||
"response_elapsed": "در انتظار تایید سرور…",
|
||||
"response_elapsed": "در انتظار تأیید سرور…",
|
||||
"response_notify_note": "نتیجه را به شما اطلاع میدهیم",
|
||||
"countdown_about_hours": "حدود {hours} ساعت",
|
||||
"countdown_about_minutes": "حدود {minutes} دقیقه",
|
||||
"accepted_badge": "پرستار تایید کرد",
|
||||
"accepted_badge": "پرستار تأیید کرد",
|
||||
"accepted_body": "برای نهاییشدن رزرو، در مهلت زیر پرداخت را انجام دهید.",
|
||||
"payment_countdown_label": "مهلت پرداخت",
|
||||
"payment_elapsed": "مهلت پرداخت به پایان رسید",
|
||||
"continue_payment": "ادامه پرداخت ←",
|
||||
"continue_payment": "ادامه پرداخت",
|
||||
"cancel_request": "انصراف از درخواست",
|
||||
"cancelling": "در حال لغو…",
|
||||
"cancel_confirm_title": "این درخواست لغو شود؟",
|
||||
@@ -629,13 +629,13 @@
|
||||
"bd_my_bookings": "رزروهای من",
|
||||
"timeline_title": "روند رزرو",
|
||||
"bstatus_pending_payment": "در انتظار پرداخت",
|
||||
"bstatus_confirmed": "تاییدشده",
|
||||
"bstatus_confirmed": "تأییدشده",
|
||||
"bstatus_in_progress": "در حال انجام",
|
||||
"bstatus_completed": "تکمیلشده",
|
||||
"bstatus_disputed": "در حال بررسی اختلاف",
|
||||
"bstatus_closed": "بستهشده",
|
||||
"bstatus_cancelled": "لغوشده",
|
||||
"confirmed_note": "رزرو شما تایید شد. ویزیتهای زیر برنامهریزی شدهاند.",
|
||||
"confirmed_note": "رزرو شما تأیید شد. ویزیتهای زیر برنامهریزی شدهاند.",
|
||||
"in_progress_note": "یک ویزیت هماکنون در حال انجام است.",
|
||||
"completed_note": "همهٔ ویزیتها به پایان رسید.",
|
||||
"dispute_window_note": "مبلغ پس از پایان مهلت بررسی در {date} برای پرستار آزاد میشود.",
|
||||
@@ -643,7 +643,7 @@
|
||||
"disputed_note": "این رزرو در حال بررسی توسط تیم پشتیبانی است.",
|
||||
"sessions_title": "برنامهٔ ویزیتها",
|
||||
"session_index": "ویزیت {n}",
|
||||
"session_count": "{count} ویزیت",
|
||||
"session_count": "{count, plural, =0 {ویزیتی نیست} one {# ویزیت} other {# ویزیت}}",
|
||||
"sstatus_scheduled": "برنامهریزیشده",
|
||||
"sstatus_in_progress": "در حال انجام",
|
||||
"sstatus_completed": "تکمیلشده",
|
||||
@@ -658,20 +658,20 @@
|
||||
"money_nurse_earning": "درآمد شما",
|
||||
"evv_today_title": "ویزیت امروز",
|
||||
"evv_visits_title": "ویزیتهای امروز",
|
||||
"evv_visits_subtitle": "برای هر ویزیت، ورود و خروج را با EVV ثبت کنید.",
|
||||
"evv_visits_subtitle": "ثبت حضور الکترونیکی (EVV) — ورود و خروج شما ثبت میشود تا ویزیت بدون اختلاف تأیید شود.",
|
||||
"evv_no_visits": "برای امروز ویزیتی برنامهریزی نشده است.",
|
||||
"evv_check_in": "ثبت ورود (EVV)",
|
||||
"evv_check_out": "ثبت خروج (EVV)",
|
||||
"evv_checking_in": "در حال ثبت ورود…",
|
||||
"evv_checking_out": "در حال ثبت خروج…",
|
||||
"evv_acquiring_location": "در حال دریافت موقعیت…",
|
||||
"evv_banner_in_range": "ورود ثبت شد {time} · موقعیت تایید شد (EVV)",
|
||||
"evv_banner_in_range": "ورود ثبت شد {time} · موقعیت تأیید شد (EVV)",
|
||||
"evv_banner_out_of_range": "ورود ثبت شد {time} · موقعیت خارج از محدوده (در حال بررسی)",
|
||||
"evv_banner_no_gps": "ورود ثبت شد {time} · موقعیت ثبت نشد",
|
||||
"evv_presence_headline": "پرستار در محل است · ورود {time}",
|
||||
"evv_checked_out_at": "خروج ثبت شد {time}",
|
||||
"evv_gps_denied_note": "دسترسی به موقعیت داده نشد — همچنان میتوانید ویزیت را ثبت کنید؛ برای بررسی علامتگذاری میشود.",
|
||||
"evv_no_open_check_in": "ورود بازی برای ثبت خروج وجود ندارد.",
|
||||
"evv_no_open_check_in": "ورودِ ثبتشدهای برای این ویزیت وجود ندارد؛ ابتدا ورود را ثبت کنید.",
|
||||
"evv_not_startable": "این ویزیت هنوز قابل شروع نیست.",
|
||||
"evv_checked_in_toast": "ورود ثبت شد.",
|
||||
"evv_checked_out_toast": "خروج ثبت شد.",
|
||||
@@ -680,7 +680,7 @@
|
||||
"evv_nurse_view": "نمای پرستار",
|
||||
"view_booking": "مشاهدهٔ رزرو",
|
||||
"care_title": "دستورالعمل مراقبت",
|
||||
"care_subtitle": "پس از تایید، بهعنوان پرستار مسئول با شما به اشتراک گذاشته شده است.",
|
||||
"care_subtitle": "پس از تأیید، بهعنوان پرستار مسئول با شما به اشتراک گذاشته شده است.",
|
||||
"care_conditions": "شرایط پزشکی",
|
||||
"care_medications": "داروها",
|
||||
"care_allergies": "حساسیتها",
|
||||
@@ -692,7 +692,7 @@
|
||||
"care_locked_title": "شرح مراقبت",
|
||||
"care_locked_body": "شرح کامل مراقبت تنها برای پرستار مسئول شما و پشتیبانی قابل مشاهده است.",
|
||||
"list_title": "رزروهای من",
|
||||
"list_subtitle": "مراقبتهای تاییدشدهٔ شما.",
|
||||
"list_subtitle": "مراقبتهای تأییدشدهٔ شما.",
|
||||
"list_error": "بارگذاری رزروها ممکن نشد.",
|
||||
"list_total": "مبلغ کل",
|
||||
"inbox_error": "درخواستهای شما بارگذاری نشد.",
|
||||
@@ -709,7 +709,7 @@
|
||||
"address_title": "آدرس",
|
||||
"address_open_map": "مسیریابی",
|
||||
"address_open_web_map": "نمایش روی نقشه",
|
||||
"address_pending_note": "آدرس پس از تایید رزرو در دسترس قرار میگیرد.",
|
||||
"address_pending_note": "آدرس پس از تأیید رزرو در دسترس قرار میگیرد.",
|
||||
"in_visit_title": "در حال ویزیت",
|
||||
"in_visit_elapsed": "{duration} در محل",
|
||||
"evv_checkout_confirm_title": "اتمام این ویزیت؟",
|
||||
@@ -747,7 +747,7 @@
|
||||
"notifications_entry_empty": "اعلان جدیدی ندارید"
|
||||
},
|
||||
"payment": {
|
||||
"title_checkout": "تایید و پرداخت",
|
||||
"title_checkout": "تأیید و پرداخت",
|
||||
"row_service_cost": "هزینه خدمت",
|
||||
"row_service_cost_with_count": "هزینه خدمت ({count, plural, one {# ویزیت} other {# ویزیت}})",
|
||||
"row_commission": "کارمزد بالینیار",
|
||||
@@ -757,7 +757,7 @@
|
||||
"escrow_explainer_toggle": "چطور کار میکند؟",
|
||||
"escrow_step_pay": "پرداخت",
|
||||
"escrow_step_hold": "امانت نزد بالینیار",
|
||||
"escrow_step_release": "آزادسازی پس از تایید پایان ویزیت",
|
||||
"escrow_step_release": "آزادسازی پس از تأیید پایان ویزیت",
|
||||
"escrow_cancellation_note": "در صورت لغو رزرو پیش از انجام ویزیت، مبلغ طبق سیاست لغو بازپرداخت میشود.",
|
||||
"total_payable_label": "مبلغ قابل پرداخت",
|
||||
"secure_gateway_notice": "پرداخت امن از طریق درگاه بانکی",
|
||||
@@ -765,10 +765,10 @@
|
||||
"bnpl_option": "یا پرداخت اقساطی",
|
||||
"state_initiating": "در حال آغاز پرداخت…",
|
||||
"state_redirecting": "در حال انتقال به درگاه پرداخت…",
|
||||
"state_pending_title": "در حال تایید پرداخت…",
|
||||
"state_pending_hint": "پرداخت شما نزد درگاه در حال تایید است؛ این مرحله معمولاً چند لحظه طول میکشد.",
|
||||
"state_pending_title": "در حال تأیید پرداخت…",
|
||||
"state_pending_hint": "پرداخت شما نزد درگاه در حال تأیید است؛ این مرحله معمولاً چند لحظه طول میکشد.",
|
||||
"stage_returned": "بازگشت از درگاه",
|
||||
"stage_confirming": "در انتظار تایید بانک",
|
||||
"stage_confirming": "در انتظار تأیید بانک",
|
||||
"state_pending_duration_hint": "معمولاً کمتر از یک دقیقه طول میکشد.",
|
||||
"check_again": "بررسی دوباره",
|
||||
"state_failed_title": "پرداخت ناموفق بود",
|
||||
@@ -819,7 +819,7 @@
|
||||
"moadian_submitted": "ارسالشده",
|
||||
"moadian_registered": "ثبتشده",
|
||||
"moadian_failed": "ناموفق",
|
||||
"pstatus_pending": "در انتظار تایید",
|
||||
"pstatus_pending": "در انتظار تأیید",
|
||||
"pstatus_succeeded": "موفق",
|
||||
"pstatus_failed": "ناموفق",
|
||||
"error_title": "مشکلی پیش آمد",
|
||||
@@ -828,27 +828,27 @@
|
||||
"issuer_platform": "بالینیار"
|
||||
},
|
||||
"auth": {
|
||||
"customer_title": "ورود به بالین یار",
|
||||
"customer_title": "ورود به بالینیار",
|
||||
"customer_subtitle": "با شماره موبایل خود وارد شوید",
|
||||
"nurse_title": "ورود پرستاران",
|
||||
"nurse_subtitle": "ویژه پرستاران دارای پروانه نظام پرستاری",
|
||||
"phone_label": "شماره موبایل",
|
||||
"phone_invalid": "شماره موبایل معتبر وارد کنید",
|
||||
"request_code": "دریافت کد تایید",
|
||||
"nurse_switch": "پرستار هستید؟ ورود پرستاران ←",
|
||||
"customer_switch": "خانواده هستید؟ ورود خانوادهها ←",
|
||||
"request_code": "دریافت کد تأیید",
|
||||
"nurse_switch": "پرستار هستید؟ ورود پرستاران",
|
||||
"customer_switch": "خانواده هستید؟ ورود خانوادهها",
|
||||
"rate_limited": "بهدلیل تلاش زیاد، کمی بعد دوباره تلاش کنید",
|
||||
"otp_title": "کد تایید را وارد کنید",
|
||||
"otp_title": "کد تأیید را وارد کنید",
|
||||
"otp_sent_to": "کد به شماره {phone} ارسال شد",
|
||||
"otp_verify_customer": "تایید و ادامه",
|
||||
"otp_verify_nurse": "تایید و ورود",
|
||||
"otp_verify_customer": "تأیید و ادامه",
|
||||
"otp_verify_nurse": "تأیید و ورود",
|
||||
"otp_invalid": "کد وارد شده نادرست یا منقضی شده است",
|
||||
"otp_locked": "بهدلیل تلاشهای زیاد، ورود موقتاً قفل شد. کد جدید دریافت کنید.",
|
||||
"resend_in": "ارسال مجدد کد تا {time}",
|
||||
"resend": "ارسال مجدد کد",
|
||||
"change_number": "تغییر شماره",
|
||||
"routing_title": "در حال ورود…",
|
||||
"select_role_title": "به بالین یار خوش آمدید",
|
||||
"select_role_title": "به بالینیار خوش آمدید",
|
||||
"select_role_subtitle": "برای شروع، نقش خود را انتخاب کنید",
|
||||
"role_customer": "خانواده",
|
||||
"role_customer_desc": "برای رزرو پرستار و مراقبت در منزل",
|
||||
@@ -858,28 +858,28 @@
|
||||
"continue": "ادامه",
|
||||
"guard_denied": "شما به این بخش دسترسی ندارید.",
|
||||
"account_error_title": "حساب شما بارگذاری نشد",
|
||||
"account_error_body": "در ارتباط با بالین یار برای بارگذاری حساب شما مشکلی پیش آمد. اتصال خود را بررسی کنید و دوباره تلاش کنید.",
|
||||
"account_error_body": "در ارتباط با بالینیار برای بارگذاری حساب شما مشکلی پیش آمد. اتصال خود را بررسی کنید و دوباره تلاش کنید.",
|
||||
"account_error_retry": "تلاش مجدد",
|
||||
"trust_verified_nurses": "پرستاران دارای پروانه صلاحیت حرفهای و احراز هویتشده",
|
||||
"trust_escrow_payment": "پرداخت شما تا تایید انجام خدمت، امانی نزد بالینیار میماند",
|
||||
"trust_escrow_payment": "پرداخت شما تا تأیید انجام خدمت، امانی نزد بالینیار میماند",
|
||||
"trust_support": "پشتیبانی در تمام مراحل مراقبت",
|
||||
"consent_line": "با ورود، <terms>شرایط استفاده</terms> و <privacy>حریم خصوصی</privacy> را میپذیرید."
|
||||
},
|
||||
"verification": {
|
||||
"title": "احراز هویت",
|
||||
"title": "تأیید صلاحیت",
|
||||
"subtitle": "برای فعالشدن پروفایل و دریافت رزرو، این مراحل را کامل کنید.",
|
||||
"retry": "تلاش مجدد",
|
||||
"load_error": "بارگذاری وضعیت احراز هویت ممکن نشد.",
|
||||
"start_title": "احراز هویت را آغاز کنید",
|
||||
"start_body": "اعتماد، بنیان بالین یار است. با تکمیل این مراحل، خانوادهها با اطمینان شما را انتخاب میکنند.",
|
||||
"start_cta": "شروع احراز هویت",
|
||||
"load_error": "بارگذاری وضعیت تأیید صلاحیت ممکن نشد.",
|
||||
"start_title": "تأیید صلاحیت را آغاز کنید",
|
||||
"start_body": "اعتماد، بنیان بالینیار است. با تکمیل این مراحل، خانوادهها با اطمینان شما را انتخاب میکنند.",
|
||||
"start_cta": "شروع تأیید صلاحیت",
|
||||
"starting": "در حال آمادهسازی…",
|
||||
"progress_title": "پیشرفت احراز هویت",
|
||||
"progress_title": "پیشرفت تأیید صلاحیت",
|
||||
"progress_count": "{passed} از {total}",
|
||||
"blocking_summary": "تا تکمیل همهٔ مراحل، پروفایل شما قابل نمایش و رزرو نیست.",
|
||||
"continue": "ادامه",
|
||||
"continue_step": "ادامه مرحلهٔ {n}",
|
||||
"approved_title": "احراز هویت تکمیل شد",
|
||||
"approved_title": "تأیید صلاحیت تکمیل شد",
|
||||
"approved_body": "همهچیز تأیید شد. اکنون میتوانید پروفایل و خدمات خود را منتشر کنید.",
|
||||
"approved_cta": "انتشار خدمات",
|
||||
"step_mobile_verified": "شماره موبایل",
|
||||
@@ -896,7 +896,7 @@
|
||||
"step_criminal_record_desc": "آپلود گواهی؛ بررسی دستی. دارای تاریخ انقضا.",
|
||||
"step_bank_account_verification": "تأیید حساب بانکی",
|
||||
"step_bank_account_verification_desc": "استعلام خودکار مالکیت شبا با کد ملی.",
|
||||
"status_passed": "تاییدشده",
|
||||
"status_passed": "تأییدشده",
|
||||
"status_in_review": "در حال بررسی",
|
||||
"status_pending": "در انتظار",
|
||||
"status_failed": "رد شد",
|
||||
@@ -938,7 +938,7 @@
|
||||
"back_to_checklist": "بازگشت به فهرست",
|
||||
"credentials_title": "مدارک حرفهای",
|
||||
"credentials_subtitle": "مدارک شما پس از بارگذاری، توسط کارشناس بررسی میشود.",
|
||||
"credentials_needs_start": "ابتدا احراز هویت را از فهرست وضعیت آغاز کنید.",
|
||||
"credentials_needs_start": "ابتدا تأیید صلاحیت را از فهرست وضعیت آغاز کنید.",
|
||||
"ino_number_label": "شماره نظام پرستاری",
|
||||
"ino_number_hint": "شماره عضویت نظام پرستاری خود را وارد کنید.",
|
||||
"ino_number_required": "شماره نظام پرستاری را وارد کنید.",
|
||||
@@ -972,20 +972,20 @@
|
||||
"review_timeline_submitted": "مدارک ثبت شد",
|
||||
"review_timeline_review": "بررسی توسط کارشناس",
|
||||
"review_timeline_activation": "فعالسازی نشان",
|
||||
"review_approved_title": "احراز هویت تکمیل شد",
|
||||
"review_approved_title": "تأیید صلاحیت تکمیل شد",
|
||||
"review_approved_body": "همهٔ مراحل تأیید شد. اکنون میتوانید پروفایل خود را منتشر کنید.",
|
||||
"review_summary_title": "خلاصهٔ وضعیت",
|
||||
"review_view_status": "مشاهده وضعیت",
|
||||
"badge_verified": "تاییدشده",
|
||||
"badge_verified": "تأییدشده",
|
||||
"badge_unverified": "احراز نشده",
|
||||
"badge_expired": "منقضی شده",
|
||||
"explainer_title": "بالینیار چه چیزی را تایید کرده است",
|
||||
"explainer_open_label": "مشاهدهٔ جزئیات تاییدیه",
|
||||
"explainer_title": "بالینیار چه چیزی را تأیید کرده است",
|
||||
"explainer_open_label": "مشاهدهٔ جزئیات تأییدیه",
|
||||
"explainer_close": "بستن",
|
||||
"explainer_intro": "این پرستار این مراحل را با موفقیت گذرانده است:",
|
||||
"explainer_approved_at": "تاریخ تایید: {date}",
|
||||
"explainer_not_verified": "این پرستار هنوز احراز هویت نشده است.",
|
||||
"explainer_error": "بارگذاری جزئیات تاییدیه ممکن نشد.",
|
||||
"explainer_approved_at": "تاریخ تأیید: {date}",
|
||||
"explainer_not_verified": "صلاحیت این پرستار هنوز تأیید نشده است.",
|
||||
"explainer_error": "بارگذاری جزئیات تأییدیه ممکن نشد.",
|
||||
"publish_ready_title": "آمادهٔ فعالسازی",
|
||||
"publish_ready_body": "راهاندازی کامل است. پذیرش رزرو را روشن کنید تا در جستجو نمایش داده شوید و درخواست دریافت کنید.",
|
||||
"publish_blocked_title": "برای فعالسازی، راهاندازی را کامل کنید",
|
||||
@@ -1017,15 +1017,15 @@
|
||||
"refunds": {
|
||||
"cancel_title": "لغو رزرو",
|
||||
"step_review": "بازبینی",
|
||||
"step_confirm": "تایید",
|
||||
"step_confirm": "تأیید",
|
||||
"offramp_note": "پیش از لغو، این گزینهها را هم در نظر بگیرید — لغو ممکن است زمان پرستار شما را از دست بدهد.",
|
||||
"reschedule_cta": "تغییر زمان",
|
||||
"contact_support_cta": "گفتگو با پشتیبانی",
|
||||
"policy_free_24h": "لغو رایگان",
|
||||
"policy_partial_under_24h": "بازپرداخت جزئی",
|
||||
"policy_customer_no_show": "بدون بازپرداخت",
|
||||
"lead_gt_24h": "بیش از ۲۴ ساعت مانده به ویزیت",
|
||||
"lead_lt_24h": "کمتر از ۲۴ ساعت مانده به ویزیت",
|
||||
"lead_gt_24h": "بیش از {hours} ساعت مانده به ویزیت",
|
||||
"lead_lt_24h": "کمتر از {hours} ساعت مانده به ویزیت",
|
||||
"lead_started": "ویزیت آغاز شده است",
|
||||
"refund_percent": "{percent}٪ بازپرداخت میشود",
|
||||
"fee_percent": "کارمزد لغو: {percent}٪",
|
||||
@@ -1049,9 +1049,9 @@
|
||||
"reason_notes_label": "توضیحات (اختیاری)",
|
||||
"acknowledge_label": "بازپرداخت و کارمزد نمایشدادهشده را میپذیرم.",
|
||||
"continue_cta": "ادامه",
|
||||
"confirm_title": "تایید لغو",
|
||||
"confirm_restate": "مبلغ <refund></refund> به شما بازپرداخت میشود و کارمزد لغو <fee></fee> کسر میگردد.",
|
||||
"confirm_cta": "تایید لغو",
|
||||
"confirm_title": "تأیید لغو",
|
||||
"confirm_restate": "مبلغ <refund></refund> به شما بازپرداخت میشود و کارمزد لغو <fee></fee> کسر میشود.",
|
||||
"confirm_cta": "تأیید لغو",
|
||||
"submitting": "در حال ثبت…",
|
||||
"not_cancellable_title": "این رزرو قابل لغو نیست",
|
||||
"not_cancellable_body": "جلسهٔ باقیماندهای برای لغو وجود ندارد. اگر پیشتر لغو شده است، وضعیت بازپرداخت را دنبال کنید.",
|
||||
@@ -1086,7 +1086,7 @@
|
||||
"eta_bnpl_body": "اقساط پرداختنشده لغو و معادل آن به کیف اعتباری اقساطی شما بازگردانده میشود؛ هر قسطی که پرداخت کردهاید به حساب بانکی شما برمیگردد.",
|
||||
"eta_manual_title": "واریز بانکی دستی",
|
||||
"eta_manual_body": "مبلغ توسط تیم بالینیار به حساب بانکی شما واریز میشود.",
|
||||
"eta_business_days": "حدود ۷ تا ۱۰ روز کاری",
|
||||
"eta_business_days": "حدود {minDays} تا {maxDays} روز کاری",
|
||||
"eta_expected_label": "تا حدود {date}",
|
||||
"cancel_booking_cta": "لغو رزرو",
|
||||
"refund_section_title": "بازپرداخت",
|
||||
@@ -1096,7 +1096,7 @@
|
||||
},
|
||||
"bnpl": {
|
||||
"title": "پرداخت اقساطی",
|
||||
"ownership_note": "ارائهدهنده کل مبلغ را یکجا به بالینیار میپردازد و ریسک نکول مشتری کاملاً با اوست؛ بازپرداخت اقساط میان شما و ارائهدهنده است.",
|
||||
"ownership_note": "قسطها را مستقیماً به ارائهدهنده میپردازید؛ بالینیار مبلغ کامل رزرو را همان ابتدا دریافت میکند و اگر قسطی پرداخت نشود، مسئولیت آن با ارائهدهنده است — نه شما و نه پرستار.",
|
||||
"payable_amount": "مبلغ قابل پرداخت",
|
||||
"total_amount_named": "مبلغ کل با طرح {plan}",
|
||||
"total_repayment": "مجموع بازپرداخت",
|
||||
@@ -1117,7 +1117,7 @@
|
||||
"provider_tagline_torobpay": "طرح اقساطی",
|
||||
"method_title": "روش پرداخت",
|
||||
"method_card": "پرداخت کامل (کارت بانکی)",
|
||||
"method_card_hint": "پرداخت یکجا و تایید فوری رزرو",
|
||||
"method_card_hint": "پرداخت یکجا و تأیید فوری رزرو",
|
||||
"installments_heading": "پرداخت اقساطی",
|
||||
"continue_with": "ادامه با {provider}",
|
||||
"no_providers_title": "اقساط در دسترس نیست",
|
||||
@@ -1140,11 +1140,11 @@
|
||||
"consent_label": "با استعلام اعتبارسنجی و سابقه اعتباری من توسط {provider} موافقم.",
|
||||
"check_eligibility": "استعلام اعتبار",
|
||||
"checking_eligibility": "در حال استعلام اعتبار…",
|
||||
"approved_title": "اعتبار شما تایید شد",
|
||||
"approved_title": "اعتبار شما تأیید شد",
|
||||
"credit_ceiling_label": "سقف اعتبار قابل استفاده",
|
||||
"approve_continue": "تایید و ادامه",
|
||||
"declined_not_eligible_title": "اعتبارسنجی تایید نشد",
|
||||
"declined_not_eligible_body": "متأسفانه ارائهدهنده اعتبار لازم را تایید نکرد.",
|
||||
"approve_continue": "تأیید و ادامه",
|
||||
"declined_not_eligible_title": "اعتبارسنجی تأیید نشد",
|
||||
"declined_not_eligible_body": "متأسفانه ارائهدهنده اعتبار لازم را تأیید نکرد.",
|
||||
"declined_ceiling_title": "سقف اعتبار کافی نیست",
|
||||
"declined_ceiling_body": "مبلغ این رزرو از سقف اعتبار قابل استفاده شما بیشتر است.",
|
||||
"pay_with_card": "پرداخت با کارت",
|
||||
@@ -1154,13 +1154,13 @@
|
||||
"installment_n": "قسط {n, number}",
|
||||
"contract_note": "قرارداد اقساط میان شما و {provider} است؛ بالینیار مبلغ کامل رزرو را یکجا دریافت میکند.",
|
||||
"contract_consent": "شرایط و قرارداد اقساط را خواندهام و میپذیرم.",
|
||||
"pay_down_payment": "تایید نهایی و پرداخت پیشپرداخت",
|
||||
"pay_down_payment": "تأیید نهایی و پرداخت پیشپرداخت",
|
||||
"redirecting": "در حال انتقال به {provider}…",
|
||||
"handoff_title": "در حال انتقال به ارائهدهنده",
|
||||
"handoff_body": "برای تایید رزرو، قرارداد اقساط را با {provider} تکمیل کنید.",
|
||||
"handoff_body": "برای تأیید رزرو، قرارداد اقساط را با {provider} تکمیل کنید.",
|
||||
"handoff_pay_success": "پرداخت پیشپرداخت (نمایشی)",
|
||||
"handoff_pay_fail": "انصراف",
|
||||
"settling_title": "در حال تایید طرح اقساط شما",
|
||||
"settling_title": "در حال تأیید طرح اقساط شما",
|
||||
"settling_body": "ارائهدهنده در حال تسویه سفارش است. طولی نمیکشد.",
|
||||
"settle_failed_title": "پرداخت اقساطی کامل نشد",
|
||||
"settle_failed_body": "ارائهدهنده تراکنش را کامل نکرد. میتوانید دوباره تلاش کنید یا با کارت پرداخت کنید.",
|
||||
@@ -1209,8 +1209,8 @@
|
||||
"bucket_paid": "پرداختشده (کل)",
|
||||
"bucket_clawback": "بازپسگیری معوق",
|
||||
"explainer_title": "پرداختها چگونه کار میکنند",
|
||||
"explainer_point_1": "پرداختها بهصورت هفتگی و به حساب تاییدشده اصلی شما واریز میشود.",
|
||||
"explainer_point_2": "هر مبلغ تنها پس از تایید ویزیت (خروج) و بستهشدن پنجره ۷۲ ساعته اعتراض، آماده پرداخت میشود.",
|
||||
"explainer_point_1": "پرداختها بهصورت هفتگی و به حساب تأییدشده اصلی شما واریز میشود.",
|
||||
"explainer_point_2": "هر مبلغ تنها پس از تأیید ویزیت (خروج) و بستهشدن پنجرهٔ {hours} ساعتهٔ اعتراض، آماده پرداخت میشود.",
|
||||
"explainer_point_3": "چه خانواده با کارت پرداخت کند چه اقساطی، مبلغ شما یکسان است — کارمزد ارائهدهنده اقساط هرگز از شما کسر نمیشود.",
|
||||
"tab_all": "همه",
|
||||
"tab_pending": "در انتظار",
|
||||
@@ -1240,7 +1240,7 @@
|
||||
"transfer_reference_label": "کد پیگیری واریز",
|
||||
"list_error": "بارگذاری درآمدها ممکن نشد.",
|
||||
"list_empty_title": "هنوز درآمدی ندارید",
|
||||
"list_empty_body": "ویزیتهای تکمیل و تاییدشده اینجا نمایش داده میشوند.",
|
||||
"list_empty_body": "ویزیتهای تکمیل و تأییدشده اینجا نمایش داده میشوند.",
|
||||
"retry": "تلاش دوباره",
|
||||
"page_prev": "قبلی",
|
||||
"page_next": "بعدی",
|
||||
@@ -1434,7 +1434,7 @@
|
||||
"back_to_tickets": "بازگشت به تیکتها",
|
||||
"thread_error": "بارگذاری گفتگو ممکن نشد.",
|
||||
"thread_empty_title": "هنوز پیامی نیست",
|
||||
"thread_empty_body": "هنوز پیامی نیست، هماهنگی را شروع کنید.",
|
||||
"thread_empty_body": "هنوز پیامی نیست. هماهنگی را شروع کنید.",
|
||||
"closed_notice": "این گفتگو بسته شده است.",
|
||||
"sending": "در حال ارسال…",
|
||||
"send": "ارسال پیام",
|
||||
@@ -1499,7 +1499,7 @@
|
||||
"no_permission": "شما به این بخش دسترسی ندارید.",
|
||||
"role_gate_hint": "این اقدام تنها برای نقشهای مشخصی از مدیران در دسترس است.",
|
||||
"masked_iban_label": "شبا",
|
||||
"ver_title": "صف احراز هویت",
|
||||
"ver_title": "صف تأیید صلاحیت",
|
||||
"ver_subtitle": "پرستاران در انتظار بررسی مدارک.",
|
||||
"ver_empty": "صف خالی است — موردی برای بررسی نیست.",
|
||||
"ver_col_nurse": "پرستار",
|
||||
@@ -1512,7 +1512,7 @@
|
||||
"ver_progress": "{done} از {total}",
|
||||
"ver_next_step": "بعدی: {step}",
|
||||
"ver_open_case": "بررسی",
|
||||
"ver_case_title": "پرونده احراز هویت",
|
||||
"ver_case_title": "پرونده تأیید صلاحیت",
|
||||
"ver_next_case": "پروندهٔ بعدی",
|
||||
"ver_prev_case": "پروندهٔ قبلی",
|
||||
"ver_identity_name": "نام ثبتشده",
|
||||
@@ -1571,7 +1571,7 @@
|
||||
"payout_run_confirm_body": "مبلغ به پرستاران منتقل میشود. این اقدام با کلید یکتا محافظت میشود — کلیک دوباره باعث پرداخت مضاعف نمیشود.",
|
||||
"payout_run_summary_intro": "این دسته اجرا و مبلغ زیر واریز میشود:",
|
||||
"payout_run_count_label": "تعداد پرستاران",
|
||||
"payout_run_type_to_confirm": "برای تایید، «تایید» یا مبلغ را تایپ کنید",
|
||||
"payout_run_type_to_confirm": "برای تأیید، «تأیید» یا مبلغ را تایپ کنید",
|
||||
"payout_running": "در حال ارسال به سامانهٔ بانکی…",
|
||||
"payout_skipped": "پرستاران نادیدهگرفتهشده",
|
||||
"payout_batch_title": "دسته #{id}",
|
||||
@@ -1619,7 +1619,7 @@
|
||||
"mod_publish": "انتشار",
|
||||
"mod_hide": "پنهانسازی",
|
||||
"mod_reject": "رد",
|
||||
"mod_confirm_publish": "این نظر منتشر شود؟ عمومی میشود و امتیاز پرستار بازمحاسبه میگردد.",
|
||||
"mod_confirm_publish": "این نظر منتشر شود؟ عمومی میشود و امتیاز پرستار بازمحاسبه میشود.",
|
||||
"mod_confirm_hide": "این نظر با دلیل واردشده پنهان شود؟",
|
||||
"mod_confirm_reject": "این نظر با دلیل واردشده رد شود؟",
|
||||
"mod_done": "نظر بهروزرسانی شد.",
|
||||
@@ -1638,11 +1638,12 @@
|
||||
"cfg_json_error": "JSON نامعتبر است.",
|
||||
"cfg_empty_error": "مقدار الزامی است.",
|
||||
"cfg_save_confirm_title": "این تغییر پیکربندی ذخیره شود؟",
|
||||
"cfg_save_confirm_body": "این تغییر ممیزی میشود و بلافاصله اعمال میگردد. رزروها و ثبتهای مالی قبلی را بازنمیگرداند.",
|
||||
"cfg_save_confirm_body": "این تغییر ممیزی میشود و بلافاصله اعمال میشود. رزروها و ثبتهای مالی قبلی را بازنمیگرداند.",
|
||||
"cfg_saved": "پیکربندی ذخیره شد.",
|
||||
"cfg_history": "تاریخچهٔ تغییرات",
|
||||
"cfg_history_title": "تاریخچه — {key}",
|
||||
"cfg_history_change": "{old} ← {new}",
|
||||
"cfg_history_change_old": "{old}",
|
||||
"cfg_history_change_new": "{new}",
|
||||
"cfg_history_empty": "هنوز تغییری ثبت نشده.",
|
||||
"cfg_group_fees": "کارمزد و مالیات",
|
||||
"cfg_group_deadlines": "مهلتها و پنجرهها",
|
||||
@@ -1665,7 +1666,7 @@
|
||||
"hol_year": "سال",
|
||||
"alert_title": "هشدارهای پشتیبانی",
|
||||
"alert_subtitle": "صف داخلی — هرگز به مشتری یا پرستار نمایش داده نمیشود.",
|
||||
"alert_empty": "هشدار بازی وجود ندارد.",
|
||||
"alert_empty": "هشداری برای رسیدگی نیست.",
|
||||
"alert_col_type": "نوع",
|
||||
"alert_col_entity": "مرتبط با",
|
||||
"alert_col_owner": "مسئول",
|
||||
@@ -1801,7 +1802,7 @@
|
||||
"step_pending": "در انتظار",
|
||||
"step_in_review": "در حال بررسی",
|
||||
"step_passed": "تأییدشده",
|
||||
"step_failed": "ناموفق",
|
||||
"step_failed": "ردشده",
|
||||
"step_expired": "منقضی",
|
||||
"step_identity_kyc": "احراز هویت",
|
||||
"step_shahkar_match": "تطبیق شاهکار",
|
||||
@@ -1897,7 +1898,7 @@
|
||||
"bookings_col_date": "تاریخ",
|
||||
"bookings_col_status": "وضعیت",
|
||||
"bstatus_pending_payment": "در انتظار پرداخت",
|
||||
"bstatus_confirmed": "تایید شده",
|
||||
"bstatus_confirmed": "تأییدشده",
|
||||
"bstatus_in_progress": "در حال انجام",
|
||||
"bstatus_completed": "تکمیلشده",
|
||||
"bstatus_disputed": "مورد اختلاف",
|
||||
@@ -1948,12 +1949,12 @@
|
||||
"terms_title": "شرایط استفاده",
|
||||
"privacy_title": "حریم خصوصی",
|
||||
"draft_banner": "این متن پیشنویس است و هنوز توسط تیم حقوقی بازبینی نشده؛ پیش از انتشار نهایی قابل استناد نیست.",
|
||||
"terms_intro": "این شرایط پیشنویس، نحوه ارتباط بالینیار بین خانوادهها و پرستاران مستقل و تاییدشده مراقبت در منزل را توضیح میدهد. با ساخت حساب کاربری، شرایط زیر را میپذیرید.",
|
||||
"terms_intro": "این شرایط پیشنویس، نحوه ارتباط بالینیار بین خانوادهها و پرستاران مستقل و تأییدشده مراقبت در منزل را توضیح میدهد. با ساخت حساب کاربری، شرایط زیر را میپذیرید.",
|
||||
"terms_sections": [
|
||||
{ "title": "ماهیت خدمت", "body": "بالینیار یک بازارگاه است و پرستاران را استخدام نمیکند. پرستاران مستقل یا شاغل در مراکز پرستاری، خدمات خود را ثبت میکنند و خانوادهها از طریق پلتفرم جستوجو، رزرو و پرداخت انجام میدهند." },
|
||||
{ "title": "رزرو و پرداخت", "body": "مبلغ کامل رزرو را از طریق بالینیار و با کارت پرداخت میکنید. این مبلغ بهصورت امانی نزد بالینیار نگهداری میشود و تنها پس از تایید انجام خدمت و پایان مهلت اعتراض، بهصورت هفتگی به پرستار پرداخت میشود." },
|
||||
{ "title": "لغو و بازگشت وجه", "body": "لغو یک رزرو تاییدشده، بسته به فاصله زمانی تا زمان مراجعه، ممکن است مشمول کارمزد شود که پیش از تایید نهایی به شما نمایش داده میشود. مبالغ بازگشتی تاییدشده به همان روش پرداخت اصلی یا ارائهدهنده مربوطه بازمیگردد." },
|
||||
{ "title": "احراز هویت پرستاران", "body": "هر پرستار پیش از قابلرزرو شدن، مراحل احراز هویت، بررسی پروانه صلاحیت حرفهای و سایر مراحل الزامی را میگذراند. آنچه تایید شده در پروفایل او نمایش داده میشود." },
|
||||
{ "title": "ماهیت خدمت", "body": "بالینیار یک بازارگاه است و پرستاران را استخدام نمیکند. پرستاران مستقل یا شاغل در مراکز پرستاری، خدمات خود را ثبت میکنند و خانوادهها از طریق پلتفرم جستجو، رزرو و پرداخت انجام میدهند." },
|
||||
{ "title": "رزرو و پرداخت", "body": "مبلغ کامل رزرو را از طریق بالینیار و با کارت پرداخت میکنید. این مبلغ بهصورت امانی نزد بالینیار نگهداری میشود و تنها پس از تأیید انجام خدمت و پایان مهلت اعتراض، بهصورت هفتگی به پرستار پرداخت میشود." },
|
||||
{ "title": "لغو و بازگشت وجه", "body": "لغو یک رزرو تأییدشده، بسته به فاصله زمانی تا زمان مراجعه، ممکن است مشمول کارمزد شود که پیش از تأیید نهایی به شما نمایش داده میشود. مبالغ بازگشتی تأییدشده به همان روش پرداخت اصلی یا ارائهدهنده مربوطه بازمیگردد." },
|
||||
{ "title": "احراز هویت پرستاران", "body": "هر پرستار پیش از قابلرزرو شدن، مراحل احراز هویت، بررسی پروانه صلاحیت حرفهای و سایر مراحل الزامی را میگذراند. آنچه تأیید شده در پروفایل او نمایش داده میشود." },
|
||||
{ "title": "مسئولیتهای شما", "body": "اطلاعات دقیق درباره فرد دریافتکننده مراقبت ارائه دهید، برای هر موضوع مرتبط با رزرو از طریق سامانه تیکت پشتیبانی اپلیکیشن ارتباط بگیرید و با پرستاران محترمانه رفتار کنید." },
|
||||
{ "title": "مسئولیتپذیری", "body": "بالینیار واسط رزرو بین خانوادهها و پرستاران مستقل است و خود ارائهدهنده خدمات درمانی نیست. اختلافات از طریق سامانه تیکت پشتیبانی رسیدگی میشود." },
|
||||
{ "title": "تغییر این شرایط", "body": "ممکن است این شرایط با تحول خدمت بهروزرسانی شود. تغییرات مهم پیش از اعمال، در اپلیکیشن اطلاعرسانی میشود." },
|
||||
|
||||
+2
-1
@@ -9,11 +9,12 @@
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Lints client/messages/fa.json against the banned-orthography-variant rules in
|
||||
* client/messages/STYLE.md — enforces the phase-12 sweep so it cannot silently regress.
|
||||
* Exits non-zero (and prints every offending key) on any match.
|
||||
*/
|
||||
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();
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Suspense, useRef } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Avatar, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { Avatar, Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import {
|
||||
AppButton,
|
||||
AppIcon,
|
||||
@@ -159,70 +159,7 @@ function CheckoutScreen() {
|
||||
? t('initiate_failed')
|
||||
: null;
|
||||
|
||||
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>
|
||||
|
||||
<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: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<CountdownTimer
|
||||
deadlineIso={summary.paymentDeadlineAt}
|
||||
label={tb('payment_countdown_label')}
|
||||
elapsedText={tb('payment_elapsed')}
|
||||
urgent
|
||||
onElapsed={() => refetch()}
|
||||
/>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<PriceBreakdown
|
||||
rows={[
|
||||
{
|
||||
key: 'service_cost',
|
||||
// Quantity context per the wireframe («هزینه خدمت (۸ ساعت)») — the visit count is the only
|
||||
// quantity that always matches the charged gross (variant price × session count).
|
||||
label: t('row_service_cost_with_count', { count: summary.sessionCount }),
|
||||
amountIrr: summary.serviceCostIrr,
|
||||
},
|
||||
{ key: 'commission', label: t('row_commission'), amountIrr: summary.commissionIrr },
|
||||
{ key: 'vat', label: t('row_vat'), amountIrr: summary.vatIrr },
|
||||
]}
|
||||
totalLabel={t('row_total')}
|
||||
totalAmountIrr={summary.totalIrr}
|
||||
/>
|
||||
|
||||
<EscrowExplainer />
|
||||
|
||||
{/* Spacer so the sticky bar never overlaps the last scrollable content on a short viewport. */}
|
||||
<Stack sx={{ pb: 1 }} />
|
||||
|
||||
<StickyActionBar>
|
||||
const payActions = (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{inlineError ? (
|
||||
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
|
||||
@@ -270,7 +207,95 @@ function CheckoutScreen() {
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
</StickyActionBar>
|
||||
);
|
||||
|
||||
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: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<CountdownTimer
|
||||
deadlineIso={summary.paymentDeadlineAt}
|
||||
label={tb('payment_countdown_label')}
|
||||
elapsedText={tb('payment_elapsed')}
|
||||
urgent
|
||||
onElapsed={() => refetch()}
|
||||
/>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<PriceBreakdown
|
||||
rows={[
|
||||
{
|
||||
key: 'service_cost',
|
||||
// Quantity context per the wireframe («هزینه خدمت (۸ ساعت)») — the visit count is the only
|
||||
// quantity that always matches the charged gross (variant price × session count).
|
||||
label: t('row_service_cost_with_count', { count: summary.sessionCount }),
|
||||
amountIrr: summary.serviceCostIrr,
|
||||
},
|
||||
{ key: 'commission', label: t('row_commission'), amountIrr: summary.commissionIrr },
|
||||
{ key: 'vat', label: t('row_vat'), amountIrr: summary.vatIrr },
|
||||
]}
|
||||
totalLabel={t('row_total')}
|
||||
totalAmountIrr={summary.totalIrr}
|
||||
/>
|
||||
|
||||
<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: 2, 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ export default function BookingRequestStatusPage() {
|
||||
color="secondary"
|
||||
variant="contained"
|
||||
size="large"
|
||||
endIcon="payment"
|
||||
endIcon="forward"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.CHECKOUT}?request_id=${request.id}`)}
|
||||
sx={{ py: 1.25 }}
|
||||
>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { Suspense, useCallback, useMemo, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Chip, Stack, Typography } from '@mui/material';
|
||||
import { Box, Chip, Stack, Typography } from '@mui/material';
|
||||
import type { SxProps, Theme } from '@mui/material';
|
||||
import { AppButton, AppLoading, EmptyState, ErrorState, NurseResultCard } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useServiceCategories } from '@/services/catalog';
|
||||
@@ -15,6 +16,15 @@ 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),
|
||||
@@ -123,17 +133,19 @@ function ResultsScreen() {
|
||||
</Stack>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Box sx={RESULTS_GRID_SX}>
|
||||
{[0, 1, 2, 3].map((key) => (
|
||||
<NurseResultCard.Skeleton key={key} />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : isError ? (
|
||||
<ErrorState message={t('results_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />
|
||||
) : items.length === 0 ? (
|
||||
<RelaxFiltersEmptyState onRelax={backToFilters} />
|
||||
) : (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
// Above ~900px (`md`), a two-column grid uses the extra width instead of one long phone-column
|
||||
// (the doc's "wider cards" option — a full list+detail split is DEFERRED, see the phase doc).
|
||||
<Box sx={RESULTS_GRID_SX}>
|
||||
{items.map((nurse) => (
|
||||
<NurseResultCard
|
||||
key={`${nurse.nurseId}-${nurse.variantId}`}
|
||||
@@ -148,12 +160,12 @@ function ResultsScreen() {
|
||||
color="primary"
|
||||
onClick={() => setPageSize((size) => size + SEARCH_PAGE_SIZE)}
|
||||
disabled={isFetching}
|
||||
sx={{ alignSelf: 'center' }}
|
||||
sx={{ alignSelf: 'center', gridColumn: '1 / -1' }}
|
||||
>
|
||||
{t('load_more')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -230,9 +230,15 @@ function ConfigHistoryDrawer({ configKey, onClose }: { configKey: string | null;
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{history.data?.items.map((change) => (
|
||||
<Box key={change.id} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1.5 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 700 }}>
|
||||
{t('cfg_history_change', { old: change.oldValue ?? '—', new: change.newValue ?? '—' })}
|
||||
{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}` : ''}
|
||||
|
||||
@@ -28,6 +28,7 @@ import type { TicketAuthorRole, TicketStatus } from '@/services/tickets/types';
|
||||
|
||||
/** Status → chip color: an open ticket is pending work, a closed one is neutral (mirrors the queue). */
|
||||
const STATUS_KIND: Record<TicketStatus, StatusKind> = { open: 'pending', closed: 'neutral' };
|
||||
const REFUND_PANEL_ID = 'admin-ticket-refund-panel';
|
||||
|
||||
/** The `tickets` author-label key. `admin` has no `author_admin` key — staff read as "support" (`author_support`). */
|
||||
function authorLabelKey(role: TicketAuthorRole): string {
|
||||
@@ -191,12 +192,18 @@ export default function AdminTicketThreadPage() {
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon="refunds"
|
||||
aria-expanded={refundShown}
|
||||
aria-controls={REFUND_PANEL_ID}
|
||||
onClick={() => setRefundShown((v) => !v)}
|
||||
>
|
||||
{t('refund_open')}
|
||||
</AppButton>
|
||||
<Collapse in={refundShown} unmountOnExit>
|
||||
<Paper elevation={0} sx={{ mt: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Paper
|
||||
id={REFUND_PANEL_ID}
|
||||
elevation={0}
|
||||
sx={{ mt: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<RefundPanel bookingId={detail.bookingId as number} ticketId={detail.id} />
|
||||
</Paper>
|
||||
</Collapse>
|
||||
|
||||
@@ -283,7 +283,9 @@ function NotificationsEntryRow() {
|
||||
</Stack>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: unread > 0 ? 'var(--bal-secondary)' : 'text.secondary', fontWeight: unread > 0 ? 700 : 400 }}
|
||||
// --bal-secondary-dark, not --bal-secondary: the plain terracotta fails AA contrast for
|
||||
// small text on a light surface (frontend-designer skill §2) — this is body copy, not an icon.
|
||||
sx={{ color: unread > 0 ? 'var(--bal-secondary-dark)' : 'text.secondary', fontWeight: unread > 0 ? 700 : 400 }}
|
||||
>
|
||||
{unread > 0 ? t('notifications_entry_unread', { count: unread }) : t('notifications_entry_empty')}
|
||||
</Typography>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { Box, ButtonBase, Collapse, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
|
||||
import { AppIcon, EarningsBalanceHeader, EarningsRow, EmptyState, ErrorState, Money, Pager, SurfaceCard } from '@/components';
|
||||
import { CONTENT_MAX_WIDTH } from '@/components/config';
|
||||
import { nurseBookingDetailPath, nursePayoutDetailPath } from '@/constants';
|
||||
import { DISPUTE_WINDOW_HOURS, nurseBookingDetailPath, nursePayoutDetailPath } from '@/constants';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
|
||||
import { EARNINGS_STATES, type EarningsState } from '@/services/payouts/types';
|
||||
@@ -187,7 +187,7 @@ function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void
|
||||
<Stack component="ul" sx={{ gap: 0.75, mt: 1.5, mb: 0, pl: 2.5 }}>
|
||||
{points.map((key) => (
|
||||
<Typography key={key} component="li" variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t(key)}
|
||||
{key === 'explainer_point_2' ? t(key, { hours: DISPUTE_WINDOW_HOURS }) : t(key)}
|
||||
</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -28,3 +28,42 @@ a {
|
||||
[dir='rtl'] [data-icon-directional] {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
/*
|
||||
* App-wide motion pass (phase 12) — one restrained language: a calm 150–200ms fade + small slide on
|
||||
* route-group content and skeleton→content swaps. `key`-triggered remounts (RouteFadeIn) or a plain
|
||||
* mount (a skeleton branch replaced by a populated one) both replay this the same way — there is
|
||||
* exactly one animation definition for both cases, never a per-screen bespoke one.
|
||||
*/
|
||||
@keyframes bal-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
[data-bal-route-fade] {
|
||||
animation: bal-fade-in var(--bal-motion-base) var(--bal-easing-standard);
|
||||
}
|
||||
|
||||
/*
|
||||
* The single reduced-motion gate for the whole app (client/CLAUDE.md "Golden rules" — CSS over JS for
|
||||
* theme mechanics). This is the ONE place `prefers-reduced-motion: reduce` is handled — every animation
|
||||
* and transition this phase adds (the route fade above, MUI's own Dialog/Drawer/Menu/Collapse/Fade
|
||||
* transitions, the admin config-history/ExplainerCard/EscrowExplainer chevron rotations) collapses to
|
||||
* an effectively-instant appearance. Do not add a second, component-local reduced-motion check anywhere
|
||||
* else — extend this rule instead if a new motion primitive needs the same treatment.
|
||||
*/
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import StatusChip from '@/components/StatusChip';
|
||||
import AppAlert from '@/components/common/AppAlert';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
import RefundEtaBanner from '@/components/RefundEtaBanner';
|
||||
import { CANCELLATION_LEAD_HOURS } from '@/constants';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import type { CancellationPolicyPreview } from '@/services/refunds/types';
|
||||
|
||||
@@ -50,7 +51,7 @@ const CancellationPolicyDisclosure: FunctionComponent<CancellationPolicyDisclosu
|
||||
/>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t(`lead_${preview.leadTimeLabel}`)}
|
||||
{t(`lead_${preview.leadTimeLabel}`, { hours: CANCELLATION_LEAD_HOURS })}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-money-emphasis)', fontWeight: 500 }}>
|
||||
{t('fee_percent', { percent: feePercent })}
|
||||
|
||||
@@ -61,8 +61,9 @@ const CLOCK_ICON_SIZE: Record<CountdownTimerSize, number> = { sm: 16, md: 20, lg
|
||||
* v2 adds, all opt-in and backward compatible: a progress-ring variant (`windowStart`), three urgency
|
||||
* tiers (calm teal → amber → terracotta, via thresholds — `urgent` still force-overrides for the two
|
||||
* existing payment-window consumers), and a humanized coarse mode above `coarseThresholdSeconds`
|
||||
* (`coarseLabel`) with `aria-live="polite"` — applied only in coarse mode, since the fine ticking clock
|
||||
* updates every second and would spam assistive tech if announced.
|
||||
* (`coarseLabel`) with `aria-live="polite"` — deliberately **not** on the fine ticking clock (every
|
||||
* second would spam assistive tech), but present on the coarse label and on the one-time elapsed
|
||||
* transition (`elapsedText`), the two moments actually worth announcing.
|
||||
* @component CountdownTimer
|
||||
*/
|
||||
const CountdownTimer: FunctionComponent<CountdownTimerProps> = ({
|
||||
@@ -121,7 +122,11 @@ const CountdownTimer: FunctionComponent<CountdownTimerProps> = ({
|
||||
: undefined;
|
||||
|
||||
const content = elapsed ? (
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', color: 'text.secondary' }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ gap: 0.75, alignItems: 'center', color: 'text.secondary' }}
|
||||
aria-live="polite"
|
||||
>
|
||||
<AppIcon icon="schedule" size={CLOCK_ICON_SIZE[size]} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{elapsedText}
|
||||
|
||||
@@ -25,6 +25,8 @@ const STEPS: ExplainerStep[] = [
|
||||
* alert line at the moment of maximum skepticism.
|
||||
* @component EscrowExplainer
|
||||
*/
|
||||
const EXPLAINER_CONTENT_ID = 'escrow-explainer-content';
|
||||
|
||||
const EscrowExplainer: FunctionComponent = () => {
|
||||
const t = useTranslations('payment');
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -38,6 +40,7 @@ const EscrowExplainer: FunctionComponent = () => {
|
||||
size="small"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-controls={EXPLAINER_CONTENT_ID}
|
||||
endIcon={
|
||||
<Box
|
||||
component="span"
|
||||
@@ -50,7 +53,7 @@ const EscrowExplainer: FunctionComponent = () => {
|
||||
>
|
||||
{t('escrow_explainer_toggle')}
|
||||
</AppButton>
|
||||
<Collapse in={open}>
|
||||
<Collapse in={open} id={EXPLAINER_CONTENT_ID}>
|
||||
<Stack sx={{ gap: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
|
||||
@@ -20,12 +20,15 @@ export interface PaymentStateCardProps {
|
||||
* private `MessageCard`/`StateCard` functions (`checkout/page.tsx` + `bnpl/page.tsx`,
|
||||
* `checkout/return/page.tsx` + `bnpl/return/page.tsx`) so the two flows can no longer visually drift.
|
||||
* Presentational, caller-owned i18n; `children` is the actions slot (a single CTA or a stack of them).
|
||||
* `aria-live="polite"` — this card also carries the payment-status-poll's pending→succeeded/failed
|
||||
* transition (money's most anxious wait), so the outcome is announced without the user re-focusing it.
|
||||
* @component PaymentStateCard
|
||||
*/
|
||||
const PaymentStateCard: FunctionComponent<PaymentStateCardProps> = ({ icon, tone, title, body, children }) => (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-payment-state-card
|
||||
aria-live="polite"
|
||||
sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import AppAlert from '@/components/common/AppAlert';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
import { REFUND_ETA_MAX_BUSINESS_DAYS, REFUND_ETA_MIN_BUSINESS_DAYS } from '@/constants';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import type { RefundChannel } from '@/services/refunds/types';
|
||||
|
||||
@@ -53,7 +54,10 @@ const RefundEtaBanner: FunctionComponent<RefundEtaBannerProps> = ({ channel, eta
|
||||
<Typography variant="body2">{t(copy.bodyKey)}</Typography>
|
||||
{channel === 'bnpl_revert' && (
|
||||
<Typography variant="caption" sx={{ fontWeight: 500 }} data-testid="refund-eta-window">
|
||||
{t('eta_business_days')}
|
||||
{t('eta_business_days', {
|
||||
minDays: REFUND_ETA_MIN_BUSINESS_DAYS,
|
||||
maxDays: REFUND_ETA_MAX_BUSINESS_DAYS,
|
||||
})}
|
||||
{eta ? ` · ${t('eta_expected_label', { date: formatShamsiDate(eta, locale) })}` : ''}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CircularProgress, Link as MuiLink, Stack, Typography } from '@mui/mater
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
|
||||
import PhoneNumberField, { isIranianMobile } from '@/components/PhoneNumberField';
|
||||
import { AppButton, AppLink } from '@/components';
|
||||
import { AppButton, AppIcon, AppLink } from '@/components';
|
||||
import { APP_ROLES, ROUTES, type AppRole } from '@/constants';
|
||||
import { useRequestOtp } from '@/services/auth';
|
||||
import type { ApiError } from '@/lib/api/errors';
|
||||
@@ -102,9 +102,18 @@ const PhoneStep: FunctionComponent<PhoneStepProps> = ({ intendedRole, onSwitchRo
|
||||
type="button"
|
||||
onClick={onSwitchRole}
|
||||
underline="hover"
|
||||
sx={{ color: 'text.secondary', textAlign: 'center' }}
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
textAlign: 'center',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 0.5,
|
||||
mx: 'auto',
|
||||
}}
|
||||
>
|
||||
{isNurse ? t('customer_switch') : t('nurse_switch')}
|
||||
<AppIcon icon="forward" size={14} />
|
||||
</MuiLink>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -126,4 +126,16 @@ describe('<AppIconButton/> component', () => {
|
||||
expect(tooltip).toHaveTextContent(title);
|
||||
expect(tooltip).toHaveClass('MuiTooltip-popper');
|
||||
});
|
||||
|
||||
it('keeps an accessible name from .title even when disabled (no Tooltip is rendered)', () => {
|
||||
const testId = randomText(8);
|
||||
const title = randomText(16);
|
||||
render(<ComponentToTest data-testid={testId} title={title} disabled />);
|
||||
|
||||
const button = screen.getByTestId(testId);
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveAttribute('aria-label', title);
|
||||
// No Tooltip popper mounted for a disabled button — the name comes from the button itself.
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,6 +75,10 @@ const AppIconButton: FunctionComponent<AppIconButtonProps> = ({
|
||||
component={componentToRender}
|
||||
color={colorToRender}
|
||||
disabled={disabled}
|
||||
// Named directly on the button (not only via the Tooltip wrap below) so an icon-only button
|
||||
// keeps an accessible name even when disabled, when the Tooltip isn't rendered at all. A
|
||||
// caller-supplied `aria-label` in restOfProps still wins (spread last).
|
||||
aria-label={title}
|
||||
sx={sxToRender}
|
||||
{...restOfProps}
|
||||
>
|
||||
@@ -82,7 +86,7 @@ const AppIconButton: FunctionComponent<AppIconButtonProps> = ({
|
||||
{children}
|
||||
</IconButton>
|
||||
);
|
||||
}, [color, componentToRender, children, disabled, icon, isMuiColor, sx, iconProps, restOfProps]);
|
||||
}, [color, componentToRender, children, disabled, icon, isMuiColor, sx, iconProps, restOfProps, title]);
|
||||
|
||||
// When title is set, wrap the IconButton with Tooltip.
|
||||
// Note: when IconButton is disabled the Tooltip is not working, so we don't need it
|
||||
|
||||
@@ -53,7 +53,10 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, State> {
|
||||
if (this.state.hasError) {
|
||||
const { error, errorInfo } = this.state;
|
||||
return (
|
||||
<Stack sx={{ alignItems: 'center', justifyContent: 'center', gap: 1.5, py: 6, px: 2, textAlign: 'center' }}>
|
||||
<Stack
|
||||
role="alert"
|
||||
sx={{ alignItems: 'center', justifyContent: 'center', gap: 1.5, py: 6, px: 2, textAlign: 'center' }}
|
||||
>
|
||||
<AppIcon icon="warning" size={36} color="var(--bal-warning)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{this.props.title}
|
||||
|
||||
@@ -21,13 +21,16 @@ export interface ErrorStateProps {
|
||||
* The calm, branded "something went wrong" panel with a **required** retry affordance — the fix for the
|
||||
* false-empty class of defect (a failed query rendering as "no data" instead of an error). `onRetry` is
|
||||
* mandatory by the type; there is no way to render this component without a way back. Presentational,
|
||||
* caller-owned i18n throughout — everything is already-translated copy.
|
||||
* caller-owned i18n throughout — everything is already-translated copy. `role="alert"` (implicit
|
||||
* `aria-live="assertive"`) announces the failure to assistive tech without a per-call-site aria-live —
|
||||
* used at ~55 sites, this one change covers every query-failed moment in the app.
|
||||
* @component ErrorState
|
||||
*/
|
||||
const ErrorState: FunctionComponent<ErrorStateProps> = ({ message, retryLabel, onRetry }) => (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-error-state
|
||||
role="alert"
|
||||
sx={{
|
||||
p: { xs: 3, sm: 5 },
|
||||
textAlign: 'center',
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
|
||||
let pathname = '/nurse/earnings';
|
||||
|
||||
jest.mock('@/i18n/navigation', () => ({
|
||||
usePathname: () => pathname,
|
||||
}));
|
||||
|
||||
import RouteFadeIn from './RouteFadeIn';
|
||||
|
||||
describe('<RouteFadeIn/> component', () => {
|
||||
beforeEach(() => {
|
||||
pathname = '/nurse/earnings';
|
||||
});
|
||||
|
||||
it('renders its children', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<RouteFadeIn>
|
||||
<span>content</span>
|
||||
</RouteFadeIn>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByText('content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks the wrapper with the shared fade-in animation hook', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<RouteFadeIn>
|
||||
<span>content</span>
|
||||
</RouteFadeIn>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByText('content').parentElement).toHaveAttribute('data-bal-route-fade');
|
||||
});
|
||||
|
||||
it('remounts (and would replay the animation) when the route changes', () => {
|
||||
const { rerender } = render(
|
||||
<ThemeProvider>
|
||||
<RouteFadeIn>
|
||||
<span data-testid="marker">a</span>
|
||||
</RouteFadeIn>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
const first = screen.getByTestId('marker');
|
||||
|
||||
pathname = '/nurse/visits';
|
||||
rerender(
|
||||
<ThemeProvider>
|
||||
<RouteFadeIn>
|
||||
<span data-testid="marker">b</span>
|
||||
</RouteFadeIn>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByTestId('marker')).not.toBe(first);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
import { FunctionComponent, PropsWithChildren } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { usePathname } from '@/i18n/navigation';
|
||||
|
||||
/**
|
||||
* The one route/content-transition primitive for the app-wide motion pass: a calm 150–200ms fade +
|
||||
* small slide (the `bal-fade-in` keyframe in `globals.css`) that plays once when its subtree mounts.
|
||||
* Keyed on the locale-stripped pathname so React remounts (and replays the animation) on navigation,
|
||||
* but never on an in-place re-render (a refetch, a state update) — motion marks a *route change*, not
|
||||
* "data changed". The keyframe itself carries no reduced-motion branch; the single app-wide gate in
|
||||
* `globals.css` handles that for every consumer, this one included.
|
||||
* @component RouteFadeIn
|
||||
*/
|
||||
const RouteFadeIn: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<Box key={pathname} data-bal-route-fade sx={{ minWidth: 0 }}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RouteFadeIn;
|
||||
@@ -0,0 +1,3 @@
|
||||
import RouteFadeIn from './RouteFadeIn';
|
||||
|
||||
export default RouteFadeIn;
|
||||
@@ -22,6 +22,7 @@ import StickyActionBar from './StickyActionBar';
|
||||
import Pager from './Pager';
|
||||
import InitialsAvatar from './InitialsAvatar';
|
||||
import FormDialogShell from './FormDialogShell';
|
||||
import RouteFadeIn from './RouteFadeIn';
|
||||
|
||||
export {
|
||||
ErrorBoundary,
|
||||
@@ -48,6 +49,7 @@ export {
|
||||
Pager,
|
||||
InitialsAvatar,
|
||||
FormDialogShell,
|
||||
RouteFadeIn,
|
||||
};
|
||||
export type { EmptyStateProps } from './EmptyState';
|
||||
export type { ErrorStateProps } from './ErrorState';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './headers';
|
||||
export * from './policy';
|
||||
export * from './roles';
|
||||
export * from './routes';
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Policy numbers that appear in trust-critical, legally-sensitive copy (dispute window, cancellation
|
||||
* lead time, refund ETA) but are, in reality, server config (`platform_config` — the `cfg_group_deadlines`
|
||||
* / `cfg_group_cancellation` rows the admin config editor already edits). No public/authenticated
|
||||
* config read exists yet for a non-admin caller to consume — only the admin-scoped `platform_config/*`
|
||||
* routes do — so these constants are the single source until REQ-065 (a public policy-config read)
|
||||
* lands. Message keys take these as ICU params (`{hours}`, `{minDays}`/`{maxDays}`) instead of baking
|
||||
* the numbers into the string, so a future server-served value is a one-line source change here, never
|
||||
* a hunt through translated copy.
|
||||
*/
|
||||
|
||||
/** The nurse payout dispute window: `payouts.explainer_point_2` (booking `dispute_window_ends_at`). */
|
||||
export const DISPUTE_WINDOW_HOURS = 72;
|
||||
|
||||
/** The cancellation lead-time tier boundary: `refunds.lead_gt_24h` / `refunds.lead_lt_24h`. */
|
||||
export const CANCELLATION_LEAD_HOURS = 24;
|
||||
|
||||
/** The BNPL-revert refund ETA window: `refunds.eta_business_days`. */
|
||||
export const REFUND_ETA_MIN_BUSINESS_DAYS = 7;
|
||||
export const REFUND_ETA_MAX_BUSINESS_DAYS = 10;
|
||||
@@ -2,7 +2,7 @@
|
||||
import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
|
||||
import { Badge, Box, Stack, Tab, Tabs } from '@mui/material';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppIcon, AppIconButton, ErrorBoundary } from '@/components';
|
||||
import { AppIcon, AppIconButton, ErrorBoundary, RouteFadeIn } from '@/components';
|
||||
import { NotificationBell } from '@/components/notifications';
|
||||
import { CONTENT_MAX_WIDTH } from '@/components/config';
|
||||
import { ROUTES } from '@/constants';
|
||||
@@ -138,7 +138,7 @@ const CustomerLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
body={tChrome('error_body')}
|
||||
retryLabel={tChrome('error_retry')}
|
||||
>
|
||||
{children}
|
||||
<RouteFadeIn>{children}</RouteFadeIn>
|
||||
</ErrorBoundary>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { FunctionComponent, PropsWithChildren } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Stack } from '@mui/material';
|
||||
import { AppIcon, ErrorBoundary } from '@/components';
|
||||
import { AppIcon, ErrorBoundary, RouteFadeIn } from '@/components';
|
||||
|
||||
/**
|
||||
* Chrome-free shell for a focused, can't-tab-away flow — today only first-run onboarding
|
||||
@@ -27,7 +27,7 @@ const FocusedLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
body={tChrome('error_body')}
|
||||
retryLabel={tChrome('error_retry')}
|
||||
>
|
||||
{children}
|
||||
<RouteFadeIn>{children}</RouteFadeIn>
|
||||
</ErrorBoundary>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { FunctionComponent, PropsWithChildren } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Stack } from '@mui/material';
|
||||
import { AppIcon, ErrorBoundary } from '@/components';
|
||||
import { AppIcon, ErrorBoundary, RouteFadeIn } from '@/components';
|
||||
import LocaleSwitcher from '@/components/common/LocaleSwitcher';
|
||||
import { DarkModeToggleButton } from './components/DarkModeButton';
|
||||
|
||||
@@ -36,7 +36,7 @@ const PublicLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
body={tChrome('error_body')}
|
||||
retryLabel={tChrome('error_retry')}
|
||||
>
|
||||
{children}
|
||||
<RouteFadeIn>{children}</RouteFadeIn>
|
||||
</ErrorBoundary>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { FunctionComponent, PropsWithChildren, ReactNode, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Box, Stack, useTheme } from '@mui/material';
|
||||
import { AppIconButton, ErrorBoundary } from '@/components';
|
||||
import { AppIconButton, ErrorBoundary, RouteFadeIn } from '@/components';
|
||||
import { LinkToPage } from '@/utils';
|
||||
import { TopBar } from './components';
|
||||
import SideBar from './components/SideBar';
|
||||
@@ -91,7 +91,7 @@ const TopBarAndSideBarLayout: FunctionComponent<PropsWithChildren<Props>> = ({
|
||||
body={tChrome('error_body')}
|
||||
retryLabel={tChrome('error_retry')}
|
||||
>
|
||||
{children}
|
||||
<RouteFadeIn>{children}</RouteFadeIn>
|
||||
</ErrorBoundary>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { REFUND_ETA_MAX_BUSINESS_DAYS } from '@/constants';
|
||||
import type { CancellationPolicyCode } from './types';
|
||||
|
||||
/**
|
||||
@@ -59,6 +60,7 @@ export const ADMIN_REFUND_PREVIEW_STALE_TIME = 10 * 1000;
|
||||
/**
|
||||
* The BNPL customer cash-back window the mock projects onto `expectedCustomerRefundEta` — the product's
|
||||
* ~7–10 business-day truth, Fridays skipped (see `cancellation-and-payout.md`). Surface it honestly;
|
||||
* never imply the money is back instantly.
|
||||
* never imply the money is back instantly. Sourced from the single policy-numbers file (`constants/policy.ts`)
|
||||
* so the mock's projected date and `refunds.eta_business_days`'s displayed window can never drift apart.
|
||||
*/
|
||||
export const BNPL_REFUND_ETA_BUSINESS_DAYS = 10;
|
||||
export const BNPL_REFUND_ETA_BUSINESS_DAYS = REFUND_ETA_MAX_BUSINESS_DAYS;
|
||||
|
||||
@@ -82,6 +82,10 @@ function createAppTheme(direction: 'ltr' | 'rtl') {
|
||||
},
|
||||
},
|
||||
MuiDrawer: {
|
||||
// Calm enter/exit for the side-nav + bottom-sheet uses (phase-12 motion pass) — same numbers as
|
||||
// MuiDialog below, one place; the app-wide reduced-motion gate (globals.css) collapses MUI's own
|
||||
// JS-driven transition durations regardless, so no per-drawer reduced-motion branch.
|
||||
defaultProps: { transitionDuration: { enter: 200, exit: 120 } },
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
backgroundColor: 'var(--bal-bg-default)',
|
||||
@@ -158,6 +162,10 @@ function createAppTheme(direction: 'ltr' | 'rtl') {
|
||||
},
|
||||
},
|
||||
MuiDialog: {
|
||||
// Calm enter/exit — one place for every dialog/bottom-sheet (phase-12 motion pass), mirroring
|
||||
// --bal-motion-base/-fast (tokens.css); the app-wide reduced-motion gate (globals.css) collapses
|
||||
// MUI's own JS-driven transition durations regardless, so no per-dialog reduced-motion branch.
|
||||
defaultProps: { transitionDuration: { enter: 200, exit: 120 } },
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
borderRadius: 'var(--bal-radius-lg)',
|
||||
@@ -166,6 +174,12 @@ function createAppTheme(direction: 'ltr' | 'rtl') {
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPopover: {
|
||||
defaultProps: { transitionDuration: { enter: 200, exit: 120 } },
|
||||
},
|
||||
MuiMenu: {
|
||||
defaultProps: { transitionDuration: { enter: 200, exit: 120 } },
|
||||
},
|
||||
MuiTabs: {
|
||||
styleOverrides: {
|
||||
indicator: {
|
||||
|
||||
@@ -51,6 +51,21 @@
|
||||
--bal-easing-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/*
|
||||
* Reduced motion — the token half of the single gate (globals.css carries the universal
|
||||
* transition/animation-duration override that makes this bite even for consumers, like MUI's own
|
||||
* Dialog/Drawer/Menu transitions, that don't read these custom properties directly). Zeroing the
|
||||
* durations here too means anything that DOES read the token (this file's own consumers, any future
|
||||
* one) needs no reduced-motion branch of its own.
|
||||
*/
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--bal-motion-fast: 0ms;
|
||||
--bal-motion-base: 0ms;
|
||||
--bal-motion-slow: 0ms;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Light scheme — the unconditional default ──────────────────────────── */
|
||||
:root,
|
||||
[data-mui-color-scheme='light'] {
|
||||
|
||||
@@ -1034,7 +1034,28 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
||||
primary, `USE_PARTNER_MOCK`); the real client call is written but unreachable until this route exists.
|
||||
- **Status:** open
|
||||
|
||||
## REQ-061 — Admin user lookup: name/phone search + batch id→label resolve — filed by ui-phase-11 — 2026-07-19
|
||||
## REQ-065 — Public/authenticated policy-config read (dispute window, cancellation tiers, refund ETA) — filed by ui-phase-12 — 2026-07-19
|
||||
- **Need:** A public or any-authenticated-caller read exposing the legally/financially sensitive policy
|
||||
numbers that trust-critical copy states in plain language: the nurse-payout dispute-window hours
|
||||
(currently 72), the cancellation lead-time tier boundary hours (currently 24), and the BNPL-revert
|
||||
refund ETA business-day range (currently 7–10). Proposed: `GET api/v1/platform_config/public` (or a
|
||||
narrower `.../policy_summary`) → `{ disputeWindowHours, cancellationLeadHours,
|
||||
refundEtaMinBusinessDays, refundEtaMaxBusinessDays }` — a read-only projection of the existing
|
||||
admin-only `platform_config` rows (`cfg_group_deadlines`/`cfg_group_cancellation`), not a new config
|
||||
system.
|
||||
- **Why:** §3.6 of the phase-12 copy sweep parameterized `payouts.explainer_point_2`,
|
||||
`refunds.lead_gt_24h`/`lead_lt_24h`, and `refunds.eta_business_days` to take `{hours}`/`{minDays}`/
|
||||
`{maxDays}` instead of hard-coding the numbers into the string — but at authoring time only the
|
||||
**admin-scoped** `platform_config/*` routes exist (verified against `dev/contracts/domains/`), so a
|
||||
non-admin nurse/customer client has nothing to read these numbers from. Until this lands, the numbers
|
||||
are single-sourced in `client/src/constants/policy.ts` (`DISPUTE_WINDOW_HOURS`,
|
||||
`CANCELLATION_LEAD_HOURS`, `REFUND_ETA_MIN_BUSINESS_DAYS`, `REFUND_ETA_MAX_BUSINESS_DAYS`) and fed into
|
||||
the three call sites (`CancellationPolicyDisclosure`, `RefundEtaBanner`, the nurse earnings
|
||||
`ExplainerCard`) as ICU params — a config edit today still requires a client deploy to stay honest,
|
||||
but at least the numbers live in exactly one file with a comment pointing at this REQ, not scattered
|
||||
across message strings.
|
||||
- **Proposed shape:** as above; every field a plain number matching the admin config's current value.
|
||||
- **Status:** open
|
||||
- **Need:** Two endpoints backing a directory of platform users for admin use:
|
||||
1. `GET admin_users/search?q=&role=` — search by name/phone (min 2 chars), optionally narrowed to one
|
||||
coarse role (`customer`/`nurse`/`admin`/`partner`), returning up to ~10 matches: `{ id, displayName,
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
# UI Phase 12 — Copy, motion & final polish — Report (2026-07-19)
|
||||
|
||||
This is the closing phase of the UI build chain (phases 0–12). Scope: a checked-in Persian style guide
|
||||
+ lint, the verified copy defects from the microcopy/cross-cutting audits, ICU plurals, arrows out of
|
||||
strings, a trust-copy pass, config-served policy numbers, a restrained app-wide motion language behind a
|
||||
single reduced-motion gate, an a11y sweep, two desktop-aware layouts, and this closing QA pass.
|
||||
|
||||
## What was built
|
||||
|
||||
### 3.1 — Persian style guide + enforcement
|
||||
- `client/messages/STYLE.md` — the binding one-pager: brand spelling (ZWNJ), تأیید hamza, one جستجو
|
||||
form, ZWNJ rules, punctuation, domain glossary (بیمار not مددجو), the shell-naming system
|
||||
(اپلیکیشن for end-user shells, کنسول for back-office), the verification-pipeline-vs-KYC-step naming
|
||||
split, a nurse-facing/admin-facing status-vocabulary rule, digits policy, register, and the
|
||||
policy-number-interpolation rule.
|
||||
- `client/scripts/check-copy.mjs` — a ~100-line dependency-free Node script that flattens `fa.json` and
|
||||
greps every leaf string against 5 banned-variant rules (plain-space brand, hamza-less تایید, جستوجو/جست
|
||||
و جو, the «بازی» word-boundary trap, the archaic میگردد passive — with a leading-space anchor so the
|
||||
legitimate verb «برمیگردد» is never a false positive). Wired as `npm run lint:copy`, folded into
|
||||
`npm run check`. Currently: **1919 strings checked, 0 banned variants.**
|
||||
|
||||
### 3.2/hamza/جستجو/میگردد sweep — ~90 keys swept across `fa.json`
|
||||
- Brand name: 5 keys (`common.brand`, `auth.customer_title`/`select_role_title`/`account_error_body`,
|
||||
`verification.start_body`) — plain-space → ZWNJ.
|
||||
- تأیید hamza: ~65 occurrences normalized to the hamza form across `home`, `profile`, `nurseProfile`,
|
||||
`bank`, `activation`, `search`, `booking`, `payment`, `auth`, `verification`, `refunds`, `bnpl`,
|
||||
`payouts`, `admin`, `partner`, `legal`. `bank.status_verified_chip`'s typo («تاییدشد» missing the final
|
||||
ه) is fixed to «تأییدشده» in the same edit.
|
||||
- جستجو: 3 keys folded (`coverage.empty_warning`, `booking.missing_nurse_body`, one `legal` section body).
|
||||
- Archaic میگردد → میشود: `refunds.confirm_restate`, `admin.mod_confirm_publish`,
|
||||
`admin.cfg_save_confirm_body` (3 occurrences, matching the audit's own recount, not the "1" a naive grep
|
||||
would find).
|
||||
- Grammar bugs: `booking.evv_no_open_check_in` («ورود بازی» → «ورودِ ثبتشدهای برای این ویزیت وجود
|
||||
ندارد؛ ابتدا ورود را ثبت کنید.») and `admin.alert_empty` («هشدار بازی» → «هشداری برای رسیدگی نیست.»).
|
||||
- `tickets.thread_empty_body` comma splice → two sentences.
|
||||
- `address.line_hint` — **already fixed by ui-phase-9** (verified in its report); not re-touched.
|
||||
- EVV introduced once: `booking.evv_visits_subtitle` now spells out «ثبت حضور الکترونیکی (EVV) — ورود و
|
||||
خروج شما ثبت میشود تا ویزیت بدون اختلاف تأیید شود» (this subtitle renders once per visits-page
|
||||
load); the short chips (`evv_check_in`/`evv_check_out`) stay abbreviated.
|
||||
- en: `auth.nurse_subtitle` "licence" → "license"; 6 curly-apostrophe holdouts in the `admin` namespace
|
||||
(`no_permission`, `doc_error`, `payout_run_confirm_body`, `mod_confirm_publish`, `access_denied`,
|
||||
`invoice_pdf_error`) → straight quotes. Zero curly quotes remain (verified by grep).
|
||||
|
||||
### Shell naming + verification-pipeline naming (fa only — en had no overload)
|
||||
- `shell.nurse_app`/`switch_to_nurse`: «نمای پرستار» → «اپلیکیشن پرستار» (end-user shells share one
|
||||
metaphor); `shell.partner_console`: «پرتال همکار» → «کنسول همکار» (back-office shells share the other).
|
||||
`booking.evv_nurse_view` ("نمای پرستار" as a perspective chip label, not a shell name) is untouched —
|
||||
it's a different concept and STYLE.md calls out the distinction explicitly.
|
||||
- «احراز هویت» renamed to «تأیید صلاحیت» for the **pipeline** (11 keys: `nav.verification`,
|
||||
`nurseProfile.unverified_body`/`unverified_cta`, `verification.title`/`load_error`/`start_title`/
|
||||
`start_cta`/`progress_title`/`approved_title`/`credentials_needs_start`/`review_approved_title`,
|
||||
`admin.ver_title`/`ver_case_title`); `verification.explainer_not_verified` rephrased to «صلاحیت این
|
||||
پرستار هنوز تأیید نشده است.» for the same reason. The **KYC step** keeps «احراز هویت» everywhere
|
||||
(`verification.step_identity_kyc`, `admin.step_identity_kyc`, `activation.row_identity`) — the two no
|
||||
longer share a name, so a nurse who passed KYC no longer sees the pipeline nav item read as
|
||||
contradictorily incomplete under the same word.
|
||||
- Status vocabulary: `admin.step_failed` («ناموفق» → «ردشده») now matches admin's own
|
||||
`agg_rejected`/`rstatus_rejected`/`mstatus_rejected` pattern (all «ردشده»); nurse-facing
|
||||
`verification.status_failed` keeps «رد شد» (STYLE.md §9 — one nurse-facing form, one admin-facing
|
||||
form). Money-failure vocabulary (`ناموفق` for payouts/refunds/batches) is untouched — a payment
|
||||
*failing* is a different concept from a document being *rejected*.
|
||||
|
||||
### 3.3 — ICU plurals with designed `=0` cases
|
||||
- fa gained ICU plural + a real zero-state on the 4 keys the audit flagged:
|
||||
`search.cta_view_results`/`results_count`/`reviews_count`, `booking.session_count`. Zero renders
|
||||
«پرستاری یافت نشد» / «بدون نظر» / «ویزیتی نیست», never «۰ پرستار».
|
||||
`search/SearchScreen.tsx` doesn't need a call-site change — `cta_view_results` is only reached
|
||||
when `count > 0` (the sibling `cta_zero_title` branch handles the zero state), so the ICU `=0` case is
|
||||
a defensive designed fallback, not the primary render path.
|
||||
- en's own `cta_view_results`/`results_count` `=0` case fixed from the audit's flagged "View no nurses" /
|
||||
"No nurses" to "No nurses found" (`cta_view_results` restructured so "View" only prefixes the non-zero
|
||||
cases — `{count, plural, =0 {No nurses found} one {View # nurse} other {View # nurses}}` — rather than
|
||||
gluing "View" onto a full sentence).
|
||||
|
||||
### 3.4 — Arrows out of strings
|
||||
- 4 keys per catalog had a literal ←/→ (not 5 — `payment.cta_pay` already lost its arrow before this
|
||||
phase): `booking.continue_payment`, `auth.nurse_switch`/`customer_switch`, `admin.cfg_history_change`.
|
||||
All four are now plain text; direction moves into a mirrored `AppIcon icon="forward"` (registered in
|
||||
`DIRECTIONAL_ICONS`, auto-flips under `[dir='rtl']` regardless of host component — verified by reading
|
||||
`AppIcon.tsx`, no per-call-site mirroring logic needed):
|
||||
- `bookings/request/[id]/page.tsx`'s continue-to-payment button: `endIcon="payment"` → `endIcon="forward"`
|
||||
(fixes the en wrong-direction arrow **by construction** — the icon mirrors, the string never carried
|
||||
direction to begin with).
|
||||
- `PhoneStep.tsx`'s role-switch link: an inline `<AppIcon icon="forward" size={14}>` after the text.
|
||||
- `admin/config/page.tsx`'s config-history diff: `cfg_history_change` split into
|
||||
`cfg_history_change_old`/`_new` (each holding one interpolation), rendered as two `Typography`s joined
|
||||
by a mirrored `AppIcon icon="forward"` — the old defect (a translator hand-mirroring `{old} ← {new}`)
|
||||
can't recur.
|
||||
- Grep for `←|→` across both catalogs → zero hits (STYLE.md prose itself has none either).
|
||||
|
||||
### 3.5 — Trust-moments copy pass
|
||||
- **BNPL de-jargon**: `bnpl.ownership_note` rewritten reader-first, «نکول» removed entirely — "you pay
|
||||
installments directly to the provider; Balinyaar receives the full amount up front, and if an
|
||||
installment goes unpaid, that risk sits with the provider — never you or the nurse" (both catalogs).
|
||||
Kept provider-agnostic (no `{provider}` interpolation) because the call site (`MethodStep.tsx`) shows
|
||||
this line **before** a provider is chosen, ahead of the provider list.
|
||||
- **OTP-screen reassurance** and **checkout "why is this safe"**: found **already delivered** —
|
||||
`AuthCard`/`TrustBullets` (ui-phase-3) already renders three trust bullets (verified nurses, escrow
|
||||
payment, support) directly under the phone-entry card on both the customer and nurse login steps, and
|
||||
`EscrowExplainer` (ui-phase-6) already supplements `EscrowNotice` with a 3-step visual + the
|
||||
cancellation-policy implication at checkout. Neither needed new copy; verified by reading `AuthCard.tsx`/
|
||||
`PhoneStep.tsx`/`EscrowExplainer.tsx` rather than assumed from the audit's older snapshot.
|
||||
- Verification pipeline naming + status vocabulary: see above.
|
||||
|
||||
### 3.6 — Config-served policy numbers
|
||||
- New `client/src/constants/policy.ts`: `DISPUTE_WINDOW_HOURS=72`, `CANCELLATION_LEAD_HOURS=24`,
|
||||
`REFUND_ETA_MIN_BUSINESS_DAYS=7`, `REFUND_ETA_MAX_BUSINESS_DAYS=10` — re-exported via
|
||||
`src/constants/index.ts`.
|
||||
- `payouts.explainer_point_2` takes `{hours}`; `refunds.lead_gt_24h`/`lead_lt_24h` take `{hours}`;
|
||||
`refunds.eta_business_days` takes `{minDays}`/`{maxDays}` (both catalogs). Wired at the 3 call sites:
|
||||
`nurse/earnings/page.tsx`'s `ExplainerCard`, `CancellationPolicyDisclosure.tsx`, `RefundEtaBanner.tsx`.
|
||||
`services/refunds/constants.ts`'s `BNPL_REFUND_ETA_BUSINESS_DAYS` now sources from
|
||||
`REFUND_ETA_MAX_BUSINESS_DAYS` instead of its own hardcoded `10`, so the mock's projected refund date and
|
||||
the displayed ETA window can never drift apart.
|
||||
- **REQ-065** filed (`dev/shared-working-context/frontend/requests/for-backend.md`) — a
|
||||
public/authenticated policy-config read; verified first that only admin-scoped `platform_config/*`
|
||||
exists (per the contract docs), so this is a genuine gap, not a guess.
|
||||
|
||||
### 3.7 — Honest search empty state
|
||||
- **Already fixed by ui-phase-4** — `search.empty_suggest_city` (the Mashhad/Isfahan/Shiraz nonsense
|
||||
suggestion) was already deleted and replaced with `empty_suggest_date` in both catalogs. Verified by
|
||||
grep (`مشهد|اصفهان|شیراز` → no matches) rather than re-implemented.
|
||||
|
||||
### 3.8 — Motion pass
|
||||
- `globals.css`: one `bal-fade-in` keyframe (150–200ms fade + 4px slide, `var(--bal-motion-base)` +
|
||||
`var(--bal-easing-standard)`) applied via a `data-bal-route-fade` attribute selector, and **the single
|
||||
reduced-motion gate for the whole app** — a universal `*, *::before, *::after { animation-duration:
|
||||
0.01ms !important; transition-duration: 0.01ms !important; … }` under
|
||||
`@media (prefers-reduced-motion: reduce)`. This is the one rule that makes MUI's own JS-driven
|
||||
Dialog/Drawer/Menu/Collapse/Fade transitions collapse too (they don't read CSS custom properties, so a
|
||||
token-only gate wouldn't reach them) — no component anywhere needs its own reduced-motion branch.
|
||||
- `tokens.css` additionally zeroes `--bal-motion-fast/base/slow` under the same media feature, for any
|
||||
future consumer that reads the token value directly.
|
||||
- New shared component `components/common/RouteFadeIn/` (tested) — wraps `{children}`, keyed on the
|
||||
locale-stripped pathname (`@/i18n/navigation`'s `usePathname`) so it remounts (replaying the fade) on
|
||||
navigation but never on an in-place re-render. Mounted inside the `ErrorBoundary` in **all five** shells
|
||||
(`CustomerLayout`, `TopBarAndSideBarLayout` — nurse/admin/partner, `FocusedLayout`, `PublicLayout`) —
|
||||
every route in the app gets the fade, not a hand-picked subset.
|
||||
- `theme.ts`: `MuiDialog`/`MuiDrawer`/`MuiPopover`/`MuiMenu` all get an explicit
|
||||
`defaultProps.transitionDuration: { enter: 200, exit: 120 }` in **one place** (matching
|
||||
`--bal-motion-base`/`-fast`) instead of MUI's per-variant defaults, so every dialog/bottom-sheet/menu in
|
||||
the app now shares one calm timing.
|
||||
- Skeleton→content crossfade: the `data-bal-route-fade` primitive is reusable for this (any screen can
|
||||
attach it to its populated-state branch); demonstrated on `search/results/page.tsx`'s results grid
|
||||
(§3.10) as a worked example rather than retrofitted to every list screen in the app — flagged as a
|
||||
mechanical follow-up below, not a silent scope cut.
|
||||
|
||||
### 3.9 — A11y sweep
|
||||
- **`AppIconButton`**: the underlying `IconButton` now always receives `aria-label={title}` directly (not
|
||||
only via the `Tooltip` wrap), so a **disabled** icon-only button — where the Tooltip isn't rendered at
|
||||
all — keeps an accessible name. A caller-supplied `aria-label` still wins (spread order). New test:
|
||||
"keeps an accessible name from `.title` even when disabled". `useMemo` dependency array fixed
|
||||
(`react-hooks/exhaustive-deps`) as part of the change.
|
||||
- **`aria-live`**: `CountdownTimer`'s one-time elapsed transition now carries `aria-live="polite"` (the
|
||||
coarse-mode label already had it from an earlier phase; the fine per-second clock deliberately still
|
||||
doesn't — spam risk, unchanged). `ErrorState` (the one "query failed" pattern, ~55 sites) gets
|
||||
`role="alert"` — one change covers every error/retry region in the app. `ErrorBoundary` gets the same.
|
||||
`PaymentStateCard` (the one terminal/wait-state card for card + BNPL checkout, including the
|
||||
pending→succeeded/failed poll) gets `aria-live="polite"`.
|
||||
- **`aria-expanded`/`aria-controls`**: `EscrowExplainer`'s toggle gained `aria-controls` (it already had
|
||||
`aria-expanded`); the admin ticket thread's refund-panel toggle (`admin/tickets/[id]/page.tsx`) gained
|
||||
both (it had neither). `nurse/earnings`'s `ExplainerCard` and `AuditLogRow`/`EmergencyPlaybookRow`
|
||||
already had both from earlier phases — verified, not re-touched.
|
||||
- **Contrast**: `NurseDashboardScreen.tsx`'s unread-notifications label was the one real terracotta-on-text
|
||||
finding (`color: 'var(--bal-secondary)'` on a `body2` unread count) — swapped to
|
||||
`--bal-secondary-dark`. Every other `--bal-secondary` text usage found by grep was already
|
||||
`--bal-secondary-dark` (BNPL screens) or an icon/background (non-text contrast rules, not the AA text
|
||||
threshold the audit flagged).
|
||||
|
||||
### 3.10 — Desktop-aware layouts (scope-boxed to the two named surfaces)
|
||||
- **Checkout** (`bookings/checkout/page.tsx`): above `md` (~900px), a two-column layout — the
|
||||
summary/total/countdown/breakdown/escrow content on the reading side (flex `62%`), a `position: sticky`
|
||||
order-summary panel with the pay CTA on the other. The pay-action JSX (total, CTA, secure-gateway note,
|
||||
BNPL button, inline error) is a single `payActions` value rendered in **both** the desktop panel and the
|
||||
mobile `StickyActionBar` — same handlers, same state, no duplicated logic; only one is visible at a time
|
||||
via `sx={{ display: { xs: …, md: … } }}`, the same pattern the codebase already uses for
|
||||
`BottomBar`/`CustomerDesktopNav`.
|
||||
- **Search results** (`search/results/page.tsx`): the populated/skeleton lists switch from a single-column
|
||||
`Stack` to a `display: grid` with `gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }` — two columns above
|
||||
`md`. Bounded by the customer shell's existing `CONTENT_MAX_WIDTH` (800px, unchanged, shared across the
|
||||
whole customer app) — each card gets meaningfully more width than the old full-width single column, but
|
||||
a true full-bleed desktop layout or list+detail split would need a per-route shell override, which is
|
||||
exactly the "full responsive pass" the phase doc explicitly defers post-chain. Flagged, not silently cut.
|
||||
|
||||
### 3.11 — Final QA walkthrough
|
||||
|
||||
**Static verification performed** (all green): `npm run check` (type + lint + the new `lint:copy`),
|
||||
`npm run test:ci` (115 suites / 526 tests, including 2 new/extended ones — `RouteFadeIn.test.tsx`,
|
||||
`AppIconButton.test.tsx`'s new disabled-name case), a key-parity script (`fa.json`/`en.json` — 1891 leaf
|
||||
keys each, zero one-sided keys, matching array lengths on `legal.terms_sections`/`privacy_sections`), and
|
||||
a manual grep pass for stray hex literals in every changed file (none found — every new color reference is
|
||||
a `var(--bal-*)` token).
|
||||
|
||||
**Could not perform a live in-browser visual pass.** This sandbox has no headless-browser tool
|
||||
(`chromium-cli` is not installed) and direct HTTP access to the dev server's `localhost` port returns a
|
||||
proxy `502` rather than reaching Next.js (confirmed: the dev server itself started cleanly — `✓ Ready in
|
||||
5.8s` in its log — the request never reached it). Per this repo's own verification guidance, I'm stating
|
||||
this explicitly rather than claiming a visual check that didn't happen. **A human should run `npm run dev`
|
||||
and spot-check, on `/fa` and `/en` × light and dark × a narrow and a ≥1100px viewport:**
|
||||
- [ ] Login (`/login`): brand reads «بالینیار» identically everywhere it appears (header, trust bullets,
|
||||
account-error retry copy); trust bullets render under the phone-entry card.
|
||||
- [ ] Search (`/search` → results): filters producing zero results show «پرستاری یافت نشد» (fa) / "No
|
||||
nurses found" (en) — never «۰ پرستار»/"View no nurses"; at ≥900px, result cards render two-up.
|
||||
- [ ] Nurse EVV (`/nurse/visits`): the visits subtitle spells out EVV once; attempting a check-out with no
|
||||
open check-in shows the corrected sentence (no «ورود بازی»).
|
||||
- [ ] Bank (`/nurse/bank`): the verified chip reads «تأییدشده» (not «تاییدشد»).
|
||||
- [ ] Checkout (`/bookings/checkout?request_id=…`): at ≥900px, a two-column layout with a sticky right-side
|
||||
pay panel; at <900px, the original bottom sticky bar; the "continue to payment" button's chevron
|
||||
points the reading-forward direction on both locales (mirrored, not baked into the string).
|
||||
- Currently only reachable via the request-flow with a real/mocked `accepted_awaiting_payment` request.
|
||||
- [ ] BNPL comparison (`/bookings/checkout/bnpl`): the ownership note reads plainly, no «نکول».
|
||||
- [ ] Any route transition: a calm ~150–200ms fade/slide plays once; with DevTools' "Emulate CSS media
|
||||
feature `prefers-reduced-motion: reduce`" enabled, everything appears instantly (no fade, no MUI
|
||||
dialog/menu transition either).
|
||||
- [ ] Any icon-only disabled button (e.g. a paused/loading admin action): inspect the accessibility tree —
|
||||
it still has a name.
|
||||
- [ ] Config history (`/admin/config`, open a row's history drawer): the old→new diff shows a mirrored
|
||||
arrow icon between two values, not a baked-in `←`/`→` character.
|
||||
|
||||
## What is now testable (and exactly how)
|
||||
|
||||
1. `cd client && npm run check` — passes, including `lint:copy`. Temporarily add «بالین یار» (plain
|
||||
space) to any `fa.json` value → `npm run lint:copy` fails with the exact key path; revert.
|
||||
2. `npm run test:ci` — 115 suites / 526 tests pass, including the two touched/added shared-component tests.
|
||||
3. Grep both catalogs for `←|→` → zero hits. Grep for `تایید` (hamza-less) → zero hits (only `تأیید` and
|
||||
its compounds remain). Grep for `جستوجو`/`جست و جو` → zero hits.
|
||||
4. The four visual-axis items above, once a human can reach a browser.
|
||||
|
||||
## What is mocked / waiting on a real service
|
||||
|
||||
- **REQ-065** (new, this phase) — public/authenticated policy-config read (dispute-window hours,
|
||||
cancellation lead hours, refund ETA days). Until delivered, `client/src/constants/policy.ts` is the
|
||||
single source; every message key that used to hardcode a policy number now takes it as an ICU param from
|
||||
this file. No mock-registry entry needed — this isn't a seam behind a DI interface, it's a constants file
|
||||
standing in for a config read that doesn't exist yet for a non-admin caller.
|
||||
- No other new mocks this phase; no `services/{domain}` seam was touched.
|
||||
|
||||
## Contracts
|
||||
|
||||
- Consumed: none new. REQ-065 filed against the existing `platform_config` admin-only surface
|
||||
(`dev/contracts/domains/` — verified no public projection exists before filing, per the phase's own
|
||||
instruction not to file speculatively).
|
||||
|
||||
## Docs updated
|
||||
|
||||
- `client/messages/STYLE.md` — new, the Persian style guide (this phase's own deliverable).
|
||||
- `client/CLAUDE.md` — see the diff in this change: mentions `messages/STYLE.md` + `npm run lint:copy` in
|
||||
the i18n section, the reduced-motion gate location (`globals.css`), `RouteFadeIn` in the component-library
|
||||
table, and `constants/policy.ts` in the constants section.
|
||||
- `dev/shared-working-context/frontend/requests/for-backend.md` — REQ-065 appended.
|
||||
|
||||
## Follow-ups for later phases (post-chain)
|
||||
|
||||
- **Skeleton→content crossfade beyond the one worked example.** `data-bal-route-fade` is reusable
|
||||
(a one-line addition per screen) but wasn't retrofitted onto every list/detail page in the app — a
|
||||
mechanical, low-risk follow-up, not a defect.
|
||||
- **A true desktop search layout** (list+detail split, or breaking out of the 800px `CONTENT_MAX_WIDTH`
|
||||
for this one route) — the phase doc explicitly defers "a full responsive pass" post-chain; the 2-column
|
||||
grid shipped here is the scope-boxed interim step.
|
||||
- **REQ-065** — once delivered, replace `constants/policy.ts`'s hardcoded numbers with a fetched config
|
||||
read (the ICU-param call sites don't change, only where the numbers come from).
|
||||
- A live in-browser visual confirmation of the walkthrough checklist above — this environment couldn't run
|
||||
one; flag to the human reviewer.
|
||||
|
||||
## Memory
|
||||
|
||||
A `project`-type memory note was saved (`ui_phase_12_copy_motion_polish.md`) summarizing the STYLE.md
|
||||
decisions, the reduced-motion gate's location and mechanism, and the sandbox's browser-verification gap, so
|
||||
a future agent doesn't have to rediscover any of it.
|
||||
Reference in New Issue
Block a user