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,6 +159,56 @@ function CheckoutScreen() {
|
||||
? t('initiate_failed')
|
||||
: null;
|
||||
|
||||
const payActions = (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{inlineError ? (
|
||||
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
|
||||
{inlineError}
|
||||
</AppAlert>
|
||||
) : null}
|
||||
|
||||
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between', gap: 2 }}>
|
||||
<Stack sx={{ gap: 0 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('row_total')}
|
||||
</Typography>
|
||||
<Money amountIrr={summary.totalIrr} tone="emphasis" size="md" sx={{ fontWeight: 800 }} />
|
||||
</Stack>
|
||||
<AppButton
|
||||
color="secondary"
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={busy}
|
||||
onClick={handlePay}
|
||||
endIcon="forward"
|
||||
sx={{ py: 1.25, flex: 'none', minWidth: 168 }}
|
||||
>
|
||||
{initiate.isPending ? t('state_initiating') : initiate.isSuccess ? t('state_redirecting') : t('cta_pay')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<AppIcon icon="lock" size={14} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('secure_gateway_notice')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{/* The f11 BNPL branch (D1): «پرداخت اقساطی» → the installment wizard, reached with `?request_id=`. */}
|
||||
{BNPL_ENABLED ? (
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
startIcon="installments"
|
||||
disabled={busy}
|
||||
onClick={() => router.push(`/${locale}${ROUTES.CHECKOUT_BNPL}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)}
|
||||
>
|
||||
{t('bnpl_option')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 3 }}>
|
||||
<Stack sx={{ gap: 1, alignItems: 'center', textAlign: 'center' }}>
|
||||
@@ -178,99 +228,74 @@ function CheckoutScreen() {
|
||||
</AppButton>
|
||||
</Stack>
|
||||
|
||||
<EngagementSummary summary={summary} locale={locale} />
|
||||
{/* 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 }} />
|
||||
{/* 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>
|
||||
|
||||
{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>
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{inlineError ? (
|
||||
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
|
||||
{inlineError}
|
||||
</AppAlert>
|
||||
) : null}
|
||||
|
||||
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between', gap: 2 }}>
|
||||
<Stack sx={{ gap: 0 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('row_total')}
|
||||
</Typography>
|
||||
<Money amountIrr={summary.totalIrr} tone="emphasis" size="md" sx={{ fontWeight: 800 }} />
|
||||
</Stack>
|
||||
<AppButton
|
||||
color="secondary"
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={busy}
|
||||
onClick={handlePay}
|
||||
endIcon="forward"
|
||||
sx={{ py: 1.25, flex: 'none', minWidth: 168 }}
|
||||
>
|
||||
{initiate.isPending ? t('state_initiating') : initiate.isSuccess ? t('state_redirecting') : t('cta_pay')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<AppIcon icon="lock" size={14} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('secure_gateway_notice')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{/* The f11 BNPL branch (D1): «پرداخت اقساطی» → the installment wizard, reached with `?request_id=`. */}
|
||||
{BNPL_ENABLED ? (
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
startIcon="installments"
|
||||
disabled={busy}
|
||||
onClick={() => router.push(`/${locale}${ROUTES.CHECKOUT_BNPL}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)}
|
||||
>
|
||||
{t('bnpl_option')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
</StickyActionBar>
|
||||
{/* 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 }}>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 700 }}>
|
||||
{t('cfg_history_change', { old: change.oldValue ?? '—', new: change.newValue ?? '—' })}
|
||||
</Typography>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 700 }}>
|
||||
{t('cfg_history_change_old', { old: change.oldValue ?? '—' })}
|
||||
</Typography>
|
||||
<AppIcon icon="forward" size={14} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 700 }}>
|
||||
{t('cfg_history_change_new', { new: change.newValue ?? '—' })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{formatShamsiDateTime(change.occurredAt, locale)}
|
||||
{change.actorUserId != null ? ` · #${change.actorUserId}` : ''}
|
||||
|
||||
@@ -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'] {
|
||||
|
||||
Reference in New Issue
Block a user