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>