ui phase 8

This commit is contained in:
hamid
2026-07-19 13:57:11 +03:30
parent edc38543fd
commit 1ef4feb911
41 changed files with 2113 additions and 667 deletions
@@ -1,56 +1,13 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Skeleton, Stack, Typography } from '@mui/material';
import { AccentCard, AppButton, AppIcon } from '@/components';
import { ROUTES } from '@/constants';
import { isApproved, type VerificationStatus } from '@/services/verification/types';
export interface DashboardActivationSlotProps {
status: VerificationStatus | undefined;
isLoading: boolean;
}
import { ActivationChecklist } from '@/components';
/**
* The dashboard's activation/go-live composition point — **named and exported so a later phase can find
* it**. This phase fills it only with the existing verification-status banner (rendered while the nurse
* isn't yet approved; nothing once approved). The fuller "go live" checklist (profile/services/coverage/
* bank all done) is **DEFERRED to ui-phase-8**, which owns this slot's content from here — extend this
* component in place rather than adding a second slot.
* The dashboard's activation/go-live composition point — **named and exported so a later phase can
* find it** (ui-phase-7's hand-off note). ui-phase-8 fills it with the real `ActivationChecklist`
* (the same shared component mounted on `/nurse/services`) — replacing the placeholder single-row
* verification banner phase 7 left here. Extend this component in place, don't add a second slot.
* @component DashboardActivationSlot
*/
export default function DashboardActivationSlot({ status, isLoading }: DashboardActivationSlotProps) {
const t = useTranslations('dashboard');
const locale = useLocale();
const router = useRouter();
if (isLoading) return <Skeleton variant="rounded" height={96} />;
if (isApproved(status)) return null;
return (
<AccentCard tone="warning">
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Stack direction="row" sx={{ gap: 1.25, alignItems: 'flex-start' }}>
<AppIcon icon="verification" size={20} color="var(--bal-warning)" />
<Stack sx={{ gap: 0.25 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('activation_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('activation_body')}
</Typography>
</Stack>
</Stack>
<AppButton
variant="outlined"
color="primary"
size="small"
endIcon="verification"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_VERIFICATION}`)}
>
{t('activation_cta')}
</AppButton>
</Stack>
</AccentCard>
);
export default function DashboardActivationSlot() {
return <ActivationChecklist />;
}
@@ -68,7 +68,7 @@ export default function NurseDashboardScreen() {
<NextVisitCard />
<RequestsStrip />
<EarningsSnapshotCard />
<DashboardActivationSlot status={verification.data} isLoading={verification.isLoading} />
<DashboardActivationSlot />
<NotificationsEntryRow />
</Stack>
);
@@ -3,21 +3,26 @@ import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Box, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppLoading, BankStatusPanel, EmptyState } from '@/components';
import { AppButton, AppLoading, BankStatusPanel, EmptyState, ErrorState } from '@/components';
import { useNurseBankAccounts, useAddNurseBankAccount, useSetPrimaryBankAccount } from '@/services/nurse';
import { isValidSheba } from '@/services/nurse/iban';
import { deriveBankStatus } from '@/services/nurse/types';
/**
* Nurse payout bank settings — submit an IBAN (شبا) + account-holder name, then watch the
* ownership inquiry resolve through its three states (pending → verified / mismatch). The list
* polls only while pending; a verified account shows the masked IBAN; mismatch offers re-enter.
* Nurse payout bank settings — an **accounts section**, not a one-shot form (ui-phase-8 §3.7): submit
* an IBAN (شبا) + account-holder name, then watch the ownership inquiry resolve through its three
* states (pending verified / mismatch, `BankStatusPanel` unchanged). Once at least one account
* exists, a persistent «افزودن حساب دیگر» CTA replaces the old form-only-when-empty gate, so a nurse
* switching banks is never dead-ended — the old account stays listed until the nurse makes the new
* one primary. A failed accounts query renders the error state with retry, **never** the empty-state
* form (which would invite a duplicate-IBAN submission blind).
*/
export default function NurseBankPage() {
const t = useTranslations('bank');
const tc = useTranslations('common');
const { enqueueSnackbar } = useSnackbar();
const { data, isLoading } = useNurseBankAccounts();
const { data, isLoading, isError, refetch } = useNurseBankAccounts();
const addAccount = useAddNurseBankAccount();
const setPrimary = useSetPrimaryBankAccount();
@@ -28,7 +33,7 @@ export default function NurseBankPage() {
const [showForm, setShowForm] = useState(false);
const accounts = data ?? [];
const showFormNow = !isLoading && (accounts.length === 0 || showForm);
const showFormNow = !isLoading && !isError && (accounts.length === 0 || showForm);
const submit = () => {
const ibanInvalid = !isValidSheba(iban);
@@ -64,47 +69,67 @@ export default function NurseBankPage() {
{isLoading ? <AppLoading /> : null}
{accounts.map((account) => {
const status = deriveBankStatus(account);
return (
<Stack key={account.id} sx={{ gap: 1 }}>
<BankStatusPanel
status={status}
chipLabel={t(`status_${status}_chip`)}
title={t(`status_${status}_title`)}
body={t(`status_${status}_body`)}
ibanMasked={status === 'verified' ? account.ibanMasked : undefined}
ibanLabel={t('iban_masked_label')}
bankName={account.bankName || undefined}
isPrimary={account.isPrimary}
primaryLabel={t('primary')}
onReenter={status === 'mismatch' ? () => setShowForm(true) : undefined}
reenterLabel={t('reenter')}
/>
{/* Promote a verified non-primary account so payouts (gated on matchedNationalId) target it. */}
{status === 'verified' && !account.isPrimary ? (
<AppButton
variant="text"
color="primary"
disabled={setPrimary.isPending}
onClick={() =>
setPrimary.mutate(account.id, {
onSuccess: () => enqueueSnackbar(t('primary_set'), { variant: 'success' }),
})
}
sx={{ alignSelf: 'flex-start' }}
>
{t('make_primary')}
</AppButton>
) : null}
</Stack>
);
})}
{isError ? <ErrorState message={t('load_error')} retryLabel={tc('retry')} onRetry={() => refetch()} /> : null}
{!isLoading && accounts.length === 0 ? (
{!isError
? accounts.map((account) => {
const status = deriveBankStatus(account);
return (
<Stack key={account.id} sx={{ gap: 1 }}>
<BankStatusPanel
status={status}
chipLabel={t(`status_${status}_chip`)}
title={t(`status_${status}_title`)}
body={t(`status_${status}_body`)}
ibanMasked={status === 'verified' ? account.ibanMasked : undefined}
ibanLabel={t('iban_masked_label')}
bankName={account.bankName || undefined}
isPrimary={account.isPrimary}
primaryLabel={t('primary')}
onReenter={status === 'mismatch' ? () => setShowForm(true) : undefined}
reenterLabel={t('reenter')}
/>
{/* Promote a verified non-primary account so payouts (gated on matchedNationalId) target it. */}
{status === 'verified' && !account.isPrimary ? (
<AppButton
variant="text"
color="primary"
disabled={setPrimary.isPending}
onClick={() =>
setPrimary.mutate(account.id, {
onSuccess: () => enqueueSnackbar(t('primary_set'), { variant: 'success' }),
onError: () => enqueueSnackbar(t('primary_set_error'), { variant: 'error' }),
})
}
sx={{ alignSelf: 'flex-start' }}
>
{t('make_primary')}
</AppButton>
) : null}
</Stack>
);
})
: null}
{!isLoading && !isError && accounts.length === 0 ? (
<EmptyState icon="bank" title={t('empty_title')} body={t('empty_body')} />
) : null}
{/* An accounts section, not a one-shot form: once at least one account exists, a persistent CTA
(rather than "no account yet") lets a nurse switching banks add another — the old account
stays listed until they make the new one primary. */}
{!isLoading && !isError && accounts.length > 0 && !showForm ? (
<AppButton
variant="outlined"
color="primary"
startIcon="add"
onClick={() => setShowForm(true)}
sx={{ alignSelf: 'flex-start' }}
>
{t('add_another')}
</AppButton>
) : null}
{showFormNow ? (
<Stack sx={{ gap: 2 }}>
<TextField
@@ -130,16 +155,26 @@ export default function NurseBankPage() {
helperText={holderError ? t('holder_required') : t('holder_hint')}
fullWidth
/>
<AppButton
color="primary"
variant="contained"
startIcon="bank"
onClick={submit}
disabled={addAccount.isPending}
sx={{ alignSelf: 'flex-start' }}
>
{addAccount.isPending ? t('submitting') : t('submit')}
</AppButton>
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton color="primary" variant="contained" startIcon="bank" onClick={submit} disabled={addAccount.isPending}>
{addAccount.isPending ? t('submitting') : t('submit')}
</AppButton>
{accounts.length > 0 ? (
<AppButton
variant="text"
onClick={() => {
setShowForm(false);
setIban('');
setHolder('');
setIbanError(false);
setHolderError(false);
}}
disabled={addAccount.isPending}
>
{tc('cancel')}
</AppButton>
) : null}
</Stack>
</Stack>
) : null}
</Box>
@@ -2,36 +2,25 @@
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Paper,
Skeleton,
Stack,
ToggleButton,
ToggleButtonGroup,
Typography,
} from '@mui/material';
import { Box, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon } from '@/components';
import { CascadingRegionSelect, type CascadingRegionValue } from '@/components/geography';
import { ApiError } from '@/lib/api/errors';
import { useDistricts } from '@/services/geography';
import { useServiceAreas, useAddServiceArea, useRemoveServiceArea } from '@/services/serviceAreas';
import { areaExists, type NurseServiceArea } from '@/services/serviceAreas/types';
type Scope = 'whole_city' | 'districts';
const EMPTY_REGION: CascadingRegionValue = { provinceId: null, cityId: null, districtId: null };
/**
* The nurse coverage-area editor — the cities/districts a nurse will travel to, so search (f6)
* can fan them out geographically. Areas render as chips (whole-city shown explicitly); the add
* control is the cascading dropdowns + a whole-city vs specific-districts scope toggle. A
* duplicate `(city, district)` is blocked inline before the request (and the server's 409 maps to
* the same message). Empty → a warning that the nurse won't appear in search.
* can fan them out geographically. Areas render as chips (whole-city shown explicitly).
*
* ui-phase-8: **one control owns the whole-city choice** — `CascadingRegionSelect`'s own district
* level, whose "کل شهر" empty option *is* the choice (`districtId = null`, matching the serviceAreas
* contract both ways). The separate scope toggle this page used to render is gone — it let a nurse
* pick "specific districts" and then still land on the district select's own whole-city option,
* tripping a "district required" error the UI itself had offered. City is the only required field;
* leaving the district unset is a complete, valid whole-city submission, never an error state.
*/
export default function NurseCoveragePage() {
const t = useTranslations('coverage');
@@ -44,22 +33,12 @@ export default function NurseCoveragePage() {
const removeArea = useRemoveServiceArea();
const [region, setRegion] = useState<CascadingRegionValue>(EMPTY_REGION);
const [scope, setScope] = useState<Scope>('whole_city');
const [cityError, setCityError] = useState(false);
const [districtError, setDistrictError] = useState(false);
const [duplicate, setDuplicate] = useState(false);
const [removeTarget, setRemoveTarget] = useState<NurseServiceArea | null>(null);
const areas = data?.items ?? [];
// A whole-city-only city (no districts, e.g. Mashhad) can't satisfy "specific districts" — reads the
// same cached districts query the cascade uses to force whole-city, so the toggle never dead-ends on a
// district that cannot exist.
const districtsQuery = useDistricts(region.cityId);
const cityHasNoDistricts =
region.cityId != null && districtsQuery.isSuccess && (districtsQuery.data?.length ?? 0) === 0;
const effectiveScope: Scope = cityHasNoDistricts ? 'whole_city' : scope;
const chipLabel = (area: NurseServiceArea) => {
const city = locale === 'en' ? area.cityNameEn : area.cityNameFa;
if (area.isWholeCity) return `${city} · ${t('whole_city_chip')}`;
@@ -67,33 +46,21 @@ export default function NurseCoveragePage() {
return `${city} · ${district}`;
};
const changeScope = (next: Scope | null) => {
if (!next) return;
setScope(next);
setDistrictError(false);
setDuplicate(false);
// Whole-city ignores any picked district — clear it so the submitted pair is unambiguous.
if (next === 'whole_city') setRegion((prev) => ({ ...prev, districtId: null }));
};
const resetForm = () => {
setRegion(EMPTY_REGION);
setScope('whole_city');
setCityError(false);
setDistrictError(false);
setDuplicate(false);
};
const handleAdd = () => {
const cityInvalid = region.cityId == null;
const districtInvalid = effectiveScope === 'districts' && region.districtId == null;
setCityError(cityInvalid);
setDistrictError(districtInvalid);
setDuplicate(false);
if (cityInvalid || districtInvalid) return;
if (cityInvalid) return;
const cityId = region.cityId as number;
const districtId = effectiveScope === 'whole_city' ? null : region.districtId;
// Whatever the district select currently holds is the complete choice — `null` = whole city.
const districtId = region.districtId;
// Fast path: block a duplicate before firing (null district treated as a real value).
if (areaExists(areas, cityId, districtId)) {
@@ -123,6 +90,7 @@ export default function NurseCoveragePage() {
setRemoveTarget(null);
removeArea.mutate(id, {
onSuccess: () => enqueueSnackbar(t('removed'), { variant: 'success' }),
onError: () => enqueueSnackbar(t('remove_error'), { variant: 'error' }),
});
};
@@ -191,38 +159,15 @@ export default function NurseCoveragePage() {
{t('add_title')}
</Typography>
<Stack sx={{ gap: 1 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('scope_label')}
</Typography>
<ToggleButtonGroup
exclusive
size="small"
color="primary"
value={effectiveScope}
onChange={(_event, next: Scope | null) => changeScope(next)}
>
<ToggleButton value="whole_city">{t('scope_whole_city')}</ToggleButton>
{/* A district-less city forces whole-city — disable the option rather than dead-end on it. */}
<ToggleButton value="districts" disabled={cityHasNoDistricts}>
{t('scope_districts')}
</ToggleButton>
</ToggleButtonGroup>
</Stack>
<CascadingRegionSelect
value={region}
onChange={(next) => {
setRegion(next);
if (cityError && next.cityId != null) setCityError(false);
if (districtError && next.districtId != null) setDistrictError(false);
setDuplicate(false);
}}
includeDistrict={effectiveScope === 'districts'}
cityError={cityError}
cityErrorText={t('city_required')}
districtError={districtError}
districtErrorText={t('district_required')}
/>
{duplicate ? (
@@ -1,18 +1,30 @@
'use client';
import { ChangeEvent, FunctionComponent, useRef, useState } from 'react';
import { ChangeEvent, FunctionComponent, useEffect, useRef, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Avatar, Box, Paper, Stack, TextField, Typography } from '@mui/material';
import { Avatar, Box, Chip, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, TrustBadge } from '@/components';
import { ROUTES } from '@/constants';
import { useNurseProfile, useUpsertNurseProfile, useUploadAvatar } from '@/services/profiles';
import type { NurseProfile } from '@/services/profiles/types';
import { useVerificationStatus } from '@/services/verification';
import { ownBadgeState } from '@/services/verification/types';
import { ownBadgeState, SPECIALTY_PRESETS } from '@/services/verification/types';
const MAX_YEARS = 80;
const OTHER_CODE = '__other';
const EDUCATION_LEVELS = ['diploma', 'associate', 'bachelor', 'master', 'doctorate'] as const;
const EDUCATION_FIELDS = ['nursing', 'midwifery', 'anesthesia', 'operating_room', 'public_health'] as const;
/** Nurse profile bootstrap (B7 header): avatar + bio + years. Services/availability are deferred (f4). */
function parseSpecializations(json: string): string[] {
try {
const parsed = JSON.parse(json);
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : [];
} catch {
return [];
}
}
/** Nurse profile bootstrap (B7 header): avatar + bio + years + qualifications. */
export default function NurseProfilePage() {
const { data: profile, isLoading } = useNurseProfile();
if (isLoading) return <AppLoading />;
@@ -21,6 +33,7 @@ export default function NurseProfilePage() {
const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({ initial }) => {
const t = useTranslations('nurseProfile');
const tv = useTranslations('verification');
const tc = useTranslations('common');
const locale = useLocale();
const { enqueueSnackbar } = useSnackbar();
@@ -35,6 +48,37 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
const [years, setYears] = useState(initial ? String(initial.yearsOfExperience) : '');
const [yearsError, setYearsError] = useState(false);
const initialLevel = initial?.educationLevel ?? '';
const initialField = initial?.educationField ?? '';
const [educationLevel, setEducationLevel] = useState(
(EDUCATION_LEVELS as readonly string[]).includes(initialLevel) ? initialLevel : initialLevel ? OTHER_CODE : '',
);
const [educationLevelOther, setEducationLevelOther] = useState(
(EDUCATION_LEVELS as readonly string[]).includes(initialLevel) ? '' : initialLevel,
);
const [educationField, setEducationField] = useState(
(EDUCATION_FIELDS as readonly string[]).includes(initialField) ? initialField : initialField ? OTHER_CODE : '',
);
const [educationFieldOther, setEducationFieldOther] = useState(
(EDUCATION_FIELDS as readonly string[]).includes(initialField) ? '' : initialField,
);
const [specializations, setSpecializations] = useState<string[]>(
parseSpecializations(initial?.specializationsJson ?? '[]'),
);
// A staged-but-unsaved avatar must never be silently discarded — warn on reload/tab-close.
const avatarDirty = avatarUrl !== (initial?.avatarUrl ?? null);
useEffect(() => {
if (!avatarDirty) return;
const handler = (event: BeforeUnloadEvent) => {
event.preventDefault();
// Chrome (and most engines) only show the native confirm dialog when returnValue is set.
event.returnValue = '';
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [avatarDirty]);
const pickFile = () => fileInputRef.current?.click();
const onFileSelected = (event: ChangeEvent<HTMLInputElement>) => {
@@ -47,6 +91,9 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
});
};
const toggleSpecialty = (value: string) =>
setSpecializations((prev) => (prev.includes(value) ? prev.filter((item) => item !== value) : [...prev, value]));
const handleSave = () => {
const trimmed = years.trim();
const yearsNum = trimmed === '' ? 0 : Number(trimmed);
@@ -54,13 +101,16 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
setYearsError(yearsInvalid);
if (yearsInvalid) return;
const resolvedLevel = educationLevel === OTHER_CODE ? educationLevelOther.trim() : educationLevel;
const resolvedField = educationField === OTHER_CODE ? educationFieldOther.trim() : educationField;
upsert.mutate(
{
bio: bio.trim(),
yearsOfExperience: yearsNum,
educationLevel: initial?.educationLevel ?? '',
educationField: initial?.educationField ?? '',
specializationsJson: initial?.specializationsJson ?? '[]',
educationLevel: resolvedLevel,
educationField: resolvedField,
specializationsJson: JSON.stringify(specializations),
avatarUrl,
},
{
@@ -163,10 +213,81 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
sx={{ maxWidth: 200 }}
/>
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField select label={t('education_level_label')} value={educationLevel} onChange={(e) => setEducationLevel(e.target.value)} fullWidth>
{EDUCATION_LEVELS.map((code) => (
<MenuItem key={code} value={code}>
{t(`education_level_${code}`)}
</MenuItem>
))}
<MenuItem value={OTHER_CODE}>{t('education_other')}</MenuItem>
</TextField>
<TextField select label={t('education_field_label')} value={educationField} onChange={(e) => setEducationField(e.target.value)} fullWidth>
{EDUCATION_FIELDS.map((code) => (
<MenuItem key={code} value={code}>
{t(`education_field_${code}`)}
</MenuItem>
))}
<MenuItem value={OTHER_CODE}>{t('education_other')}</MenuItem>
</TextField>
</Stack>
{educationLevel === OTHER_CODE ? (
<TextField
label={t('education_level_other_label')}
value={educationLevelOther}
onChange={(e) => setEducationLevelOther(e.target.value)}
fullWidth
/>
) : null}
{educationField === OTHER_CODE ? (
<TextField
label={t('education_field_other_label')}
value={educationFieldOther}
onChange={(e) => setEducationFieldOther(e.target.value)}
fullWidth
/>
) : null}
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('specializations_label')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{SPECIALTY_PRESETS.map((code) => {
const selected = specializations.includes(code);
return (
<Chip
key={code}
label={tv.has(`specialty_${code}`) ? tv(`specialty_${code}`) : code}
onClick={() => toggleSpecialty(code)}
variant={selected ? 'filled' : 'outlined'}
sx={{
fontWeight: 500,
backgroundColor: selected ? 'var(--bal-primary)' : 'transparent',
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
borderColor: 'var(--bal-primary)',
}}
/>
);
})}
</Stack>
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('deferred_services')}
</Typography>
<AppButton
variant="text"
color="primary"
startIcon="account"
to={`/${locale}${ROUTES.NURSE_PROFILE_PREVIEW}`}
sx={{ alignSelf: 'flex-start' }}
>
{t('preview_cta')}
</AppButton>
<AppButton
color="primary"
variant="contained"
@@ -0,0 +1,182 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { Avatar, Box, Chip, Skeleton, Stack, Typography } from '@mui/material';
import {
AppIcon,
EmptyState,
PageHeader,
ServicePriceRow,
SurfaceCard,
TrustBadge,
VerificationPanel,
} from '@/components';
import { ROUTES } from '@/constants';
import { formatNumber } from '@/utils';
import { useMe } from '@/services/auth';
import { useMyVariants } from '@/services/catalog';
import { useServiceAreas } from '@/services/serviceAreas';
import { useNurseProfile } from '@/services/profiles';
import { useNurseTrustBadge, useVerificationStatus } from '@/services/verification';
import { ownBadgeState } from '@/services/verification/types';
function parseSpecializations(json: string): string[] {
try {
const parsed = JSON.parse(json);
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : [];
} catch {
return [];
}
}
/**
* "نمایهٔ عمومی من" — how families see this nurse. Composes the same C3 trust-dossier pieces
* (`TrustBadge`, `VerificationPanel`, `ServicePriceRow`) but **entirely from the nurse's own cached
* data** (own profile + `useMyVariants` + `useServiceAreas` + own badge) — no dependency on the search
* index, so it renders truthfully even pre-publish (before `is_searchable` can ever be true). Linked
* from the profile and services pages — the strongest motivator to finish bio/photo/credentials.
*/
export default function NursePublicProfilePreviewPage() {
const t = useTranslations('nurseProfile');
const tv = useTranslations('verification');
const locale = useLocale();
const { data: me, isLoading: meLoading } = useMe();
const { data: profile, isLoading: profileLoading } = useNurseProfile();
const verification = useVerificationStatus();
const trustBadge = useNurseTrustBadge(me?.id);
const variantsQuery = useMyVariants();
const areasQuery = useServiceAreas();
const isLoading = meLoading || profileLoading || verification.isLoading;
if (isLoading) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<PageHeader title={t('preview_title')} backTo={`/${locale}${ROUTES.NURSE_PROFILE}`} backLabel={t('back_to_profile')} />
<Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}>
<Skeleton variant="circular" width={72} height={72} />
<Skeleton variant="text" width="50%" height={32} />
</Stack>
<Skeleton variant="rounded" height={96} />
<Skeleton variant="rounded" height={140} />
</Box>
);
}
const displayName = me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : '';
const specializations = parseSpecializations(profile?.specializationsJson ?? '[]');
const activeVariants = (variantsQuery.data?.items ?? []).filter((variant) => variant.isActive);
const areas = areasQuery.data?.items ?? [];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<PageHeader title={t('preview_title')} subtitle={t('preview_subtitle')} backTo={`/${locale}${ROUTES.NURSE_PROFILE}`} backLabel={t('back_to_profile')} />
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}>
<Avatar
src={profile?.avatarUrl ?? undefined}
sx={{ width: 72, height: 72, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700, fontSize: 28 }}
>
{profile?.avatarUrl ? null : (displayName || '؟').charAt(0)}
</Avatar>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="h5" component="h2">
{displayName}
</Typography>
{profile && profile.totalReviews > 0 ? (
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="star" size={18} color="var(--bal-rating)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{formatNumber(profile.averageRating, locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 })}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('preview_reviews_count', { count: profile.totalReviews })}
</Typography>
</Stack>
) : null}
{profile ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('preview_completed_visits', { count: formatNumber(profile.totalCompletedBookings, locale) })}
</Typography>
) : null}
</Stack>
</Stack>
<TrustBadge state={ownBadgeState(verification.data)} sx={{ alignSelf: 'flex-start' }} />
{profile?.bio ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{profile.bio}
</Typography>
) : null}
</Stack>
{profile?.yearsOfExperience || specializations.length > 0 ? (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{profile?.yearsOfExperience ? (
<Chip variant="outlined" label={t('preview_years_experience', { years: formatNumber(profile.yearsOfExperience, locale) })} />
) : null}
{specializations.map((code) => (
<Chip key={code} variant="outlined" label={tv.has(`specialty_${code}`) ? tv(`specialty_${code}`) : code} />
))}
</Stack>
) : null}
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('preview_verified_title')}
</Typography>
<SurfaceCard padding="md">
<VerificationPanel badge={trustBadge.data} isLoading={trustBadge.isLoading} isError={trustBadge.isError} />
</SurfaceCard>
</Stack>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('preview_services_title')}
</Typography>
{activeVariants.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('preview_services_empty')}
</Typography>
) : (
<Box>
{activeVariants.map((variant) => (
<ServicePriceRow
key={variant.id}
displayName={variant.displayName}
priceIrr={variant.price}
priceUnit={variant.priceUnit}
sessionCount={variant.sessionCount}
/>
))}
</Box>
)}
</Stack>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('preview_coverage_title')}
</Typography>
{areas.length === 0 ? (
<EmptyState icon="coverage" title={t('preview_coverage_empty')} />
) : (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{areas.map((area) => {
const city = locale === 'en' ? area.cityNameEn : area.cityNameFa;
const district = locale === 'en' ? area.districtNameEn : area.districtNameFa;
return (
<Chip
key={area.id}
label={area.isWholeCity ? city : `${city} · ${district}`}
sx={{ bgcolor: 'var(--bal-primary-soft)', fontWeight: 500 }}
/>
);
})}
</Stack>
)}
</Stack>
</Box>
);
}
@@ -1,6 +1,6 @@
'use client';
import { FunctionComponent, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
@@ -12,7 +12,8 @@ import {
Stack,
Typography,
} from '@mui/material';
import { AppButton, EmptyState, ErrorState, VariantCard } from '@/components';
import { ActivationChecklist, AppButton, EmptyState, ErrorState, VariantCard } from '@/components';
import { ROUTES } from '@/constants';
import { useMyVariants, useSetVariantActive } from '@/services/catalog';
import type { NurseServiceVariant } from '@/services/catalog/types';
import PublishGate from './PublishGate';
@@ -31,6 +32,8 @@ interface MyServicesListProps {
const MyServicesList: FunctionComponent<MyServicesListProps> = ({ onAdd, onEdit }) => {
const t = useTranslations('services');
const tc = useTranslations('common');
const tNurseProfile = useTranslations('nurseProfile');
const locale = useLocale();
const { enqueueSnackbar } = useSnackbar();
const { data, isLoading, isError, refetch } = useMyVariants();
@@ -86,8 +89,19 @@ const MyServicesList: FunctionComponent<MyServicesListProps> = ({ onAdd, onEdit
) : null}
</Stack>
<ActivationChecklist />
<PublishGate />
<AppButton
variant="text"
color="primary"
startIcon="account"
to={`/${locale}${ROUTES.NURSE_PROFILE_PREVIEW}`}
sx={{ alignSelf: 'flex-start' }}
>
{tNurseProfile('preview_cta')}
</AppButton>
{isLoading ? (
<Stack sx={{ gap: 1.5 }}>
{[0, 1].map((key) => (
@@ -1,81 +1,99 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon } from '@/components';
import { ROUTES } from '@/constants';
import { useVerificationStatus } from '@/services/verification';
import { isApproved } from '@/services/verification/types';
import { Skeleton, Stack, Typography } from '@mui/material';
import { AccentCard, AppButton, AppIcon } from '@/components';
import { useActivationChecklist } from '@/components/ActivationChecklist';
import { useSetAcceptingBookings } from '@/services/profiles';
/**
* The go-live gate for the nurse's services (the f4 publish stub, wired to verification here). A nurse
* is **not bookable and cannot publish until verified**: when the aggregate isn't `approved` the publish
* CTA is **disabled** with a blocked-until-verified explanation that links to the B3 checklist; once
* approved it enables. Mirrors the server's guarded `is_verified` flip — the UI never implies a nurse is
* live before verification completes. Reads the shared `VerificationStatus` query (cached across the app).
* The go-live gate for the nurse's services — **the real switch**, not a snackbar. Reads the same
* `useActivationChecklist` state `ActivationChecklist` renders (one source of truth, no duplicated
* search-visibility logic): unmet conditions render guidance naming exactly what's missing; met but
* paused renders the real `set_accepting_bookings` CTA; live renders the on state + a pause action.
* Success copy fires only after the mutation actually succeeds — this replaces the old
* `enqueueSnackbar('published')` no-op.
*/
const PublishGate: FunctionComponent = () => {
const t = useTranslations('verification');
const locale = useLocale();
const tActivation = useTranslations('activation');
const { enqueueSnackbar } = useSnackbar();
const { data: status, isLoading } = useVerificationStatus();
const state = useActivationChecklist();
const setAccepting = useSetAcceptingBookings();
if (isLoading) return null;
const approved = isApproved(status);
if (state.isLoading) return <Skeleton variant="rounded" height={96} sx={{ borderRadius: 2 }} />;
if (state.isError) return null; // ActivationChecklist (mounted alongside) already surfaces the error + retry.
const toggle = (accepting: boolean) => {
setAccepting.mutate(accepting, {
onSuccess: () =>
enqueueSnackbar(accepting ? t('publish_accepting_success') : t('publish_paused_success'), {
variant: 'success',
}),
onError: () => enqueueSnackbar(t('publish_toggle_error'), { variant: 'error' }),
});
};
const live = state.isSearchVisible && state.isAcceptingBookings;
return (
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStartColor: approved ? 'var(--bal-success)' : 'var(--bal-warning)',
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<AppIcon
icon={approved ? 'verified' : 'warning'}
size={24}
color={approved ? 'var(--bal-success)' : 'var(--bal-warning)'}
/>
<Stack sx={{ gap: 0.5, flexGrow: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{approved ? t('publish_ready_title') : t('publish_blocked_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{approved ? t('publish_ready_body') : t('publish_blocked_body')}
</Typography>
<AccentCard tone={live ? 'success' : state.isSearchVisible ? 'primary' : 'warning'} padding="md">
<Stack sx={{ gap: 1 }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<AppIcon
icon={live ? 'verified' : state.isSearchVisible ? 'publish' : 'warning'}
size={24}
color={live ? 'var(--bal-success)' : state.isSearchVisible ? 'var(--bal-primary)' : 'var(--bal-warning)'}
/>
<Stack sx={{ gap: 0.5, flexGrow: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{live
? t('publish_live_title')
: state.isSearchVisible
? t('publish_ready_title')
: t('publish_blocked_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{live
? t('publish_live_body')
: state.isSearchVisible
? t('publish_ready_body')
: t('publish_unmet_intro', {
items: state.searchRows
.filter((row) => !row.passed)
.map((row) => tActivation(row.labelKey))
.join('),
})}
</Typography>
</Stack>
</Stack>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{live ? (
<AppButton
color="inherit"
variant="outlined"
startIcon="pending"
onClick={() => toggle(false)}
disabled={setAccepting.isPending}
>
{setAccepting.isPending ? t('publish_toggling') : t('publish_pause_accepting')}
</AppButton>
) : state.isSearchVisible ? (
<AppButton
color="primary"
variant="contained"
startIcon="publish"
onClick={() => toggle(true)}
disabled={setAccepting.isPending}
>
{setAccepting.isPending ? t('publish_toggling') : t('publish_start_accepting')}
</AppButton>
) : null}
</Stack>
</Stack>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton
color="primary"
variant="contained"
startIcon="publish"
disabled={!approved}
onClick={() => enqueueSnackbar(t('publish_done'), { variant: 'success' })}
>
{t('publish_cta')}
</AppButton>
{!approved ? (
<AppButton
variant="outlined"
color="primary"
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
>
{t('publish_complete_verification')}
</AppButton>
) : null}
</Stack>
</Paper>
</AccentCard>
);
};
@@ -2,30 +2,21 @@
import { FunctionComponent, useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
Chip,
MenuItem,
Paper,
Skeleton,
Stack,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
} from '@mui/material';
import { AppButton, AppLoading, CategoryTile, ErrorState, PriceDisplay, StepperHeader } from '@/components';
import { Box, Chip, MenuItem, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AccentCard, AppButton, AppIcon, AppLoading, CategoryTile, ErrorState, StepperHeader, VariantCard } from '@/components';
import { ApiError } from '@/lib/api/errors';
import { digitsOnly, rialToToman, tomanToRial } from '@/utils';
import {
useCategoryOptionGroups,
useCreateVariant,
useMyVariants,
useServiceCategories,
useUpdateVariant,
} from '@/services/catalog';
import { pickCatalogName } from '@/services/catalog/names';
import {
PRICE_UNITS,
optionSetSignature,
type NurseServiceVariant,
type PriceUnit,
type VariantOptionSelection,
@@ -36,6 +27,8 @@ interface VariantBuilderProps {
initial: NurseServiceVariant | null;
onDone: () => void;
onCancel: () => void;
/** Jump straight into editing an already-existing listing — the create step's 409-duplicate recovery. */
onEditExisting: (variant: NurseServiceVariant) => void;
}
const DEFAULT_UNIT: PriceUnit = 'per_hour';
@@ -55,7 +48,7 @@ const MAX_DURATION_DIGITS = 4;
* **Edit** locks the category + option-set (changing them would change identity) and edits only
* price/unit/duration/display via `update`.
*/
const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDone, onCancel }) => {
const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDone, onCancel, onEditExisting }) => {
const t = useTranslations('services');
const tCatalog = useTranslations('catalog');
const tc = useTranslations('common');
@@ -92,6 +85,21 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
const selectedCategory = categories.find((category) => category.id === categoryId) ?? null;
const missingRequiredGroups = groups.filter((group) => group.isRequired && selectedOptions[group.id] == null);
// Reuses the already-cached offerings list (MyServicesList holds the same query) to resolve which
// existing listing a 409 duplicate collided with, so the recovery can offer "edit that one" directly.
const myVariantsQuery = useMyVariants();
const existingMatch = useMemo(() => {
if (isEdit || categoryId == null) return null;
const signature = optionSetSignature(categoryId, Object.values(selectedOptions));
return (
(myVariantsQuery.data?.items ?? []).find(
(variant) =>
variant.serviceCategoryId === categoryId &&
optionSetSignature(categoryId, variant.options.map((option) => option.optionValueId)) === signature,
) ?? null
);
}, [isEdit, categoryId, selectedOptions, myVariantsQuery.data]);
// Auto-generated display name preview (create): category + chosen value labels, in the active locale.
const autoName = useMemo(() => {
if (isEdit) return initial.displayName;
@@ -188,6 +196,21 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
);
};
// Step 3's live preview — the actual VariantCard, so the nurse sees the listing they're composing,
// not just an abstract price readout.
const previewVariant: NurseServiceVariant = {
id: 0,
serviceCategoryId: categoryId ?? 0,
categoryNameFa: selectedCategory?.nameFa ?? '',
categoryNameEn: selectedCategory?.nameEn ?? '',
price: irr ?? '0',
priceUnit,
sessionCount,
displayName: displayValue || t('preview_untitled'),
isActive: true,
options: [],
};
const priceStep = (
<Stack sx={{ gap: 2.5 }}>
<TextField
@@ -230,14 +253,17 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
</Stack>
{irr ? (
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, bgcolor: 'var(--bal-primary-soft)' }}>
<PriceDisplay price={irr} priceUnit={priceUnit} sessionCount={sessionCount} showEstimate />
<Stack sx={{ gap: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t('preview_heading')}
</Typography>
<VariantCard variant={previewVariant} interactive={false} />
{!sessionCount ? (
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mt: 0.5 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('rate_note')}
</Typography>
) : null}
</Paper>
</Stack>
) : null}
<TextField
@@ -249,21 +275,28 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
/>
{duplicate ? (
<Paper
elevation={0}
sx={{
p: 1.5,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStartColor: 'var(--bal-warning)',
}}
>
<Typography variant="body2" sx={{ color: 'var(--bal-warning)', fontWeight: 500 }}>
{t('duplicate_warning')}
</Typography>
</Paper>
<AccentCard tone="warning" padding="sm">
<Stack sx={{ gap: 1 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<AppIcon icon="warning" size={20} color="var(--bal-warning)" />
{/* Warning is reserved for the edge + icon; the body reads as normal text, not amber-on-paper. */}
<Typography variant="body2" sx={{ color: 'text.primary', fontWeight: 500 }}>
{t('duplicate_warning')}
</Typography>
</Stack>
{existingMatch ? (
<AppButton
variant="text"
color="primary"
startIcon="edit"
onClick={() => onEditExisting(existingMatch)}
sx={{ alignSelf: 'flex-start' }}
>
{t('duplicate_edit_existing')}
</AppButton>
) : null}
</Stack>
</AccentCard>
) : null}
</Stack>
);
@@ -423,20 +456,25 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
}}
/>
</Stack>
<ToggleButtonGroup
exclusive
size="small"
color="primary"
value={selectedOptions[group.id] ?? null}
onChange={(_event, valueId: number | null) => changeOption(group.id, valueId)}
sx={{ flexWrap: 'wrap' }}
>
{group.values.map((value) => (
<ToggleButton key={value.id} value={value.id}>
{pickCatalogName(value, locale)}
</ToggleButton>
))}
</ToggleButtonGroup>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{group.values.map((value) => {
const selected = selectedOptions[group.id] === value.id;
return (
<Chip
key={value.id}
label={pickCatalogName(value, locale)}
onClick={() => changeOption(group.id, selected ? null : value.id)}
variant={selected ? 'filled' : 'outlined'}
sx={{
fontWeight: 500,
backgroundColor: selected ? 'var(--bal-primary)' : 'transparent',
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
borderColor: 'var(--bal-primary)',
}}
/>
);
})}
</Stack>
</Stack>
);
})
@@ -22,6 +22,7 @@ export default function NurseServicesPage() {
initial={builder.editing}
onDone={() => setBuilder({ open: false })}
onCancel={() => setBuilder({ open: false })}
onEditExisting={(variant) => setBuilder({ open: true, editing: variant })}
/>
);
}
@@ -0,0 +1,60 @@
'use client';
import { FunctionComponent } from 'react';
import { useTranslations } from 'next-intl';
import { Stack, Typography } from '@mui/material';
import { AppIcon, SurfaceCard, TrustBadge } from '@/components';
import { ownBadgeState, type VerificationStatus } from '@/services/verification/types';
import { groupLabelKey, groupStatus, groupedDisplaySteps } from './verificationSteps';
export interface TrustBadgePreviewPanelProps {
status: VerificationStatus | undefined;
}
/**
* The hub's payoff moment (ui-phase-8 §3.2) a live preview of the badge families actually see,
* with a per-group fill indicator so progress on this screen visibly maps to the trust signal it
* earns. Never invents a "verified" state: the chip is the same `ownBadgeState` derivation the
* profile page and dashboard use, so it only ever shows what the approved aggregate supports.
* @component TrustBadgePreviewPanel
*/
const TrustBadgePreviewPanel: FunctionComponent<TrustBadgePreviewPanelProps> = ({ status }) => {
const t = useTranslations('verification');
const groups = groupedDisplaySteps(status);
return (
<SurfaceCard padding="md" data-trust-badge-preview>
<Stack sx={{ gap: 1.5 }}>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('payoff_title')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('payoff_subtitle')}
</Typography>
</Stack>
<TrustBadge state={ownBadgeState(status)} sx={{ alignSelf: 'flex-start' }} />
<Stack direction="row" sx={{ gap: 2, flexWrap: 'wrap' }}>
{groups.map(({ group, steps }) => {
const passed = groupStatus(steps) === 'passed';
return (
<Stack key={group} direction="row" sx={{ gap: 0.5, alignItems: 'center' }} data-payoff-group={group}>
<AppIcon
icon={passed ? 'verified' : 'pending'}
size={16}
color={passed ? 'var(--bal-success)' : 'var(--bal-text-secondary)'}
/>
<Typography variant="caption" sx={{ color: passed ? 'text.primary' : 'text.secondary' }}>
{t(groupLabelKey(group))}
</Typography>
</Stack>
);
})}
</Stack>
</Stack>
</SurfaceCard>
);
};
export default TrustBadgePreviewPanel;
@@ -1,138 +1,133 @@
'use client';
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 { Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, StatusChip, SurfaceCard } from '@/components';
import type { VerificationStatus, VerificationStep } from '@/services/verification/types';
import {
displaySteps,
progressCounts,
routeForStep,
groupLabelKey,
groupRoute,
groupStatus,
groupedDisplaySteps,
stepDescriptionKey,
stepLabelKey,
stepStatusChip,
type StepGroupKey,
} from './verificationSteps';
interface VerificationChecklistProps {
status: VerificationStatus;
}
const GROUP_ICON: Record<StepGroupKey, string> = { identity: 'identity', credentials: 'license', bank: 'bank' };
/**
* The B3 checklist body: the "X از Y" progress meter + the data-driven, ordered step rows (reusing the
* shared `StatusChip`). Rows are rendered from `displaySteps(status)` a new step type appears without a
* code change. Failed/expired rows surface their reason and a re-submit path; the first actionable
* automated/manual step gets an inline "go" link to its screen.
* The B3 journey body (ui-phase-8 §3.2) ONE vertical spine of grouped step cards (هویت / مدارک
* حرفهای / بانک), replacing the old flat "X از Y" + 7-row list. Each card folds the data-driven
* steps `verificationSteps.ts` already catalogs (regrouped presentation only the catalog/synthetic
* mobile-step architecture is unchanged) and links to the one screen that owns that group's submission.
*/
const VerificationChecklist: FunctionComponent<VerificationChecklistProps> = ({ status }) => {
const { passed, total } = progressCounts(status);
const steps = displaySteps(status);
const firstActionableId = steps.find(
(step) => step.status !== 'passed' && step.status !== 'in_review' && routeForStep(step.code) !== null,
)?.id;
const groups = groupedDisplaySteps(status);
return (
<Stack sx={{ gap: 2 }}>
<ProgressMeter passed={passed} total={total} />
<Stack sx={{ gap: 1 }}>
{steps.map((step) => (
<StepRow key={step.code} step={step} highlighted={step.id === firstActionableId} />
))}
</Stack>
{groups.map(({ group, steps }) => (
<GroupCard key={group} group={group} steps={steps} />
))}
</Stack>
);
};
const ProgressMeter: FunctionComponent<{ passed: number; total: number }> = ({ passed, total }) => {
const GroupCard: FunctionComponent<{ group: StepGroupKey; steps: VerificationStep[] }> = ({ group, steps }) => {
const t = useTranslations('verification');
const locale = useLocale();
const percent = total === 0 ? 0 : (passed / total) * 100;
const format = (value: number) => formatNumber(value, locale);
const aggregate = groupStatus(steps);
const chip = stepStatusChip(aggregate);
const route = groupRoute(group);
const actionable = aggregate !== 'passed' && aggregate !== 'in_review';
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 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('progress_title')}
</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'var(--bal-primary)' }}>
{t('progress_count', { passed: format(passed), total: format(total) })}
</Typography>
<SurfaceCard
padding="md"
data-journey-group={group}
data-journey-group-status={aggregate}
sx={{
borderInlineStart: '4px solid',
borderInlineStartColor:
aggregate === 'failed' ? 'var(--bal-error)' : aggregate === 'passed' ? 'var(--bal-success)' : 'divider',
}}
>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon={GROUP_ICON[group]} size={22} color="var(--bal-text-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t(groupLabelKey(group))}
</Typography>
</Stack>
<StatusChip status={chip.kind} label={t(chip.labelKey)} sx={{ flexShrink: 0 }} />
</Stack>
<Stack sx={{ gap: 1 }}>
{steps.map((step) => (
<StepLine key={step.code} step={step} />
))}
</Stack>
{actionable ? (
<AppButton
variant="text"
color="primary"
endIcon="edit"
to={`/${locale}${route}`}
sx={{ alignSelf: 'flex-start' }}
>
{aggregate === 'failed' ? t('row_fix') : t('row_go')}
</AppButton>
) : null}
</Stack>
<LinearProgress variant="determinate" value={percent} sx={{ height: 8, borderRadius: 1 }} />
</Paper>
</SurfaceCard>
);
};
const StepRow: FunctionComponent<{ step: VerificationStep; highlighted: boolean }> = ({ step, highlighted }) => {
const StepLine: FunctionComponent<{ step: VerificationStep }> = ({ step }) => {
const t = useTranslations('verification');
const locale = useLocale();
const chip = stepStatusChip(step.status);
const labelKey = stepLabelKey(step.code);
const descKey = stepDescriptionKey(step.code);
const label = t.has(labelKey) ? t(labelKey) : step.displayName;
const description = t.has(descKey) ? t(descKey) : '';
const route = routeForStep(step.code);
const showReason = step.status === 'failed' || step.status === 'expired';
const description = step.status !== 'passed' && t.has(descKey) ? t(descKey) : null;
const reason = step.failureReason
? t.has(`reason_${step.failureReason}`)
? t(`reason_${step.failureReason}`)
: step.failureReason
: null;
const showReason = (step.status === 'failed' || step.status === 'expired') && reason;
// Only the genuinely automated checks may advertise "استعلام خودکار" — the honesty constraint.
const showAutoNote = step.isAutomated && step.status === 'not_started';
return (
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: highlighted ? 'var(--bal-primary)' : 'divider',
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={step.isAutomated ? 'verified' : 'document'} size={22} color="var(--bal-text-secondary)" />
<Stack sx={{ gap: 0.25, flexGrow: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{label}
</Typography>
{description ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{description}
</Typography>
) : null}
</Stack>
<StatusChip status={chip.kind} label={t(chip.labelKey)} sx={{ flexShrink: 0 }} />
<Stack sx={{ gap: 0.25 }} data-step-code={step.code}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="body2">{label}</Typography>
<StatusChip status={chip.kind} label={t(chip.labelKey)} size="small" sx={{ flexShrink: 0 }} />
</Stack>
{showReason && reason ? (
<Typography variant="body2" sx={{ color: 'var(--bal-error)' }}>
{description ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{description}
</Typography>
) : null}
{showReason ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{reason}
</Typography>
) : null}
{showAutoNote ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('auto_query_note')}
</Typography>
) : null}
{route && (highlighted || step.status === 'failed' || step.status === 'expired') ? (
<AppButton
variant="text"
color="primary"
endIcon="edit"
to={`/${locale}${route}`}
sx={{ alignSelf: 'flex-start' }}
>
{step.status === 'failed' || step.status === 'expired' ? t('row_fix') : t('row_go')}
</AppButton>
) : null}
</Paper>
</Stack>
);
};
@@ -0,0 +1,33 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { PageHeader } from '@/components';
import { ROUTES } from '@/constants';
import type { StepGroupKey } from './verificationSteps';
import { groupLabelKey } from './verificationSteps';
export interface VerificationJourneyHeaderProps {
/** Which journey group this screen submits (or `'review'` for the B6 status screen) — drives the title. */
group: StepGroupKey | 'review';
}
/**
* The ONE progress answer B4/B5/B6 share (ui-phase-8 §3.2) replaces the competing bare 3-step
* `StepperHeader` each of those screens used to render alongside the B3 hub's own "X از Y" meter.
* Just the group name + a "بازگشت به مسیر تأیید" back link into the B3 hub, which is the single
* place progress is now shown.
* @component VerificationJourneyHeader
*/
const VerificationJourneyHeader: FunctionComponent<VerificationJourneyHeaderProps> = ({ group }) => {
const t = useTranslations('verification');
const locale = useLocale();
return (
<PageHeader
title={t(groupLabelKey(group))}
backTo={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
backLabel={t('journey_back')}
/>
);
};
export default VerificationJourneyHeader;
@@ -4,9 +4,11 @@ import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { useSnackbar } from 'notistack';
import { Box, Chip, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, DocumentUpload, StepperHeader } from '@/components';
import { AppButton, AppIcon, AppLoading, DocumentUpload, JalaliDateField } from '@/components';
import type { UploadedDocInfo } from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants';
import { formatShamsiDate } from '@/utils';
import {
useSubmitCredentials,
useUploadVerificationDocument,
@@ -15,14 +17,22 @@ import {
import { SPECIALTY_PRESETS } from '@/services/verification/types';
import type { VerificationStep } from '@/services/verification/types';
import { stepDescriptionKey, stepLabelKey } from '../verificationSteps';
import VerificationJourneyHeader from '../VerificationJourneyHeader';
const MANUAL_CREDENTIAL_CODES = ['moh_competency_license', 'ino_membership', 'criminal_record'];
/**
* B5 professional credentials. Renders a `DocumentUpload` for **each manual credential step in the
* status** (data-driven a new manual step renders without a code change); each upload moves its step to
* `in_review` (manual admin review copy never claims an automated authority check). Collects the INO
* number + specialty chips + optional registry fields, persisted on submit. Lands on B6 (under review).
* status** (data-driven a new manual step renders without a code change); each upload moves its step
* to `in_review` (manual admin review copy never claims an automated authority check).
*
* Hydrates from `status.credentialSubmission` (REQ-056, mock-tolerant): once the INO number has been
* recorded, the field locks into a "شمارهٔ نظام ثبت شد" summary never re-prompted as if lost, and
* never re-sent blank (the server's `CredentialDetailsInput.inoNumber` is required; the raw number is
* never read back by design, so re-submitting it isn't possible without the nurse re-entering it via
* "تغییر"). A returning, already-submitted nurse can still fix a **rejected** document directly (each
* upload takes effect immediately, no re-submit needed) and simply returns to the journey no dead
* disabled button, because there is no button to be dead.
*/
export default function CredentialsSubmitPage() {
const t = useTranslations('verification');
@@ -34,14 +44,29 @@ export default function CredentialsSubmitPage() {
const uploadDocument = useUploadVerificationDocument();
const submitCredentials = useSubmitCredentials();
const submission = status?.credentialSubmission;
const [inoNumber, setInoNumber] = useState('');
const [inoError, setInoError] = useState(false);
const [editingIno, setEditingIno] = useState(true);
const [specialties, setSpecialties] = useState<string[]>([]);
const [customSpecialty, setCustomSpecialty] = useState('');
const [issuingAuthority, setIssuingAuthority] = useState('');
const [issuedAt, setIssuedAt] = useState('');
const [expiresAt, setExpiresAt] = useState('');
const [issuedAt, setIssuedAt] = useState<string | null>(null);
const [expiresAt, setExpiresAt] = useState<string | null>(null);
const [uploadedSteps, setUploadedSteps] = useState<Record<number, boolean>>({});
const [hydrated, setHydrated] = useState(false);
// Hydrate once from the server's read-back, adjusted directly during render (never in an effect —
// that would cascade an extra render) — and never overwrite what the nurse is actively editing.
if (!hydrated && submission) {
setHydrated(true);
setEditingIno(!submission.inoNumberSubmitted);
setSpecialties(submission.specialties);
setIssuingAuthority(submission.issuingAuthority ?? '');
setIssuedAt(submission.issuedAt ?? null);
setExpiresAt(submission.expiresAt ?? null);
}
const manualSteps = useMemo(
() => (status?.steps ?? []).filter((step) => MANUAL_CREDENTIAL_CODES.includes(step.code)),
@@ -72,8 +97,8 @@ export default function CredentialsSubmitPage() {
inoNumber: inoNumber.trim(),
specialties,
issuingAuthority: issuingAuthority.trim() || undefined,
issuedAt: issuedAt || undefined,
expiresAt: expiresAt || undefined,
issuedAt: issuedAt ?? undefined,
expiresAt: expiresAt ?? undefined,
},
{
onSuccess: () => {
@@ -89,10 +114,8 @@ export default function CredentialsSubmitPage() {
if (!status || manualSteps.length === 0) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: 560 }}>
<Typography variant="h5" component="h1">
{t('credentials_title')}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<VerificationJourneyHeader group="credentials" />
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('credentials_needs_start')}
</Typography>
@@ -103,12 +126,20 @@ export default function CredentialsSubmitPage() {
);
}
const anyUploaded = Object.values(uploadedSteps).some(Boolean);
// A returning nurse with any manual step already on file (server truth) is never dead-ended: while
// actively (re-)entering the INO number, the gate also counts server-side documents, not only this
// session's uploads.
const hasAnyDocument =
Object.values(uploadedSteps).some(Boolean) ||
manualSteps.some((step) => step.status === 'in_review' || step.status === 'passed');
const canSubmit = hasAnyDocument && !submitCredentials.isPending;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<VerificationJourneyHeader group="credentials" />
<Box>
<Typography variant="h5" component="h1">
<Typography variant="h6" component="h2" sx={{ fontWeight: 700 }}>
{t('credentials_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
@@ -116,24 +147,36 @@ export default function CredentialsSubmitPage() {
</Typography>
</Box>
<Box sx={{ overflowX: 'auto' }}>
<StepperHeader steps={[t('journey_identity'), t('journey_credentials'), t('journey_review')]} activeStep={1} />
</Box>
{editingIno ? (
<TextField
label={t('ino_number_label')}
value={inoNumber}
onChange={(event) => {
setInoNumber(event.target.value);
if (inoError) setInoError(false);
}}
error={inoError}
helperText={inoError ? t('ino_number_required') : t('ino_number_hint')}
slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start' } } }}
fullWidth
/>
) : (
<Stack
direction="row"
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="verified" size={18} color="var(--bal-success)" />
<Typography variant="body2">{t('ino_number_submitted')}</Typography>
</Stack>
<AppButton variant="text" color="primary" onClick={() => setEditingIno(true)}>
{t('ino_number_change')}
</AppButton>
</Stack>
)}
<TextField
label={t('ino_number_label')}
value={inoNumber}
onChange={(event) => {
setInoNumber(event.target.value);
if (inoError) setInoError(false);
}}
error={inoError}
helperText={inoError ? t('ino_number_required') : t('ino_number_hint')}
slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start' } } }}
fullWidth
/>
{/* One uploader per manual credential step — data-driven from the status. */}
{/* One uploader per manual credential step data-driven from the status. Re-uploading a
rejected document always takes effect immediately, whether or not the INO number is locked. */}
<Stack sx={{ gap: 2 }}>
{manualSteps.map((step) => (
<DocumentUpload
@@ -144,97 +187,134 @@ export default function CredentialsSubmitPage() {
onUploaded={() => setUploadedSteps((prev) => ({ ...prev, [step.id]: true }))}
rejected={step.status === 'failed'}
rejectionReason={step.failureReason ?? undefined}
existingDoc={step.status === 'in_review' ? { name: t('doc_uploaded') } : null}
existingDoc={step.status === 'in_review' || step.status === 'passed' ? { name: t('doc_uploaded') } : null}
/>
))}
</Stack>
{/* Supplementary education certificate — a local attachment (no dedicated step). */}
<DocumentUpload label={t('education_label')} hint={t('education_hint')} onUpload={async (file) => ({ name: file.name })} />
{editingIno ? (
<DocumentUpload label={t('education_label')} hint={t('education_hint')} onUpload={async (file) => ({ name: file.name })} />
) : null}
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('specialties_label')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('specialties_hint')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{SPECIALTY_PRESETS.map((preset) => {
const selected = specialties.includes(preset);
return (
<Chip
key={preset}
label={t.has(`specialty_${preset}`) ? t(`specialty_${preset}`) : preset}
onClick={() => toggleSpecialty(preset)}
sx={{
fontWeight: 500,
backgroundColor: selected ? 'var(--bal-primary)' : 'var(--bal-primary-soft)',
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
{editingIno ? (
<>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('specialties_hint')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{SPECIALTY_PRESETS.map((preset) => {
const selected = specialties.includes(preset);
return (
<Chip
key={preset}
label={t.has(`specialty_${preset}`) ? t(`specialty_${preset}`) : preset}
onClick={() => toggleSpecialty(preset)}
icon={selected ? <AppIcon icon="verified" size={16} color="var(--bal-primary-contrast)" /> : undefined}
variant={selected ? 'filled' : 'outlined'}
sx={{
fontWeight: 500,
backgroundColor: selected ? 'var(--bal-primary)' : 'transparent',
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
borderColor: 'var(--bal-primary)',
}}
/>
);
})}
{specialties
.filter((value) => !SPECIALTY_PRESETS.includes(value))
.map((value) => (
<Chip
key={value}
label={value}
onDelete={() => toggleSpecialty(value)}
sx={{ fontWeight: 500, backgroundColor: 'var(--bal-primary)', color: 'var(--bal-primary-contrast)' }}
/>
))}
</Stack>
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<TextField
size="small"
placeholder={t('specialty_add_placeholder')}
value={customSpecialty}
onChange={(event) => setCustomSpecialty(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addCustomSpecialty();
}
}}
/>
);
})}
{specialties
.filter((value) => !SPECIALTY_PRESETS.includes(value))
.map((value) => (
<AppButton variant="outlined" color="primary" startIcon="add" onClick={addCustomSpecialty}>
{t('specialty_add')}
</AppButton>
</Stack>
</>
) : specialties.length > 0 ? (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{specialties.map((value) => (
<Chip
key={value}
label={value}
onDelete={() => toggleSpecialty(value)}
sx={{ fontWeight: 500, backgroundColor: 'var(--bal-primary)', color: 'var(--bal-primary-contrast)' }}
label={t.has(`specialty_${value}`) ? t(`specialty_${value}`) : value}
sx={{ fontWeight: 500, backgroundColor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)' }}
/>
))}
</Stack>
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<TextField
size="small"
placeholder={t('specialty_add_placeholder')}
value={customSpecialty}
onChange={(event) => setCustomSpecialty(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addCustomSpecialty();
}
}}
/>
<AppButton variant="outlined" color="primary" startIcon="add" onClick={addCustomSpecialty}>
{t('specialty_add')}
</AppButton>
</Stack>
</Stack>
) : (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('summary_none')}
</Typography>
)}
</Stack>
{/* Optional registry details the admin cross-checks — issue/expiry are stored UTC (Shamsi shown elsewhere). */}
<Stack sx={{ gap: 1.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('registry_details_label')}
</Typography>
<TextField
label={t('issuing_authority_label')}
value={issuingAuthority}
onChange={(event) => setIssuingAuthority(event.target.value)}
fullWidth
/>
<Stack direction="row" sx={{ gap: 1.5, flexWrap: 'wrap' }}>
{/* Optional registry details the admin cross-checks issue/expiry feed the credential-expiry sweep, so
wrong dates are a correctness risk; a Jalali picker replaces the Gregorian-only native input. */}
{editingIno ? (
<Stack sx={{ gap: 1.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('registry_details_label')}
</Typography>
<TextField
label={t('issued_at_label')}
type="date"
value={issuedAt}
onChange={(event) => setIssuedAt(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
sx={{ flex: 1, minWidth: 160 }}
/>
<TextField
label={t('expires_at_label')}
type="date"
value={expiresAt}
onChange={(event) => setExpiresAt(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
sx={{ flex: 1, minWidth: 160 }}
label={t('issuing_authority_label')}
value={issuingAuthority}
onChange={(event) => setIssuingAuthority(event.target.value)}
fullWidth
/>
<Stack direction="row" sx={{ gap: 1.5, flexWrap: 'wrap' }}>
<JalaliDateField
label={t('issued_at_label')}
value={issuedAt}
onChange={setIssuedAt}
max={expiresAt ?? undefined}
sx={{ flex: 1, minWidth: 160 }}
/>
<JalaliDateField
label={t('expires_at_label')}
value={expiresAt}
onChange={setExpiresAt}
min={issuedAt ?? undefined}
sx={{ flex: 1, minWidth: 160 }}
/>
</Stack>
</Stack>
</Stack>
) : issuingAuthority || issuedAt || expiresAt ? (
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('registry_details_label')}
</Typography>
{issuingAuthority ? <Typography variant="body2">{issuingAuthority}</Typography> : null}
{issuedAt || expiresAt ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{issuedAt ? formatShamsiDate(issuedAt, locale) : ''}
{issuedAt && expiresAt ? ' ' : ''}
{expiresAt ? formatShamsiDate(expiresAt, locale) : ''}
</Typography>
) : null}
</Stack>
) : null}
<Paper
elevation={0}
@@ -246,20 +326,27 @@ export default function CredentialsSubmitPage() {
</Typography>
</Paper>
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton
color="primary"
variant="contained"
startIcon="license"
onClick={handleSubmit}
disabled={submitCredentials.isPending || !anyUploaded}
>
{submitCredentials.isPending ? t('credentials_submitting') : t('credentials_submit')}
</AppButton>
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}>
{editingIno ? (
<>
{!hasAnyDocument ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('credentials_needs_document')}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton color="primary" variant="contained" startIcon="license" onClick={handleSubmit} disabled={!canSubmit}>
{submitCredentials.isPending ? t('credentials_submitting') : t('credentials_submit')}
</AppButton>
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}>
{t('back_to_checklist')}
</AppButton>
</Stack>
</>
) : (
<AppButton color="primary" variant="contained" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`} sx={{ alignSelf: 'flex-start' }}>
{t('back_to_checklist')}
</AppButton>
</Stack>
)}
</Box>
);
}
@@ -4,13 +4,15 @@ import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { useSnackbar } from 'notistack';
import { Box, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppAlert, AppButton, AppIcon, DocumentUpload, StepperHeader } from '@/components';
import { AppAlert, AppButton, AppIcon, DocumentUpload } from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants';
import { toEnglishDigits } from '@/utils';
import { useSubmitIdentity } from '@/services/verification';
import { isValidNationalId } from '@/services/verification/validation';
import { ACCEPTED_IMAGE_TYPES, NATIONAL_ID_LENGTH } from '@/services/verification/constants';
import type { SubmitIdentityResult } from '@/services/verification/hooks/useSubmitIdentity';
import VerificationJourneyHeader from '../VerificationJourneyHeader';
type SubmitError = { key: 'national_id_mismatch' | 'shared_sim' | 'shahkar_mismatch' } | null;
@@ -66,9 +68,11 @@ export default function IdentitySubmitPage() {
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<VerificationJourneyHeader group="identity" />
<Box>
<Typography variant="h5" component="h1">
<Typography variant="h6" component="h2" sx={{ fontWeight: 700 }}>
{t('identity_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
@@ -76,13 +80,6 @@ export default function IdentitySubmitPage() {
</Typography>
</Box>
<Box sx={{ overflowX: 'auto' }}>
<StepperHeader
steps={[t('journey_identity'), t('journey_credentials'), t('journey_review')]}
activeStep={0}
/>
</Box>
<TextField
label={t('national_id_label')}
value={nationalId}
@@ -96,23 +93,29 @@ export default function IdentitySubmitPage() {
fullWidth
/>
<DocumentUpload
label={t('card_label')}
hint={t('card_hint')}
accept={ACCEPTED_IMAGE_TYPES}
capture="environment"
onUpload={captureLocally}
onUploaded={() => setCardCaptured(true)}
/>
<Stack sx={{ gap: 1 }}>
<CaptureGuideFrame variant="card" />
<DocumentUpload
label={t('card_label')}
hint={t('card_hint')}
accept={ACCEPTED_IMAGE_TYPES}
capture="environment"
onUpload={captureLocally}
onUploaded={() => setCardCaptured(true)}
/>
</Stack>
<DocumentUpload
label={t('selfie_label')}
hint={t('selfie_hint')}
accept={ACCEPTED_IMAGE_TYPES}
capture="user"
onUpload={captureLocally}
onUploaded={() => setSelfieCaptured(true)}
/>
<Stack sx={{ gap: 1 }}>
<CaptureGuideFrame variant="selfie" />
<DocumentUpload
label={t('selfie_label')}
hint={t('selfie_hint')}
accept={ACCEPTED_IMAGE_TYPES}
capture="user"
onUpload={captureLocally}
onUploaded={() => setSelfieCaptured(true)}
/>
</Stack>
<Paper
elevation={0}
@@ -153,3 +156,48 @@ export default function IdentitySubmitPage() {
</Box>
);
}
const CORNER_SIZE = 18;
/** The four viewfinder-style corner brackets on the card guide (no-op for the round selfie guide). */
const CORNER_POSITIONS = [
{ insetBlockStart: -1, insetInlineStart: -1, borderBlockStart: '3px solid', borderInlineStart: '3px solid' },
{ insetBlockStart: -1, insetInlineEnd: -1, borderBlockStart: '3px solid', borderInlineEnd: '3px solid' },
{ insetBlockEnd: -1, insetInlineStart: -1, borderBlockEnd: '3px solid', borderInlineStart: '3px solid' },
{ insetBlockEnd: -1, insetInlineEnd: -1, borderBlockEnd: '3px solid', borderInlineEnd: '3px solid' },
] as const;
/**
* A cheap, dependency-free capture guide a dashed frame (viewfinder corners for the card, an oval
* for the selfie) plus a static hint line, shown above the corresponding `DocumentUpload`. There is no
* live camera preview to overlay (the native camera app owns capture via the file input's `capture`
* attribute), so this illustrates *how to frame the shot* rather than tracking the actual photo.
*/
function CaptureGuideFrame({ variant }: { variant: 'card' | 'selfie' }) {
const t = useTranslations('verification');
const isCard = variant === 'card';
return (
<Stack sx={{ alignItems: 'center', gap: 0.75 }}>
<Box
sx={{
position: 'relative',
width: isCard ? 180 : 112,
height: isCard ? 112 : 140,
borderRadius: isCard ? 2 : '50%',
border: '2px dashed var(--bal-divider)',
}}
>
{isCard
? CORNER_POSITIONS.map((pos, index) => (
<Box
key={index}
sx={{ position: 'absolute', width: CORNER_SIZE, height: CORNER_SIZE, borderColor: 'var(--bal-primary)', ...pos }}
/>
))
: null}
</Box>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center', maxWidth: 220 }}>
{t(isCard ? 'capture_hint_card' : 'capture_hint_selfie')}
</Typography>
</Stack>
);
}
@@ -3,12 +3,14 @@ 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, EmptyState } from '@/components';
import { AppAlert, AppButton, AppIcon, EmptyState, PageHeader } from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants';
import { useStartVerification, useVerificationStatus } from '@/services/verification';
import { verificationKeys } from '@/services/verification/keys';
import { USE_VERIFICATION_MOCK } from '@/services/verification/constants';
import { __mockApproveAll, __mockRejectStep } from '@/services/verification/apis/mockApi';
import TrustBadgePreviewPanel from './TrustBadgePreviewPanel';
import VerificationChecklist from './VerificationChecklist';
import { nextActionRoute, nextActionableIndex } from './verificationSteps';
@@ -44,15 +46,8 @@ export default function NurseVerificationPage() {
const refreshStatus = () => queryClient.invalidateQueries({ queryKey: verificationKeys.status() });
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 620 }}>
<Box>
<Typography variant="h5" component="h1">
{t('title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<PageHeader title={t('title')} subtitle={t('subtitle')} />
{isLoading ? (
<Stack sx={{ gap: 1.5 }}>
@@ -78,6 +73,7 @@ export default function NurseVerificationPage() {
<Approved onPublish={() => go(ROUTES.NURSE_SERVICES)} />
) : (
<>
<TrustBadgePreviewPanel status={status} />
<VerificationChecklist status={status} />
<BlockingSummary hasBlocking={status.blockingSteps.length > 0} />
<ContinueCta status={status} onContinue={handleContinue} />
@@ -1,16 +1,21 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, StatusChip, StepperHeader } from '@/components';
import { Box, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, StatusChip, StatusTimeline, SurfaceCard } from '@/components';
import type { TimelineNode } from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants';
import { formatShamsiDate } from '@/utils';
import { useVerificationStatus } from '@/services/verification';
import { displaySteps, stepLabelKey, stepStatusChip } from '../verificationSteps';
import VerificationJourneyHeader from '../VerificationJourneyHeader';
/**
* B6 under review. A focused view of the **same cached `VerificationStatus`** B3 reads (one query, two
* views never a second fetch). Shows the waiting message + the 2448h expectation + a condensed
* mini-checklist (reusing the shared `StatusChip`) of what is passed vs in-review vs pending. "مشاهده
* وضعیت" returns to the canonical B3 hub.
* B6 under review. A focused view of the **same cached `VerificationStatus`** B3 reads (one query,
* two views never a second fetch). ui-phase-8: the same journey header as B4/B5, a Shamsi submitted
* timestamp when the server serves one (REQ-055, mock-tolerant omitted rather than faked), a
* what-happens-next `StatusTimeline` (بررسی توسط کارشناس نتیجه در ۲۴۴۸ ساعت فعالسازی نشان), and
* the condensed mini-checklist of what is passed vs in-review vs pending.
*/
export default function UnderReviewPage() {
const t = useTranslations('verification');
@@ -22,41 +27,53 @@ export default function UnderReviewPage() {
const steps = displaySteps(status);
const isApproved = status?.status === 'approved';
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Box sx={{ overflowX: 'auto' }}>
<StepperHeader steps={[t('journey_identity'), t('journey_credentials'), t('journey_review')]} activeStep={2} />
</Box>
const timelineNodes: TimelineNode[] = [
{
key: 'submitted',
label: t('review_timeline_submitted'),
timestamp: status?.submittedAt ? formatShamsiDate(status.submittedAt, locale) : undefined,
state: 'completed',
},
{
key: 'review',
label: t('review_timeline_review'),
note: isApproved ? undefined : t('review_eta'),
state: isApproved ? 'completed' : 'current',
},
{
key: 'activation',
label: t('review_timeline_activation'),
state: isApproved ? 'completed' : 'pending',
},
];
<Paper
elevation={0}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<VerificationJourneyHeader group="review" />
<SurfaceCard
padding="md"
sx={{
p: 3,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStart: '4px solid',
borderInlineStartColor: isApproved ? 'var(--bal-success)' : 'var(--bal-warning)',
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={isApproved ? 'verified' : 'pending'} size={28} color={isApproved ? 'var(--bal-success)' : 'var(--bal-warning)'} />
<Typography variant="h6" component="h1">
{isApproved ? t('review_approved_title') : t('review_title')}
<Stack sx={{ gap: 1 }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={isApproved ? 'verified' : 'pending'} size={28} color={isApproved ? 'var(--bal-success)' : 'var(--bal-warning)'} />
<Typography variant="h6" component="h2">
{isApproved ? t('review_approved_title') : t('review_title')}
</Typography>
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{isApproved ? t('review_approved_body') : t('review_body')}
</Typography>
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{isApproved ? t('review_approved_body') : t('review_body')}
</Typography>
{!isApproved ? (
<Typography variant="body2" sx={{ fontWeight: 500, color: 'var(--bal-warning)' }}>
{t('review_eta')}
</Typography>
) : null}
</Paper>
</SurfaceCard>
<SurfaceCard padding="md">
<StatusTimeline nodes={timelineNodes} />
</SurfaceCard>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
@@ -115,3 +115,62 @@ export function nextActionableIndex(status: VerificationStatus | undefined): num
const index = steps.findIndex(isActionable);
return index === -1 ? null : index + 1;
}
/**
* The unified vertical journey (ui-phase-8 §3.2) groups the flat step list into three spines
* هویت (mobile + identity KYC + Shahkar), مدارک حرفهای (the three manual credential steps), بانک
* (IBAN-owner match) matching the three submission screens (B4/B5/bank). This is presentation
* grouping only; the underlying data-driven `steps[]` catalog is unchanged.
*/
export type StepGroupKey = 'identity' | 'credentials' | 'bank';
export const GROUP_ORDER: readonly StepGroupKey[] = ['identity', 'credentials', 'bank'] as const;
const GROUP_BY_CODE: Record<string, StepGroupKey> = {
mobile_verified: 'identity',
identity_kyc: 'identity',
shahkar_match: 'identity',
moh_competency_license: 'credentials',
ino_membership: 'credentials',
criminal_record: 'credentials',
bank_account_verification: 'bank',
};
/** Which journey group a step code belongs to (unrecognised codes fall into `credentials`). */
export function stepGroup(code: string): StepGroupKey {
return GROUP_BY_CODE[code] ?? 'credentials';
}
/** The i18n label key for a journey group (also accepts `'review'`, the B6 status screen). */
export function groupLabelKey(group: StepGroupKey | 'review'): string {
return `group_${group}`;
}
/** The screen that owns a journey group's submission (identity/credentials pages, or the bank page). */
export function groupRoute(group: StepGroupKey): string {
switch (group) {
case 'identity':
return ROUTES.NURSE_VERIFICATION_IDENTITY;
case 'credentials':
return ROUTES.NURSE_VERIFICATION_CREDENTIALS;
case 'bank':
return ROUTES.NURSE_BANK;
}
}
/** The full checklist folded into ordered `{ group, steps }` buckets — one card per journey group. */
export function groupedDisplaySteps(
status: VerificationStatus | undefined,
): Array<{ group: StepGroupKey; steps: VerificationStep[] }> {
const steps = displaySteps(status);
return GROUP_ORDER.map((group) => ({ group, steps: steps.filter((step) => stepGroup(step.code) === group) }));
}
/** A group's own aggregate status, coarsest-first: failed/expired > in_review > pending > not_started > passed. */
export function groupStatus(steps: VerificationStep[]): VerificationStepStatus {
if (steps.some((step) => step.status === 'failed' || step.status === 'expired')) return 'failed';
if (steps.some((step) => step.status === 'in_review')) return 'in_review';
if (steps.every((step) => step.status === 'passed')) return 'passed';
if (steps.some((step) => step.status === 'pending')) return 'pending';
return 'not_started';
}
@@ -0,0 +1,115 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
const useNurseProfile = jest.fn();
const useMyVariants = jest.fn();
const useServiceAreas = jest.fn();
const useNurseBankAccounts = jest.fn();
const useVerificationStatus = jest.fn();
jest.mock('@/services/profiles', () => ({ useNurseProfile: () => useNurseProfile() }));
jest.mock('@/services/catalog', () => ({ useMyVariants: () => useMyVariants() }));
jest.mock('@/services/serviceAreas', () => ({ useServiceAreas: () => useServiceAreas() }));
jest.mock('@/services/nurse', () => ({ useNurseBankAccounts: () => useNurseBankAccounts() }));
jest.mock('@/services/verification', () => ({ useVerificationStatus: () => useVerificationStatus() }));
import ActivationChecklist from './ActivationChecklist';
const APPROVED_STATUS = { status: 'approved' as const, isBookable: true, blockingSteps: [], steps: [] };
const NOT_STARTED_STATUS = { status: 'not_started' as const, isBookable: false, blockingSteps: [], steps: [] };
function mockAllPassing(overrides: { accepting?: boolean; bankVerified?: boolean } = {}) {
useVerificationStatus.mockReturnValue({ data: APPROVED_STATUS, isLoading: false, isError: false, refetch: jest.fn() });
useNurseProfile.mockReturnValue({
data: { bio: 'Experienced nurse', avatarUrl: 'https://x/y.png', isAcceptingBookings: overrides.accepting ?? true },
isLoading: false,
isError: false,
refetch: jest.fn(),
});
useMyVariants.mockReturnValue({ data: { items: [{ isActive: true }] }, isLoading: false, isError: false, refetch: jest.fn() });
useServiceAreas.mockReturnValue({ data: { items: [{ id: 1 }] }, isLoading: false, isError: false, refetch: jest.fn() });
useNurseBankAccounts.mockReturnValue({
data: overrides.bankVerified === false ? [] : [{ matchedNationalId: true }],
isLoading: false,
isError: false,
refetch: jest.fn(),
});
}
describe('<ActivationChecklist/> component', () => {
it('renders a skeleton while any query is loading', () => {
useVerificationStatus.mockReturnValue({ data: undefined, isLoading: true, isError: false, refetch: jest.fn() });
useNurseProfile.mockReturnValue({ data: undefined, isLoading: true, isError: false, refetch: jest.fn() });
useMyVariants.mockReturnValue({ data: undefined, isLoading: true, isError: false, refetch: jest.fn() });
useServiceAreas.mockReturnValue({ data: undefined, isLoading: true, isError: false, refetch: jest.fn() });
useNurseBankAccounts.mockReturnValue({ data: undefined, isLoading: true, isError: false, refetch: jest.fn() });
const { container } = render(
<ThemeProvider>
<ActivationChecklist />
</ThemeProvider>,
);
expect(container.querySelector('.MuiSkeleton-root')).toBeInTheDocument();
expect(screen.queryByText('title')).not.toBeInTheDocument();
});
it('never renders empty on a failed query — shows the error state with retry', () => {
useVerificationStatus.mockReturnValue({ data: undefined, isLoading: false, isError: true, refetch: jest.fn() });
useNurseProfile.mockReturnValue({ data: null, isLoading: false, isError: false, refetch: jest.fn() });
useMyVariants.mockReturnValue({ data: { items: [] }, isLoading: false, isError: false, refetch: jest.fn() });
useServiceAreas.mockReturnValue({ data: { items: [] }, isLoading: false, isError: false, refetch: jest.fn() });
useNurseBankAccounts.mockReturnValue({ data: [], isLoading: false, isError: false, refetch: jest.fn() });
render(
<ThemeProvider>
<ActivationChecklist />
</ThemeProvider>,
);
expect(screen.getByText('load_error')).toBeInTheDocument();
expect(screen.getByText('retry')).toBeInTheDocument();
});
it('lists unmet rows with a fix link when nothing has been done yet', () => {
useVerificationStatus.mockReturnValue({ data: NOT_STARTED_STATUS, isLoading: false, isError: false, refetch: jest.fn() });
useNurseProfile.mockReturnValue({ data: { bio: '', avatarUrl: null, isAcceptingBookings: false }, isLoading: false, isError: false, refetch: jest.fn() });
useMyVariants.mockReturnValue({ data: { items: [] }, isLoading: false, isError: false, refetch: jest.fn() });
useServiceAreas.mockReturnValue({ data: { items: [] }, isLoading: false, isError: false, refetch: jest.fn() });
useNurseBankAccounts.mockReturnValue({ data: [], isLoading: false, isError: false, refetch: jest.fn() });
const { container } = render(
<ThemeProvider>
<ActivationChecklist />
</ThemeProvider>,
);
expect(screen.getByText('title')).toBeInTheDocument();
expect(container.querySelectorAll('[data-passed="false"]')).toHaveLength(5);
expect(screen.getAllByText('row_fix').length).toBeGreaterThan(0);
});
it('collapses to the compact live state once every row passes and accepting is on', () => {
mockAllPassing();
render(
<ThemeProvider>
<ActivationChecklist />
</ThemeProvider>,
);
expect(screen.getByText('live_title')).toBeInTheDocument();
expect(screen.queryByText('title')).not.toBeInTheDocument();
});
it('stays expanded when search-visible but not yet accepting bookings (bank still unmet)', () => {
mockAllPassing({ accepting: false });
const { container } = render(
<ThemeProvider>
<ActivationChecklist />
</ThemeProvider>,
);
expect(screen.queryByText('live_title')).not.toBeInTheDocument();
expect(container.querySelectorAll('[data-passed="false"]')).toHaveLength(0);
});
});
@@ -0,0 +1,104 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import Divider from '@mui/material/Divider';
import Skeleton from '@mui/material/Skeleton';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import AccentCard from '../common/AccentCard';
import AppButton from '../common/AppButton';
import AppIcon from '../common/AppIcon';
import ErrorState from '../common/ErrorState';
import StatusChip from '../StatusChip';
import { useActivationChecklist, type ActivationRow } from './useActivationChecklist';
/**
* The unified "راه‌اندازی" tracker replaces the four scattered go-live nags (the profile
* blocked-until-verified banner, `PublishGate`, the coverage empty warning, the bank empty state)
* with one tracker composing five already-cached queries. **Two-tier honesty**: the first four rows
* are exactly the server's `is_searchable` gate; the bank row is labelled separately as "getting
* paid" — it is never part of search visibility. Collapses to a compact "فعال در جستجو" confirmation
* once every row passes and the nurse is actively accepting bookings. Mounted on `/nurse/services`
* (above the offerings list) and in the dashboard's `DashboardActivationSlot` one shared component,
* never forked.
* @component ActivationChecklist
*/
const ActivationChecklist: FunctionComponent = () => {
const t = useTranslations('activation');
const state = useActivationChecklist();
if (state.isLoading) return <Skeleton variant="rounded" height={220} sx={{ borderRadius: 2 }} />;
if (state.isError) {
return <ErrorState message={t('load_error')} retryLabel={t('retry')} onRetry={state.retry} />;
}
if (state.isFullyLive) {
return (
<AccentCard tone="success" padding="sm" data-activation-state="live">
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon="verified" size={22} color="var(--bal-success)" />
<Stack sx={{ gap: 0.25 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('live_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('live_body')}
</Typography>
</Stack>
</Stack>
</AccentCard>
);
}
return (
<AccentCard tone="primary" padding="md" data-activation-state="incomplete">
<Stack sx={{ gap: 1.5 }}>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('title')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('subtitle_visibility')}
</Typography>
</Stack>
<Stack sx={{ gap: 1 }}>
{state.searchRows.map((row) => (
<ActivationRowItem key={row.key} row={row} />
))}
</Stack>
<Divider />
<Stack sx={{ gap: 0.5 }}>
<ActivationRowItem row={state.bankRow} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('row_bank_hint')}
</Typography>
</Stack>
</Stack>
</AccentCard>
);
};
const ActivationRowItem: FunctionComponent<{ row: ActivationRow }> = ({ row }) => {
const t = useTranslations('activation');
const locale = useLocale();
return (
<Stack
direction="row"
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}
data-activation-row={row.key}
data-passed={row.passed}
>
<StatusChip status={row.passed ? 'verified' : 'pending'} label={t(row.labelKey)} />
{!row.passed ? (
<AppButton variant="text" color="primary" size="small" endIcon="forward" to={`/${locale}${row.href}`}>
{t('row_fix')}
</AppButton>
) : null}
</Stack>
);
};
export default ActivationChecklist;
@@ -0,0 +1,5 @@
import ActivationChecklist from './ActivationChecklist';
export default ActivationChecklist;
export { useActivationChecklist } from './useActivationChecklist';
export type { ActivationRow, ActivationChecklistState } from './useActivationChecklist';
@@ -0,0 +1,101 @@
import { ROUTES } from '@/constants';
import { useNurseProfile } from '@/services/profiles';
import { useMyVariants } from '@/services/catalog';
import { useServiceAreas } from '@/services/serviceAreas';
import { useNurseBankAccounts } from '@/services/nurse';
import { deriveBankStatus } from '@/services/nurse/types';
import { useVerificationStatus } from '@/services/verification';
import { isApproved } from '@/services/verification/types';
export interface ActivationRow {
key: 'identity' | 'profile' | 'services' | 'coverage' | 'bank';
labelKey: string;
passed: boolean;
href: string;
}
export interface ActivationChecklistState {
isLoading: boolean;
isError: boolean;
retry: () => void;
/** The four conditions the server's `is_searchable` gate actually checks. */
searchRows: ActivationRow[];
/** Verified-primary-IBAN — drives getting paid, NOT search visibility. Kept separate on purpose. */
bankRow: ActivationRow;
isSearchVisible: boolean;
isAcceptingBookings: boolean;
/** Nothing left to do at all — every row passes and the nurse is actively accepting bookings. */
isFullyLive: boolean;
}
function isProfileComplete(bio: string, avatarUrl: string | null): boolean {
return bio.trim().length > 0 && avatarUrl != null;
}
/**
* The single source of the nurse's go-live state five already-cached queries folded into the rows
* `ActivationChecklist` renders and the state `PublishGate` gates on, so the two components (and the
* dashboard slot) never compute this twice with drifting logic. **Two-tier honesty**: the first four
* rows are exactly the server's `is_searchable` gate (verification, profile, 1 active service, 1
* coverage area); the bank row is a separate "getting paid" concern the search index never reads.
*/
export function useActivationChecklist(): ActivationChecklistState {
const verification = useVerificationStatus();
const profile = useNurseProfile();
const variants = useMyVariants();
const areas = useServiceAreas();
const bank = useNurseBankAccounts();
const isLoading =
verification.isLoading || profile.isLoading || variants.isLoading || areas.isLoading || bank.isLoading;
const isError = verification.isError || profile.isError || variants.isError || areas.isError || bank.isError;
const retry = () => {
if (verification.isError) verification.refetch();
if (profile.isError) profile.refetch();
if (variants.isError) variants.refetch();
if (areas.isError) areas.refetch();
if (bank.isError) bank.refetch();
};
const hasActiveService = (variants.data?.items ?? []).some((variant) => variant.isActive);
const hasCoverageArea = (areas.data?.items ?? []).length > 0;
const hasVerifiedBank = (bank.data ?? []).some((account) => deriveBankStatus(account) === 'verified');
const searchRows: ActivationRow[] = [
{
key: 'identity',
labelKey: 'row_identity',
passed: isApproved(verification.data),
href: ROUTES.NURSE_VERIFICATION,
},
{
key: 'profile',
labelKey: 'row_profile',
passed: isProfileComplete(profile.data?.bio ?? '', profile.data?.avatarUrl ?? null),
href: ROUTES.NURSE_PROFILE,
},
{ key: 'services', labelKey: 'row_services', passed: hasActiveService, href: ROUTES.NURSE_SERVICES },
{ key: 'coverage', labelKey: 'row_coverage', passed: hasCoverageArea, href: ROUTES.NURSE_COVERAGE },
];
const bankRow: ActivationRow = {
key: 'bank',
labelKey: 'row_bank',
passed: hasVerifiedBank,
href: ROUTES.NURSE_BANK,
};
const isSearchVisible = searchRows.every((row) => row.passed);
const isAcceptingBookings = profile.data?.isAcceptingBookings ?? false;
return {
isLoading,
isError,
retry,
searchRows,
bankRow,
isSearchVisible,
isAcceptingBookings,
isFullyLive: isSearchVisible && bankRow.passed && isAcceptingBookings,
};
}
@@ -71,6 +71,30 @@ describe('<DocumentUpload/> component', () => {
expect(screen.getByText('upload_reupload')).toBeInTheDocument();
});
it('shows live progress (reason still visible, re-upload button gone) when re-uploading a rejected document, then succeeds', async () => {
let resolveUpload: ((doc: { name: string }) => void) | undefined;
const onUpload = jest.fn(
() =>
new Promise<{ name: string }>((resolve) => {
resolveUpload = resolve;
}),
);
const { input, container } = renderUpload({ rejected: true, rejectionReason: 'Blurry scan', onUpload });
expect(container.querySelector('[data-upload-state="rejected"]')).toBeInTheDocument();
selectFile(input, new File(['%PDF'], 'license.pdf', { type: 'application/pdf' }));
// The precedence fix: `state === 'uploading'` wins over the still-true `rejected` prop.
await waitFor(() => expect(container.querySelector('[data-upload-state="uploading"]')).toBeInTheDocument());
expect(container.querySelector('[data-upload-state="rejected"]')).not.toBeInTheDocument();
expect(screen.getByText('Blurry scan')).toBeInTheDocument();
expect(screen.queryByText('upload_reupload')).not.toBeInTheDocument();
resolveUpload?.({ name: 'license-v2.pdf' });
await waitFor(() => expect(container.querySelector('[data-upload-state="success"]')).toBeInTheDocument());
expect(screen.getByText('license-v2.pdf')).toBeInTheDocument();
});
it('renders an already-uploaded document from server metadata', () => {
const { container } = renderUpload({ existingDoc: { name: 'prior-license.pdf' } });
expect(container.querySelector('[data-upload-state="success"]')).toBeInTheDocument();
@@ -153,32 +153,15 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
onChange={onFileSelected}
/>
{rejected ? (
<AccentCard tone="error" padding="sm" data-upload-state="rejected" sx={UPLOAD_PANEL_SX}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="rejected" size={20} color="var(--bal-error)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('upload_rejected')}
</Typography>
</Stack>
{rejectionReason ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{state === 'uploading' ? (
<SurfaceCard padding="sm" data-upload-state="uploading" sx={UPLOAD_PANEL_SX}>
{/* A re-upload of a rejected doc must show LIVE progress, not the frozen rejected card the
reason stays visible above the bar so context isn't lost while the new attempt runs. */}
{rejected && rejectionReason ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{rejectionReason}
</Typography>
) : null}
<AppButton
variant="outlined"
color="primary"
startIcon="upload"
onClick={openPicker}
disabled={disabled}
sx={{ alignSelf: 'flex-start' }}
>
{t('upload_reupload')}
</AppButton>
</AccentCard>
) : state === 'uploading' ? (
<SurfaceCard padding="sm" data-upload-state="uploading" sx={UPLOAD_PANEL_SX}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="upload" size={20} color="var(--bal-primary)" />
<Typography variant="body2" sx={{ fontWeight: 500, flexGrow: 1, wordBreak: 'break-all' }}>
@@ -222,6 +205,30 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
</AppButton>
</Stack>
</SurfaceCard>
) : rejected ? (
<AccentCard tone="error" padding="sm" data-upload-state="rejected" sx={UPLOAD_PANEL_SX}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="rejected" size={20} color="var(--bal-error)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('upload_rejected')}
</Typography>
</Stack>
{rejectionReason ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{rejectionReason}
</Typography>
) : null}
<AppButton
variant="outlined"
color="primary"
startIcon="upload"
onClick={openPicker}
disabled={disabled}
sx={{ alignSelf: 'flex-start' }}
>
{t('upload_reupload')}
</AppButton>
</AccentCard>
) : state === 'error' ? (
<AccentCard tone="error" padding="sm" data-upload-state="error" sx={UPLOAD_PANEL_SX}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
@@ -65,4 +65,15 @@ describe('<VariantCard/> component', () => {
fireEvent.click(screen.getByText('deactivate'));
expect(onToggleActive).toHaveBeenCalledTimes(1);
});
it('drops the action row in a read-only (interactive=false) preview', () => {
render(
<ThemeProvider>
<VariantCard variant={baseVariant} interactive={false} />
</ThemeProvider>,
);
expect(screen.getByText('Elderly Care · Live-in')).toBeInTheDocument();
expect(screen.queryByText('edit')).not.toBeInTheDocument();
expect(screen.queryByText('deactivate')).not.toBeInTheDocument();
});
});
@@ -11,20 +11,27 @@ import PriceDisplay from '../PriceDisplay';
export interface VariantCardProps {
variant: NurseServiceVariant;
/** Open the builder in edit mode for this variant. */
onEdit: () => void;
/** Deactivate (when active) or reactivate (when inactive) — soft only, never a delete. */
onToggleActive: () => void;
/** Open the builder in edit mode for this variant. Required unless `interactive` is `false`. */
onEdit?: () => void;
/** Deactivate (when active) or reactivate (when inactive) soft only, never a delete. Required
* unless `interactive` is `false`. */
onToggleActive?: () => void;
/**
* When `false`, renders a read-only preview (no Edit/Deactivate row) the variant builder's step-3
* "این‌گونه در جستجو دیده می‌شوید" live listing preview. Defaults to `true` (the offerings-list card).
*/
interactive?: boolean;
}
/**
* One nurse offering (variant) in the services list: its `display_name`, the category, the price via
* the money util (`PriceDisplay`), and an active/deactivated distinction (dimmed + a neutral chip +
* an "can't be booked" hint on inactive rows). Row actions are Edit and Deactivate/Reactivate there
* is **no delete affordance**. RTL-safe.
* One nurse offering (variant): its `display_name`, the category, the price via the money util
* (`PriceDisplay`), and an active/deactivated distinction (dimmed + a neutral chip + a "can't be
* booked" hint on inactive rows). Row actions are Edit and Deactivate/Reactivate there is **no
* delete affordance**. `interactive={false}` drops the action row for a read-only preview use (the
* builder's live listing preview). RTL-safe.
* @component VariantCard
*/
const VariantCard: FunctionComponent<VariantCardProps> = ({ variant, onEdit, onToggleActive }) => {
const VariantCard: FunctionComponent<VariantCardProps> = ({ variant, onEdit, onToggleActive, interactive = true }) => {
const t = useTranslations('services');
const locale = useLocale();
const active = variant.isActive;
@@ -72,19 +79,21 @@ const VariantCard: FunctionComponent<VariantCardProps> = ({ variant, onEdit, onT
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton variant="outlined" color="primary" startIcon="edit" onClick={onEdit}>
{t('edit')}
</AppButton>
<AppButton
variant="text"
color={active ? 'inherit' : 'primary'}
startIcon={active ? 'visibilityoff' : 'visibilityon'}
onClick={onToggleActive}
>
{active ? t('deactivate') : t('activate')}
</AppButton>
</Stack>
{interactive ? (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton variant="outlined" color="primary" startIcon="edit" onClick={onEdit}>
{t('edit')}
</AppButton>
<AppButton
variant="text"
color={active ? 'inherit' : 'primary'}
startIcon={active ? 'visibilityoff' : 'visibilityon'}
onClick={onToggleActive}
>
{active ? t('deactivate') : t('activate')}
</AppButton>
</Stack>
) : null}
</Stack>
</SurfaceCard>
);
+2
View File
@@ -37,6 +37,7 @@ import VerificationPanel from './VerificationPanel';
import PaymentStateCard from './PaymentStateCard';
import BnplProviderLogo from './BnplProviderLogo';
import EscrowExplainer from './EscrowExplainer';
import ActivationChecklist from './ActivationChecklist';
export {
ProfileSummary,
@@ -76,6 +77,7 @@ export {
PaymentStateCard,
BnplProviderLogo,
EscrowExplainer,
ActivationChecklist,
};
export type { PlaceholderScreenProps } from './PlaceholderScreen';
export type { OtpInputProps } from './OtpInput';
+3
View File
@@ -48,6 +48,9 @@ export const ROUTES = {
// Nurse app
NURSE: '/nurse',
NURSE_PROFILE: '/nurse/profile',
// Public-profile preview ("نمایهٔ عمومی من") — the C3 trust-dossier pieces composed from the nurse's
// own data (own profile + variants + coverage + badge), reachable pre-publish.
NURSE_PROFILE_PREVIEW: '/nurse/profile/preview',
// Services & prices — the nurse variant builder + offerings list (B7 services half).
NURSE_SERVICES: '/nurse/services',
// Coverage-area editor — the cities/districts the nurse will travel to (feeds f6 search).
@@ -107,4 +107,13 @@ export const profilesClientApi: ProfilesApi = {
}),
);
},
// ui-phase-8: the real, previously-unwired go-live switch — flips `is_accepting_bookings`
// independently of `is_verified`; the server reindexes the nurse's search rows in-transaction.
setAcceptingBookings: async (accepting: boolean): Promise<void> => {
await clientFetch<ApiEnvelope<boolean>>(`${BASE}/nurse_profiles/set_accepting_bookings`, {
method: 'POST',
body: JSON.stringify({ accepting }),
});
},
};
@@ -70,4 +70,11 @@ export const profilesMockApi: ProfilesApi = {
// object-storage URL (REQ-006).
return { url: URL.createObjectURL(file) };
},
// Mirrors the real endpoint's flip: `isVerified` is never touched, matching the guarded server field.
setAcceptingBookings: async (accepting: boolean): Promise<void> => {
await sleep(MOCK_LATENCY_MS);
if (!nurseProfile) return;
nurseProfile = { ...nurseProfile, isAcceptingBookings: accepting };
},
};
@@ -0,0 +1,17 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { profilesApi } from '../apis';
import { profileKeys } from '../keys';
/**
* The real go-live switch (`POST nurse_profiles/set_accepting_bookings`) flips
* `isAcceptingBookings` independently of verification. Invalidates the nurse profile so the
* activation checklist / publish gate re-render the live state from the server, never an optimistic
* "published" toast before the mutation actually succeeds.
*/
export function useSetAcceptingBookings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (accepting: boolean) => profilesApi.setAcceptingBookings(accepting),
onSuccess: () => queryClient.invalidateQueries({ queryKey: profileKeys.nurse() }),
});
}
+1
View File
@@ -3,3 +3,4 @@ export { useUpsertCustomerProfile } from './hooks/useUpsertCustomerProfile';
export { useNurseProfile } from './hooks/useNurseProfile';
export { useUpsertNurseProfile } from './hooks/useUpsertNurseProfile';
export { useUploadAvatar } from './hooks/useUploadAvatar';
export { useSetAcceptingBookings } from './hooks/useSetAcceptingBookings';
+6
View File
@@ -73,4 +73,10 @@ export interface ProfilesApi {
getNurseProfile(): Promise<NurseProfile | null>;
upsertNurseProfile(input: UpsertNurseProfileInput): Promise<NurseProfile>;
uploadAvatar(file: File): Promise<AvatarUploadResult>;
/**
* The real, previously-unwired `POST nurse_profiles/set_accepting_bookings` flips the
* nurse's go-live switch independently of `isVerified` (the server reindexes every one of the
* nurse's search rows in the same transaction). Never a client-side simulation of "publish".
*/
setAcceptingBookings(accepting: boolean): Promise<void>;
}
@@ -42,6 +42,12 @@ let nextStepId = 1;
let nextDocId = 1;
let boundNationalId = '';
let approvedAt: string | null = null;
// REQ-055: stamped once when the nurse first leaves `not_started` — stands in for the not-yet-served
// nurse-facing `submittedAt` (B6's timestamp line).
let submittedAt: string | null = null;
// REQ-056: the structured B5 fields, kept so a returning nurse's form hydrates instead of re-prompting
// blank — never the raw INO number, which the real server would never re-serve either.
let credentialSubmission: NonNullable<VerificationStatus['credentialSubmission']> | null = null;
const nationalIdShape = new RegExp(`^\\d{${NATIONAL_ID_LENGTH}}$`);
@@ -56,13 +62,20 @@ function setStepStatus(code: string, status: VerificationStepStatus, failureReas
/** Re-aggregate exactly as the server would: approved only when every step passes; blockers are the rest. */
function aggregate(): VerificationStatus {
if (steps.length === 0) {
return { status: 'not_started', isBookable: false, blockingSteps: [], steps: [] };
return { status: 'not_started', isBookable: false, blockingSteps: [], steps: [], submittedAt: null };
}
const blockingSteps = steps.filter((step) => step.status !== 'passed').map((step) => step.code);
const allPassed = blockingSteps.length === 0;
const anyInReview = steps.some((step) => step.status === 'in_review');
const status = allPassed ? 'approved' : anyInReview ? 'in_review' : 'pending';
return { status, isBookable: allPassed, blockingSteps, steps: steps.map((step) => ({ ...step })) };
return {
status,
isBookable: allPassed,
blockingSteps,
steps: steps.map((step) => ({ ...step })),
submittedAt,
credentialSubmission,
};
}
function seedSteps(): void {
@@ -76,6 +89,7 @@ function seedSteps(): void {
expiresAt: null,
failureReason: null,
}));
submittedAt = new Date().toISOString();
}
/* --- Admin review queue fixtures + state ---------------------------------------------------------
@@ -351,6 +365,14 @@ export const verificationMockApi: VerificationApi = {
if (input.inoNumber.trim().length === 0) {
throw new ApiError(400, 'INO number is required', 'ino_number_required');
}
// Stands in for REQ-056 — the mock persists what a real read-back would serve (never the raw number).
credentialSubmission = {
inoNumberSubmitted: true,
specialties: input.specialties,
issuingAuthority: input.issuingAuthority ?? null,
issuedAt: input.issuedAt ?? null,
expiresAt: input.expiresAt ?? null,
};
},
getTrustBadge: async (nurseId) => {
+21
View File
@@ -77,6 +77,27 @@ export interface VerificationStatus {
/** Step codes still blocking go-live. */
blockingSteps: string[];
steps: VerificationStep[];
/**
* When the nurse first submitted (left `not_started`). **Not on the nurse-facing wire yet** the
* admin queue DTO already serves it, but `VerificationStatusDto` doesn't (REQ-055, filed by
* ui-phase-8). Mock-tolerant: `undefined` on the real path omits the B6 timestamp line rather than
* fake one.
*/
submittedAt?: string | null;
/**
* A nurse-facing read-back of the structured credential details B5 collects so a returning nurse
* sees a submitted summary instead of blank fields. **Not on the wire yet** (REQ-011 delivered only
* the write; there is no nurse-facing read REQ-056, filed by ui-phase-8). `inoNumberSubmitted` is a
* boolean, never the number itself the raw value is encrypted server-side by design and never
* re-served. Mock-tolerant: `undefined` on the real path renders every B5 field blank, as today.
*/
credentialSubmission?: {
inoNumberSubmitted: boolean;
specialties: string[];
issuingAuthority: string | null;
issuedAt: string | null;
expiresAt: string | null;
} | null;
}
/** `UploadUrlResult` — a signed PUT target for a manual step's document. */