ui phase 12
This commit is contained in:
@@ -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