ui phase 12

This commit is contained in:
hamid
2026-07-19 21:31:59 +03:30
parent 87fa4cd497
commit d33568bf31
39 changed files with 1049 additions and 247 deletions
@@ -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>
+39
View File
@@ -28,3 +28,42 @@ a {
[dir='rtl'] [data-icon-directional] {
transform: scaleX(-1);
}
/*
* App-wide motion pass (phase 12) one restrained language: a calm 150200ms fade + small slide on
* route-group content and skeletoncontent 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 pendingsucceeded/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>
)}
+11 -2
View File
@@ -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 150200ms 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;
+2
View File
@@ -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
View File
@@ -1,3 +1,4 @@
export * from './headers';
export * from './policy';
export * from './roles';
export * from './routes';
+20
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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>
+4 -2
View File
@@ -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
* ~710 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;
+14
View File
@@ -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: {
+15
View File
@@ -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'] {