ui phase 1
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
'use client';
|
||||
import { FormEvent, FunctionComponent, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Avatar, Box, InputAdornment, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading, CategoryTile, EmptyState, ErrorState } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useMe } from '@/services/auth';
|
||||
import { usePatients } from '@/services/patients';
|
||||
import { useServiceCategories } from '@/services/catalog';
|
||||
import { pickCatalogName } from '@/services/catalog/names';
|
||||
|
||||
interface NudgeCardProps {
|
||||
icon: string;
|
||||
title: string;
|
||||
body: string;
|
||||
ctaLabel: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLabel, to }) => (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', gap: 2 }}
|
||||
>
|
||||
<AppIcon icon={icon} size={28} color="var(--bal-primary)" />
|
||||
<Stack sx={{ gap: 1, flexGrow: 1 }}>
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{body}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<AppButton color="primary" variant="outlined" to={to} sx={{ alignSelf: 'flex-start' }}>
|
||||
{ctaLabel}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
/**
|
||||
* A5 — the family Home: the front door of the app. Greeting + avatar, the search bar (which hands a
|
||||
* query / chosen `service_category_id` toward the f6 search flow — results are not built here), the
|
||||
* **data-driven** service-category grid (from the cached `services/catalog` reference data), and the
|
||||
* complete-patient-record nudge (derived from the f2 patient cache — no extra fetch).
|
||||
*
|
||||
* First-login gate: a customer with no patients is sent into onboarding (A3). The redirect waits for
|
||||
* a settled list so a post-create refetch never bounces the user back to onboarding.
|
||||
*/
|
||||
export default function HomeScreen() {
|
||||
const t = useTranslations('home');
|
||||
const tc = useTranslations('common');
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
|
||||
const { data: me } = useMe();
|
||||
const { data, isError, refetch } = usePatients();
|
||||
|
||||
const isEmpty = data?.total === 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (isEmpty) router.replace(`/${locale}${ROUTES.ONBOARDING}`);
|
||||
}, [isEmpty, router, locale]);
|
||||
|
||||
if (isError) {
|
||||
return <ErrorState message={t('patients_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />;
|
||||
}
|
||||
|
||||
if (data == null || isEmpty) {
|
||||
return <AppLoading />;
|
||||
}
|
||||
|
||||
const href = (path: string) => `/${locale}${path}`;
|
||||
const profileComplete = me?.hasCustomerProfile ?? false;
|
||||
const firstName = me?.firstName?.trim() || null;
|
||||
const greeting = firstName ? t('greeting_named', { name: firstName }) : t('greeting_plain');
|
||||
const avatarInitial = firstName ? firstName.charAt(0).toUpperCase() : null;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<Avatar sx={{ width: 48, height: 48, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}>
|
||||
{avatarInitial ?? <AppIcon icon="account" size={28} color="var(--bal-primary)" />}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{greeting}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<HomeSearchBar />
|
||||
|
||||
<CategoryGrid onSelect={(categoryId) => router.push(`${href(ROUTES.SEARCH)}?category_id=${categoryId}`)} />
|
||||
|
||||
<NudgeCard
|
||||
icon="patients"
|
||||
title={t('nudge_patient_title')}
|
||||
body={t('nudge_patient_body')}
|
||||
ctaLabel={t('nudge_patient_cta')}
|
||||
to={href(ROUTES.PATIENTS)}
|
||||
/>
|
||||
{!profileComplete ? (
|
||||
<NudgeCard
|
||||
icon="profile"
|
||||
title={t('nudge_profile_title')}
|
||||
body={t('nudge_profile_body')}
|
||||
ctaLabel={t('nudge_profile_cta')}
|
||||
to={href(ROUTES.PROFILE)}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Home search field. Rendering + query capture live here; **execution is f6** — submitting
|
||||
* navigates toward the (future) search route carrying the typed query. Results/filters are DEFERRED
|
||||
* → frontend-phase-6-b7.
|
||||
*/
|
||||
const HomeSearchBar: FunctionComponent = () => {
|
||||
const t = useTranslations('home');
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const q = query.trim();
|
||||
router.push(`/${locale}${ROUTES.SEARCH}${q ? `?q=${encodeURIComponent(q)}` : ''}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box component="form" onSubmit={submit} role="search">
|
||||
<TextField
|
||||
fullWidth
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t('search_placeholder')}
|
||||
aria-label={t('search_action')}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<AppIcon icon="search" size={22} color="var(--bal-text-secondary)" />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/** The data-driven service-category grid — one tile per `service_category`, with all four states. */
|
||||
const CategoryGrid: FunctionComponent<{ onSelect: (categoryId: number) => void }> = ({ onSelect }) => {
|
||||
const t = useTranslations('home');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const { data, isLoading, isError, refetch } = useServiceCategories();
|
||||
const categories = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('categories_title')}
|
||||
</Typography>
|
||||
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
|
||||
{[0, 1, 2, 3].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
|
||||
))}
|
||||
</Box>
|
||||
) : isError ? (
|
||||
<ErrorState message={t('categories_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />
|
||||
) : categories.length === 0 ? (
|
||||
<EmptyState title={t('categories_empty')} />
|
||||
) : (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
|
||||
{categories.map((category) => (
|
||||
<CategoryTile
|
||||
key={category.id}
|
||||
label={pickCatalogName(category, locale)}
|
||||
iconKey={category.iconKey}
|
||||
onClick={() => onSelect(category.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -2,18 +2,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
Box,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, AppIcon } from '@/components';
|
||||
import { Box, Dialog, DialogActions, DialogContent, DialogTitle, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, EmptyState } from '@/components';
|
||||
import { AddressCard, AddressForm } from '@/components/geography';
|
||||
import {
|
||||
useAddresses,
|
||||
@@ -115,31 +105,16 @@ export default function AddressesPage() {
|
||||
))}
|
||||
</Stack>
|
||||
) : isEmpty ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<AppIcon icon="location" size={40} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('empty_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('empty_body')}
|
||||
</Typography>
|
||||
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ mt: 1 }}>
|
||||
{t('add')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
<EmptyState
|
||||
icon="location"
|
||||
title={t('empty_title')}
|
||||
body={t('empty_body')}
|
||||
action={
|
||||
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ mt: 1 }}>
|
||||
{t('add')}
|
||||
</AppButton>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{addresses.map((address) => (
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, EmptyState, ErrorState, Money, StatusChip } from '@/components';
|
||||
import { BOOKING_STATUS_KIND } from '@/components/booking/statusKind';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { useBookingList } from '@/services/bookings';
|
||||
import type { BookingListItemDto } from '@/services/bookings/types';
|
||||
|
||||
/**
|
||||
* Customer رزروها — the "My bookings" list. Reads `useBookingList('customer')`; each row opens the
|
||||
* booking detail (`/bookings/{id}`). This is the customer entry to the f8 booking-detail surface (the C5
|
||||
* `converted` state also lands here). Amounts render in Toman via the money util.
|
||||
*/
|
||||
export default function BookingsScreen() {
|
||||
const t = useTranslations('booking');
|
||||
const { data, isLoading, isError, refetch } = useBookingList('customer');
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('list_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('list_subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{[0, 1].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={120} />
|
||||
))}
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
<ErrorState message={t('list_error')} retryLabel={t('retry')} onRetry={() => refetch()} />
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState icon="bookings" title={t('list_empty_title')} body={t('list_empty_body')} />
|
||||
) : (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{items.map((item) => (
|
||||
<BookingRow key={item.id} item={item} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingRow({ item }: { item: BookingListItemDto }) {
|
||||
const t = useTranslations('booking');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5, flexWrap: 'wrap' }}>
|
||||
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{item.counterpartyName}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{formatShamsiDate(item.scheduledDate, locale)} · {t('session_count', { count: item.sessionCount })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<StatusChip status={BOOKING_STATUS_KIND[item.status]} label={t(`bstatus_${item.status}`)} />
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{t('list_total')}: <Money amountIrr={item.amountIrr} size="sm" sx={{ fontWeight: 700 }} />
|
||||
</Typography>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
endIcon="bookings"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${item.id}`)}
|
||||
>
|
||||
{t('view_booking')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,11 @@ import { Checkbox, FormControlLabel, MenuItem, Paper, Stack, TextField, Typograp
|
||||
import AppButton from '@/components/common/AppButton';
|
||||
import AppAlert from '@/components/common/AppAlert';
|
||||
import AppLoading from '@/components/common/AppLoading';
|
||||
import Money from '@/components/common/Money';
|
||||
import StepperHeader from '@/components/StepperHeader';
|
||||
import CancellationPolicyDisclosure from '@/components/CancellationPolicyDisclosure';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { bookingRefundStatusPath, ROUTES } from '@/constants';
|
||||
import { formatIrrToToman } from '@/utils';
|
||||
import { useCancelBooking, useCancellationPolicyPreview } from '@/services/refunds';
|
||||
import type { CancelReasonCategory } from '@/services/refunds/types';
|
||||
|
||||
@@ -104,9 +104,6 @@ export default function CancelBookingPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const refundToman = `${formatIrrToToman(preview.refundAmountIrr, locale)} ${tc('currency_toman')}`;
|
||||
const feeToman = `${formatIrrToToman(preview.feeAmountIrr, locale)} ${tc('currency_toman')}`;
|
||||
|
||||
const submit = () =>
|
||||
cancel.mutate(
|
||||
{
|
||||
@@ -175,7 +172,14 @@ export default function CancelBookingPage() {
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1 }}>
|
||||
{t('confirm_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2">{t('confirm_restate', { refund: refundToman, fee: feeToman })}</Typography>
|
||||
<Typography variant="body2" component="div">
|
||||
{t.rich('confirm_restate', {
|
||||
refund: () => (
|
||||
<Money amountIrr={preview.refundAmountIrr} size="sm" sx={{ fontWeight: 700 }} />
|
||||
),
|
||||
fee: () => <Money amountIrr={preview.feeAmountIrr} size="sm" sx={{ fontWeight: 700 }} />,
|
||||
})}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{cancel.isError && (
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Divider, GlobalStyles, Paper, Skeleton, Stack, Typography } from '@mui/
|
||||
import { AppButton, AppIcon, PriceBreakdown, StatusChip, type StatusKind } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { formatShamsiDate, parseIrr } from '@/utils';
|
||||
import { formatShamsiDate, localeTag, parseIrr } from '@/utils';
|
||||
import { useInvoice } from '@/services/payment';
|
||||
import type { MoadianStatus } from '@/services/payment/types';
|
||||
|
||||
@@ -118,7 +118,7 @@ export default function BookingInvoicePage() {
|
||||
parseIrr(invoice.grossIrr) - parseIrr(invoice.platformCommissionIrr) - parseIrr(invoice.vatIrr)
|
||||
).toString();
|
||||
// maximumFractionDigits: the default (0) would silently round a fractional served rate (e.g. 9.5%).
|
||||
const vatPercent = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
const vatPercent = new Intl.NumberFormat(localeTag(locale), {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 2,
|
||||
}).format(invoice.vatRate);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, RatingInput, ReviewTagSelector, StatusChip } from '@/components';
|
||||
import { AppButton, EmptyState, RatingInput, ReviewTagSelector, StatusChip } from '@/components';
|
||||
import type { StatusKind } from '@/components';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { useBookingDetail } from '@/services/bookings';
|
||||
@@ -119,11 +119,7 @@ export default function LeaveReviewPage() {
|
||||
return (
|
||||
<Stack sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
|
||||
<PageHeading title={t('not_eligible_title')} />
|
||||
<Paper elevation={0} sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t(`reason_${reason}`)}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<EmptyState title={t(`reason_${reason}`)} />
|
||||
<AppButton variant="outlined" color="primary" onClick={() => router.back()} sx={{ alignSelf: 'flex-start' }}>
|
||||
{tc('back')}
|
||||
</AppButton>
|
||||
|
||||
+4
-7
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Checkbox, FormControlLabel, Paper, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, PhoneNumberField } from '@/components';
|
||||
import { digitsOnly, formatIrrToToman } from '@/utils';
|
||||
import { AppButton, AppIcon, Money, PhoneNumberField } from '@/components';
|
||||
import { digitsOnly } from '@/utils';
|
||||
import { useCheckEligibility } from '@/services/bnpl';
|
||||
import { NATIONAL_ID_LENGTH, NATIONAL_ID_PATTERN } from '@/services/bnpl/constants';
|
||||
import type { BnplEligibilityResult, ProviderCode } from '@/services/bnpl/types';
|
||||
@@ -35,7 +35,6 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
|
||||
}) => {
|
||||
const t = useTranslations('bnpl');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
|
||||
const [nationalId, setNationalId] = useState('');
|
||||
const [consent, setConsent] = useState(false);
|
||||
@@ -79,9 +78,7 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{t('credit_ceiling_label')}
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800 }}>
|
||||
{formatIrrToToman(result.creditCeilingIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<Money amountIrr={result.creditCeilingIrr} tone="emphasis" size="lg" />
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
+4
-16
@@ -1,9 +1,8 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Box, ButtonBase, Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon } from '@/components';
|
||||
import { formatIrrToToman } from '@/utils';
|
||||
import { AppButton, AppIcon, EmptyState, Money } from '@/components';
|
||||
import type { BnplOptions, BnplProvider, ProviderCode } from '@/services/bnpl/types';
|
||||
|
||||
interface MethodStepProps {
|
||||
@@ -36,8 +35,6 @@ const MethodStep: FunctionComponent<MethodStepProps> = ({
|
||||
onPayWithCard,
|
||||
}) => {
|
||||
const t = useTranslations('bnpl');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const hasProviders = options.providers.length > 0;
|
||||
|
||||
return (
|
||||
@@ -53,9 +50,7 @@ const MethodStep: FunctionComponent<MethodStepProps> = ({
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('payable_amount')}
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, mt: 0.5 }}>
|
||||
{formatIrrToToman(options.orderAmountIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<Money amountIrr={options.orderAmountIrr} tone="emphasis" size="lg" sx={{ mt: 0.5 }} />
|
||||
</Paper>
|
||||
|
||||
{/* Full-card option — selecting it continues the f9 card flow (C6), which this phase does not rebuild. */}
|
||||
@@ -116,14 +111,7 @@ const MethodStep: FunctionComponent<MethodStepProps> = ({
|
||||
</AppButton>
|
||||
</>
|
||||
) : (
|
||||
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px dashed', borderColor: 'divider' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{t('no_providers_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{t('no_providers_body')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<EmptyState title={t('no_providers_title')} body={t('no_providers_body')} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
+4
-15
@@ -1,9 +1,8 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, BnplPlanCard } from '@/components';
|
||||
import { formatIrrToToman } from '@/utils';
|
||||
import { AppButton, BnplPlanCard, EmptyState, Money } from '@/components';
|
||||
import type { BnplPlanOption, ProviderCode } from '@/services/bnpl/types';
|
||||
|
||||
interface PlanStepProps {
|
||||
@@ -31,19 +30,11 @@ const PlanStep: FunctionComponent<PlanStepProps> = ({
|
||||
}) => {
|
||||
const t = useTranslations('bnpl');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
|
||||
if (plans.length === 0) {
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px dashed', borderColor: 'divider' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{t('no_plans_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{t('no_plans_body')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<EmptyState title={t('no_plans_title')} body={t('no_plans_body')} />
|
||||
<AppButton variant="outlined" color="primary" onClick={onBack}>
|
||||
{t('back_to_providers')}
|
||||
</AppButton>
|
||||
@@ -69,9 +60,7 @@ const PlanStep: FunctionComponent<PlanStepProps> = ({
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('total_amount')}
|
||||
</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
|
||||
{formatIrrToToman(shownPlan.totalIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<Money amountIrr={shownPlan.totalIrr} tone="emphasis" size="sm" />
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
|
||||
+17
-19
@@ -2,8 +2,8 @@
|
||||
import { Suspense } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading } from '@/components';
|
||||
import { Stack } from '@mui/material';
|
||||
import { AppButton, AppLoading, EmptyState } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import {
|
||||
BNPL_QUERY_OUTCOME,
|
||||
@@ -52,22 +52,20 @@ function BnplGatewayScreen() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<AppIcon icon="installments" size={44} color="var(--bal-secondary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('handoff_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('handoff_body', { provider: providerName })}
|
||||
</Typography>
|
||||
<AppButton color="secondary" variant="contained" size="large" onClick={() => returnWith('success')}>
|
||||
{t('handoff_pay_success')}
|
||||
</AppButton>
|
||||
<AppButton variant="text" color="error" onClick={() => returnWith('failure')}>
|
||||
{t('handoff_pay_fail')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<EmptyState
|
||||
icon="installments"
|
||||
title={t('handoff_title')}
|
||||
body={t('handoff_body', { provider: providerName })}
|
||||
action={
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center', width: '100%' }}>
|
||||
<AppButton color="secondary" variant="contained" size="large" onClick={() => returnWith('success')}>
|
||||
{t('handoff_pay_success')}
|
||||
</AppButton>
|
||||
<AppButton variant="text" color="error" onClick={() => returnWith('failure')}>
|
||||
{t('handoff_pay_fail')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-6
@@ -3,9 +3,8 @@ import { Suspense } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading } from '@/components';
|
||||
import { AppButton, AppIcon, AppLoading, Money } from '@/components';
|
||||
import { bookingInvoicePath, ROUTES } from '@/constants';
|
||||
import { formatIrrToToman } from '@/utils';
|
||||
import { useCheckoutSummary } from '@/services/payment';
|
||||
import { CHECKOUT_QUERY_BOOKING_ID, CHECKOUT_QUERY_REQUEST_ID } from '@/services/payment/constants';
|
||||
import {
|
||||
@@ -32,7 +31,6 @@ export default function CheckoutConfirmationPage() {
|
||||
|
||||
function ConfirmationScreen() {
|
||||
const t = useTranslations('payment');
|
||||
const tc = useTranslations('common');
|
||||
const tBnpl = useTranslations('bnpl');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
@@ -69,9 +67,7 @@ function ConfirmationScreen() {
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('total_paid_label')}
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ color: 'var(--bal-secondary)' }}>
|
||||
{formatIrrToToman(summary.totalIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<Money amountIrr={summary.totalIrr} tone="emphasis" size="lg" />
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{summary.variantLabel} · {summary.nurseName}
|
||||
</Typography>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import AppAlert from '@/components/common/AppAlert';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { formatShamsiDate, localeTag } from '@/utils';
|
||||
import { useCheckoutSummary, useInitiatePayment } from '@/services/payment';
|
||||
import {
|
||||
BNPL_ENABLED,
|
||||
@@ -230,7 +230,7 @@ function CheckoutScreen() {
|
||||
function EngagementSummary({ summary, locale }: { summary: CheckoutSummaryDto; locale: string }) {
|
||||
const start = new Date(`${summary.requestedDate}T${summary.requestedTimeStart}`);
|
||||
const end = new Date(`${summary.requestedDate}T${summary.requestedTimeEnd}`);
|
||||
const timeFmt = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
const timeFmt = new Intl.DateTimeFormat(localeTag(locale), {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
@@ -1,103 +1,13 @@
|
||||
'use client';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, StatusChip } from '@/components';
|
||||
import { BOOKING_STATUS_KIND } from '@/components/booking/statusKind';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { formatIrrToToman, formatShamsiDate } from '@/utils';
|
||||
import { useBookingList } from '@/services/bookings';
|
||||
import type { BookingListItemDto } from '@/services/bookings/types';
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import BookingsScreen from './BookingsScreen';
|
||||
|
||||
/**
|
||||
* Customer رزروها — the "My bookings" list. Reads `useBookingList('customer')`; each row opens the
|
||||
* booking detail (`/bookings/{id}`). This is the customer entry to the f8 booking-detail surface (the C5
|
||||
* `converted` state also lands here). Amounts render in Toman via the money util.
|
||||
*/
|
||||
export default function CustomerBookingsPage() {
|
||||
const t = useTranslations('booking');
|
||||
const { data, isLoading, isError } = useBookingList('customer');
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('list_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('list_subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{[0, 1].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={120} />
|
||||
))}
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('list_error')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : items.length === 0 ? (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<AppIcon icon="bookings" size={40} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1 }}>
|
||||
{t('list_empty_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{t('list_empty_body')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{items.map((item) => (
|
||||
<BookingRow key={item.id} item={item} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'booking' });
|
||||
return { title: t('list_title') };
|
||||
}
|
||||
|
||||
function BookingRow({ item }: { item: BookingListItemDto }) {
|
||||
const t = useTranslations('booking');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5, flexWrap: 'wrap' }}>
|
||||
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{item.counterpartyName}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{formatShamsiDate(item.scheduledDate, locale)} · {t('session_count', { count: item.sessionCount })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<StatusChip status={BOOKING_STATUS_KIND[item.status]} label={t(`bstatus_${item.status}`)} />
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{t('list_total')}: {formatIrrToToman(item.amountIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
endIcon="bookings"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${item.id}`)}
|
||||
>
|
||||
{t('view_booking')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
export default function Page() {
|
||||
return <BookingsScreen />;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
ToggleButtonGroup,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading, PriceDisplay } from '@/components';
|
||||
import { AppButton, AppLoading, EmptyState, PriceDisplay } from '@/components';
|
||||
import { AddressMapPicker } from '@/components/geography';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
@@ -192,8 +192,16 @@ function BookingRequestForm() {
|
||||
icon="search"
|
||||
title={t('missing_nurse_title')}
|
||||
body={t('missing_nurse_body')}
|
||||
ctaLabel={t('missing_nurse_cta')}
|
||||
onCta={() => router.push(`/${locale}${ROUTES.SEARCH}`)}
|
||||
action={
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.SEARCH}`)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('missing_nurse_cta')}
|
||||
</AppButton>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -489,49 +497,18 @@ function FieldEmpty({
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Paper elevation={0} sx={{ p: 2, border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{message}
|
||||
</Typography>
|
||||
<EmptyState
|
||||
title={message}
|
||||
action={
|
||||
<AppButton variant="outlined" color="primary" startIcon="add" onClick={onCta}>
|
||||
{ctaLabel}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
body,
|
||||
ctaLabel,
|
||||
onCta,
|
||||
}: {
|
||||
icon: string;
|
||||
title: string;
|
||||
body: string;
|
||||
ctaLabel: string;
|
||||
onCta: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<AppIcon icon={icon} size={40} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: 0.5 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
||||
{body}
|
||||
</Typography>
|
||||
<AppButton variant="contained" color="primary" onClick={onCta}>
|
||||
{ctaLabel}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function FormSkeleton() {
|
||||
return (
|
||||
<Stack sx={{ gap: 2.5 }}>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Box from '@mui/material/Box';
|
||||
import SurfaceCard from '@/components/common/SurfaceCard';
|
||||
import { CONTENT_MAX_WIDTH } from '@/components/config';
|
||||
|
||||
/**
|
||||
* The (customer) home shell shape: greeting/avatar + search bar + a category-tile row + a short card
|
||||
* stack — mirrors A5 (`(customer)/page.tsx`) closely enough that switching from skeleton to real content
|
||||
* doesn't jump. `CustomerLayout` (top bar + bottom tabs) is already rendered by the enclosing layout.
|
||||
*/
|
||||
export default function Loading() {
|
||||
return (
|
||||
<Box sx={{ maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
|
||||
<Stack sx={{ gap: 2.5 }}>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<Skeleton variant="circular" width={48} height={48} />
|
||||
<Skeleton variant="text" width="45%" height={28} />
|
||||
</Stack>
|
||||
<Skeleton variant="rounded" height={48} sx={{ borderRadius: 'var(--bal-radius-sm)' }} />
|
||||
<Stack direction="row" sx={{ gap: 1.5, flexWrap: 'wrap' }}>
|
||||
{[0, 1, 2, 3].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" width={92} height={92} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
|
||||
))}
|
||||
</Stack>
|
||||
{[0, 1].map((key) => (
|
||||
<SurfaceCard key={key}>
|
||||
<Stack sx={{ gap: 0.75 }}>
|
||||
<Skeleton variant="text" width="50%" height={22} />
|
||||
<Skeleton variant="text" width="80%" height={18} />
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,210 +1,13 @@
|
||||
'use client';
|
||||
import { FormEvent, FunctionComponent, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Avatar, Box, InputAdornment, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading, CategoryTile } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useMe } from '@/services/auth';
|
||||
import { usePatients } from '@/services/patients';
|
||||
import { useServiceCategories } from '@/services/catalog';
|
||||
import { pickCatalogName } from '@/services/catalog/names';
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import HomeScreen from './HomeScreen';
|
||||
|
||||
interface NudgeCardProps {
|
||||
icon: string;
|
||||
title: string;
|
||||
body: string;
|
||||
ctaLabel: string;
|
||||
to: string;
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'shell' });
|
||||
return { title: t('customer_app') };
|
||||
}
|
||||
|
||||
const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLabel, to }) => (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', gap: 2 }}
|
||||
>
|
||||
<AppIcon icon={icon} size={28} color="var(--bal-primary)" />
|
||||
<Stack sx={{ gap: 1, flexGrow: 1 }}>
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{body}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<AppButton color="primary" variant="outlined" to={to} sx={{ alignSelf: 'flex-start' }}>
|
||||
{ctaLabel}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
/**
|
||||
* A5 — the family Home: the front door of the app. Greeting + avatar, the search bar (which hands a
|
||||
* query / chosen `service_category_id` toward the f6 search flow — results are not built here), the
|
||||
* **data-driven** service-category grid (from the cached `services/catalog` reference data), and the
|
||||
* complete-patient-record nudge (derived from the f2 patient cache — no extra fetch).
|
||||
*
|
||||
* First-login gate: a customer with no patients is sent into onboarding (A3). The redirect waits for
|
||||
* a settled list so a post-create refetch never bounces the user back to onboarding.
|
||||
*/
|
||||
export default function CustomerHomePage() {
|
||||
const t = useTranslations('home');
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
|
||||
const { data: me } = useMe();
|
||||
const { data } = usePatients();
|
||||
|
||||
const isEmpty = data?.total === 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (isEmpty) router.replace(`/${locale}${ROUTES.ONBOARDING}`);
|
||||
}, [isEmpty, router, locale]);
|
||||
|
||||
if (data == null || isEmpty) {
|
||||
return <AppLoading />;
|
||||
}
|
||||
|
||||
const href = (path: string) => `/${locale}${path}`;
|
||||
const profileComplete = me?.hasCustomerProfile ?? false;
|
||||
const firstName = me?.firstName?.trim() || null;
|
||||
const greeting = firstName ? t('greeting_named', { name: firstName }) : t('greeting_plain');
|
||||
const avatarInitial = firstName ? firstName.charAt(0).toUpperCase() : null;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<Avatar sx={{ width: 48, height: 48, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}>
|
||||
{avatarInitial ?? <AppIcon icon="account" size={28} color="var(--bal-primary)" />}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{greeting}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<HomeSearchBar />
|
||||
|
||||
<CategoryGrid onSelect={(categoryId) => router.push(`${href(ROUTES.SEARCH)}?category_id=${categoryId}`)} />
|
||||
|
||||
<NudgeCard
|
||||
icon="patients"
|
||||
title={t('nudge_patient_title')}
|
||||
body={t('nudge_patient_body')}
|
||||
ctaLabel={t('nudge_patient_cta')}
|
||||
to={href(ROUTES.PATIENTS)}
|
||||
/>
|
||||
{!profileComplete ? (
|
||||
<NudgeCard
|
||||
icon="profile"
|
||||
title={t('nudge_profile_title')}
|
||||
body={t('nudge_profile_body')}
|
||||
ctaLabel={t('nudge_profile_cta')}
|
||||
to={href(ROUTES.PROFILE)}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
export default function Page() {
|
||||
return <HomeScreen />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Home search field. Rendering + query capture live here; **execution is f6** — submitting
|
||||
* navigates toward the (future) search route carrying the typed query. Results/filters are DEFERRED
|
||||
* → frontend-phase-6-b7.
|
||||
*/
|
||||
const HomeSearchBar: FunctionComponent = () => {
|
||||
const t = useTranslations('home');
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const q = query.trim();
|
||||
router.push(`/${locale}${ROUTES.SEARCH}${q ? `?q=${encodeURIComponent(q)}` : ''}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box component="form" onSubmit={submit} role="search">
|
||||
<TextField
|
||||
fullWidth
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t('search_placeholder')}
|
||||
aria-label={t('search_action')}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<AppIcon icon="search" size={22} color="var(--bal-text-secondary)" />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/** The data-driven service-category grid — one tile per `service_category`, with all four states. */
|
||||
const CategoryGrid: FunctionComponent<{ onSelect: (categoryId: number) => void }> = ({ onSelect }) => {
|
||||
const t = useTranslations('home');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const { data, isLoading, isError, refetch } = useServiceCategories();
|
||||
const categories = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('categories_title')}
|
||||
</Typography>
|
||||
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
|
||||
{[0, 1, 2, 3].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
|
||||
))}
|
||||
</Box>
|
||||
) : isError ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1 }}>
|
||||
{t('categories_error')}
|
||||
</Typography>
|
||||
<AppButton variant="outlined" color="primary" onClick={() => refetch()}>
|
||||
{tc('retry')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
) : categories.length === 0 ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('categories_empty')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
|
||||
{categories.map((category) => (
|
||||
<CategoryTile
|
||||
key={category.id}
|
||||
label={pickCatalogName(category, locale)}
|
||||
iconKey={category.iconKey}
|
||||
onClick={() => onSelect(category.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, AppIcon, PatientHeader, VisitNoteCard } from '@/components';
|
||||
import { AppButton, AppIcon, EmptyState, PatientHeader, VisitNoteCard } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { usePatient } from '@/services/patients';
|
||||
@@ -59,15 +59,7 @@ export default function PatientRecordPage() {
|
||||
if (access.data && !access.data.canView) {
|
||||
return (
|
||||
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%' }}>
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<AppIcon icon="lock" size={40} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1.5, mb: 0.5 }}>
|
||||
{t('access_denied_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('access_denied_body')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<EmptyState icon="lock" title={t('access_denied_title')} body={t('access_denied_body')} />
|
||||
<BackToPatients />
|
||||
</Stack>
|
||||
);
|
||||
@@ -78,14 +70,7 @@ export default function PatientRecordPage() {
|
||||
if (patient.isError || !patient.data) {
|
||||
return (
|
||||
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%' }}>
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 0.5 }}>
|
||||
{t('not_found_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('not_found_body')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<EmptyState title={t('not_found_title')} body={t('not_found_body')} />
|
||||
<BackToPatients />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -9,12 +9,11 @@ import {
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, AppIcon, PatientCard, PatientForm } from '@/components';
|
||||
import { AppButton, EmptyState, ErrorState, PatientCard, PatientForm } from '@/components';
|
||||
import { patientRecordPath } from '@/constants';
|
||||
import { usePatients, useCreatePatient, useUpdatePatient, useArchivePatient } from '@/services/patients';
|
||||
import { birthDateToAge } from '@/services/patients/age';
|
||||
@@ -33,7 +32,7 @@ export default function PatientsPage() {
|
||||
const locale = useLocale();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const { data, isLoading } = usePatients();
|
||||
const { data, isLoading, isError, refetch } = usePatients();
|
||||
const createPatient = useCreatePatient();
|
||||
const updatePatient = useUpdatePatient();
|
||||
const archivePatient = useArchivePatient();
|
||||
@@ -80,7 +79,7 @@ export default function PatientsPage() {
|
||||
};
|
||||
|
||||
const patients = data?.items ?? [];
|
||||
const isEmpty = !isLoading && patients.length === 0;
|
||||
const isEmpty = !isLoading && !isError && patients.length === 0;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
@@ -93,7 +92,7 @@ export default function PatientsPage() {
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
{!isEmpty ? (
|
||||
{!isEmpty && !isError ? (
|
||||
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ flexShrink: 0 }}>
|
||||
{t('add')}
|
||||
</AppButton>
|
||||
@@ -106,32 +105,19 @@ export default function PatientsPage() {
|
||||
<Skeleton key={key} variant="rounded" height={96} />
|
||||
))}
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
<ErrorState message={t('load_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />
|
||||
) : isEmpty ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<AppIcon icon="patients" size={40} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('empty_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('empty_body')}
|
||||
</Typography>
|
||||
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ mt: 1 }}>
|
||||
{t('add')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
<EmptyState
|
||||
icon="patients"
|
||||
title={t('empty_title')}
|
||||
body={t('empty_body')}
|
||||
action={
|
||||
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd}>
|
||||
{t('add')}
|
||||
</AppButton>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{patients.map((patient) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FunctionComponent, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Box, Divider, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading, PhoneNumberField } from '@/components';
|
||||
import { AppButton, AppIcon, AppLoading, ErrorState, PhoneNumberField } from '@/components';
|
||||
import { isIranianMobile } from '@/components/PhoneNumberField';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { digitsOnly } from '@/utils';
|
||||
@@ -13,9 +13,14 @@ import type { CustomerProfile } from '@/services/profiles/types';
|
||||
|
||||
/** Customer profile — name, preferred language, and the emergency contact. No national-ID KYC. */
|
||||
export default function CustomerProfilePage() {
|
||||
const { data: profile, isLoading } = useCustomerProfile();
|
||||
const t = useTranslations('profile');
|
||||
const tc = useTranslations('common');
|
||||
const { data: profile, isLoading, isError, refetch } = useCustomerProfile();
|
||||
const { data: me } = useMe();
|
||||
if (isLoading) return <AppLoading />;
|
||||
// The form must never render on a failed fetch — it would otherwise show blank/undefined fields
|
||||
// whose save could overwrite server truth.
|
||||
if (isError) return <ErrorState message={t('load_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />;
|
||||
// The customer name is owned by `/me` (REQ-007), not `CustomerProfileDto` — prefill it from there so
|
||||
// editing the emergency contact never blanks (and re-saves as null) the existing name.
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
'use client';
|
||||
import { Suspense, type FunctionComponent, type ReactNode } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import {
|
||||
Box,
|
||||
InputAdornment,
|
||||
Skeleton,
|
||||
Stack,
|
||||
TextField,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, AppLoading, CategoryTile, ErrorState } from '@/components';
|
||||
import CascadingRegionSelect from '@/components/geography/CascadingRegionSelect';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useServiceCategories } from '@/services/catalog';
|
||||
import { pickCatalogName } from '@/services/catalog/names';
|
||||
import { useNurseSearch } from '@/services/search';
|
||||
import { filtersToSearchParams } from '@/services/search/filterParams';
|
||||
import type { NurseGender } from '@/services/search/types';
|
||||
import { useSearchFilters } from './useSearchFilters';
|
||||
|
||||
/**
|
||||
* C1 — Search & filter (جستجو و فیلتر): the discovery entry screen. Pick a care category (reusing the
|
||||
* f4 catalog grid), a city (reusing the f3 cascading region picker; district optional = whole city),
|
||||
* the **prominent same-gender facet**, and an optional Toman price range; a live result count drives the
|
||||
* "مشاهده N پرستار" CTA into C2. Availability (date) is intent-only at MVP — it is carried to booking,
|
||||
* never used to hard-filter results. `useSearchParams` needs a Suspense boundary under static rendering.
|
||||
*/
|
||||
export default function SearchScreen() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<SearchFilterScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const GENDER_OPTIONS: readonly (NurseGender | 'any')[] = ['female', 'male', 'any'];
|
||||
|
||||
function SearchFilterScreen() {
|
||||
const t = useTranslations('search');
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const params = useSearchParams();
|
||||
|
||||
const initialCategoryRaw = Number(params.get('category_id'));
|
||||
const initialCategoryId = Number.isInteger(initialCategoryRaw) && initialCategoryRaw > 0 ? initialCategoryRaw : undefined;
|
||||
|
||||
const controller = useSearchFilters(initialCategoryId);
|
||||
const { data, isFetching } = useNurseSearch(controller.filters);
|
||||
const count = data?.total;
|
||||
|
||||
const goToResults = () => {
|
||||
const query = filtersToSearchParams(controller.filters);
|
||||
if (controller.dateIntent) query.set('date', controller.dateIntent);
|
||||
router.push(`/${locale}${ROUTES.SEARCH_RESULTS}?${query.toString()}`);
|
||||
};
|
||||
|
||||
const ctaLabel = !controller.isReady
|
||||
? t('cta_choose_category_city')
|
||||
: isFetching || count == null
|
||||
? t('cta_loading')
|
||||
: t('cta_view_results', { count });
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<CategorySelect selectedId={controller.categoryId} onSelect={controller.setCategoryId} />
|
||||
|
||||
<FilterSection title={t('section_location')}>
|
||||
<CascadingRegionSelect value={controller.region} onChange={controller.setRegion} includeDistrict />
|
||||
</FilterSection>
|
||||
|
||||
<FilterSection title={t('section_gender')} hint={t('gender_hint')}>
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
fullWidth
|
||||
color="primary"
|
||||
value={controller.gender ?? 'any'}
|
||||
onChange={(_event, value: NurseGender | 'any' | null) => {
|
||||
if (value != null) controller.setGender(value === 'any' ? undefined : value);
|
||||
}}
|
||||
>
|
||||
{GENDER_OPTIONS.map((option) => (
|
||||
<ToggleButton key={option} value={option} sx={{ fontWeight: 700 }}>
|
||||
{t(`gender_${option}`)}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
</FilterSection>
|
||||
|
||||
<FilterSection title={t('section_date')} hint={t('date_hint')}>
|
||||
<TextField
|
||||
type="date"
|
||||
fullWidth
|
||||
value={controller.dateIntent}
|
||||
onChange={(event) => controller.setDateIntent(event.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
</FilterSection>
|
||||
|
||||
<FilterSection title={t('section_price')} hint={t('price_hint')}>
|
||||
<Stack direction="row" sx={{ gap: 2 }}>
|
||||
<PriceField
|
||||
label={t('price_min')}
|
||||
value={controller.priceMinToman}
|
||||
onChange={controller.setPriceMinToman}
|
||||
adornment={t('toman')}
|
||||
/>
|
||||
<PriceField
|
||||
label={t('price_max')}
|
||||
value={controller.priceMaxToman}
|
||||
onChange={controller.setPriceMaxToman}
|
||||
adornment={t('toman')}
|
||||
/>
|
||||
</Stack>
|
||||
</FilterSection>
|
||||
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={!controller.isReady}
|
||||
onClick={goToResults}
|
||||
startIcon="search"
|
||||
sx={{ py: 1.5 }}
|
||||
>
|
||||
{ctaLabel}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const FilterSection: FunctionComponent<{ title: string; hint?: string; children: ReactNode }> = ({
|
||||
title,
|
||||
hint,
|
||||
children,
|
||||
}) => (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{hint ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{hint}
|
||||
</Typography>
|
||||
) : null}
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const PriceField: FunctionComponent<{
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
adornment: string;
|
||||
}> = ({ label, value, onChange, adornment }) => (
|
||||
<TextField
|
||||
label={label}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
inputMode="numeric"
|
||||
fullWidth
|
||||
slotProps={{
|
||||
input: { endAdornment: <InputAdornment position="end">{adornment}</InputAdornment> },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
/** The reused f4 category grid (data-driven from the cached catalog reference data), with selection. */
|
||||
const CategorySelect: FunctionComponent<{ selectedId: number | null; onSelect: (id: number) => void }> = ({
|
||||
selectedId,
|
||||
onSelect,
|
||||
}) => {
|
||||
const t = useTranslations('search');
|
||||
const locale = useLocale();
|
||||
const { data, isLoading, isError, refetch } = useServiceCategories();
|
||||
const categories = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<FilterSection title={t('section_category')}>
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
|
||||
{[0, 1, 2, 3].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
|
||||
))}
|
||||
</Box>
|
||||
) : isError ? (
|
||||
<ErrorState message={t('categories_error')} retryLabel={t('retry')} onRetry={() => refetch()} />
|
||||
) : (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
|
||||
{categories.map((category) => (
|
||||
<CategoryTile
|
||||
key={category.id}
|
||||
label={pickCatalogName(category, locale)}
|
||||
iconKey={category.iconKey}
|
||||
selected={category.id === selectedId}
|
||||
onClick={() => onSelect(category.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</FilterSection>
|
||||
);
|
||||
};
|
||||
+21
-34
@@ -3,10 +3,10 @@ import { useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Avatar, Box, Chip, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, RatingInput, ServicePriceRow, TrustBadge } from '@/components';
|
||||
import { AppButton, AppIcon, EmptyState, ErrorState, RatingInput, ServicePriceRow, TrustBadge } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { formatNumber, formatShamsiDate } from '@/utils';
|
||||
import { useNurseProfile } from '@/services/search';
|
||||
import type { NurseProfile } from '@/services/search/types';
|
||||
import { useNurseReviews } from '@/services/reviews';
|
||||
@@ -37,22 +37,18 @@ export default function NurseProfilePage() {
|
||||
|
||||
if (isError) {
|
||||
const notFound = error instanceof ApiError && error.status === 404;
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
{notFound ? t('profile_not_found_title') : t('profile_error_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
||||
{notFound ? t('profile_not_found_body') : t('profile_error_body')}
|
||||
</Typography>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => (notFound ? router.push(`/${locale}${ROUTES.SEARCH}`) : refetch())}
|
||||
>
|
||||
{notFound ? t('profile_not_found_cta') : t('retry')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
return notFound ? (
|
||||
<EmptyState
|
||||
title={t('profile_not_found_title')}
|
||||
body={t('profile_not_found_body')}
|
||||
action={
|
||||
<AppButton variant="outlined" color="primary" onClick={() => router.push(`/${locale}${ROUTES.SEARCH}`)}>
|
||||
{t('profile_not_found_cta')}
|
||||
</AppButton>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ErrorState message={t('profile_error_body')} retryLabel={t('retry')} onRetry={() => refetch()} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,10 +102,10 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) {
|
||||
const t = useTranslations('search');
|
||||
const locale = useLocale();
|
||||
const name = profile.nurseName.trim() || t('unnamed_nurse');
|
||||
const rating = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
const rating = formatNumber(profile.averageRating, locale, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(profile.averageRating);
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
@@ -161,7 +157,7 @@ function AttributeChips({ profile }: { profile: NurseProfile }) {
|
||||
const locale = useLocale();
|
||||
const chips: string[] = [];
|
||||
if (profile.yearsExperience != null && profile.yearsExperience > 0) {
|
||||
const years = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(profile.yearsExperience);
|
||||
const years = formatNumber(profile.yearsExperience, locale);
|
||||
chips.push(t('years_experience', { years }));
|
||||
}
|
||||
for (const code of profile.attributeChips) {
|
||||
@@ -224,16 +220,7 @@ function ReviewsPanel({ nurseId }: { nurseId: number }) {
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1.5 }}>
|
||||
{t('load_error')}
|
||||
</Typography>
|
||||
<AppButton variant="outlined" color="primary" onClick={() => refetch()}>
|
||||
{t('retry')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
);
|
||||
return <ErrorState message={t('load_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
|
||||
}
|
||||
|
||||
const aggregate = data?.pages[0]?.aggregate;
|
||||
@@ -248,15 +235,15 @@ function ReviewsPanel({ nurseId }: { nurseId: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
const average = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
const average = formatNumber(aggregate?.averageRating ?? 0, locale, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(aggregate?.averageRating ?? 0);
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<RatingInput value={Math.round(aggregate?.averageRating ?? 0)} readOnly size={20} ariaLabel={t('rating_label')} />
|
||||
<RatingInput value={aggregate?.averageRating ?? 0} readOnly size={20} ariaLabel={t('rating_label')} />
|
||||
<Typography variant="h6" component="p" sx={{ fontWeight: 700 }}>
|
||||
{average}
|
||||
</Typography>
|
||||
|
||||
@@ -1,220 +1,13 @@
|
||||
'use client';
|
||||
import { Suspense, type FunctionComponent, type ReactNode } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import {
|
||||
Box,
|
||||
InputAdornment,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
TextField,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading, CategoryTile } from '@/components';
|
||||
import CascadingRegionSelect from '@/components/geography/CascadingRegionSelect';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useServiceCategories } from '@/services/catalog';
|
||||
import { pickCatalogName } from '@/services/catalog/names';
|
||||
import { useNurseSearch } from '@/services/search';
|
||||
import { filtersToSearchParams } from '@/services/search/filterParams';
|
||||
import type { NurseGender } from '@/services/search/types';
|
||||
import { useSearchFilters } from './useSearchFilters';
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import SearchScreen from './SearchScreen';
|
||||
|
||||
/**
|
||||
* C1 — Search & filter (جستجو و فیلتر): the discovery entry screen. Pick a care category (reusing the
|
||||
* f4 catalog grid), a city (reusing the f3 cascading region picker; district optional = whole city),
|
||||
* the **prominent same-gender facet**, and an optional Toman price range; a live result count drives the
|
||||
* "مشاهده N پرستار" CTA into C2. Availability (date) is intent-only at MVP — it is carried to booking,
|
||||
* never used to hard-filter results. `useSearchParams` needs a Suspense boundary under static rendering.
|
||||
*/
|
||||
export default function SearchPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<SearchFilterScreen />
|
||||
</Suspense>
|
||||
);
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'search' });
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
const GENDER_OPTIONS: readonly (NurseGender | 'any')[] = ['female', 'male', 'any'];
|
||||
|
||||
function SearchFilterScreen() {
|
||||
const t = useTranslations('search');
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const params = useSearchParams();
|
||||
|
||||
const initialCategoryRaw = Number(params.get('category_id'));
|
||||
const initialCategoryId = Number.isInteger(initialCategoryRaw) && initialCategoryRaw > 0 ? initialCategoryRaw : undefined;
|
||||
|
||||
const controller = useSearchFilters(initialCategoryId);
|
||||
const { data, isFetching } = useNurseSearch(controller.filters);
|
||||
const count = data?.total;
|
||||
|
||||
const goToResults = () => {
|
||||
const query = filtersToSearchParams(controller.filters);
|
||||
if (controller.dateIntent) query.set('date', controller.dateIntent);
|
||||
router.push(`/${locale}${ROUTES.SEARCH_RESULTS}?${query.toString()}`);
|
||||
};
|
||||
|
||||
const ctaLabel = !controller.isReady
|
||||
? t('cta_choose_category_city')
|
||||
: isFetching || count == null
|
||||
? t('cta_loading')
|
||||
: t('cta_view_results', { count });
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<CategorySelect selectedId={controller.categoryId} onSelect={controller.setCategoryId} />
|
||||
|
||||
<FilterSection title={t('section_location')}>
|
||||
<CascadingRegionSelect value={controller.region} onChange={controller.setRegion} includeDistrict />
|
||||
</FilterSection>
|
||||
|
||||
<FilterSection title={t('section_gender')} hint={t('gender_hint')}>
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
fullWidth
|
||||
color="primary"
|
||||
value={controller.gender ?? 'any'}
|
||||
onChange={(_event, value: NurseGender | 'any' | null) => {
|
||||
if (value != null) controller.setGender(value === 'any' ? undefined : value);
|
||||
}}
|
||||
>
|
||||
{GENDER_OPTIONS.map((option) => (
|
||||
<ToggleButton key={option} value={option} sx={{ fontWeight: 700 }}>
|
||||
{t(`gender_${option}`)}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
</FilterSection>
|
||||
|
||||
<FilterSection title={t('section_date')} hint={t('date_hint')}>
|
||||
<TextField
|
||||
type="date"
|
||||
fullWidth
|
||||
value={controller.dateIntent}
|
||||
onChange={(event) => controller.setDateIntent(event.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
</FilterSection>
|
||||
|
||||
<FilterSection title={t('section_price')} hint={t('price_hint')}>
|
||||
<Stack direction="row" sx={{ gap: 2 }}>
|
||||
<PriceField
|
||||
label={t('price_min')}
|
||||
value={controller.priceMinToman}
|
||||
onChange={controller.setPriceMinToman}
|
||||
adornment={t('toman')}
|
||||
/>
|
||||
<PriceField
|
||||
label={t('price_max')}
|
||||
value={controller.priceMaxToman}
|
||||
onChange={controller.setPriceMaxToman}
|
||||
adornment={t('toman')}
|
||||
/>
|
||||
</Stack>
|
||||
</FilterSection>
|
||||
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={!controller.isReady}
|
||||
onClick={goToResults}
|
||||
startIcon="search"
|
||||
sx={{ py: 1.5 }}
|
||||
>
|
||||
{ctaLabel}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
);
|
||||
export default function Page() {
|
||||
return <SearchScreen />;
|
||||
}
|
||||
|
||||
const FilterSection: FunctionComponent<{ title: string; hint?: string; children: ReactNode }> = ({
|
||||
title,
|
||||
hint,
|
||||
children,
|
||||
}) => (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{hint ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{hint}
|
||||
</Typography>
|
||||
) : null}
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const PriceField: FunctionComponent<{
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
adornment: string;
|
||||
}> = ({ label, value, onChange, adornment }) => (
|
||||
<TextField
|
||||
label={label}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
inputMode="numeric"
|
||||
fullWidth
|
||||
slotProps={{
|
||||
input: { endAdornment: <InputAdornment position="end">{adornment}</InputAdornment> },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
/** The reused f4 category grid (data-driven from the cached catalog reference data), with selection. */
|
||||
const CategorySelect: FunctionComponent<{ selectedId: number | null; onSelect: (id: number) => void }> = ({
|
||||
selectedId,
|
||||
onSelect,
|
||||
}) => {
|
||||
const t = useTranslations('search');
|
||||
const locale = useLocale();
|
||||
const { data, isLoading, isError } = useServiceCategories();
|
||||
const categories = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<FilterSection title={t('section_category')}>
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
|
||||
{[0, 1, 2, 3].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
|
||||
))}
|
||||
</Box>
|
||||
) : isError ? (
|
||||
<Paper elevation={0} sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('categories_error')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
|
||||
{categories.map((category) => (
|
||||
<CategoryTile
|
||||
key={category.id}
|
||||
label={pickCatalogName(category, locale)}
|
||||
iconKey={category.iconKey}
|
||||
selected={category.id === selectedId}
|
||||
onClick={() => onSelect(category.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</FilterSection>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { Suspense, useCallback, useMemo, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, MenuItem, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading, NurseResultCard } from '@/components';
|
||||
import { MenuItem, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, AppLoading, EmptyState, ErrorState, NurseResultCard } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useNurseSearch } from '@/services/search';
|
||||
import { searchParamsToFilters } from '@/services/search/filterParams';
|
||||
@@ -27,6 +27,7 @@ export default function SearchResultsPage() {
|
||||
|
||||
function ResultsScreen() {
|
||||
const t = useTranslations('search');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
@@ -72,20 +73,13 @@ function ResultsScreen() {
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{[0, 1, 2, 3].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={112} sx={{ borderRadius: 2 }} />
|
||||
<NurseResultCard.Skeleton key={key} />
|
||||
))}
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
||||
{t('results_error')}
|
||||
</Typography>
|
||||
<AppButton variant="outlined" color="primary" onClick={() => refetch()}>
|
||||
{t('retry')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
<ErrorState message={t('results_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState onRelax={backToFilters} />
|
||||
<RelaxFiltersEmptyState onRelax={backToFilters} />
|
||||
) : (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{items.map((nurse) => (
|
||||
@@ -109,28 +103,30 @@ function ResultsScreen() {
|
||||
}
|
||||
|
||||
/** The "no nurses match → relax your filters" state with concrete, product-aligned suggestions. */
|
||||
function EmptyState({ onRelax }: { onRelax: () => void }) {
|
||||
function RelaxFiltersEmptyState({ onRelax }: { onRelax: () => void }) {
|
||||
const t = useTranslations('search');
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<AppIcon icon="search" size={40} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1 }}>
|
||||
{t('empty_title')}
|
||||
</Typography>
|
||||
<Stack sx={{ gap: 0.5, mt: 1, mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('empty_suggest_gender')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('empty_suggest_district')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('empty_suggest_city')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<AppButton variant="contained" color="primary" onClick={onRelax} startIcon="tune">
|
||||
{t('empty_cta')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
<EmptyState
|
||||
icon="search"
|
||||
title={t('empty_title')}
|
||||
body={
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('empty_suggest_gender')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('empty_suggest_district')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('empty_suggest_city')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
}
|
||||
action={
|
||||
<AppButton variant="contained" color="primary" onClick={onRelax} startIcon="tune">
|
||||
{t('empty_cta')}
|
||||
</AppButton>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, InstallmentScheduleRow, PlaceholderScreen } from '@/components';
|
||||
import { formatIrrToToman, formatShamsiDate } from '@/utils';
|
||||
import { AppButton, AppIcon, InstallmentScheduleRow, Money, PlaceholderScreen } from '@/components';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { useWalletInstallments } from '@/services/bnpl';
|
||||
import type { WalletInstallmentPlan } from '@/services/bnpl/types';
|
||||
|
||||
@@ -62,7 +62,6 @@ const WalletInstallments: FunctionComponent = () => {
|
||||
|
||||
function InstallmentPlanSection({ plan }: { plan: WalletInstallmentPlan }) {
|
||||
const t = useTranslations('bnpl');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const providerName = t(`provider_${plan.providerCode}`);
|
||||
|
||||
@@ -81,9 +80,7 @@ function InstallmentPlanSection({ plan }: { plan: WalletInstallmentPlan }) {
|
||||
<Typography variant="caption" sx={{ opacity: 0.85 }}>
|
||||
{t('outstanding_balance')}
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 800, mt: 0.25 }}>
|
||||
{formatIrrToToman(plan.outstandingBalanceIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<Money amountIrr={plan.outstandingBalanceIrr} size="lg" sx={{ fontWeight: 800, mt: 0.25 }} />
|
||||
<Typography variant="caption" sx={{ opacity: 0.85, display: 'block', mt: 0.5 }}>
|
||||
{plan.serviceLabel}
|
||||
</Typography>
|
||||
@@ -95,9 +92,7 @@ function InstallmentPlanSection({ plan }: { plan: WalletInstallmentPlan }) {
|
||||
{t('next_installment')} · {formatShamsiDate(`${plan.nextDueDate}T00:00:00`, locale)}
|
||||
</Typography>
|
||||
{plan.nextAmountIrr ? (
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
|
||||
{formatIrrToToman(plan.nextAmountIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<Money amountIrr={plan.nextAmountIrr} size="sm" sx={{ fontWeight: 800 }} />
|
||||
) : null}
|
||||
</Box>
|
||||
{plan.earlyPayUrl ? (
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import SurfaceCard from '@/components/common/SurfaceCard';
|
||||
|
||||
/**
|
||||
* The shared loading skeleton for the sidebar-shell route groups (`nurse`, `admin`, `partner`) — the
|
||||
* `TopBarAndSideBarLayout` chrome (top bar + sidebar) is already rendered by the enclosing `layout.tsx`
|
||||
* by the time this shows, so this only needs to shape the content area: a heading line + a short stack of
|
||||
* generic worklist/detail cards. A private (`_`-prefixed) folder — not a route.
|
||||
*/
|
||||
export default function SidebarShellSkeleton() {
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Skeleton variant="text" width={220} height={32} />
|
||||
{[0, 1, 2].map((key) => (
|
||||
<SurfaceCard key={key}>
|
||||
<Stack sx={{ gap: 0.75 }}>
|
||||
<Skeleton variant="text" width="40%" height={22} />
|
||||
<Skeleton variant="text" width="70%" height={18} />
|
||||
<Skeleton variant="text" width="55%" height={18} />
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Paper, Typography } from '@mui/material';
|
||||
import { AppIcon, AppLink } from '@/components';
|
||||
import { AdminPageHeader } from '@/components/admin';
|
||||
import { useAdminCapabilities } from '@/hooks';
|
||||
import { ROUTES } from '@/constants';
|
||||
|
||||
/**
|
||||
* Admin overview landing (f15) — the backoffice home. Renders one **console card** per worklist the current
|
||||
* principal may act on, derived from `useAdminCapabilities()` (a UI hint; the server still enforces every
|
||||
* command's role scope). A `support` admin sees verification/tickets/alerts; a `finance` admin sees
|
||||
* payouts/config; only a `super_admin` sees roles. Each card deep-links into its console.
|
||||
*/
|
||||
export default function AdminOverviewScreen() {
|
||||
const t = useTranslations('admin');
|
||||
const tNav = useTranslations('nav');
|
||||
const locale = useLocale();
|
||||
const caps = useAdminCapabilities();
|
||||
|
||||
// `key` doubles as the `nav` i18n key for the card label.
|
||||
const consoles: { key: string; route: string; icon: string; enabled: boolean }[] = [
|
||||
{ key: 'verification', route: ROUTES.ADMIN_VERIFICATION, icon: 'verification', enabled: caps.canVerify },
|
||||
{ key: 'tickets', route: ROUTES.ADMIN_TICKETS, icon: 'support', enabled: caps.canManageTickets },
|
||||
{ key: 'payouts', route: ROUTES.ADMIN_PAYOUTS, icon: 'earnings', enabled: caps.canPayout },
|
||||
{ key: 'reviews', route: ROUTES.ADMIN_REVIEWS, icon: 'moderation', enabled: caps.canModerate },
|
||||
{ key: 'config', route: ROUTES.ADMIN_CONFIG, icon: 'config', enabled: caps.canConfig },
|
||||
{ key: 'holidays', route: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', enabled: caps.canConfig },
|
||||
{ key: 'alerts', route: ROUTES.ADMIN_ALERTS, icon: 'alerts', enabled: caps.canManageAlerts },
|
||||
{ key: 'audit', route: ROUTES.ADMIN_AUDIT, icon: 'audit', enabled: caps.canViewAudit },
|
||||
{ key: 'partners', route: ROUTES.ADMIN_PARTNERS, icon: 'partners', enabled: caps.canManagePartners },
|
||||
{ key: 'roles', route: ROUTES.ADMIN_ROLES, icon: 'roles', enabled: caps.canManageRoles },
|
||||
].filter((c) => c.enabled);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<AdminPageHeader title={t('overview_title')} subtitle={t('overview_subtitle')} />
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr' },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{consoles.map((c) => (
|
||||
<AppLink
|
||||
key={c.key}
|
||||
to={`/${locale}${c.route}`}
|
||||
color="inherit"
|
||||
underline="none"
|
||||
sx={{ display: 'block', height: '100%' }}
|
||||
>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
height: '100%',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: 1.5,
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 150ms ease, box-shadow 150ms ease',
|
||||
'&:hover': { borderColor: 'primary.main', boxShadow: 3 },
|
||||
}}
|
||||
>
|
||||
<AppIcon icon={c.icon} size={32} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{tNav(c.key)}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</AppLink>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import SidebarShellSkeleton from '../_chrome/SidebarShellSkeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return <SidebarShellSkeleton />;
|
||||
}
|
||||
@@ -1,81 +1,13 @@
|
||||
'use client';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Paper, Typography } from '@mui/material';
|
||||
import { AppIcon, AppLink } from '@/components';
|
||||
import { AdminPageHeader } from '@/components/admin';
|
||||
import { useAdminCapabilities } from '@/hooks';
|
||||
import { ROUTES } from '@/constants';
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import AdminOverviewScreen from './AdminOverviewScreen';
|
||||
|
||||
/**
|
||||
* Admin overview landing (f15) — the backoffice home. Renders one **console card** per worklist the current
|
||||
* principal may act on, derived from `useAdminCapabilities()` (a UI hint; the server still enforces every
|
||||
* command's role scope). A `support` admin sees verification/tickets/alerts; a `finance` admin sees
|
||||
* payouts/config; only a `super_admin` sees roles. Each card deep-links into its console.
|
||||
*/
|
||||
export default function AdminOverviewPage() {
|
||||
const t = useTranslations('admin');
|
||||
const tNav = useTranslations('nav');
|
||||
const locale = useLocale();
|
||||
const caps = useAdminCapabilities();
|
||||
|
||||
// `key` doubles as the `nav` i18n key for the card label.
|
||||
const consoles: { key: string; route: string; icon: string; enabled: boolean }[] = [
|
||||
{ key: 'verification', route: ROUTES.ADMIN_VERIFICATION, icon: 'verification', enabled: caps.canVerify },
|
||||
{ key: 'tickets', route: ROUTES.ADMIN_TICKETS, icon: 'support', enabled: caps.canManageTickets },
|
||||
{ key: 'payouts', route: ROUTES.ADMIN_PAYOUTS, icon: 'earnings', enabled: caps.canPayout },
|
||||
{ key: 'reviews', route: ROUTES.ADMIN_REVIEWS, icon: 'moderation', enabled: caps.canModerate },
|
||||
{ key: 'config', route: ROUTES.ADMIN_CONFIG, icon: 'config', enabled: caps.canConfig },
|
||||
{ key: 'holidays', route: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', enabled: caps.canConfig },
|
||||
{ key: 'alerts', route: ROUTES.ADMIN_ALERTS, icon: 'alerts', enabled: caps.canManageAlerts },
|
||||
{ key: 'audit', route: ROUTES.ADMIN_AUDIT, icon: 'audit', enabled: caps.canViewAudit },
|
||||
{ key: 'partners', route: ROUTES.ADMIN_PARTNERS, icon: 'partners', enabled: caps.canManagePartners },
|
||||
{ key: 'roles', route: ROUTES.ADMIN_ROLES, icon: 'roles', enabled: caps.canManageRoles },
|
||||
].filter((c) => c.enabled);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<AdminPageHeader title={t('overview_title')} subtitle={t('overview_subtitle')} />
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr' },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{consoles.map((c) => (
|
||||
<AppLink
|
||||
key={c.key}
|
||||
to={`/${locale}${c.route}`}
|
||||
color="inherit"
|
||||
underline="none"
|
||||
sx={{ display: 'block', height: '100%' }}
|
||||
>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
height: '100%',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: 1.5,
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 150ms ease, box-shadow 150ms ease',
|
||||
'&:hover': { borderColor: 'primary.main', boxShadow: 3 },
|
||||
}}
|
||||
>
|
||||
<AppIcon icon={c.icon} size={32} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{tNav(c.key)}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</AppLink>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'admin' });
|
||||
return { title: t('overview_title') };
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return <AdminOverviewScreen />;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, StatusChip } from '@/components';
|
||||
import { AppButton, Money, StatusChip } from '@/components';
|
||||
import type { StatusKind } from '@/components';
|
||||
import {
|
||||
AdminDataTable,
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
import type { AdminTableColumn } from '@/components/admin';
|
||||
import { useAdminCapabilities } from '@/hooks';
|
||||
import { adminPayoutBatchPath } from '@/constants';
|
||||
import { formatIrrToToman, formatShamsiDate } from '@/utils';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
|
||||
import { usePayoutBatches, usePreviewPayoutBatch, useRunPayoutBatch } from '@/services/payouts';
|
||||
import type { PayoutBatchStatus, PayoutBatchSummary } from '@/services/payouts/types';
|
||||
@@ -63,7 +63,6 @@ const isoDate = (d: Date): string => d.toISOString().slice(0, 10);
|
||||
*/
|
||||
export default function AdminPayoutsPage() {
|
||||
const t = useTranslations('admin');
|
||||
const tCommon = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const caps = useAdminCapabilities();
|
||||
@@ -92,7 +91,7 @@ export default function AdminPayoutsPage() {
|
||||
{
|
||||
key: 'total',
|
||||
header: t('payout_col_total'),
|
||||
render: (b) => `${formatIrrToToman(b.totalAmount, locale)} ${tCommon('currency_toman')}`,
|
||||
render: (b) => <Money amountIrr={b.totalAmount} size="sm" />,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
@@ -189,7 +188,6 @@ export default function AdminPayoutsPage() {
|
||||
*/
|
||||
function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClose: () => void }) {
|
||||
const t = useTranslations('admin');
|
||||
const tCommon = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
@@ -304,9 +302,9 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
|
||||
) : null}
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('payout_col_gross')}: {formatIrrToToman(n.grossEarningsIrr, locale)} ·{' '}
|
||||
{t('payout_col_clawback')}: {formatIrrToToman(n.clawbackAppliedIrr, locale)} ·{' '}
|
||||
{t('payout_col_net')}: {formatIrrToToman(n.netAmountIrr, locale)} {tCommon('currency_toman')}
|
||||
{t('payout_col_gross')}: <Money amountIrr={n.grossEarningsIrr} size="sm" hideUnit /> ·{' '}
|
||||
{t('payout_col_clawback')}: <Money amountIrr={n.clawbackAppliedIrr} size="sm" hideUnit /> ·{' '}
|
||||
{t('payout_col_net')}: <Money amountIrr={n.netAmountIrr} size="sm" />
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Box, Paper, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading, BankStatusPanel } from '@/components';
|
||||
import { Box, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, AppLoading, BankStatusPanel, EmptyState } from '@/components';
|
||||
import { useNurseBankAccounts, useAddNurseBankAccount, useSetPrimaryBankAccount } from '@/services/nurse';
|
||||
import { isValidSheba } from '@/services/nurse/iban';
|
||||
import { deriveBankStatus } from '@/services/nurse/types';
|
||||
@@ -102,18 +102,7 @@ export default function NurseBankPage() {
|
||||
})}
|
||||
|
||||
{!isLoading && accounts.length === 0 ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}
|
||||
>
|
||||
<AppIcon icon="bank" size={36} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('empty_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('empty_body')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<EmptyState icon="bank" title={t('empty_title')} body={t('empty_body')} />
|
||||
) : null}
|
||||
|
||||
{showFormNow ? (
|
||||
|
||||
@@ -3,8 +3,9 @@ import { useMemo, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Box, Collapse, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, EarningsBalanceHeader, EarningsRow } from '@/components';
|
||||
import { AppButton, AppIcon, EarningsBalanceHeader, EarningsRow, EmptyState, ErrorState } from '@/components';
|
||||
import { nurseBookingDetailPath, nursePayoutDetailPath } from '@/constants';
|
||||
import { formatNumber } from '@/utils';
|
||||
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
|
||||
import { EARNINGS_STATES, type EarningsState } from '@/services/payouts/types';
|
||||
import { useNurseEarnings, useNurseEarningsBalance } from '@/services/payouts';
|
||||
@@ -58,7 +59,7 @@ export default function NurseEarningsPage() {
|
||||
{balance.isLoading ? (
|
||||
<Skeleton variant="rounded" height={200} />
|
||||
) : balance.isError ? (
|
||||
<ErrorPanel message={t('balance_error')} onRetry={() => balance.refetch()} retryLabel={t('retry')} />
|
||||
<ErrorState message={t('balance_error')} retryLabel={t('retry')} onRetry={() => balance.refetch()} />
|
||||
) : balance.data ? (
|
||||
<EarningsBalanceHeader summary={balance.data} />
|
||||
) : null}
|
||||
@@ -85,9 +86,9 @@ export default function NurseEarningsPage() {
|
||||
))}
|
||||
</Stack>
|
||||
) : earnings.isError ? (
|
||||
<ErrorPanel message={t('list_error')} onRetry={() => earnings.refetch()} retryLabel={t('retry')} />
|
||||
<ErrorState message={t('list_error')} retryLabel={t('retry')} onRetry={() => earnings.refetch()} />
|
||||
) : items.length === 0 ? (
|
||||
<EmptyPanel title={t('list_empty_title')} body={t('list_empty_body')} />
|
||||
<EmptyState icon="earnings" title={t('list_empty_title')} body={t('list_empty_body')} />
|
||||
) : (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{items.map((item) => (
|
||||
@@ -150,36 +151,6 @@ function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyPanel({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<AppIcon icon="earnings" size={40} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{body}
|
||||
</Typography>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorPanel({ message, onRetry, retryLabel }: { message: string; onRetry: () => void; retryLabel: string }) {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1.5 }}>
|
||||
{message}
|
||||
</Typography>
|
||||
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={onRetry}>
|
||||
{retryLabel}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/** Prev/next pager — rendered only when there is more than one page. */
|
||||
function Pager({
|
||||
page,
|
||||
@@ -195,7 +166,7 @@ function Pager({
|
||||
const t = useTranslations('payouts');
|
||||
const locale = useLocale();
|
||||
if (pageCount <= 1) return null;
|
||||
const fmt = (n: number) => new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(n);
|
||||
const fmt = (n: number) => formatNumber(n, locale);
|
||||
|
||||
return (
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
|
||||
@@ -3,10 +3,10 @@ import { FunctionComponent, ReactNode } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Divider, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, PriceBreakdown, StatusChip } from '@/components';
|
||||
import { AppButton, AppIcon, Money, PriceBreakdown, StatusChip } from '@/components';
|
||||
import type { StatusKind } from '@/components';
|
||||
import { nurseBookingDetailPath, ROUTES } from '@/constants';
|
||||
import { formatIrrToToman, formatShamsiDate, parseIrr } from '@/utils';
|
||||
import { formatShamsiDate, parseIrr } from '@/utils';
|
||||
import { useNursePayoutDetail } from '@/services/payouts';
|
||||
import type { PayoutBatchStatus, PayoutStatus } from '@/services/payouts/types';
|
||||
|
||||
@@ -34,7 +34,6 @@ const BATCH_STATUS_KIND: Record<PayoutBatchStatus, StatusKind> = {
|
||||
*/
|
||||
export default function NursePayoutDetailPage() {
|
||||
const t = useTranslations('payouts');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const params = useParams<{ id: string }>();
|
||||
@@ -159,9 +158,7 @@ export default function NursePayoutDetailPage() {
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('amount_transferred_label')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{formatIrrToToman(data.amountIrr, locale)}
|
||||
</Typography>
|
||||
<Money amountIrr={data.amountIrr} size="sm" hideUnit sx={{ fontWeight: 700 }} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -184,9 +181,7 @@ export default function NursePayoutDetailPage() {
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{t('booking_ref', { id: link.bookingId })}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{formatIrrToToman(link.payoutAmountIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<Money amountIrr={link.payoutAmountIrr} size="sm" tone="muted" />
|
||||
</Stack>
|
||||
<AppButton
|
||||
variant="text"
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, PayoutHistoryRow } from '@/components';
|
||||
import { Box, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, EmptyState, ErrorState, PayoutHistoryRow } from '@/components';
|
||||
import { nursePayoutDetailPath, ROUTES } from '@/constants';
|
||||
import { formatNumber } from '@/utils';
|
||||
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
|
||||
import { useNursePayoutHistory } from '@/services/payouts';
|
||||
|
||||
@@ -25,7 +26,7 @@ export default function NursePayoutHistoryPage() {
|
||||
const items = history.data?.items ?? [];
|
||||
const total = history.data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / PAYOUTS_PAGE_SIZE));
|
||||
const fmt = (n: number) => new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(n);
|
||||
const fmt = (n: number) => formatNumber(n, locale);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
@@ -54,24 +55,9 @@ export default function NursePayoutHistoryPage() {
|
||||
))}
|
||||
</Stack>
|
||||
) : history.isError ? (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1.5 }}>
|
||||
{t('history_error')}
|
||||
</Typography>
|
||||
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={() => history.refetch()}>
|
||||
{t('retry')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
<ErrorState message={t('history_error')} retryLabel={t('retry')} onRetry={() => history.refetch()} />
|
||||
) : items.length === 0 ? (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<AppIcon icon="earnings" size={40} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1 }}>
|
||||
{t('history_empty_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{t('history_empty_body')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<EmptyState icon="earnings" title={t('history_empty_title')} body={t('history_empty_body')} />
|
||||
) : (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{items.map((item) => (
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import SidebarShellSkeleton from '../_chrome/SidebarShellSkeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return <SidebarShellSkeleton />;
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { PlaceholderScreen } from '@/components';
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'nav' });
|
||||
return { title: t('dashboard') };
|
||||
}
|
||||
|
||||
export default async function NurseDashboardPage() {
|
||||
const t = await getTranslations('nav');
|
||||
const tShell = await getTranslations('shell');
|
||||
|
||||
@@ -41,7 +41,10 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
uploadAvatar.mutate(file, { onSuccess: (result) => setAvatarUrl(result.url) });
|
||||
uploadAvatar.mutate(file, {
|
||||
onSuccess: (result) => setAvatarUrl(result.url),
|
||||
onError: () => enqueueSnackbar(t('avatar_upload_error'), { variant: 'error' }),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
@@ -60,7 +63,10 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
|
||||
specializationsJson: initial?.specializationsJson ?? '[]',
|
||||
avatarUrl,
|
||||
},
|
||||
{ onSuccess: () => enqueueSnackbar(t('saved'), { variant: 'success' }) },
|
||||
{
|
||||
onSuccess: () => enqueueSnackbar(t('saved'), { variant: 'success' }),
|
||||
onError: () => enqueueSnackbar(t('save_error'), { variant: 'error' }),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { AppButton, AppIcon, CountdownTimer, PriceDisplay, StatusChip } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { formatShamsiDate, localeTag } from '@/utils';
|
||||
import {
|
||||
useAcceptBookingRequest,
|
||||
useBookingRequest,
|
||||
@@ -117,7 +117,7 @@ export default function NurseRequestDetailPage() {
|
||||
|
||||
const startDate = new Date(`${request.requestedDate}T${request.requestedTimeStart}`);
|
||||
const endDate = new Date(`${request.requestedDate}T${request.requestedTimeEnd}`);
|
||||
const timeFmt = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { hour: '2-digit', minute: '2-digit' });
|
||||
const timeFmt = new Intl.DateTimeFormat(localeTag(locale), { hour: '2-digit', minute: '2-digit' });
|
||||
const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} – ${timeFmt.format(endDate)}`;
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Box, Chip, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, CountdownTimer } from '@/components';
|
||||
import { AppButton, CountdownTimer, EmptyState, ErrorState } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { formatShamsiDate, localeTag } from '@/utils';
|
||||
import { useNurseRequestInbox } from '@/services/bookingRequests';
|
||||
import type { BookingRequestListItem } from '@/services/bookingRequests/types';
|
||||
|
||||
@@ -16,7 +16,8 @@ import type { BookingRequestListItem } from '@/services/bookingRequests/types';
|
||||
*/
|
||||
export default function NurseRequestsPage() {
|
||||
const t = useTranslations('booking');
|
||||
const { data, isLoading } = useNurseRequestInbox();
|
||||
const tc = useTranslations('common');
|
||||
const { data, isLoading, isError, refetch } = useNurseRequestInbox();
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
@@ -36,13 +37,10 @@ export default function NurseRequestsPage() {
|
||||
<Skeleton key={key} variant="rounded" height={140} />
|
||||
))}
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
<ErrorState message={t('inbox_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />
|
||||
) : items.length === 0 ? (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<AppIcon icon="requests" size={40} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
|
||||
{t('inbox_empty')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<EmptyState icon="requests" title={t('inbox_empty')} />
|
||||
) : (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{items.map((item) => (
|
||||
@@ -61,7 +59,7 @@ function InboxCard({ item }: { item: BookingRequestListItem }) {
|
||||
|
||||
const startDate = new Date(`${item.requestedDate}T${item.requestedTimeStart}`);
|
||||
const endDate = new Date(`${item.requestedDate}T${item.requestedTimeEnd}`);
|
||||
const timeFmt = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { hour: '2-digit', minute: '2-digit' });
|
||||
const timeFmt = new Intl.DateTimeFormat(localeTag(locale), { hour: '2-digit', minute: '2-digit' });
|
||||
const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} – ${timeFmt.format(endDate)}`;
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,12 +8,11 @@ import {
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, AppIcon, VariantCard } from '@/components';
|
||||
import { AppButton, EmptyState, ErrorState, VariantCard } from '@/components';
|
||||
import { useMyVariants, useSetVariantActive } from '@/services/catalog';
|
||||
import type { NurseServiceVariant } from '@/services/catalog/types';
|
||||
import PublishGate from './PublishGate';
|
||||
@@ -34,12 +33,12 @@ const MyServicesList: FunctionComponent<MyServicesListProps> = ({ onAdd, onEdit
|
||||
const tc = useTranslations('common');
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const { data, isLoading } = useMyVariants();
|
||||
const { data, isLoading, isError, refetch } = useMyVariants();
|
||||
const setActive = useSetVariantActive();
|
||||
const [deactivateTarget, setDeactivateTarget] = useState<NurseServiceVariant | null>(null);
|
||||
|
||||
const variants = data?.items ?? [];
|
||||
const isEmpty = !isLoading && variants.length === 0;
|
||||
const isEmpty = !isLoading && !isError && variants.length === 0;
|
||||
|
||||
const toggleActive = (variant: NurseServiceVariant) => {
|
||||
// Deactivating is guarded by a confirm; reactivating is safe, so it fires immediately.
|
||||
@@ -80,7 +79,7 @@ const MyServicesList: FunctionComponent<MyServicesListProps> = ({ onAdd, onEdit
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
{!isEmpty && !isLoading ? (
|
||||
{!isEmpty && !isLoading && !isError ? (
|
||||
<AppButton color="primary" variant="contained" startIcon="add" onClick={onAdd} sx={{ flexShrink: 0 }}>
|
||||
{t('add')}
|
||||
</AppButton>
|
||||
@@ -95,32 +94,19 @@ const MyServicesList: FunctionComponent<MyServicesListProps> = ({ onAdd, onEdit
|
||||
<Skeleton key={key} variant="rounded" height={150} sx={{ borderRadius: 2 }} />
|
||||
))}
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
<ErrorState message={t('list_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />
|
||||
) : isEmpty ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<AppIcon icon="services" size={40} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('empty_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 420 }}>
|
||||
{t('empty_body')}
|
||||
</Typography>
|
||||
<AppButton color="primary" variant="contained" startIcon="add" onClick={onAdd} sx={{ mt: 1 }}>
|
||||
{t('add')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
<EmptyState
|
||||
icon="services"
|
||||
title={t('empty_title')}
|
||||
body={t('empty_body')}
|
||||
action={
|
||||
<AppButton color="primary" variant="contained" startIcon="add" onClick={onAdd}>
|
||||
{t('add')}
|
||||
</AppButton>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{variants.map((variant) => (
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
ToggleButtonGroup,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, AppLoading, CategoryTile, PriceDisplay, StepperHeader } from '@/components';
|
||||
import { AppButton, AppLoading, CategoryTile, ErrorState, PriceDisplay, StepperHeader } from '@/components';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { digitsOnly, rialToToman, tomanToRial } from '@/utils';
|
||||
import {
|
||||
@@ -383,6 +383,14 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
|
||||
|
||||
{optionGroupsQuery.isLoading ? (
|
||||
<AppLoading />
|
||||
) : optionGroupsQuery.isError ? (
|
||||
// A failed fetch must never read as "this category has zero options" — that would let the
|
||||
// nurse skip required options entirely. Block progression until the retry succeeds.
|
||||
<ErrorState
|
||||
message={t('options_error')}
|
||||
retryLabel={tc('retry')}
|
||||
onRetry={() => optionGroupsQuery.refetch()}
|
||||
/>
|
||||
) : groups.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('options_none')}
|
||||
@@ -472,7 +480,12 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
|
||||
{t('next')}
|
||||
</AppButton>
|
||||
) : activeStep === 1 ? (
|
||||
<AppButton color="primary" variant="contained" onClick={goNextFromOptions}>
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
onClick={goNextFromOptions}
|
||||
disabled={optionGroupsQuery.isError}
|
||||
>
|
||||
{t('next')}
|
||||
</AppButton>
|
||||
) : (
|
||||
|
||||
+2
-1
@@ -3,6 +3,7 @@ import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, LinearProgress, Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, StatusChip } from '@/components';
|
||||
import { formatNumber } from '@/utils';
|
||||
import type { VerificationStatus, VerificationStep } from '@/services/verification/types';
|
||||
import {
|
||||
displaySteps,
|
||||
@@ -46,7 +47,7 @@ const ProgressMeter: FunctionComponent<{ passed: number; total: number }> = ({ p
|
||||
const t = useTranslations('verification');
|
||||
const locale = useLocale();
|
||||
const percent = total === 0 ? 0 : (passed / total) * 100;
|
||||
const format = (value: number) => new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(value);
|
||||
const format = (value: number) => formatNumber(value, locale);
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'baseline', mb: 1 }}>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppAlert, AppButton, AppIcon } from '@/components';
|
||||
import { AppAlert, AppButton, AppIcon, EmptyState } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useStartVerification, useVerificationStatus } from '@/services/verification';
|
||||
import { verificationKeys } from '@/services/verification/keys';
|
||||
@@ -104,38 +104,23 @@ export default function NurseVerificationPage() {
|
||||
function NotStarted({ onStart, pending }: { onStart: () => void; pending: boolean }) {
|
||||
const t = useTranslations('verification');
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<AppIcon icon="verification" size={44} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('start_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 440 }}>
|
||||
{t('start_body')}
|
||||
</Typography>
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
startIcon="verification"
|
||||
onClick={onStart}
|
||||
disabled={pending}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
{pending ? t('starting') : t('start_cta')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
<EmptyState
|
||||
icon="verification"
|
||||
title={t('start_title')}
|
||||
body={t('start_body')}
|
||||
action={
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
startIcon="verification"
|
||||
onClick={onStart}
|
||||
disabled={pending}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
{pending ? t('starting') : t('start_cta')}
|
||||
</AppButton>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -209,29 +194,18 @@ function ContinueCta({
|
||||
function MockAdminControls({ onApprove, onReject }: { onApprove: () => void; onReject: () => void }) {
|
||||
const t = useTranslations('verification');
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
|
||||
{t('mock_admin_title')}
|
||||
</Typography>
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||
<AppButton variant="outlined" color="primary" onClick={onApprove}>
|
||||
{t('mock_admin_approve')}
|
||||
</AppButton>
|
||||
<AppButton variant="outlined" color="error" onClick={onReject}>
|
||||
{t('mock_admin_reject')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<EmptyState
|
||||
title={t('mock_admin_title')}
|
||||
action={
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<AppButton variant="outlined" color="primary" onClick={onApprove}>
|
||||
{t('mock_admin_approve')}
|
||||
</AppButton>
|
||||
<AppButton variant="outlined" color="error" onClick={onReject}>
|
||||
{t('mock_admin_reject')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon } from '@/components';
|
||||
import { Box, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, EmptyState } from '@/components';
|
||||
import { SessionCard, useEvvController } from '@/components/booking';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useSessionEvv, useTodaySessions } from '@/services/bookings';
|
||||
@@ -39,12 +39,7 @@ export default function NurseVisitsPage() {
|
||||
))}
|
||||
</Stack>
|
||||
) : items.length === 0 ? (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<AppIcon icon="visits" size={40} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
|
||||
{t('evv_no_visits')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<EmptyState icon="visits" title={t('evv_no_visits')} />
|
||||
) : (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{items.map((item) => (
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Alert, Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AdminEmptyState, AdminPageHeader } from '@/components/admin';
|
||||
import { StatusChip } from '@/components';
|
||||
import type { CenterOnboardingState, PartnerCenter } from '@/services/partnerCenter/types';
|
||||
import { useMyPartnerCenter } from '@/services/partnerCenter';
|
||||
|
||||
/**
|
||||
* Partner portal home (f15) — the signed-in center admin's **own** center at a glance (a separate authz
|
||||
* scope from /admin; tenancy is server-enforced). `useMyPartnerCenter` doubles as the access gate: a
|
||||
* 403/404 (non-owner / no center) surfaces the non-leaking access-denied state, never any center data.
|
||||
* On success it shows the onboarding banner (draft/pending/suspended), the license block, the
|
||||
* merchant-of-record indicator, and the masked settlement IBAN. Read-only.
|
||||
*/
|
||||
export default function PartnerHomeScreen() {
|
||||
const t = useTranslations('partner');
|
||||
const center = useMyPartnerCenter();
|
||||
|
||||
if (center.isLoading) {
|
||||
return (
|
||||
<Stack sx={{ gap: 3 }}>
|
||||
<Skeleton variant="rounded" height={72} />
|
||||
<Skeleton variant="rounded" height={64} />
|
||||
<Skeleton variant="rounded" height={220} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// The center query is the portal's access gate — a 403/404 means the caller owns no center.
|
||||
if (center.isError || !center.data) {
|
||||
return <AdminEmptyState icon="lock" title={t('access_denied')} />;
|
||||
}
|
||||
|
||||
const c = center.data;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<AdminPageHeader
|
||||
title={t('home_title')}
|
||||
subtitle={t('home_subtitle')}
|
||||
actions={
|
||||
<StatusChip
|
||||
status={c.isMerchantOfRecord ? 'active' : 'neutral'}
|
||||
label={c.isMerchantOfRecord ? t('is_mor_yes') : t('is_mor_no')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<OnboardingBanner state={c.onboardingState} />
|
||||
|
||||
<LicenseBlock center={c} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding/verification banner keyed off `onboardingState`. `verified` shows a subtle chip instead of a
|
||||
* banner; every other state shows an MUI `Alert` (draft/suspended → warning, pending → info).
|
||||
*/
|
||||
function OnboardingBanner({ state }: { state: CenterOnboardingState }) {
|
||||
const t = useTranslations('partner');
|
||||
const ta = useTranslations('admin');
|
||||
|
||||
if (state === 'verified') {
|
||||
return (
|
||||
<Box>
|
||||
<StatusChip status="verified" label={ta('center_state_verified')} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const banner: Record<Exclude<CenterOnboardingState, 'verified'>, { severity: 'warning' | 'info'; key: string }> = {
|
||||
draft: { severity: 'warning', key: 'state_banner_draft' },
|
||||
pending_verification: { severity: 'info', key: 'state_banner_pending' },
|
||||
suspended: { severity: 'warning', key: 'state_banner_suspended' },
|
||||
};
|
||||
const { severity, key } = banner[state];
|
||||
|
||||
return (
|
||||
<Alert severity={severity} sx={{ borderRadius: 2 }}>
|
||||
{t(key)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
/** License details + merchant-of-record settlement IBAN (masked last-4). Nulls render as an em dash. */
|
||||
function LicenseBlock({ center }: { center: PartnerCenter }) {
|
||||
const t = useTranslations('partner');
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 2 }}>
|
||||
{t('license_title')}
|
||||
</Typography>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<DetailRow label={t('permit')} value={center.mohEstablishmentPermitNo} />
|
||||
<DetailRow label={t('tech_director')} value={center.technicalDirectorLicenseNo} />
|
||||
<DetailRow label={t('enamad')} value={center.enamadCode} />
|
||||
<DetailRow label={t('legal_type')} value={center.legalEntityType} />
|
||||
{center.isMerchantOfRecord ? (
|
||||
<DetailRow label={t('settlement_iban')} value={center.settlementIbanMasked} ltr />
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/** One label → value row. `ltr` forces LTR display for latin/numeric values (IBAN) inside an RTL page. */
|
||||
function DetailRow({ label, value, ltr }: { label: string; value: string | null; ltr?: boolean }) {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ gap: 2, alignItems: 'baseline', justifyContent: 'space-between', flexWrap: 'wrap' }}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{ltr ? (
|
||||
<Typography component="span" dir="ltr" variant="body2" sx={{ fontWeight: 500, fontFamily: 'monospace' }}>
|
||||
{value ?? '—'}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{value ?? '—'}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import SidebarShellSkeleton from '../_chrome/SidebarShellSkeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return <SidebarShellSkeleton />;
|
||||
}
|
||||
@@ -1,130 +1,13 @@
|
||||
'use client';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Alert, Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AdminEmptyState, AdminPageHeader } from '@/components/admin';
|
||||
import { StatusChip } from '@/components';
|
||||
import type { CenterOnboardingState, PartnerCenter } from '@/services/partnerCenter/types';
|
||||
import { useMyPartnerCenter } from '@/services/partnerCenter';
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import PartnerHomeScreen from './PartnerHomeScreen';
|
||||
|
||||
/**
|
||||
* Partner portal home (f15) — the signed-in center admin's **own** center at a glance (a separate authz
|
||||
* scope from /admin; tenancy is server-enforced). `useMyPartnerCenter` doubles as the access gate: a
|
||||
* 403/404 (non-owner / no center) surfaces the non-leaking access-denied state, never any center data.
|
||||
* On success it shows the onboarding banner (draft/pending/suspended), the license block, the
|
||||
* merchant-of-record indicator, and the masked settlement IBAN. Read-only.
|
||||
*/
|
||||
export default function PartnerHomePage() {
|
||||
const t = useTranslations('partner');
|
||||
const center = useMyPartnerCenter();
|
||||
|
||||
if (center.isLoading) {
|
||||
return (
|
||||
<Stack sx={{ gap: 3 }}>
|
||||
<Skeleton variant="rounded" height={72} />
|
||||
<Skeleton variant="rounded" height={64} />
|
||||
<Skeleton variant="rounded" height={220} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// The center query is the portal's access gate — a 403/404 means the caller owns no center.
|
||||
if (center.isError || !center.data) {
|
||||
return <AdminEmptyState icon="lock" title={t('access_denied')} />;
|
||||
}
|
||||
|
||||
const c = center.data;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<AdminPageHeader
|
||||
title={t('home_title')}
|
||||
subtitle={t('home_subtitle')}
|
||||
actions={
|
||||
<StatusChip
|
||||
status={c.isMerchantOfRecord ? 'active' : 'neutral'}
|
||||
label={c.isMerchantOfRecord ? t('is_mor_yes') : t('is_mor_no')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<OnboardingBanner state={c.onboardingState} />
|
||||
|
||||
<LicenseBlock center={c} />
|
||||
</Box>
|
||||
);
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'partner' });
|
||||
return { title: t('home_title') };
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding/verification banner keyed off `onboardingState`. `verified` shows a subtle chip instead of a
|
||||
* banner; every other state shows an MUI `Alert` (draft/suspended → warning, pending → info).
|
||||
*/
|
||||
function OnboardingBanner({ state }: { state: CenterOnboardingState }) {
|
||||
const t = useTranslations('partner');
|
||||
const ta = useTranslations('admin');
|
||||
|
||||
if (state === 'verified') {
|
||||
return (
|
||||
<Box>
|
||||
<StatusChip status="verified" label={ta('center_state_verified')} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const banner: Record<Exclude<CenterOnboardingState, 'verified'>, { severity: 'warning' | 'info'; key: string }> = {
|
||||
draft: { severity: 'warning', key: 'state_banner_draft' },
|
||||
pending_verification: { severity: 'info', key: 'state_banner_pending' },
|
||||
suspended: { severity: 'warning', key: 'state_banner_suspended' },
|
||||
};
|
||||
const { severity, key } = banner[state];
|
||||
|
||||
return (
|
||||
<Alert severity={severity} sx={{ borderRadius: 2 }}>
|
||||
{t(key)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
/** License details + merchant-of-record settlement IBAN (masked last-4). Nulls render as an em dash. */
|
||||
function LicenseBlock({ center }: { center: PartnerCenter }) {
|
||||
const t = useTranslations('partner');
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 2 }}>
|
||||
{t('license_title')}
|
||||
</Typography>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<DetailRow label={t('permit')} value={center.mohEstablishmentPermitNo} />
|
||||
<DetailRow label={t('tech_director')} value={center.technicalDirectorLicenseNo} />
|
||||
<DetailRow label={t('enamad')} value={center.enamadCode} />
|
||||
<DetailRow label={t('legal_type')} value={center.legalEntityType} />
|
||||
{center.isMerchantOfRecord ? (
|
||||
<DetailRow label={t('settlement_iban')} value={center.settlementIbanMasked} ltr />
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/** One label → value row. `ltr` forces LTR display for latin/numeric values (IBAN) inside an RTL page. */
|
||||
function DetailRow({ label, value, ltr }: { label: string; value: string | null; ltr?: boolean }) {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ gap: 2, alignItems: 'baseline', justifyContent: 'space-between', flexWrap: 'wrap' }}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{ltr ? (
|
||||
<Typography component="span" dir="ltr" variant="body2" sx={{ fontWeight: 500, fontFamily: 'monospace' }}>
|
||||
{value ?? '—'}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{value ?? '—'}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
export default function Page() {
|
||||
return <PartnerHomeScreen />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user