ui phase 5

This commit is contained in:
hamid
2026-07-18 09:51:03 +03:30
parent 53b4e1b0a4
commit 4c70d8e424
42 changed files with 2834 additions and 548 deletions
@@ -1,14 +1,25 @@
'use client';
import { FormEvent, FunctionComponent, useEffect, useState } from 'react';
import { FunctionComponent, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Avatar, Box, InputAdornment, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, CategoryTile, EmptyState, ErrorState } from '@/components';
import { Avatar, Box, ButtonBase, Paper, Skeleton, Stack, Typography } from '@mui/material';
import {
AppButton,
AppIcon,
AppIconButton,
AppLoading,
CategoryTile,
EmptyState,
ErrorState,
SurfaceCard,
} from '@/components';
import { ROUTES } from '@/constants';
import { useMe } from '@/services/auth';
import { usePatients } from '@/services/patients';
import { useServiceCategories } from '@/services/catalog';
import { pickCatalogName } from '@/services/catalog/names';
import { useBookingDetail, useBookingList } from '@/services/bookings';
import type { BookingListItemDto } from '@/services/bookings/types';
interface NudgeCardProps {
icon: string;
@@ -16,17 +27,20 @@ interface NudgeCardProps {
body: string;
ctaLabel: string;
to: string;
/** Optional dismiss affordance (session-scoped) — omit for the always-relevant profile nudge. */
onDismiss?: () => void;
dismissLabel?: string;
}
const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLabel, to }) => (
const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLabel, to, onDismiss, dismissLabel }) => (
<Paper
elevation={0}
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', gap: 2 }}
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', gap: 2, position: 'relative' }}
>
<AppIcon icon={icon} size={28} color="var(--bal-primary)" />
<Stack sx={{ gap: 1, flexGrow: 1 }}>
<Stack sx={{ gap: 1, flexGrow: 1, minWidth: 0 }}>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, pr: onDismiss ? 4 : 0 }}>
{title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
@@ -37,14 +51,28 @@ const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLa
{ctaLabel}
</AppButton>
</Stack>
{onDismiss ? (
<AppIconButton
icon="close"
title={dismissLabel}
onClick={onDismiss}
size="small"
sx={{ position: 'absolute', insetInlineEnd: 8, insetBlockStart: 8 }}
/>
) : null}
</Paper>
);
// Session-scoped dismiss: a plain module variable (not a cookie/localStorage — this is ephemeral UI
// state, not app/auth state) survives client-side navigation within the same page load and resets on a
// hard reload, matching "dismissible for this session, not permanently".
let patientNudgeDismissedInSession = false;
/**
* A5 — the family Home: the front door of the app. Greeting + avatar, the search bar (which hands a
* query / chosen `service_category_id` toward the f6 search flow — results are not built here), the
* **data-driven** service-category grid (from the cached `services/catalog` reference data), and the
* complete-patient-record nudge (derived from the f2 patient cache — no extra fetch).
* A5 — the family Home: the front door of the app. Greeting + avatar, a compact ambient trust strip, a
* tappable search entry point (routes to C1 — see `HomeSearchBar`), the **data-driven** service-category
* grid (from the cached `services/catalog` reference data), a completeness-gated patient-record nudge,
* and a "رزرو دوباره" (rebook) shortcut row sourced from recent bookings.
*
* First-login gate: a customer with no patients is sent into onboarding (A3). The redirect waits for
* a settled list so a post-create refetch never bounces the user back to onboarding.
@@ -57,6 +85,7 @@ export default function HomeScreen() {
const { data: me } = useMe();
const { data, isError, refetch } = usePatients();
const [nudgeDismissed, setNudgeDismissed] = useState(patientNudgeDismissedInSession);
const isEmpty = data?.total === 0;
@@ -78,6 +107,16 @@ export default function HomeScreen() {
const greeting = firstName ? t('greeting_named', { name: firstName }) : t('greeting_plain');
const avatarInitial = firstName ? firstName.charAt(0).toUpperCase() : null;
// Completeness signal derived from the cached patients data (no extra fetch): a patient with no
// conditions recorded yet is an incomplete record — never a forever-nudge once every record is filled.
const hasIncompletePatient = data.items.some((patient) => patient.conditions.length === 0);
const showPatientNudge = hasIncompletePatient && !nudgeDismissed;
const dismissPatientNudge = () => {
patientNudgeDismissedInSession = true;
setNudgeDismissed(true);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
@@ -94,17 +133,25 @@ export default function HomeScreen() {
</Box>
</Stack>
<TrustStrip />
<HomeSearchBar />
<CategoryGrid onSelect={(categoryId) => router.push(`${href(ROUTES.SEARCH)}?category_id=${categoryId}`)} />
<NudgeCard
icon="patients"
title={t('nudge_patient_title')}
body={t('nudge_patient_body')}
ctaLabel={t('nudge_patient_cta')}
to={href(ROUTES.PATIENTS)}
/>
<RebookRow />
{showPatientNudge ? (
<NudgeCard
icon="patients"
title={t('nudge_patient_title')}
body={t('nudge_patient_body')}
ctaLabel={t('nudge_patient_cta')}
to={href(ROUTES.PATIENTS)}
onDismiss={dismissPatientNudge}
dismissLabel={tc('close')}
/>
) : null}
{!profileComplete ? (
<NudgeCard
icon="profile"
@@ -119,41 +166,63 @@ export default function HomeScreen() {
}
/**
* The Home search field. Rendering + query capture live here; **execution is f6** — submitting
* navigates toward the (future) search route carrying the typed query. Results/filters are DEFERRED
* → frontend-phase-6-b7.
* Quiet, one-line ambient reassurance under the greeting — not a hero. Three icon+label items: escrow
* payment, verified nurses, support. Purely presentational; tokens only.
*/
const TrustStrip: FunctionComponent = () => {
const t = useTranslations('home');
const items: Array<{ icon: string; label: string }> = [
{ icon: 'lock', label: t('trust_escrow') },
{ icon: 'verification', label: t('trust_verified_nurses') },
{ icon: 'support', label: t('trust_support') },
];
return (
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between' }}>
{items.map((item) => (
<Stack key={item.icon} direction="row" sx={{ gap: 0.5, alignItems: 'center', minWidth: 0 }}>
<AppIcon icon={item.icon} size={16} color="var(--bal-primary)" />
<Typography variant="caption" noWrap sx={{ color: 'text.secondary' }}>
{item.label}
</Typography>
</Stack>
))}
</Stack>
);
};
/**
* The Home search entry point — a tappable faux-input (never a half-working free-text field: the search
* index has no text column, variant names aren't client-queryable, and the only matchable dataset — 56
* cached category names — is already better served by the category grid directly below). Routes straight
* to C1 (`/search`). **Upgrade path**: once the backend serves a `q` param on `search/nurses` (REQ-041,
* matching nurse/variant/category names), this can become a real typeahead — the placeholder copy is
* already written for that future, so only the tap target need change, not the copy/i18n keys.
*/
const HomeSearchBar: FunctionComponent = () => {
const t = useTranslations('home');
const router = useRouter();
const locale = useLocale();
const [query, setQuery] = useState('');
const submit = (event: FormEvent) => {
event.preventDefault();
const q = query.trim();
router.push(`/${locale}${ROUTES.SEARCH}${q ? `?q=${encodeURIComponent(q)}` : ''}`);
};
return (
<Box component="form" onSubmit={submit} role="search">
<TextField
fullWidth
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('search_placeholder')}
aria-label={t('search_action')}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<AppIcon icon="search" size={22} color="var(--bal-text-secondary)" />
</InputAdornment>
),
},
}}
/>
</Box>
<ButtonBase
onClick={() => router.push(`/${locale}${ROUTES.SEARCH}`)}
aria-label={t('search_action')}
sx={{
justifyContent: 'flex-start',
gap: 1,
width: '100%',
px: 2,
py: 1.5,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
color: 'text.secondary',
}}
>
<AppIcon icon="search" size={22} color="var(--bal-text-secondary)" />
<Typography variant="body1">{t('search_placeholder')}</Typography>
</ButtonBase>
);
};
@@ -196,3 +265,72 @@ const CategoryGrid: FunctionComponent<{ onSelect: (categoryId: number) => void }
</Stack>
);
};
/**
* The "رزرو دوباره" shortcut row — repeat care is the dominant pattern in home nursing. Sourced from the
* existing `useBookingList('customer')` cache (no extra list fetch); renders up to 2 cards, deduplicated
* by nurse, deep-linking to the nurse's C3 profile. Renders nothing (no empty state) when there is no
* past-bookings history.
*/
const RebookRow: FunctionComponent = () => {
const { data, isLoading, isError } = useBookingList('customer', { pageSize: 5 });
const items = data?.items ?? [];
if (isLoading || isError || items.length === 0) return null;
const seen = new Set<string>();
const candidates: BookingListItemDto[] = [];
for (const item of items) {
if (seen.has(item.counterpartyName)) continue;
seen.add(item.counterpartyName);
candidates.push(item);
if (candidates.length === 2) break;
}
if (candidates.length === 0) return null;
return (
<Stack sx={{ gap: 1 }}>
{candidates.map((booking) => (
<RebookCard key={booking.id} booking={booking} />
))}
</Stack>
);
};
/** One rebook card — resolves the booking's `nurseId` (not on the list row) via the cached booking
* detail, then deep-links to the nurse's C3 profile. Renders nothing while resolving. */
const RebookCard: FunctionComponent<{ booking: BookingListItemDto }> = ({ booking }) => {
const t = useTranslations('home');
const router = useRouter();
const locale = useLocale();
const { data: detail } = useBookingDetail(booking.id, 'customer');
if (!detail) return null;
const open = () => router.push(`/${locale}${ROUTES.SEARCH_NURSE}/${detail.nurseId}`);
return (
<SurfaceCard
padding="sm"
onClick={open}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
open();
}
}}
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2, cursor: 'pointer' }}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', minWidth: 0 }}>
<AppIcon icon="history" size={20} color="var(--bal-primary)" />
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>
{t('rebook_with', { name: booking.counterpartyName })}
</Typography>
</Stack>
<AppIcon icon="forward" size={18} color="var(--bal-text-secondary)" />
</SurfaceCard>
);
};
@@ -1,63 +1,212 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, EmptyState, ErrorState, Money, StatusChip } from '@/components';
import { Badge, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
import { AccentCard, AppButton, CountdownTimer, EmptyState, ErrorState, Money, RatingInput, StatusChip } from '@/components';
import type { AccentTone, StatusKind } from '@/components';
import { BOOKING_STATUS_KIND } from '@/components/booking/statusKind';
import { ROUTES } from '@/constants';
import { formatShamsiDate } from '@/utils';
import { bookingReviewPath, ROUTES } from '@/constants';
import { formatShamsiDate, localeTag } from '@/utils';
import { useBookingList } from '@/services/bookings';
import type { BookingListItemDto } from '@/services/bookings/types';
import { BOOKINGS_PAGE_SIZE } from '@/services/bookings/constants';
import type { BookingListItemDto, BookingStatus } from '@/services/bookings/types';
import { useCustomerRequests } from '@/services/bookingRequests';
import type { BookingRequestListItem, BookingRequestStatus } from '@/services/bookingRequests/types';
import { useReviewEligibility } from '@/services/reviews';
type BookingsTab = 'pending' | 'active' | 'past';
/** `pending_payment`/`confirmed`/`in_progress` are still unfolding; the rest are resolved. */
const ACTIVE_BOOKING_STATUSES: readonly BookingStatus[] = ['pending_payment', 'confirmed', 'in_progress'];
const PAST_BOOKING_STATUSES: readonly BookingStatus[] = ['completed', 'disputed', 'closed', 'cancelled'];
const PENDING_REQUEST_STATUSES: readonly BookingRequestStatus[] = [
'pending_nurse_response',
'accepted_awaiting_payment',
];
const KIND_TO_ACCENT: Record<StatusKind, AccentTone> = {
neutral: 'neutral',
info: 'info',
pending: 'primary',
verified: 'success',
active: 'success',
rejected: 'error',
};
/**
* Customer رزروها — the "My bookings" list. Reads `useBookingList('customer')`; each row opens the
* booking detail (`/bookings/{id}`). This is the customer entry to the f8 booking-detail surface (the C5
* `converted` state also lands here). Amounts render in Toman via the money util.
* Customer رزروها — the lifecycle home. Three tabs so a money-adjacent pending request is never orphaned
* once the user leaves C5: **در انتظار پاسخ** wires the exported-but-previously-unused
* `useCustomerRequests` (live mini-countdown per row, deep-linking back to C5); **فعال** / **گذشته** split
* `useBookingList('customer')` by status. Rows carry a soft status chip + a matching `borderInlineStart`
* accent and are fully tappable (keyboard-focusable). Pagination is a "load more" over a single growing
* `pageSize` (the C2 results pattern) — booking #21+ stays reachable.
*/
export default function BookingsScreen() {
const t = useTranslations('booking');
const { data, isLoading, isError, refetch } = useBookingList('customer');
const items = data?.items ?? [];
const router = useRouter();
const locale = useLocale();
const [tab, setTab] = useState<BookingsTab>('active');
const [pageSize, setPageSize] = useState(BOOKINGS_PAGE_SIZE);
const pendingQuery = useCustomerRequests();
const pendingItems = (pendingQuery.data?.items ?? []).filter((item) =>
PENDING_REQUEST_STATUSES.includes(item.status),
);
const bookingsQuery = useBookingList('customer', { page: 1, pageSize });
const allBookings = bookingsQuery.data?.items ?? [];
const total = bookingsQuery.data?.total ?? 0;
const hasMore = allBookings.length < total;
const activeItems = allBookings.filter((item) => ACTIVE_BOOKING_STATUSES.includes(item.status));
const pastItems = allBookings.filter((item) => PAST_BOOKING_STATUSES.includes(item.status));
const openBooking = (id: number) => router.push(`/${locale}${ROUTES.BOOKINGS}/${id}`);
const openRequest = (id: number) => router.push(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${id}`);
const goToSearch = () => router.push(`/${locale}${ROUTES.SEARCH}`);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box>
<Stack sx={{ gap: 3 }}>
<Stack>
<Typography variant="h5" component="h1">
{t('list_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('list_subtitle')}
</Typography>
</Box>
</Stack>
{isLoading ? (
<Stack sx={{ gap: 2 }}>
{[0, 1].map((key) => (
<Skeleton key={key} variant="rounded" height={120} />
))}
</Stack>
) : isError ? (
<ErrorState message={t('list_error')} retryLabel={t('retry')} onRetry={() => refetch()} />
) : items.length === 0 ? (
<EmptyState icon="bookings" title={t('list_empty_title')} body={t('list_empty_body')} />
) : (
<Stack sx={{ gap: 2 }}>
{items.map((item) => (
<BookingRow key={item.id} item={item} />
))}
</Stack>
)}
</Box>
<Tabs value={tab} onChange={(_event, value: BookingsTab) => setTab(value)} variant="fullWidth">
<Tab
value="pending"
data-tab="pending"
label={
pendingItems.length > 0 ? (
<Badge badgeContent={pendingItems.length} color="secondary" sx={{ '& .MuiBadge-badge': { insetInlineEnd: -12 } }}>
{t('tab_pending')}
</Badge>
) : (
t('tab_pending')
)
}
/>
<Tab value="active" data-tab="active" label={t('tab_active')} />
<Tab value="past" data-tab="past" label={t('tab_past')} />
</Tabs>
{tab === 'pending' ? (
pendingQuery.isLoading ? (
<ListSkeleton />
) : pendingQuery.isError ? (
<ErrorState message={t('inbox_error')} retryLabel={t('retry')} onRetry={() => pendingQuery.refetch()} />
) : pendingItems.length === 0 ? (
<EmptyState
icon="pending"
title={t('pending_empty_title')}
body={t('pending_empty_body')}
action={
<AppButton variant="outlined" color="primary" startIcon="search" onClick={goToSearch}>
{t('missing_nurse_cta')}
</AppButton>
}
/>
) : (
<Stack sx={{ gap: 2 }}>
{pendingItems.map((item) => (
<PendingRequestRow key={item.id} item={item} locale={locale} onOpen={() => openRequest(item.id)} />
))}
</Stack>
)
) : null}
{tab === 'active' ? (
bookingsQuery.isLoading ? (
<ListSkeleton />
) : bookingsQuery.isError ? (
<ErrorState message={t('list_error')} retryLabel={t('retry')} onRetry={() => bookingsQuery.refetch()} />
) : activeItems.length === 0 ? (
<EmptyState
icon="bookings"
title={t('active_empty_title')}
body={t('active_empty_body')}
action={
<AppButton variant="outlined" color="primary" startIcon="search" onClick={goToSearch}>
{t('missing_nurse_cta')}
</AppButton>
}
/>
) : (
<BookingRows items={activeItems} locale={locale} onOpen={openBooking} hasMore={hasMore} onLoadMore={() => setPageSize((size) => size + BOOKINGS_PAGE_SIZE)} loadingMore={bookingsQuery.isFetching} loadMoreLabel={t('load_more')} />
)
) : null}
{tab === 'past' ? (
bookingsQuery.isLoading ? (
<ListSkeleton />
) : bookingsQuery.isError ? (
<ErrorState message={t('list_error')} retryLabel={t('retry')} onRetry={() => bookingsQuery.refetch()} />
) : pastItems.length === 0 ? (
<EmptyState icon="bookings" title={t('past_empty_title')} body={t('past_empty_body')} />
) : (
<BookingRows items={pastItems} locale={locale} onOpen={openBooking} hasMore={hasMore} onLoadMore={() => setPageSize((size) => size + BOOKINGS_PAGE_SIZE)} loadingMore={bookingsQuery.isFetching} loadMoreLabel={t('load_more')} />
)
) : null}
</Stack>
);
}
function BookingRow({ item }: { item: BookingListItemDto }) {
function BookingRows({
items,
locale,
onOpen,
hasMore,
onLoadMore,
loadingMore,
loadMoreLabel,
}: {
items: BookingListItemDto[];
locale: string;
onOpen: (id: number) => void;
hasMore: boolean;
onLoadMore: () => void;
loadingMore: boolean;
loadMoreLabel: string;
}) {
return (
<Stack sx={{ gap: 2 }}>
{items.map((item) => (
<BookingRow key={item.id} item={item} locale={locale} onOpen={() => onOpen(item.id)} />
))}
{hasMore ? (
<AppButton variant="outlined" color="primary" onClick={onLoadMore} disabled={loadingMore} sx={{ alignSelf: 'center' }}>
{loadMoreLabel}
</AppButton>
) : null}
</Stack>
);
}
function BookingRow({ item, locale, onOpen }: { item: BookingListItemDto; locale: string; onOpen: () => void }) {
const t = useTranslations('booking');
const locale = useLocale();
const router = useRouter();
const kind = BOOKING_STATUS_KIND[item.status];
const isCompleted = item.status === 'completed' || item.status === 'closed';
return (
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<AccentCard
tone={KIND_TO_ACCENT[kind]}
role="button"
tabIndex={0}
onClick={onOpen}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onOpen();
}
}}
data-booking-row={item.id}
sx={{ cursor: 'pointer', '&:focus-visible': { outline: '2px solid var(--bal-primary)', outlineOffset: 2 } }}
>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5, flexWrap: 'wrap' }}>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
@@ -68,23 +217,112 @@ function BookingRow({ item }: { item: BookingListItemDto }) {
{formatShamsiDate(item.scheduledDate, locale)} · {t('session_count', { count: item.sessionCount })}
</Typography>
</Stack>
<StatusChip status={BOOKING_STATUS_KIND[item.status]} label={t(`bstatus_${item.status}`)} />
<StatusChip status={kind} label={t(`bstatus_${item.status}`)} />
</Stack>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('list_total')}: <Money amountIrr={item.amountIrr} size="sm" sx={{ fontWeight: 700 }} />
</Typography>
<AppButton
variant="outlined"
color="primary"
endIcon="bookings"
onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${item.id}`)}
>
{t('view_booking')}
</AppButton>
</Stack>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('list_total')}: <Money amountIrr={item.amountIrr} size="sm" sx={{ fontWeight: 700 }} />
</Typography>
{isCompleted ? <CompletedReviewStrip bookingId={item.id} enabled={isCompleted} /> : null}
</Stack>
</Paper>
</AccentCard>
);
}
/** A completed booking without a review gets a compact star-strip CTA deep-linking into the review page.
* The eligibility read is gated to completed/closed rows only (`enabled`) — an active booking never fires
* it, and `canReview: false` (already reviewed or otherwise ineligible) renders nothing extra. */
function CompletedReviewStrip({ bookingId, enabled }: { bookingId: number; enabled: boolean }) {
const t = useTranslations('reviews');
const router = useRouter();
const locale = useLocale();
const eligibility = useReviewEligibility(bookingId, { enabled });
if (!eligibility.data?.canReview) return null;
return (
<AppButton
variant="text"
color="primary"
size="small"
startIcon={<RatingInput value={0} readOnly size={16} ariaLabel={t('cta_leave')} />}
onClick={(event) => {
event.stopPropagation();
router.push(`/${locale}${bookingReviewPath(bookingId)}`);
}}
sx={{ alignSelf: 'flex-start', px: 0 }}
>
{t('cta_leave')}
</AppButton>
);
}
/** «در انتظار پاسخ» row — a pending or accepted-awaiting-payment request, with a live mini-countdown. */
function PendingRequestRow({
item,
locale,
onOpen,
}: {
item: BookingRequestListItem;
locale: string;
onOpen: () => void;
}) {
const t = useTranslations('booking');
const accepted = item.status === 'accepted_awaiting_payment';
const deadline = accepted ? item.paymentDeadlineAt : item.nurseResponseDeadlineAt;
const timeFmt = new Intl.DateTimeFormat(localeTag(locale), { hour: '2-digit', minute: '2-digit' });
const startDate = new Date(`${item.requestedDate}T${item.requestedTimeStart}`);
const dateLabel = formatShamsiDate(startDate, locale);
const timeLabel = timeFmt.format(startDate);
return (
<AccentCard
tone={accepted ? 'secondary' : 'primary'}
role="button"
tabIndex={0}
onClick={onOpen}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onOpen();
}
}}
data-request-row={item.id}
sx={{ cursor: 'pointer', '&:focus-visible': { outline: '2px solid var(--bal-primary)', outlineOffset: 2 } }}
>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{item.counterpartyName}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{dateLabel} ·{' '}
<Typography component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>
{timeLabel}
</Typography>
</Typography>
<StatusChip status={accepted ? 'active' : 'pending'} label={t(`status_${item.status}`)} />
</Stack>
{deadline ? (
<CountdownTimer
deadlineIso={deadline}
elapsedText={t(accepted ? 'payment_elapsed' : 'response_elapsed')}
urgent={accepted}
size="sm"
/>
) : null}
</Stack>
</AccentCard>
);
}
function ListSkeleton() {
return (
<Stack sx={{ gap: 2 }}>
{[0, 1].map((key) => (
<Skeleton key={key} variant="rounded" height={96} />
))}
</Stack>
);
}
@@ -9,6 +9,8 @@ import AppLoading from '@/components/common/AppLoading';
import Money from '@/components/common/Money';
import StepperHeader from '@/components/StepperHeader';
import CancellationPolicyDisclosure from '@/components/CancellationPolicyDisclosure';
import { ContactSupportDialog } from '@/components/messaging';
import type { TicketCategory } from '@/services/tickets/types';
import { ApiError } from '@/lib/api/errors';
import { bookingRefundStatusPath, ROUTES } from '@/constants';
import { useCancelBooking, useCancellationPolicyPreview } from '@/services/refunds';
@@ -55,8 +57,10 @@ export default function CancelBookingPage() {
const [step, setStep] = useState<0 | 1>(0);
const [acknowledged, setAcknowledged] = useState(false);
const [reasonCategory, setReasonCategory] = useState<CancelReasonCategory>('changed_mind');
// Never pre-defaulted (keeps the reason analytics honest) — confirm stays disabled until chosen.
const [reasonCategory, setReasonCategory] = useState<CancelReasonCategory | ''>('');
const [reasonNotes, setReasonNotes] = useState('');
const [supportDialogCategory, setSupportDialogCategory] = useState<TicketCategory | null>(null);
const bookingHref = `/${locale}${ROUTES.BOOKINGS}/${bookingId}`;
@@ -109,7 +113,8 @@ export default function CancelBookingPage() {
{
bookingId,
sessionIds: preview.refundableSessionIds,
reasonCategory,
// Guaranteed non-empty: step 1 is only reachable once a reason is chosen (the continue CTA gate).
reasonCategory: reasonCategory as CancelReasonCategory,
reasonNotes: reasonNotes.trim() || undefined,
},
{ onSuccess: () => router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`) },
@@ -124,6 +129,33 @@ export default function CancelBookingPage() {
{step === 0 ? (
<>
{/* Off-ramps before the kill switch — exits, not obstacles; the destructive path stays fully
available below. Real rescheduling is DEFERRED (product decision + backend); this opens a
coordination ticket instead. */}
<Stack sx={{ gap: 1, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('offramp_note')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton
variant="outlined"
color="primary"
startIcon="schedule"
onClick={() => setSupportDialogCategory('coordination')}
>
{t('reschedule_cta')}
</AppButton>
<AppButton
variant="outlined"
color="primary"
startIcon="support"
onClick={() => setSupportDialogCategory('support')}
>
{t('contact_support_cta')}
</AppButton>
</Stack>
</Stack>
<CancellationPolicyDisclosure preview={preview} />
<TextField
@@ -133,6 +165,9 @@ export default function CancelBookingPage() {
onChange={(event) => setReasonCategory(event.target.value as CancelReasonCategory)}
fullWidth
>
<MenuItem value="" disabled>
{t('reason_placeholder')}
</MenuItem>
{REASON_CATEGORIES.map((category) => (
<MenuItem key={category} value={category}>
{t(`reason_cat_${category}`)}
@@ -159,12 +194,20 @@ export default function CancelBookingPage() {
<AppButton
variant="contained"
color="primary"
disabled={!acknowledged}
disabled={!acknowledged || reasonCategory === ''}
onClick={() => setStep(1)}
>
{t('continue_cta')}
</AppButton>
</Stack>
<ContactSupportDialog
open={supportDialogCategory !== null}
onClose={() => setSupportDialogCategory(null)}
role="customer"
bookingId={bookingId}
defaultCategory={supportDialogCategory ?? 'support'}
/>
</>
) : (
<>
@@ -3,14 +3,25 @@ import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import { useSnackbar } from 'notistack';
import { Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, EmptyState, RatingInput, ReviewTagSelector, StatusChip } from '@/components';
import { Avatar, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, EmptyState, RatingInput, ReviewTagSelector, StatusChip, SurfaceCard } from '@/components';
import type { StatusKind } from '@/components';
import { formatShamsiDate } from '@/utils';
import { useBookingDetail } from '@/services/bookings';
import type { BookingDetailDto } from '@/services/bookings/types';
import { useReviewEligibility, useMyReviewForBooking, useCreateReview } from '@/services/reviews';
import { REVIEW_TAG_CODES, type ModerationStatus } from '@/services/reviews/types';
/** Best-effort read of the frozen variant display name from the booking's variant snapshot. */
function variantName(snapshotJson: string): string | null {
try {
const parsed = JSON.parse(snapshotJson) as { displayName?: string };
return parsed?.displayName ?? null;
} catch {
return null;
}
}
const REVIEW_BODY_MAX = 2000;
/** moderationStatus → StatusChip kind (published=success, pending=warning, rejected=error, hidden=neutral). */
@@ -39,8 +50,11 @@ export default function LeaveReviewPage() {
const bookingId = Number.isInteger(rawId) && rawId > 0 ? rawId : -1;
const { data: booking } = useBookingDetail(bookingId, 'customer');
const reviewable = booking?.status === 'completed' || booking?.status === 'closed';
const eligibility = useReviewEligibility(bookingId);
const myReview = useMyReviewForBooking(bookingId);
// Gated exactly like the booking-detail page's identical call — a review can only ever exist for a
// completed/closed booking, so an in-flight/active booking never fires this query.
const myReview = useMyReviewForBooking(bookingId, { enabled: reviewable });
const createReview = useCreateReview();
const [rating, setRating] = useState(0);
@@ -69,6 +83,7 @@ export default function LeaveReviewPage() {
return (
<Stack sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
<PageHeading title={t('my_review_title')} subtitle={nurseName ? t('for_nurse', { name: nurseName }) : undefined} />
{booking ? <ReviewContextRecap booking={booking} locale={locale} /> : null}
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
@@ -131,6 +146,15 @@ export default function LeaveReviewPage() {
return (
<Stack sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
<PageHeading title={t('title')} subtitle={nurseName ? t('for_nurse', { name: nurseName }) : t('subtitle')} />
{booking ? <ReviewContextRecap booking={booking} locale={locale} /> : null}
{/* Moderation expectation, up front — not only after submit. */}
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', p: 1.5, borderRadius: 2, bgcolor: 'var(--bal-info-soft)' }}>
<AppIcon icon="info" size={18} color="var(--bal-info)" />
<Typography variant="body2" sx={{ color: 'var(--bal-info)' }}>
{t('moderation_note')}
</Typography>
</Stack>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
@@ -180,6 +204,31 @@ export default function LeaveReviewPage() {
);
}
/** "What you're reviewing" recap — service, Shamsi visit date, nurse — off the already-cached booking. */
function ReviewContextRecap({ booking, locale }: { booking: BookingDetailDto; locale: string }) {
const t = useTranslations('reviews');
const service = variantName(booking.variantSnapshotJson);
const name = booking.nurseName.trim();
return (
<SurfaceCard padding="sm">
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<Avatar sx={{ width: 40, height: 40, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}>
{(name || t('recap_fallback_nurse')).charAt(0)}
</Avatar>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{service ?? t('recap_fallback_service')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{name || t('recap_fallback_nurse')} · {formatShamsiDate(booking.scheduledDate, locale)}
</Typography>
</Stack>
</Stack>
</SurfaceCard>
);
}
function PageHeading({ title, subtitle }: { title: string; subtitle?: string }) {
return (
<Stack sx={{ gap: 0.5 }}>
@@ -2,21 +2,31 @@
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import { Paper, Skeleton, Stack, Typography } from '@mui/material';
import {
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Paper,
Skeleton,
Stack,
Typography,
} from '@mui/material';
import { AppButton, AppIcon, BookingRequestSummaryCard, CountdownTimer, StatusChip, StepperHeader } from '@/components';
AppButton,
AppIcon,
BookingRequestSummaryCard,
ConfirmDialog,
CountdownTimer,
StatusChip,
StepperHeader,
} from '@/components';
import { ROUTES } from '@/constants';
import { useBookingRequest, useCancelBookingRequest } from '@/services/bookingRequests';
import type { BookingRequestDto } from '@/services/bookingRequests/types';
const MINUTES_PER_HOUR = 60;
/** Freeform-text heuristic (the DTO carries no structured rejection-reason code — REQ-044): suppress the
* "same nurse, different time" recovery when the nurse's reason reads like a hard gender/coverage block. */
const RETRY_BLOCK_KEYWORDS = ['gender', 'coverage', 'area', 'جنسیت', 'پوشش', 'منطقه', 'محدوده'];
function rejectionAllowsSameNurseRetry(reason: string | null): boolean {
if (!reason) return true;
const lower = reason.toLowerCase();
return !RETRY_BLOCK_KEYWORDS.some((keyword) => lower.includes(keyword));
}
/**
* C5 — Awaiting nurse acceptance (در انتظار تایید پرستار). Keyed by the request id, it **polls** the
* request (`useBookingRequest`, stopping at a terminal status) so the accept / reject / expire transition
@@ -46,14 +56,37 @@ export default function BookingRequestStatusPage() {
icon="error"
tone="var(--bal-error)"
title={t('error_title')}
body={t('error_body')}
ctaLabel={t('retry')}
onCta={() => refetch()}
primary={{ label: t('retry'), onClick: () => refetch() }}
/>
);
}
const goToSearch = () => router.push(`/${locale}${ROUTES.SEARCH}`);
/** Region + gender-carried search — "پرستاران مشابه": same city/district + the same caregiver-gender
* intent, recovering the search context rather than restarting discovery from zero. */
const goToSimilarNurses = () => {
const searchParams = new URLSearchParams();
searchParams.set('city_id', String(request.cityId));
if (request.districtId != null) searchParams.set('district_id', String(request.districtId));
if (request.requiredCaregiverGender === 'male' || request.requiredCaregiverGender === 'female') {
searchParams.set('nurse_gender', request.requiredCaregiverGender);
}
router.push(`/${locale}${ROUTES.SEARCH}?${searchParams.toString()}`);
};
/** Reopens C4 for the SAME nurse/variant/patient/address, only the date/time left to re-pick — recovers
* the booking intent instead of restarting from search. */
const goToReRequestSameNurse = () => {
const requestParams = new URLSearchParams();
requestParams.set('nurse_id', String(request.nurseId));
requestParams.set('variant_id', String(request.variantId));
if (request.requiredCaregiverGender) requestParams.set('required_gender', request.requiredCaregiverGender);
requestParams.set('patient_id', String(request.patientId));
requestParams.set('address_id', String(request.customerAddressId));
router.push(`/${locale}${ROUTES.BOOKING_REQUEST}?${requestParams.toString()}`);
};
const addressLabel = customerAddressLabel(request, locale, t('address_whole_city'));
const summary = (
@@ -73,6 +106,7 @@ export default function BookingRequestStatusPage() {
// Terminal states — each is its own card with a re-request path back into discovery (or booking).
if (request.status === 'rejected_by_nurse') {
const canRetrySameNurse = rejectionAllowsSameNurseRetry(request.nurseRejectionReason);
return (
<Stack sx={{ gap: 3 }}>
{summary}
@@ -81,8 +115,12 @@ export default function BookingRequestStatusPage() {
tone="var(--bal-error)"
title={t('rejected_title')}
body={request.nurseRejectionReason ? `${t('rejected_reason_label')}: ${request.nurseRejectionReason}` : undefined}
ctaLabel={t('terminal_rerequest')}
onCta={goToSearch}
primary={
canRetrySameNurse
? { label: t('terminal_rerequest_same_nurse'), onClick: goToReRequestSameNurse }
: { label: t('terminal_similar_nurses'), onClick: goToSimilarNurses }
}
secondary={canRetrySameNurse ? { label: t('terminal_similar_nurses'), onClick: goToSimilarNurses } : undefined}
/>
</Stack>
);
@@ -91,7 +129,13 @@ export default function BookingRequestStatusPage() {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard icon="pending" tone="var(--bal-warning)" title={t('expired_title')} ctaLabel={t('terminal_rerequest')} onCta={goToSearch} />
<TerminalCard
icon="pending"
tone="var(--bal-warning)"
title={t('expired_title')}
primary={{ label: t('terminal_rerequest_same_nurse'), onClick: goToReRequestSameNurse }}
secondary={{ label: t('terminal_similar_nurses'), onClick: goToSimilarNurses }}
/>
</Stack>
);
}
@@ -99,7 +143,12 @@ export default function BookingRequestStatusPage() {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard icon="pending" tone="var(--bal-warning)" title={t('payment_expired_title')} ctaLabel={t('terminal_rerequest')} onCta={goToSearch} />
<TerminalCard
icon="pending"
tone="var(--bal-warning)"
title={t('payment_expired_title')}
primary={{ label: t('terminal_rerequest'), onClick: goToSearch }}
/>
</Stack>
);
}
@@ -107,7 +156,12 @@ export default function BookingRequestStatusPage() {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard icon="rejected" tone="var(--bal-text-secondary)" title={t('cancelled_title')} ctaLabel={t('terminal_rerequest')} onCta={goToSearch} />
<TerminalCard
icon="rejected"
tone="var(--bal-text-secondary)"
title={t('cancelled_title')}
primary={{ label: t('terminal_rerequest'), onClick: goToSearch }}
/>
</Stack>
);
}
@@ -119,13 +173,14 @@ export default function BookingRequestStatusPage() {
icon="verified"
tone="var(--bal-success)"
title={t('converted_title')}
ctaLabel={t('converted_cta')}
// Deep-link the booking when the id is known (client-augmented, REQ-017); list fallback otherwise.
onCta={() =>
router.push(
`/${locale}${request.bookingId != null ? `${ROUTES.BOOKINGS}/${request.bookingId}` : ROUTES.BOOKINGS}`,
)
}
primary={{
label: t('converted_cta'),
// Deep-link the booking when the id is known (client-augmented, REQ-017); list fallback otherwise.
onClick: () =>
router.push(
`/${locale}${request.bookingId != null ? `${ROUTES.BOOKINGS}/${request.bookingId}` : ROUTES.BOOKINGS}`,
),
}}
/>
</Stack>
);
@@ -193,12 +248,19 @@ export default function BookingRequestStatusPage() {
</Paper>
) : (
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<CountdownTimer
deadlineIso={request.nurseResponseDeadlineAt}
label={t('response_countdown_label')}
elapsedText={t('response_elapsed')}
onElapsed={() => refetch()}
/>
<Stack sx={{ gap: 1, alignItems: 'center' }}>
<CountdownTimer
deadlineIso={request.nurseResponseDeadlineAt}
windowStart={request.createdAt}
label={t('response_countdown_label')}
elapsedText={t('response_elapsed')}
coarseLabel={(minutes) => coarseResponseLabel(minutes, t)}
onElapsed={() => refetch()}
/>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('response_notify_note')}
</Typography>
</Stack>
</Paper>
)}
@@ -212,33 +274,32 @@ export default function BookingRequestStatusPage() {
{cancelRequest.isPending ? t('cancelling') : t('cancel_request')}
</AppButton>
<Dialog open={confirmCancel} onClose={() => setConfirmCancel(false)}>
<DialogTitle>{t('cancel_confirm_title')}</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('cancel_confirm_body')}
</Typography>
</DialogContent>
<DialogActions>
<AppButton variant="text" onClick={() => setConfirmCancel(false)}>
{t('cancel_request')}
</AppButton>
<AppButton
color="error"
variant="contained"
onClick={() => {
setConfirmCancel(false);
cancelRequest.mutate(request.id);
}}
>
{t('cancel_confirm_yes')}
</AppButton>
</DialogActions>
</Dialog>
<ConfirmDialog
open={confirmCancel}
title={t('cancel_confirm_title')}
body={t('cancel_confirm_body')}
cancelLabel={t('cancel_confirm_keep')}
confirmLabel={t('cancel_confirm_destructive')}
confirmColor="error"
loading={cancelRequest.isPending}
onClose={() => setConfirmCancel(false)}
onConfirm={() => {
setConfirmCancel(false);
cancelRequest.mutate(request.id);
}}
/>
</Stack>
);
}
/** Humanized minutes-remaining copy above the coarse threshold («حدود ۳ ساعت» / «حدود ۲۵ دقیقه»). */
function coarseResponseLabel(minutes: number, t: (key: string, values?: Record<string, number>) => string): string {
if (minutes >= MINUTES_PER_HOUR) {
return t('countdown_about_hours', { hours: Math.round(minutes / MINUTES_PER_HOUR) });
}
return t('countdown_about_minutes', { minutes });
}
/** "title · city · district" (or "· whole city"), locale-aware — the customer view carries the full address. */
function customerAddressLabel(request: BookingRequestDto, locale: string, wholeCityLabel: string): string {
const city = locale === 'en' ? request.cityNameEn : request.cityNameFa;
@@ -247,20 +308,25 @@ function customerAddressLabel(request: BookingRequestDto, locale: string, wholeC
return `${request.addressTitle} · ${city} · ${district}`;
}
interface TerminalAction {
label: string;
onClick: () => void;
}
function TerminalCard({
icon,
tone,
title,
body,
ctaLabel,
onCta,
primary,
secondary,
}: {
icon: string;
tone: string;
title: string;
body?: string;
ctaLabel: string;
onCta: () => void;
primary: TerminalAction;
secondary?: TerminalAction;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
@@ -273,9 +339,16 @@ function TerminalCard({
{body}
</Typography>
) : null}
<AppButton variant="contained" color="primary" onClick={onCta}>
{ctaLabel}
</AppButton>
<Stack direction="row" sx={{ gap: 1, justifyContent: 'center', flexWrap: 'wrap' }}>
<AppButton variant="contained" color="primary" onClick={primary.onClick}>
{primary.label}
</AppButton>
{secondary ? (
<AppButton variant="outlined" color="primary" onClick={secondary.onClick}>
{secondary.label}
</AppButton>
) : null}
</Stack>
</Paper>
);
}
@@ -3,9 +3,10 @@ import { Suspense, useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import {
Avatar,
Box,
Chip,
MenuItem,
Paper,
Skeleton,
Stack,
TextField,
@@ -13,23 +14,48 @@ import {
ToggleButtonGroup,
Typography,
} from '@mui/material';
import { AppButton, AppLoading, EmptyState, PriceDisplay } from '@/components';
import { AddressMapPicker } from '@/components/geography';
import {
AppButton,
AppIcon,
AppLoading,
EmptyState,
JalaliDateIntentPicker,
PriceDisplay,
StepperHeader,
TrustBadge,
} from '@/components';
import { todayIso } from '@/components/common/JalaliDatePicker';
import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors';
import { cityCentroid } from '@/services/geography/constants';
import { usePatients } from '@/services/patients';
import { useAddresses } from '@/services/addresses';
import { useNurseProfile } from '@/services/search';
import type { NurseProfile } from '@/services/search/types';
import { useCreateBookingRequest } from '@/services/bookingRequests';
import { CUSTOMER_NOTES_MAX_LENGTH } from '@/services/bookingRequests/constants';
import { formatNumber } from '@/utils';
import type {
BookingRequestDisplayContext,
RequiredCaregiverGender,
} from '@/services/bookingRequests/types';
import type { CustomerAddress } from '@/services/addresses/types';
const GENDER_OPTIONS: RequiredCaregiverGender[] = ['female', 'male', 'any'];
interface TimeWindowOption {
key: 'morning' | 'afternoon' | 'evening';
start: string;
end: string;
}
const TIME_WINDOWS: TimeWindowOption[] = [
{ key: 'morning', start: '08:00', end: '12:00' },
{ key: 'afternoon', start: '12:00', end: '16:00' },
{ key: 'evening', start: '16:00', end: '20:00' },
];
type TouchedField = 'patient' | 'service' | 'address' | 'date' | 'time' | 'gender';
/**
* C4 — Booking-request form (فرم درخواست). The destination of the C3 "درخواست رزرو" CTA (it carries the
* `nurse_id`, an optional `variant_id`, and the same-gender `required_gender` intent from search). The
@@ -48,7 +74,6 @@ export default function BookingRequestFormPage() {
function BookingRequestForm() {
const t = useTranslations('booking');
const tAddress = useTranslations('address');
const locale = useLocale();
const router = useRouter();
const query = useSearchParams();
@@ -57,6 +82,10 @@ function BookingRequestForm() {
const hasNurse = Number.isInteger(nurseId) && nurseId > 0;
const variantIdParam = Number(query.get('variant_id')) || null;
const genderParam = query.get('required_gender');
// Recovery hand-off from C5's "request again with another time" — reopens this same nurse/variant
// prefilled with the patient + address of the terminal request, extending the C3 handoff params.
const patientIdParam = Number(query.get('patient_id')) || null;
const addressIdParam = Number(query.get('address_id')) || null;
const profileQuery = useNurseProfile(hasNurse ? nurseId : undefined);
const patientsQuery = usePatients();
@@ -68,20 +97,32 @@ function BookingRequestForm() {
const addresses = useMemo(() => addressesQuery.data?.items ?? [], [addressesQuery.data]);
const services = useMemo(() => profile?.services ?? [], [profile]);
const [patientId, setPatientId] = useState<number | ''>('');
const [patientId, setPatientId] = useState<number | ''>(patientIdParam ?? '');
const [variantSel, setVariantSel] = useState<number | ''>(variantIdParam ?? '');
const [addressSel, setAddressSel] = useState<number | ''>('');
const [addressSel, setAddressSel] = useState<number | ''>(addressIdParam ?? '');
const [addressEditing, setAddressEditing] = useState(false);
const [gender, setGender] = useState<RequiredCaregiverGender | ''>(
genderParam === 'male' || genderParam === 'female' ? genderParam : '',
);
const [date, setDate] = useState('');
const [timeStart, setTimeStart] = useState('09:00');
const [timeEnd, setTimeEnd] = useState('13:00');
const [windowSel, setWindowSel] = useState<TimeWindowOption['key'] | 'custom' | null>(null);
const [timeStart, setTimeStart] = useState('');
const [timeEnd, setTimeEnd] = useState('');
const [notes, setNotes] = useState('');
const [attempted, setAttempted] = useState(false);
const [touched, setTouched] = useState<Record<TouchedField, boolean>>({
patient: false,
service: false,
address: false,
date: false,
time: false,
gender: false,
});
const [pastDateError, setPastDateError] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const markTouched = (field: TouchedField) =>
setTouched((prev) => (prev[field] ? prev : { ...prev, [field]: true }));
// Effective selection = the user's explicit choice, else a sensible default derived from the loaded
// data. Computed during render (no setState-in-effect): the variant defaults to the carried one / the
// first offered, the address to the primary / first.
@@ -112,20 +153,33 @@ function BookingRequestForm() {
const requiredChosen =
patientId !== '' && variantId !== '' && addressId !== '' && gender !== '' && date !== '' && timeStart !== '' && timeEnd !== '';
const regionLabel = (): string => {
if (!selectedAddress) return '';
const city = locale === 'en' ? selectedAddress.cityNameEn : selectedAddress.cityNameFa;
const regionLabel = (address: CustomerAddress): string => {
const city = locale === 'en' ? address.cityNameEn : address.cityNameFa;
const district =
selectedAddress.districtId == null
address.districtId == null
? t('address_whole_city')
: locale === 'en'
? selectedAddress.districtNameEn
: selectedAddress.districtNameFa;
return `${selectedAddress.title} · ${city} · ${district}`;
? address.districtNameEn
: address.districtNameFa;
return `${address.title} · ${city} · ${district}`;
};
const selectWindow = (option: TimeWindowOption) => {
setWindowSel(option.key);
setTimeStart(option.start);
setTimeEnd(option.end);
if (pastDateError) setPastDateError(false);
};
const missingFieldLabels: string[] = [];
if (patientId === '') missingFieldLabels.push(t('cta_missing_patient'));
if (variantId === '') missingFieldLabels.push(t('cta_missing_service'));
if (addressId === '') missingFieldLabels.push(t('cta_missing_address'));
if (date === '') missingFieldLabels.push(t('cta_missing_date'));
if (timeStart === '' || timeEnd === '') missingFieldLabels.push(t('cta_missing_time'));
if (gender === '') missingFieldLabels.push(t('cta_missing_gender'));
const handleSubmit = () => {
setAttempted(true);
setFormError(null);
if (!requiredChosen) return;
if (timeEnd <= timeStart) return;
@@ -208,11 +262,13 @@ function BookingRequestForm() {
if (profileQuery.isLoading) return <FormSkeleton />;
const timeError = attempted && timeStart !== '' && timeEnd !== '' && timeEnd <= timeStart;
const timeError = touched.time && timeStart !== '' && timeEnd !== '' && timeEnd <= timeStart;
const pastError = pastDateError;
return (
<Stack sx={{ gap: 3 }}>
{profile ? <NurseIdentityBar profile={profile} /> : null}
<Box>
<Typography variant="h5" component="h1">
{t('request_title')}
@@ -222,6 +278,13 @@ function BookingRequestForm() {
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>
{t('whathappens_title')}
</Typography>
<StepperHeader steps={[t('step_submitted'), t('step_awaiting'), t('step_payment')]} activeStep={0} />
</Box>
{/* Patient */}
{patients.length === 0 ? (
<FieldEmpty
@@ -235,9 +298,10 @@ function BookingRequestForm() {
select
label={t('patient_label')}
value={patientId}
error={attempted && patientId === ''}
helperText={attempted && patientId === '' ? t('error_patient_required') : undefined}
error={touched.patient && patientId === ''}
helperText={touched.patient && patientId === '' ? t('error_patient_required') : undefined}
onChange={(event) => setPatientId(Number(event.target.value))}
onBlur={() => markTouched('patient')}
fullWidth
>
<MenuItem value="" disabled>
@@ -252,41 +316,42 @@ function BookingRequestForm() {
)}
{/* Service variant */}
{services.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('service_empty')}
</Typography>
) : (
<TextField
select
label={t('service_label')}
value={variantId}
error={attempted && variantId === ''}
helperText={attempted && variantId === '' ? t('error_service_required') : undefined}
onChange={(event) => setVariantSel(Number(event.target.value))}
fullWidth
>
<MenuItem value="" disabled>
{t('service_placeholder')}
</MenuItem>
{services.map((service) => (
<MenuItem key={service.variantId} value={service.variantId}>
{service.displayName}
<Stack sx={{ gap: 1 }}>
{services.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('service_empty')}
</Typography>
) : (
<TextField
select
label={t('service_label')}
value={variantId}
error={touched.service && variantId === ''}
helperText={touched.service && variantId === '' ? t('error_service_required') : undefined}
onChange={(event) => setVariantSel(Number(event.target.value))}
onBlur={() => markTouched('service')}
fullWidth
>
<MenuItem value="" disabled>
{t('service_placeholder')}
</MenuItem>
))}
</TextField>
)}
{selectedVariant ? (
<Box sx={{ mt: -1.5 }}>
{services.map((service) => (
<MenuItem key={service.variantId} value={service.variantId}>
{service.displayName}
</MenuItem>
))}
</TextField>
)}
{selectedVariant ? (
<PriceDisplay
price={selectedVariant.priceIrr}
priceUnit={selectedVariant.priceUnit}
sessionCount={selectedVariant.sessionCount}
/>
</Box>
) : null}
) : null}
</Stack>
{/* Address */}
{/* Address — a compact confirmation row once resolved, with a way back to the select. */}
{addresses.length === 0 ? (
<FieldEmpty
label={t('address_label')}
@@ -294,90 +359,142 @@ function BookingRequestForm() {
ctaLabel={t('address_add_cta')}
onCta={() => router.push(`/${locale}${ROUTES.ADDRESSES}`)}
/>
) : (
<Stack sx={{ gap: 1 }}>
<TextField
select
label={t('address_label')}
value={addressId}
error={attempted && addressId === ''}
helperText={attempted && addressId === '' ? t('error_address_required') : undefined}
onChange={(event) => setAddressSel(Number(event.target.value))}
fullWidth
>
<MenuItem value="" disabled>
{t('address_placeholder')}
) : addressEditing || !selectedAddress ? (
<TextField
select
label={t('address_label')}
value={addressId}
error={touched.address && addressId === ''}
helperText={touched.address && addressId === '' ? t('error_address_required') : undefined}
onChange={(event) => {
setAddressSel(Number(event.target.value));
setAddressEditing(false);
}}
onBlur={() => markTouched('address')}
fullWidth
>
<MenuItem value="" disabled>
{t('address_placeholder')}
</MenuItem>
{addresses.map((address) => (
<MenuItem key={address.id} value={address.id}>
{address.title} · {locale === 'en' ? address.cityNameEn : address.cityNameFa}
</MenuItem>
{addresses.map((address) => (
<MenuItem key={address.id} value={address.id}>
{address.title} · {locale === 'en' ? address.cityNameEn : address.cityNameFa}
</MenuItem>
))}
</TextField>
{selectedAddress ? (
<Paper elevation={0} sx={{ p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1 }}>
{regionLabel()}
{selectedAddress.addressLine ? `${selectedAddress.addressLine}` : ''}
))}
</TextField>
) : (
<Stack
direction="row"
sx={{
gap: 1.5,
alignItems: 'center',
p: 1.5,
border: '1px solid',
borderColor: 'divider',
borderRadius: 'var(--bal-radius-md)',
}}
>
<AppIcon icon="location" size={20} color="var(--bal-text-secondary)" />
<Stack sx={{ gap: 0.25, minWidth: 0, flexGrow: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>
{regionLabel(selectedAddress)}
</Typography>
{selectedAddress.addressLine ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }} noWrap>
{selectedAddress.addressLine}
</Typography>
{selectedAddress.latitude != null && selectedAddress.longitude != null ? (
// Read-only preview of the address's stored pin (the pin itself is set in the f3 book).
<Box sx={{ pointerEvents: 'none' }}>
<AddressMapPicker
value={{ latitude: selectedAddress.latitude, longitude: selectedAddress.longitude }}
onChange={() => undefined}
center={cityCentroid(selectedAddress.cityId)}
helperText={regionLabel()}
latLabel={tAddress('map_lat')}
lngLabel={tAddress('map_lng')}
/>
</Box>
) : null}
</Paper>
) : null}
) : null}
</Stack>
<AppButton variant="text" size="small" onClick={() => setAddressEditing(true)} sx={{ flexShrink: 0 }}>
{t('address_change_cta')}
</AppButton>
</Stack>
)}
{/* Date + time */}
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField
type="date"
label={t('date_label')}
{/* Date */}
<Stack sx={{ gap: 1 }} onBlur={() => markTouched('date')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('date_label')}
</Typography>
<JalaliDateIntentPicker
value={date}
error={(attempted && date === '') || pastError}
helperText={pastError ? t('error_past_date') : attempted && date === '' ? t('error_date_required') : undefined}
onChange={(event) => {
setDate(event.target.value);
onChange={(iso) => {
setDate(iso);
if (pastDateError) setPastDateError(false);
}}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
type="time"
label={t('time_start_label')}
value={timeStart}
onChange={(event) => {
setTimeStart(event.target.value);
if (pastDateError) setPastDateError(false);
}}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
type="time"
label={t('time_end_label')}
value={timeEnd}
error={timeError}
helperText={timeError ? t('error_time_range') : undefined}
onChange={(event) => setTimeEnd(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
min={todayIso()}
todayLabel={t('date_today')}
tomorrowLabel={t('date_tomorrow')}
pickOtherLabel={t('date_pick_other')}
/>
{pastError ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_past_date')}
</Typography>
) : touched.date && date === '' ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_date_required')}
</Typography>
) : null}
</Stack>
{/* Time window — presets kill the end<=start error class; «زمان دلخواه» reveals free time fields. */}
<Stack sx={{ gap: 1 }} onBlur={() => markTouched('time')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('time_window_label')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{TIME_WINDOWS.map((option) => (
<Chip
key={option.key}
clickable
label={t(`window_${option.key}`)}
onClick={() => selectWindow(option)}
color={windowSel === option.key ? 'primary' : undefined}
variant={windowSel === option.key ? 'filled' : 'outlined'}
data-window={option.key}
/>
))}
<Chip
clickable
label={t('window_custom')}
onClick={() => setWindowSel('custom')}
color={windowSel === 'custom' ? 'primary' : undefined}
variant={windowSel === 'custom' ? 'filled' : 'outlined'}
data-window="custom"
/>
</Stack>
{windowSel === 'custom' ? (
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField
type="time"
label={t('time_start_label')}
value={timeStart}
onChange={(event) => setTimeStart(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
type="time"
label={t('time_end_label')}
value={timeEnd}
error={timeError}
helperText={timeError ? t('error_time_range') : undefined}
onChange={(event) => setTimeEnd(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
</Stack>
) : null}
{touched.time && (timeStart === '' || timeEnd === '') ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_time_required')}
</Typography>
) : null}
</Stack>
{/* Caregiver gender — first-class, three-way, never silently defaulted */}
<Stack sx={{ gap: 1 }}>
<Stack sx={{ gap: 1 }} onBlur={() => markTouched('gender')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('gender_label')}
</Typography>
@@ -393,7 +510,7 @@ function BookingRequestForm() {
flex: 1,
py: 1.25,
fontWeight: 700,
borderColor: attempted && gender === '' ? 'var(--bal-error)' : undefined,
borderColor: touched.gender && gender === '' ? 'var(--bal-error)' : undefined,
},
}}
>
@@ -406,7 +523,7 @@ function BookingRequestForm() {
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('gender_hint')}
</Typography>
{attempted && gender === '' ? (
{touched.gender && gender === '' ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_gender_required')}
</Typography>
@@ -419,19 +536,21 @@ function BookingRequestForm() {
</Stack>
{/* Stage-1 notes */}
<TextField
label={t('notes_label')}
placeholder={t('notes_placeholder')}
value={notes}
onChange={(event) => setNotes(event.target.value.slice(0, CUSTOMER_NOTES_MAX_LENGTH))}
multiline
minRows={3}
fullWidth
helperText={t('notes_hint')}
/>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'end', mt: -2 }}>
{t('notes_counter', { count: notes.length, max: CUSTOMER_NOTES_MAX_LENGTH })}
</Typography>
<Stack sx={{ gap: 0.5 }}>
<TextField
label={t('notes_label')}
placeholder={t('notes_placeholder')}
value={notes}
onChange={(event) => setNotes(event.target.value.slice(0, CUSTOMER_NOTES_MAX_LENGTH))}
multiline
minRows={3}
fullWidth
helperText={t('notes_hint')}
/>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'end' }}>
{t('notes_counter', { count: notes.length, max: CUSTOMER_NOTES_MAX_LENGTH })}
</Typography>
</Stack>
{formError ? (
<Typography variant="body2" sx={{ color: 'var(--bal-error)' }}>
@@ -439,17 +558,86 @@ function BookingRequestForm() {
</Typography>
) : null}
<AppButton
color="primary"
variant="contained"
size="large"
startIcon="requests"
disabled={!requiredChosen || genderMismatch || createRequest.isPending}
onClick={handleSubmit}
sx={{ py: 1.5 }}
<Stack sx={{ gap: 1 }}>
<AppButton
color="primary"
variant="contained"
size="large"
startIcon="requests"
disabled={!requiredChosen || genderMismatch || createRequest.isPending}
onClick={handleSubmit}
sx={{ py: 1.5 }}
>
{createRequest.isPending ? t('submitting') : t('submit')}
</AppButton>
{!requiredChosen && missingFieldLabels.length > 0 ? (
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}>
{t('cta_missing_caption', { fields: missingFieldLabels.join(locale === 'fa' ? '، ' : ', ') })}
</Typography>
) : null}
</Stack>
</Stack>
);
}
/** The sticky "who you're inviting home" identity summary — avatar, name, rating, trust badge, gender. */
function NurseIdentityBar({ profile }: { profile: NurseProfile }) {
const t = useTranslations('booking');
const locale = useLocale();
const name = profile.nurseName.trim() || t('unnamed_nurse');
const ratingLabel = formatNumber(profile.averageRating, locale, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
return (
<Stack
direction="row"
data-nurse-identity-bar
sx={{
position: 'sticky',
top: 0,
zIndex: 2,
gap: 1.5,
alignItems: 'center',
p: 1.5,
border: '1px solid',
borderColor: 'divider',
borderRadius: 'var(--bal-radius-md)',
bgcolor: 'background.paper',
}}
>
<Avatar
src={profile.avatarUrl ?? undefined}
sx={{ width: 44, height: 44, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}
>
{createRequest.isPending ? t('submitting') : t('submit')}
</AppButton>
{name.charAt(0)}
</Avatar>
<Stack sx={{ gap: 0.25, minWidth: 0, flexGrow: 1 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }} noWrap>
{name}
</Typography>
<TrustBadge state={profile.isVerified ? 'verified' : 'unverified'} />
</Stack>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="star" size={15} color="var(--bal-rating)" />
<Typography variant="caption" sx={{ fontWeight: 700 }}>
{ratingLabel}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
({formatNumber(profile.totalReviews, locale)})
</Typography>
</Stack>
<Chip
size="small"
variant="outlined"
label={t(`gender_${profile.nurseGender}`)}
sx={{ height: 20, fontSize: '0.7rem' }}
/>
</Stack>
</Stack>
</Stack>
);
}
@@ -512,7 +700,9 @@ function FieldEmpty({
function FormSkeleton() {
return (
<Stack sx={{ gap: 2.5 }}>
<Skeleton variant="rounded" height={76} />
<Skeleton variant="text" width="50%" height={36} />
<Skeleton variant="rounded" height={48} />
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={56} />
))}
@@ -2,32 +2,32 @@
import { Suspense, type FunctionComponent, type ReactNode } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Box, InputAdornment, Skeleton, Stack, TextField, Typography } from '@mui/material';
import {
Box,
InputAdornment,
Skeleton,
Stack,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
} from '@mui/material';
import { AppButton, AppLoading, CategoryTile, ErrorState } from '@/components';
AppButton,
AppLoading,
CategoryTile,
ErrorState,
GenderToggle,
JalaliDateIntentPicker,
StickyActionBar,
} from '@/components';
import CascadingRegionSelect from '@/components/geography/CascadingRegionSelect';
import { todayIso } from '@/components/common/JalaliDatePicker';
import { ROUTES } from '@/constants';
import { useServiceCategories } from '@/services/catalog';
import { pickCatalogName } from '@/services/catalog/names';
import { useNurseSearch } from '@/services/search';
import { filtersToSearchParams } from '@/services/search/filterParams';
import type { NurseGender } from '@/services/search/types';
import { useSearchFilters } from './useSearchFilters';
/**
* C1 — Search & filter (جستجو و فیلتر): the discovery entry screen. Pick a care category (reusing the
* f4 catalog grid), a city (reusing the f3 cascading region picker; district optional = whole city),
* the **prominent same-gender facet**, and an optional Toman price range; a live result count drives the
* "مشاهده N پرستار" CTA into C2. Availability (date) is intent-only at MVP — it is carried to booking,
* never used to hard-filter results. `useSearchParams` needs a Suspense boundary under static rendering.
* the **prominent same-gender facet** (the shared `GenderToggle`, `allowAny`), a Jalali date-intent chip
* strip, and an optional Toman price range; a live result count drives the sticky "مشاهده N پرستار" CTA
* into C2. Availability (date) is intent-only at MVP — it is carried to booking, never used to hard-filter
* results. `useSearchParams` needs a Suspense boundary under static rendering.
*/
export default function SearchScreen() {
return (
@@ -37,32 +37,24 @@ export default function SearchScreen() {
);
}
const GENDER_OPTIONS: readonly (NurseGender | 'any')[] = ['female', 'male', 'any'];
function SearchFilterScreen() {
const t = useTranslations('search');
const router = useRouter();
const locale = useLocale();
const params = useSearchParams();
const initialCategoryRaw = Number(params.get('category_id'));
const initialCategoryId = Number.isInteger(initialCategoryRaw) && initialCategoryRaw > 0 ? initialCategoryRaw : undefined;
const controller = useSearchFilters(initialCategoryId);
const controller = useSearchFilters(params);
const { data, isFetching } = useNurseSearch(controller.filters);
const count = data?.total;
const goToResults = () => {
const query = filtersToSearchParams(controller.filters);
if (controller.region.provinceId) query.set('province_id', String(controller.region.provinceId));
if (controller.dateIntent) query.set('date', controller.dateIntent);
router.push(`/${locale}${ROUTES.SEARCH_RESULTS}?${query.toString()}`);
};
const ctaLabel = !controller.isReady
? t('cta_choose_category_city')
: isFetching || count == null
? t('cta_loading')
: t('cta_view_results', { count });
const zeroResults = controller.isReady && !isFetching && count === 0;
return (
<Stack sx={{ gap: 3 }}>
@@ -82,31 +74,19 @@ function SearchFilterScreen() {
</FilterSection>
<FilterSection title={t('section_gender')} hint={t('gender_hint')}>
<ToggleButtonGroup
exclusive
fullWidth
color="primary"
<GenderToggle
allowAny
value={controller.gender ?? 'any'}
onChange={(_event, value: NurseGender | 'any' | null) => {
if (value != null) controller.setGender(value === 'any' ? undefined : value);
}}
>
{GENDER_OPTIONS.map((option) => (
<ToggleButton key={option} value={option} sx={{ fontWeight: 700 }}>
{t(`gender_${option}`)}
</ToggleButton>
))}
</ToggleButtonGroup>
onChange={(value) => controller.setGender(value === 'any' ? undefined : value)}
maleLabel={t('gender_male')}
femaleLabel={t('gender_female')}
anyLabel={t('gender_any')}
ariaLabel={t('section_gender')}
/>
</FilterSection>
<FilterSection title={t('section_date')} hint={t('date_hint')}>
<TextField
type="date"
fullWidth
value={controller.dateIntent}
onChange={(event) => controller.setDateIntent(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<DateIntentFilter value={controller.dateIntent} onChange={controller.setDateIntent} />
</FilterSection>
<FilterSection title={t('section_price')} hint={t('price_hint')}>
@@ -126,17 +106,35 @@ function SearchFilterScreen() {
</Stack>
</FilterSection>
<AppButton
color="primary"
variant="contained"
size="large"
disabled={!controller.isReady}
onClick={goToResults}
startIcon="search"
sx={{ py: 1.5 }}
>
{ctaLabel}
</AppButton>
<StickyActionBar>
{zeroResults ? (
<Stack sx={{ gap: 0.25 }} data-search-cta="zero">
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('cta_zero_title')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('cta_zero_hint')}
</Typography>
</Stack>
) : (
<AppButton
color="primary"
variant="contained"
size="large"
disabled={!controller.isReady}
onClick={goToResults}
startIcon="search"
sx={{ py: 1.5, width: '100%' }}
data-search-cta="view-results"
>
{!controller.isReady
? t('cta_choose_category_city')
: isFetching || count == null
? t('cta_loading')
: t('cta_view_results', { count })}
</AppButton>
)}
</StickyActionBar>
</Stack>
);
}
@@ -177,6 +175,29 @@ const PriceField: FunctionComponent<{
/>
);
/**
* The Jalali date-intent picker: a horizontal «امروز»/«فردا» + day-chip strip (the next 7 days) plus a
* calendar-icon entry into the full Jalali grid for later dates. Intent-only — the value stays the same
* ISO string the flow already carries and never hard-filters results.
*/
const DateIntentFilter: FunctionComponent<{ value: string; onChange: (iso: string) => void }> = ({
value,
onChange,
}) => {
const t = useTranslations('search');
return (
<JalaliDateIntentPicker
value={value}
onChange={onChange}
min={todayIso()}
todayLabel={t('date_today')}
tomorrowLabel={t('date_tomorrow')}
pickOtherLabel={t('date_pick_other')}
/>
);
};
/** The reused f4 category grid (data-driven from the cached catalog reference data), with selection. */
const CategorySelect: FunctionComponent<{ selectedId: number | null; onSelect: (id: number) => void }> = ({
selectedId,
@@ -3,22 +3,40 @@ import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { Avatar, Box, Chip, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
import { AppButton, AppIcon, EmptyState, ErrorState, RatingInput, ServicePriceRow, TrustBadge } from '@/components';
import {
AppButton,
AppIcon,
EmptyState,
ErrorState,
PriceDisplay,
RatingInput,
ServicePriceRow,
StickyActionBar,
SurfaceCard,
TrustBadge,
VerificationPanel,
} from '@/components';
import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors';
import { formatNumber, formatShamsiDate } from '@/utils';
import { useNurseProfile } from '@/services/search';
import type { NurseProfile } from '@/services/search/types';
import type { NurseProfile, NurseProfileServiceRow } from '@/services/search/types';
import { useNurseReviews } from '@/services/reviews';
import type { ReviewListItem } from '@/services/reviews/types';
import { useNurseTrustBadge } from '@/services/verification';
type ProfileTab = 'services' | 'reviews';
/**
* C3 — Nurse profile (پروفایل پرستار): identity + trust badges (✓ تاییدشده, نظام پرستاری), attribute
* chips, and a **tabbed** body — «خدمات» (the priced services list) and «نظرات» (the f13 published-reviews
* tab: aggregate rating + count + an infinite list). Only `published` reviews are ever requested/rendered.
* The primary CTA "درخواست رزرو" hands the selected nurse + variant + `required_caregiver_gender` to f7.
* C3 — Nurse profile (پروفایل پرستار): the trust dossier — identity header (completed visits + rating),
* a tappable ✓ تاییدشده badge + «نظام پرستاری» chip, the shared `VerificationPanel` (what Balinyaar
* verified, fed by the public trust-badge read), attribute chips, and a **tabbed** body — «خدمات» (the
* priced services list + an optional latest-review snippet) and «نظرات» (the f13 published-reviews tab:
* fractional aggregate rating + count + an infinite list). Only `published` reviews are ever
* requested/rendered. The primary "درخواست رزرو" CTA is a **sticky bottom bar** (price-from beside the
* button) so it survives the infinite reviews list, and hands the selected nurse + variant +
* `required_caregiver_gender` to f7. The profile DTO does not yet serve `nurseGender` (REQ-042) — the
* header intentionally omits a gender chip rather than render the client's placeholder stub.
*/
export default function NurseProfilePage() {
const t = useTranslations('search');
@@ -54,8 +72,11 @@ export default function NurseProfilePage() {
if (!profile) return null;
const carriedVariant = query.get('variant_id');
const primaryService: NurseProfileServiceRow | undefined =
profile.services.find((service) => String(service.variantId) === carriedVariant) ?? profile.services[0];
const requestBooking = () => {
const carriedVariant = query.get('variant_id');
const variantId = carriedVariant ?? String(profile.services[0]?.variantId ?? '');
const params = new URLSearchParams();
params.set('nurse_id', String(profile.nurseId));
@@ -76,6 +97,7 @@ export default function NurseProfilePage() {
<Stack sx={{ gap: 3 }}>
<ProfileHeader profile={profile} />
<AttributeChips profile={profile} />
<VerificationSection nurseId={profile.nurseId} />
<Tabs value={tab} onChange={(_, next: ProfileTab) => setTab(next)} sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tab value="services" label={t('tab_services')} sx={{ textTransform: 'none', fontWeight: 700 }} />
@@ -84,16 +106,28 @@ export default function NurseProfilePage() {
{tab === 'services' ? <ServicesSection profile={profile} /> : <ReviewsPanel nurseId={profile.nurseId} />}
<AppButton
color="primary"
variant="contained"
size="large"
onClick={requestBooking}
startIcon="bookings"
sx={{ py: 1.5 }}
>
{t('request_booking')}
</AppButton>
<StickyActionBar>
<Stack direction="row" sx={{ gap: 2, alignItems: 'center', justifyContent: 'space-between' }}>
{primaryService ? (
<Box>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('price_from')}
</Typography>
<PriceDisplay price={primaryService.priceIrr} priceUnit={primaryService.priceUnit} align="start" />
</Box>
) : null}
<AppButton
color="primary"
variant="contained"
size="large"
onClick={requestBooking}
startIcon="bookings"
sx={{ py: 1.5, flexGrow: 1 }}
>
{t('request_booking')}
</AppButton>
</Stack>
</StickyActionBar>
</Stack>
);
}
@@ -121,7 +155,7 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) {
{name}
</Typography>
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="star" size={18} color="var(--bal-warning)" />
<AppIcon icon="star" size={18} color="var(--bal-rating)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{rating}
</Typography>
@@ -129,11 +163,14 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) {
{t('reviews_count', { count: profile.totalReviews })}
</Typography>
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('completed_visits', { count: formatNumber(profile.totalCompletedBookings, locale) })}
</Typography>
</Stack>
</Stack>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<TrustBadge state="verified" />
<TrustBadge state="verified" nurseId={profile.nurseId} />
{profile.inoMembership ? (
<Chip
icon={<AppIcon icon="license" size={16} color="var(--bal-primary)" />}
@@ -152,6 +189,23 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) {
);
}
/** "What Balinyaar verified" — the shared `VerificationPanel`, fed by the public trust-badge read. */
function VerificationSection({ nurseId }: { nurseId: number }) {
const t = useTranslations('verification');
const { data: badge, isLoading, isError } = useNurseTrustBadge(nurseId);
return (
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('explainer_title')}
</Typography>
<SurfaceCard padding="md">
<VerificationPanel badge={badge} isLoading={isLoading} isError={isError} />
</SurfaceCard>
</Stack>
);
}
function AttributeChips({ profile }: { profile: NurseProfile }) {
const t = useTranslations('search');
const locale = useLocale();
@@ -177,7 +231,7 @@ function AttributeChips({ profile }: { profile: NurseProfile }) {
function ServicesSection({ profile }: { profile: NurseProfile }) {
const t = useTranslations('search');
return (
<Stack sx={{ gap: 1 }}>
<Stack sx={{ gap: 2 }}>
{profile.services.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('services_empty')}
@@ -195,10 +249,34 @@ function ServicesSection({ profile }: { profile: NurseProfile }) {
))}
</Box>
)}
{profile.latestReview ? <LatestReviewSnippet review={profile.latestReview} /> : null}
</Stack>
);
}
/** The already-fetched latest-review snippet — a small taste of the dossier's reviews tab. */
function LatestReviewSnippet({ review }: { review: NonNullable<NurseProfile['latestReview']> }) {
const t = useTranslations('search');
const tr = useTranslations('reviews');
const locale = useLocale();
return (
<SurfaceCard padding="sm">
<Stack sx={{ gap: 0.75 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t('latest_review_title')}
</Typography>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<RatingInput value={review.rating} readOnly size={16} ariaLabel={tr('rating_label')} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{review.authorMasked} · {formatShamsiDate(review.createdAt, locale)}
</Typography>
</Stack>
{review.body ? <Typography variant="body2">{review.body}</Typography> : null}
</Stack>
</SurfaceCard>
);
}
/**
* The f13 reviews tab — the aggregate rating + count and an infinite list of **published** reviews. Never
* requests or renders `pending_moderation`/`hidden`/`rejected` content; the aggregate is the server's
@@ -2,20 +2,28 @@
import { Suspense, useCallback, useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { MenuItem, Stack, TextField, Typography } from '@mui/material';
import { Chip, Stack, Typography } from '@mui/material';
import { AppButton, AppLoading, EmptyState, ErrorState, NurseResultCard } from '@/components';
import { ROUTES } from '@/constants';
import { useServiceCategories } from '@/services/catalog';
import { pickCatalogName } from '@/services/catalog/names';
import { useCities, useDistricts } from '@/services/geography';
import { pickRegionName } from '@/services/geography/names';
import { useNurseSearch } from '@/services/search';
import { searchParamsToFilters } from '@/services/search/filterParams';
import { SEARCH_PAGE_SIZE } from '@/services/search/constants';
import { formatIrrToToman } from '@/utils';
import type { NurseSearchResult } from '@/services/search/types';
/**
* C2 — Results (نتایج جستجو): the rating-sorted list of **only verified, accepting** nurses for the
* carried filter set. The filter set lives in the URL (the deep-linkable, back/forward-safe cache key),
* so returning to a prior filter URL is a cache hit with zero network calls (`useNurseSearch` +
* `keepPreviousData`). Renders all four states (loading skeletons / empty "relax filters" / error-retry
* / populated). Tapping a card opens C3, carrying the nurse + variant + gender intent.
* `keepPreviousData`). A tappable **filter-recap chip row** (category · region · gender · price) deep-
* links back to C1 carrying the *entire* current query string — every filter C1 set, including the
* client-only `province_id`/`date` params — so C1 hydrates fully instead of resetting to just the
* category. Renders all four states (loading skeletons / empty "relax filters" / error-retry /
* populated). Tapping a card opens C3, carrying the nurse + variant + gender intent.
*/
export default function SearchResultsPage() {
return (
@@ -37,12 +45,48 @@ function ResultsScreen() {
// The URL is the source of truth for the filter set; grow only the page size for "load more".
const filters = useMemo(() => ({ ...searchParamsToFilters(params), pageSize }), [params, pageSize]);
const dateIntent = params.get('date') ?? undefined;
const provinceIdParam = params.get('province_id');
const { data, isLoading, isError, isFetching, refetch } = useNurseSearch(filters);
const items = data?.items ?? [];
const total = data?.total ?? 0;
const hasMore = items.length < total;
const { data: categoriesData } = useServiceCategories();
const categories = useMemo(() => categoriesData?.items ?? [], [categoriesData]);
const categoryLabelById = useMemo(() => {
const map = new Map<number, string>();
categories.forEach((category) => map.set(category.id, pickCatalogName(category, locale)));
return map;
}, [categories, locale]);
const categoryLabel = categoryLabelById.get(filters.serviceCategoryId);
const { data: cities } = useCities(provinceIdParam ? Number(provinceIdParam) : undefined);
const { data: districts } = useDistricts(filters.cityId || undefined);
const city = cities?.find((candidate) => candidate.id === filters.cityId);
const district = filters.districtId ? districts?.find((candidate) => candidate.id === filters.districtId) : undefined;
const regionLabel = city
? `${pickRegionName(city, locale)} · ${district ? pickRegionName(district, locale) : t('whole_city')}`
: undefined;
const genderLabel = t(`gender_${filters.nurseGender ?? 'any'}`);
const priceLabel = filters.priceMin
? filters.priceMax
? t('price_chip_range', {
min: formatIrrToToman(filters.priceMin, locale),
max: formatIrrToToman(filters.priceMax, locale),
})
: t('price_chip_min', { min: formatIrrToToman(filters.priceMin, locale) })
: filters.priceMax
? t('price_chip_max', { max: formatIrrToToman(filters.priceMax, locale) })
: undefined;
const backToFilters = useCallback(
() => router.push(`/${locale}${ROUTES.SEARCH}?${params.toString()}`),
[router, locale, params],
);
const openProfile = useCallback(
(nurse: NurseSearchResult) => {
const query = new URLSearchParams();
@@ -56,18 +100,26 @@ function ResultsScreen() {
[router, locale, filters.serviceCategoryId, filters.cityId, filters.nurseGender, dateIntent],
);
const backToFilters = () => router.push(`/${locale}${ROUTES.SEARCH}`);
return (
<Stack sx={{ gap: 2 }}>
<Stack direction="row" sx={{ gap: 2, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="h6" component="h1">
{isLoading ? t('results_loading_title') : t('results_count', { count: total })}
</Typography>
{/* Rating is the only MVP sort; rendered as a control with a single option. Other sorts DEFERRED. */}
<TextField select size="small" label={t('sort_label')} value="rating" sx={{ minWidth: 160 }}>
<MenuItem value="rating">{t('sort_rating')}</MenuItem>
</TextField>
{/* Rating is the only MVP sort — a static caption, not a dead-interactive dropdown. Other
sorts are DEFERRED until the API grows them. */}
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('sort_static')}
</Typography>
</Stack>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{categoryLabel ? (
<Chip label={categoryLabel} onClick={backToFilters} data-recap-chip="category" />
) : null}
{regionLabel ? <Chip label={regionLabel} onClick={backToFilters} data-recap-chip="region" /> : null}
<Chip label={genderLabel} onClick={backToFilters} data-recap-chip="gender" />
{priceLabel ? <Chip label={priceLabel} onClick={backToFilters} data-recap-chip="price" /> : null}
</Stack>
{isLoading ? (
@@ -83,7 +135,12 @@ function ResultsScreen() {
) : (
<Stack sx={{ gap: 1.5 }}>
{items.map((nurse) => (
<NurseResultCard key={`${nurse.nurseId}-${nurse.variantId}`} nurse={nurse} onSelect={openProfile} />
<NurseResultCard
key={`${nurse.nurseId}-${nurse.variantId}`}
nurse={nurse}
serviceLabel={categoryLabelById.get(nurse.serviceCategoryId) ?? t('unnamed_service')}
onSelect={openProfile}
/>
))}
{hasMore ? (
<AppButton
@@ -118,7 +175,7 @@ function RelaxFiltersEmptyState({ onRelax }: { onRelax: () => void }) {
{t('empty_suggest_district')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('empty_suggest_city')}
{t('empty_suggest_date')}
</Typography>
</Stack>
}
@@ -1,11 +1,16 @@
import { useMemo, useState } from 'react';
import { toEnglishDigits, tomanToRial } from '@/utils';
import { rialToToman } from '@/utils/money';
import { useDebouncedValue } from '@/services/search';
import { SEARCH_FILTER_DEBOUNCE_MS, SEARCH_PAGE_SIZE } from '@/services/search/constants';
import { parsePositiveInt, searchParamsToFilters } from '@/services/search/filterParams';
import type { NurseGender, NurseSearchFilters } from '@/services/search/types';
import type { CascadingRegionValue } from '@/components/geography/CascadingRegionSelect';
const EMPTY_REGION: CascadingRegionValue = { provinceId: null, cityId: null, districtId: null };
/** Minimal read surface shared by `URLSearchParams` and Next's `ReadonlyURLSearchParams`. */
interface ParamReader {
get(name: string): string | null;
}
/** Toman input → IRR-Rial digit-string at the field boundary; undefined for blank/invalid input. */
function tomanInputToIrr(toman: string): string | undefined {
@@ -14,20 +19,41 @@ function tomanInputToIrr(toman: string): string | undefined {
return tomanToRial(digits);
}
/** IRR digit-string (or undefined) → the whole-Toman string the price fields display. */
function irrToTomanInput(irr: string | undefined): string {
return irr ? String(rialToToman(irr)) : '';
}
/**
* The C1 filter controller — fast-changing UI state kept **colocated** (not in a high context provider,
* phase §5). Holds the category, cascading region, same-gender facet, and Toman price inputs, and
* derives the canonical `NurseSearchFilters` that becomes the live-count query key and the C2 URL. The
* price inputs are **debounced** so typing doesn't fan out one search per keystroke before the value
* joins the query key. `districtId = null` (whole city) is carried as an omitted filter, never a bogus id.
*
* `params` seeds the **initial** state only (a lazy `useState` read) — either a bare `?category_id=`
* (the Home tile handoff) or a full filter set carried back from a C2 recap chip (`searchParamsToFilters`
* reads every field C2's URL carries). `province_id` is a client-only convenience param (not part of
* `NurseSearchFilters`/the search query key) so `CascadingRegionSelect` can prefill the city dropdown
* without a server round trip; `goToResults` re-carries it so the round trip back to C1 keeps working.
*/
export function useSearchFilters(initialCategoryId?: number) {
const [categoryId, setCategoryId] = useState<number | null>(initialCategoryId ?? null);
const [region, setRegion] = useState<CascadingRegionValue>(EMPTY_REGION);
const [gender, setGender] = useState<NurseGender | undefined>(undefined);
const [priceMinToman, setPriceMinToman] = useState('');
const [priceMaxToman, setPriceMaxToman] = useState('');
const [dateIntent, setDateIntent] = useState('');
export function useSearchFilters(params: ParamReader) {
const [categoryId, setCategoryId] = useState<number | null>(() => {
const raw = searchParamsToFilters(params).serviceCategoryId;
return raw > 0 ? raw : null;
});
const [region, setRegion] = useState<CascadingRegionValue>(() => {
const initial = searchParamsToFilters(params);
return {
provinceId: parsePositiveInt(params.get('province_id')) ?? null,
cityId: initial.cityId > 0 ? initial.cityId : null,
districtId: initial.districtId ?? null,
};
});
const [gender, setGender] = useState<NurseGender | undefined>(() => searchParamsToFilters(params).nurseGender);
const [priceMinToman, setPriceMinToman] = useState(() => irrToTomanInput(searchParamsToFilters(params).priceMin));
const [priceMaxToman, setPriceMaxToman] = useState(() => irrToTomanInput(searchParamsToFilters(params).priceMax));
const [dateIntent, setDateIntent] = useState(() => params.get('date') ?? '');
const debouncedMin = useDebouncedValue(priceMinToman, SEARCH_FILTER_DEBOUNCE_MS);
const debouncedMax = useDebouncedValue(priceMaxToman, SEARCH_FILTER_DEBOUNCE_MS);
@@ -55,4 +55,11 @@ describe('<BookingRequestSummaryCard/> component', () => {
renderCard({ nurseRating: null });
expect(screen.queryByText('4.8')).not.toBeInTheDocument();
});
it('bidi-isolates the time range in a dir="ltr" span (matches SessionCard\'s convention)', () => {
const { container } = renderCard();
const ltrSpan = container.querySelector('span[dir="ltr"]');
expect(ltrSpan).toBeInTheDocument();
expect(ltrSpan?.textContent).toMatch(//);
});
});
@@ -58,7 +58,8 @@ const BookingRequestSummaryCard: FunctionComponent<BookingRequestSummaryCardProp
hour: '2-digit',
minute: '2-digit',
});
const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} ${timeFmt.format(endDate)}`;
const whenDateLabel = formatShamsiDate(startDate, locale);
const whenTimeRangeLabel = `${timeFmt.format(startDate)} ${timeFmt.format(endDate)}`;
const ratingLabel =
nurseRating != null
@@ -121,7 +122,10 @@ const BookingRequestSummaryCard: FunctionComponent<BookingRequestSummaryCardProp
<SummaryRow caption={t('summary_when')}>
<Typography variant="body2" sx={{ fontWeight: 500, textAlign: 'end' }}>
{whenLabel}
{whenDateLabel} ·{' '}
<Typography component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>
{whenTimeRangeLabel}
</Typography>
</Typography>
</SummaryRow>
</Stack>
@@ -40,3 +40,39 @@ describe('<GenderToggle/> component', () => {
expect(onChange).not.toHaveBeenCalled();
});
});
describe('<GenderToggle/> allowAny mode', () => {
function renderAnyToggle(value: 'male' | 'female' | 'any' | null) {
const onChange = jest.fn();
const utils = render(
<ThemeProvider>
<GenderToggle
allowAny
value={value}
onChange={onChange}
maleLabel="Male"
femaleLabel="Female"
anyLabel="Any"
/>
</ThemeProvider>,
);
return { ...utils, onChange };
}
it('renders the third "any" option', () => {
renderAnyToggle(null);
expect(screen.getByText('Any')).toBeInTheDocument();
});
it('calls onChange with "any" when picked', async () => {
const user = userEvent.setup();
const { onChange } = renderAnyToggle(null);
await user.click(screen.getByText('Any'));
expect(onChange).toHaveBeenCalledWith('any');
});
it('does not render the "any" option when allowAny is omitted', () => {
renderToggle(null);
expect(screen.queryByText('Any')).not.toBeInTheDocument();
});
});
@@ -4,11 +4,7 @@ import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import type { Gender } from '@/services/patients/types';
export interface GenderToggleProps {
/** Current selection; `null` means nothing chosen yet (gender is never defaulted). */
value: Gender | null;
/** Fires only with a concrete gender — deselecting is ignored so the field stays required. */
onChange: (value: Gender) => void;
interface GenderToggleBaseProps {
maleLabel: string;
femaleLabel: string;
/** Marks the group invalid (e.g. submitted without a choice). */
@@ -17,45 +13,75 @@ export interface GenderToggleProps {
ariaLabel?: string;
}
/** The default, booking-context shape — required male/female only, gender never defaulted. */
export interface GenderToggleRequiredProps extends GenderToggleBaseProps {
allowAny?: false;
/** Current selection; `null` means nothing chosen yet (gender is never defaulted). */
value: Gender | null;
/** Fires only with a concrete gender — deselecting is ignored so the field stays required. */
onChange: (value: Gender) => void;
}
/** The opt-in search-context shape — adds a third "فرقی ندارد" (any) option. */
export interface GenderToggleAnyProps extends GenderToggleBaseProps {
allowAny: true;
/** Label for the "فرقی ندارد" / any-gender option (required when `allowAny`). */
anyLabel: string;
value: Gender | 'any' | null;
onChange: (value: Gender | 'any') => void;
}
export type GenderToggleProps = GenderToggleRequiredProps | GenderToggleAnyProps;
/**
* Required male/female toggle. Gender is **load-bearing** for same-gender caregiver matching
* (search/booking), so it is never defaulted and cannot be deselected back to empty via the UI.
* Labels are translated by the caller (labels are i18n keys off the code).
* Labels are translated by the caller (labels are i18n keys off the code). The opt-in `allowAny`
* mode (search's C1 facet) adds a third "فرقی ندارد" option **without** loosening the default
* booking-context contract — omitting `allowAny` keeps the exact required male/female behaviour.
* @component GenderToggle
*/
const GenderToggle: FunctionComponent<GenderToggleProps> = ({
value,
onChange,
maleLabel,
femaleLabel,
error = false,
disabled = false,
ariaLabel,
}) => (
<ToggleButtonGroup
exclusive
value={value}
disabled={disabled}
aria-label={ariaLabel}
onChange={(_event, next: Gender | null) => {
if (next) onChange(next);
}}
sx={{
'& .MuiToggleButton-root': {
flex: 1,
py: 1.25,
fontWeight: 700,
borderColor: error ? 'var(--bal-error)' : undefined,
},
}}
>
<ToggleButton value="male" data-gender="male">
{maleLabel}
</ToggleButton>
<ToggleButton value="female" data-gender="female">
{femaleLabel}
</ToggleButton>
</ToggleButtonGroup>
);
const GenderToggle: FunctionComponent<GenderToggleProps> = (props) => {
const { maleLabel, femaleLabel, error = false, disabled = false, ariaLabel } = props;
const allowAny = props.allowAny === true;
const handleChange = (next: Gender | 'any' | null) => {
if (!next) return;
if (next === 'any' && !allowAny) return;
// The two prop shapes are a discriminated union on `allowAny`; the runtime guard above already
// enforces the invariant TS can't see through a plain union call, so this cast is safe.
(props.onChange as (value: Gender | 'any') => void)(next);
};
return (
<ToggleButtonGroup
exclusive
value={props.value}
disabled={disabled}
aria-label={ariaLabel}
onChange={(_event, next: Gender | 'any' | null) => handleChange(next)}
sx={{
'& .MuiToggleButton-root': {
flex: 1,
py: 1.25,
fontWeight: 700,
borderColor: error ? 'var(--bal-error)' : undefined,
},
}}
>
<ToggleButton value="male" data-gender="male">
{maleLabel}
</ToggleButton>
<ToggleButton value="female" data-gender="female">
{femaleLabel}
</ToggleButton>
{allowAny ? (
<ToggleButton value="any" data-gender="any">
{(props as GenderToggleAnyProps).anyLabel}
</ToggleButton>
) : null}
</ToggleButtonGroup>
);
};
export default GenderToggle;
@@ -2,12 +2,23 @@ import { fireEvent, render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import type { NurseSearchResult } from '@/services/search/types';
// next-intl echoes keys; locale = en so the rating/price format with ASCII digits we can assert on.
// next-intl echoes keys so we can assert on them; locale = en so numbers format with ASCII digits.
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useTranslations: () => {
const t = (key: string, values?: Record<string, unknown>) =>
values ? `${key}:${Object.values(values).join(',')}` : key;
t.has = () => true;
return t;
},
useLocale: () => 'en',
}));
// NurseResultCard renders <TrustBadge nurseId=…>, which calls useNurseTrustBadge — mock it so the
// card doesn't need a real QueryClientProvider in this test.
jest.mock('@/services/verification', () => ({
useNurseTrustBadge: () => ({ data: undefined, isLoading: false, isError: false }),
}));
import NurseResultCard from './NurseResultCard';
const NURSE: NurseSearchResult = {
@@ -26,24 +37,36 @@ const NURSE: NurseSearchResult = {
nurseGender: 'female',
cityId: 101,
districtId: 1003,
topReviewTag: null,
};
function renderCard(nurse: NurseSearchResult, onSelect = jest.fn()) {
function renderCard(nurse: NurseSearchResult, onSelect = jest.fn(), serviceLabel = 'Elderly Care') {
render(
<ThemeProvider>
<NurseResultCard nurse={nurse} onSelect={onSelect} />
<NurseResultCard nurse={nurse} serviceLabel={serviceLabel} onSelect={onSelect} />
</ThemeProvider>,
);
return onSelect;
}
describe('<NurseResultCard/> component', () => {
it('renders the name, the reused verified badge, and the rating', () => {
it('renders the name, service label, the reused verified badge, and the rating', () => {
renderCard(NURSE);
expect(screen.getByText('Maryam Rezaei')).toBeInTheDocument();
expect(screen.getByText('Elderly Care')).toBeInTheDocument();
expect(screen.getByText('badge_verified')).toBeInTheDocument();
expect(screen.getByText('4.9')).toBeInTheDocument();
expect(screen.getByText('reviews_count')).toBeInTheDocument();
expect(screen.getByText(/reviews_count/)).toBeInTheDocument();
});
it('renders the nurse gender chip and the completed-visits count', () => {
const { container } = render(
<ThemeProvider>
<NurseResultCard nurse={NURSE} serviceLabel="Elderly Care" onSelect={jest.fn()} />
</ThemeProvider>,
);
expect(container.querySelector('[data-nurse-gender="female"]')).toBeInTheDocument();
expect(screen.getByText(/completed_visits/)).toBeInTheDocument();
});
it('renders the "from" price line as grouped Toman via the money util', () => {
@@ -56,17 +79,37 @@ describe('<NurseResultCard/> component', () => {
it('shows the distance chip only when distanceKm is present', () => {
const { rerender } = render(
<ThemeProvider>
<NurseResultCard nurse={NURSE} onSelect={jest.fn()} />
<NurseResultCard nurse={NURSE} serviceLabel="Elderly Care" onSelect={jest.fn()} />
</ThemeProvider>,
);
expect(screen.getByText('distance_km')).toBeInTheDocument();
expect(screen.getByText(/distance_km/)).toBeInTheDocument();
rerender(
<ThemeProvider>
<NurseResultCard nurse={{ ...NURSE, distanceKm: null }} onSelect={jest.fn()} />
<NurseResultCard nurse={{ ...NURSE, distanceKm: null }} serviceLabel="Elderly Care" onSelect={jest.fn()} />
</ThemeProvider>,
);
expect(screen.queryByText('distance_km')).not.toBeInTheDocument();
expect(screen.queryByText(/distance_km/)).not.toBeInTheDocument();
});
it('renders the optional top-review tag only when served', () => {
const { rerender } = render(
<ThemeProvider>
<NurseResultCard nurse={NURSE} serviceLabel="Elderly Care" onSelect={jest.fn()} />
</ThemeProvider>,
);
expect(screen.queryByText(/منظم و دقیق/)).not.toBeInTheDocument();
rerender(
<ThemeProvider>
<NurseResultCard
nurse={{ ...NURSE, topReviewTag: 'منظم و دقیق' }}
serviceLabel="Elderly Care"
onSelect={jest.fn()}
/>
</ThemeProvider>,
);
expect(screen.getByText(/منظم و دقیق/)).toBeInTheDocument();
});
it('falls back to a label when the name is missing (b7 join gap)', () => {
@@ -75,8 +118,13 @@ describe('<NurseResultCard/> component', () => {
});
it('calls onSelect with the nurse row when clicked', () => {
const onSelect = renderCard(NURSE);
fireEvent.click(screen.getByRole('button'));
const onSelect = jest.fn();
const { container } = render(
<ThemeProvider>
<NurseResultCard nurse={NURSE} serviceLabel="Elderly Care" onSelect={onSelect} />
</ThemeProvider>,
);
fireEvent.click(container.querySelector('[data-nurse-result-card]') as HTMLElement);
expect(onSelect).toHaveBeenCalledWith(NURSE);
});
@@ -1,6 +1,6 @@
import { FunctionComponent, memo } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Avatar, Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { Avatar, Box, Chip, Paper, Skeleton, Stack, Typography } from '@mui/material';
import AppIcon from '../common/AppIcon';
import TrustBadge from '../TrustBadge';
import PriceDisplay from '../PriceDisplay';
@@ -10,6 +10,14 @@ import type { NurseSearchResult } from '@/services/search/types';
export interface NurseResultCardProps {
/** One search-result row (a bookable variant in a covered area). */
nurse: NurseSearchResult;
/**
* The service/variant label for this row — the row **is** a variant, so the card must name what is
* being bought. `NurseSearchResultDto` has no display name yet (REQ-040); until it lands, the page
* passes the row's **category** name (from the cached catalog reference data) so multi-variant nurses
* are at least distinguishable by price row. The card stays data-agnostic — swap the caller's label
* source to the served `variantDisplayName` once the REQ lands, no card change needed.
*/
serviceLabel: string;
/** Tapping the card opens the nurse profile (C3), carrying the row (nurse + variant + gender intent). */
onSelect: (nurse: NurseSearchResult) => void;
}
@@ -19,14 +27,16 @@ function ratingText(rating: number, locale: string): string {
}
/**
* The C2 result card: avatar, name, the reused ✓ تاییدشده verified badge, rating + review count, an
* optional distance chip (only when `distanceKm` is present), and the "from X تومان/ساعت" rate (via the
* shared `PriceDisplay` money util). Presentational + memoized so a list of N cards doesn't re-render on
* unrelated state — pass a stable `onSelect` (e.g. `useCallback`). Every returned row is verified by the
* search-index invariant, so the badge is always shown.
* The C2 result card — the four-second decision unit. Avatar, name, the reused tappable ✓ تاییدشده
* verified badge (opens the verification explainer), the service/variant label, a quiet gender chip
* (same-gender matching is load-bearing), completed-visits count, rating + review count, an optional
* distance chip, an optional one-line top-review tag (only when served), and the "from X تومان/ساعت"
* rate. Presentational + memoized so a list of N cards doesn't re-render on unrelated state — pass a
* stable `onSelect` (e.g. `useCallback`). Every returned row is verified by the search-index invariant,
* so the badge is always shown.
* @component NurseResultCard
*/
const NurseResultCard = ({ nurse, onSelect }: NurseResultCardProps) => {
const NurseResultCard = ({ nurse, serviceLabel, onSelect }: NurseResultCardProps) => {
const t = useTranslations('search');
const locale = useLocale();
@@ -38,6 +48,7 @@ const NurseResultCard = ({ nurse, onSelect }: NurseResultCardProps) => {
return (
<Paper
elevation={0}
data-nurse-result-card
onClick={() => onSelect(nurse)}
role="button"
tabIndex={0}
@@ -68,12 +79,29 @@ const NurseResultCard = ({ nurse, onSelect }: NurseResultCardProps) => {
{initial}
</Avatar>
<Stack sx={{ gap: 0.75, flexGrow: 1, minWidth: 0 }}>
<Stack sx={{ gap: 0.5, flexGrow: 1, minWidth: 0 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{name}
</Typography>
<TrustBadge state="verified" />
<TrustBadge state="verified" nurseId={nurse.nurseId} />
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{serviceLabel}
</Typography>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap', mt: 0.25 }}>
<Chip
size="small"
variant="outlined"
label={t(`gender_${nurse.nurseGender}`)}
data-nurse-gender={nurse.nurseGender}
sx={{ height: 22, fontSize: '0.75rem' }}
/>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('completed_visits', { count: formatNumber(nurse.totalCompletedBookings, locale) })}
</Typography>
</Stack>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
@@ -97,6 +125,12 @@ const NurseResultCard = ({ nurse, onSelect }: NurseResultCardProps) => {
) : null}
</Stack>
{nurse.topReviewTag ? (
<Typography variant="caption" sx={{ color: 'var(--bal-trust)', fontWeight: 500 }}>
«{nurse.topReviewTag}»
</Typography>
) : null}
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('price_from')}
@@ -108,14 +142,15 @@ const NurseResultCard = ({ nurse, onSelect }: NurseResultCardProps) => {
);
};
/** Matches the real card's anatomy (avatar disc, name/badge row, rating row, price row) so a loading list
* doesn't jump when data lands. */
/** Matches the real card's anatomy (avatar disc, name+badge row, service-label row, gender+visits meta
* row, rating row, price row) so a loading list doesn't jump when data lands. */
const NurseResultCardSkeleton: FunctionComponent = () => (
<Paper elevation={0} sx={{ p: 2, display: 'flex', gap: 2, alignItems: 'flex-start', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Skeleton variant="circular" width={56} height={56} />
<Stack sx={{ gap: 0.75, flexGrow: 1 }}>
<Skeleton variant="text" width="55%" height={28} />
<Skeleton variant="text" width="40%" height={20} />
<Skeleton variant="text" width="35%" height={20} />
<Skeleton variant="text" width="45%" height={18} />
<Skeleton variant="text" width="30%" height={20} />
</Stack>
</Paper>
@@ -1,23 +1,39 @@
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from '../../theme';
// next-intl echoes keys so we assert on the label key each state maps to.
// next-intl echoes keys so we assert on the label key each state maps to. `VerificationPanel`
// (rendered inside the explainer dialog) also needs `t.has` + `useLocale`.
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useTranslations: () => {
const t = (key: string) => key;
t.has = () => true;
return t;
},
useLocale: () => 'en',
}));
const useNurseTrustBadgeMock = jest.fn();
jest.mock('@/services/verification', () => ({
useNurseTrustBadge: (nurseId: number | undefined) => useNurseTrustBadgeMock(nurseId),
}));
import TrustBadge from './TrustBadge';
import type { BadgeState } from '@/services/verification/types';
function renderBadge(state: BadgeState) {
function renderBadge(state: BadgeState, nurseId?: number) {
return render(
<ThemeProvider>
<TrustBadge state={state} />
<TrustBadge state={state} nurseId={nurseId} />
</ThemeProvider>,
);
}
describe('<TrustBadge/> component', () => {
beforeEach(() => {
useNurseTrustBadgeMock.mockReturnValue({ data: undefined, isLoading: false, isError: false });
});
it('renders the verified label + a data attribute for the verified state', () => {
const { container } = renderBadge('verified');
expect(screen.getByText('badge_verified')).toBeInTheDocument();
@@ -35,4 +51,31 @@ describe('<TrustBadge/> component', () => {
expect(screen.getByText('badge_expired')).toBeInTheDocument();
expect(container.querySelector('[data-badge-state="expired"]')).toBeInTheDocument();
});
it('is not clickable by default (no nurseId), and calls no query hook at all', () => {
const { container } = renderBadge('verified');
expect(container.querySelector('[data-trust-explainer]')).not.toBeInTheDocument();
// The default (non-interactive) mode never mounts the query — no QueryClientProvider needed.
expect(useNurseTrustBadgeMock).not.toHaveBeenCalled();
});
it('opens the explainer dialog on tap when a nurseId is provided, fetching the badge lazily', async () => {
useNurseTrustBadgeMock.mockReturnValue({
data: { nurseId: 7, isVerified: true, approvedAt: '2026-01-01T00:00:00Z', credentialTypes: ['ino_membership'] },
isLoading: false,
isError: false,
});
const user = userEvent.setup();
renderBadge('verified', 7);
// Disabled (undefined) until opened.
expect(useNurseTrustBadgeMock).toHaveBeenLastCalledWith(undefined);
await user.click(screen.getByText('badge_verified'));
expect(screen.getByText('explainer_title')).toBeInTheDocument();
expect(useNurseTrustBadgeMock).toHaveBeenLastCalledWith(7);
await user.click(screen.getByLabelText('explainer_close'));
// MUI's Dialog exit transition removes the node asynchronously.
await waitFor(() => expect(screen.queryByText('explainer_title')).not.toBeInTheDocument());
});
});
+90 -11
View File
@@ -1,7 +1,15 @@
import { FunctionComponent } from 'react';
'use client';
import { FunctionComponent, ReactElement, useState } from 'react';
import { useTranslations } from 'next-intl';
import Chip, { ChipProps } from '@mui/material/Chip';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import AppIcon from '../common/AppIcon';
import VerificationPanel from '../VerificationPanel';
import { useNurseTrustBadge } from '@/services/verification';
import type { BadgeState } from '@/services/verification/types';
interface BadgeStyle {
@@ -20,11 +28,22 @@ const BADGE_STYLE: Record<BadgeState, BadgeStyle> = {
expired: { bg: 'var(--bal-warning)', fg: 'var(--bal-warning-contrast)', icon: 'warning', labelKey: 'badge_expired' },
};
export interface TrustBadgeProps extends Omit<ChipProps, 'color' | 'icon' | 'label'> {
export interface TrustBadgeProps extends Omit<ChipProps, 'color' | 'icon' | 'label' | 'onClick'> {
/** The trust state — verified / unverified / expired. */
state: BadgeState;
/**
* Opt-in explainer: when provided, the badge becomes tappable and opens a bottom-sheet (mobile) /
* dialog (desktop) narrating what Balinyaar verified — the same `VerificationPanel` fed by
* `useNurseTrustBadge(nurseId)` (fetched lazily, only once the explainer is actually opened). The
* default (non-interactive) badge everywhere else is unchanged and calls no query hook at all — the
* `useNurseTrustBadge` call lives entirely inside `InteractiveTrustBadge`, mounted only in this mode,
* so a caller that never passes `nurseId` needs no `QueryClientProvider` in its tests.
*/
nurseId?: number;
}
type ChipVisualProps = Pick<ChipProps, 'size' | 'sx'> & { 'data-badge-state': BadgeState };
/**
* The public trust signal (the "✓ تاییدشده" mark) rendered on a nurse's profile and — reused unchanged
* in f6 — on search results and the public nurse profile. Fed by `GetVerifiedBadgeQuery`; the state is
@@ -32,18 +51,78 @@ export interface TrustBadgeProps extends Omit<ChipProps, 'color' | 'icon' | 'lab
* renders when the aggregate is approved; `expired` is visually distinct from never-verified.
* @component TrustBadge
*/
const TrustBadge: FunctionComponent<TrustBadgeProps> = ({ state, size = 'small', sx, ...rest }) => {
const TrustBadge: FunctionComponent<TrustBadgeProps> = ({ state, nurseId, size = 'small', sx, ...rest }) => {
const t = useTranslations('verification');
const style = BADGE_STYLE[state];
const label = t(style.labelKey);
const icon: ReactElement = <AppIcon icon={style.icon} size={16} color={style.fg} />;
const chipSx = { backgroundColor: style.bg, color: style.fg, fontWeight: 700, ...sx };
const chipProps: ChipVisualProps = { size, sx: chipSx, 'data-badge-state': state };
if (nurseId == null) {
return <Chip label={label} icon={icon} {...chipProps} {...rest} />;
}
return (
<Chip
data-badge-state={state}
size={size}
label={t(style.labelKey)}
icon={<AppIcon icon={style.icon} size={16} color={style.fg} />}
sx={{ backgroundColor: style.bg, color: style.fg, fontWeight: 700, ...sx }}
{...rest}
/>
<InteractiveTrustBadge nurseId={nurseId} label={label} icon={icon} chipProps={chipProps} rest={rest} />
);
};
/** Owns the tap-to-open state, the lazily-enabled `useNurseTrustBadge` fetch, and the explainer dialog —
* split out so `TrustBadge` itself never calls a query hook in the default (non-interactive) mode. */
const InteractiveTrustBadge: FunctionComponent<{
nurseId: number;
label: string;
icon: ReactElement;
chipProps: ChipVisualProps;
rest: Omit<ChipProps, 'color' | 'icon' | 'label' | 'onClick' | 'size' | 'sx'>;
}> = ({ nurseId, label, icon, chipProps, rest }) => {
const t = useTranslations('verification');
const [open, setOpen] = useState(false);
// Lazy: only fetches once the explainer is opened (undefined disables the query otherwise).
const { data: badge, isLoading, isError } = useNurseTrustBadge(open ? nurseId : undefined);
return (
<>
<Chip
label={label}
icon={icon}
clickable
onClick={() => setOpen(true)}
aria-label={t('explainer_open_label')}
{...chipProps}
{...rest}
/>
<Dialog
open={open}
onClose={() => setOpen(false)}
fullWidth
data-trust-explainer
sx={{ '& .MuiDialog-container': { alignItems: { xs: 'flex-end', sm: 'center' } } }}
slotProps={{
paper: {
sx: {
width: { xs: '100%', sm: 480 },
m: { xs: 0, sm: 'auto' },
borderRadius: { xs: 'var(--bal-radius-lg) var(--bal-radius-lg) 0 0', sm: 'var(--bal-radius-lg)' },
},
},
}}
>
<DialogTitle sx={{ p: 0 }}>
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between', px: 3, pt: 2.5 }}>
{t('explainer_title')}
<IconButton onClick={() => setOpen(false)} aria-label={t('explainer_close')} size="small">
<AppIcon icon="close" size={20} />
</IconButton>
</Stack>
</DialogTitle>
<DialogContent sx={{ pb: 3 }}>
<VerificationPanel badge={badge} isLoading={isLoading} isError={isError} />
</DialogContent>
</Dialog>
</>
);
};
@@ -0,0 +1,72 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
jest.mock('next-intl', () => ({
useTranslations: () => {
const t = (key: string, values?: Record<string, string>) =>
values ? `${key}:${Object.values(values).join(',')}` : key;
t.has = (key: string) => key.startsWith('step_moh') || key.startsWith('step_ino') || key.startsWith('step_criminal');
return t;
},
useLocale: () => 'en',
}));
import VerificationPanel from './VerificationPanel';
import type { TrustBadge } from '@/services/verification/types';
const VERIFIED_BADGE: TrustBadge = {
nurseId: 1,
isVerified: true,
approvedAt: '2026-01-05T00:00:00Z',
credentialTypes: ['moh_competency_license', 'ino_membership'],
};
describe('<VerificationPanel/> component', () => {
it('renders a loading skeleton', () => {
const { container } = render(
<ThemeProvider>
<VerificationPanel badge={undefined} isLoading />
</ThemeProvider>,
);
expect(container.querySelector('[data-verification-panel="loading"]')).toBeInTheDocument();
});
it('renders an error state', () => {
render(
<ThemeProvider>
<VerificationPanel badge={undefined} isError />
</ThemeProvider>,
);
expect(screen.getByText('explainer_error')).toBeInTheDocument();
});
it('renders the not-verified message when the badge is unverified', () => {
render(
<ThemeProvider>
<VerificationPanel badge={{ nurseId: 1, isVerified: false, approvedAt: null, credentialTypes: [] }} />
</ThemeProvider>,
);
expect(screen.getByText('explainer_not_verified')).toBeInTheDocument();
});
it('renders one row per credential type + the approval date, never inventing steps', () => {
const { container } = render(
<ThemeProvider>
<VerificationPanel badge={VERIFIED_BADGE} />
</ThemeProvider>,
);
expect(container.querySelector('[data-verification-row="moh_competency_license"]')).toBeInTheDocument();
expect(container.querySelector('[data-verification-row="ino_membership"]')).toBeInTheDocument();
expect(container.querySelectorAll('[data-verification-row]').length).toBe(2);
expect(screen.getByText(/explainer_approved_at/)).toBeInTheDocument();
});
it('falls back to the raw code for an unmapped credential type', () => {
render(
<ThemeProvider>
<VerificationPanel badge={{ ...VERIFIED_BADGE, credentialTypes: ['some_future_code'] }} />
</ThemeProvider>,
);
expect(screen.getByText('some_future_code')).toBeInTheDocument();
});
});
@@ -0,0 +1,84 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import Skeleton from '@mui/material/Skeleton';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import AppIcon from '../common/AppIcon';
import { formatShamsiDate } from '@/utils';
import type { TrustBadge as TrustBadgeDto } from '@/services/verification/types';
export interface VerificationPanelProps {
/** The public trust-badge payload (`nurses/{id}/trust_badge`); `undefined` while loading/absent. */
badge: TrustBadgeDto | undefined;
isLoading?: boolean;
isError?: boolean;
}
/**
* "What Balinyaar verified" — a check-listed explainer fed by the public trust-badge read: one row
* per `credentialTypes[]` entry (stable codes mapped to i18n labels off the `verification` namespace's
* step vocabulary — never a raw wire value) plus the approval date. Renders only what is served: no
* invented steps, no fake dates. Shared by the C3 profile and the `TrustBadge` tap-to-explain
* sheet/dialog; phase 8's public-profile preview reuses it unchanged.
* @component VerificationPanel
*/
const VerificationPanel: FunctionComponent<VerificationPanelProps> = ({
badge,
isLoading = false,
isError = false,
}) => {
const t = useTranslations('verification');
const locale = useLocale();
if (isLoading) {
return (
<Stack data-verification-panel="loading" sx={{ gap: 1.5 }}>
<Skeleton variant="text" width="70%" height={24} />
<Skeleton variant="text" width="90%" />
<Skeleton variant="text" width="80%" />
</Stack>
);
}
if (isError || !badge) {
return (
<Typography variant="body2" sx={{ color: 'text.secondary' }} data-verification-panel="error">
{t('explainer_error')}
</Typography>
);
}
if (!badge.isVerified) {
return (
<Typography variant="body2" sx={{ color: 'text.secondary' }} data-verification-panel="unverified">
{t('explainer_not_verified')}
</Typography>
);
}
return (
<Stack data-verification-panel="verified" sx={{ gap: 1.5 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('explainer_intro')}
</Typography>
<Stack sx={{ gap: 1 }}>
{badge.credentialTypes.map((code) => (
<Stack key={code} direction="row" data-verification-row={code} sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="verified" size={18} color="var(--bal-trust)" />
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{t.has(`step_${code}`) ? t(`step_${code}`) : code}
</Typography>
</Stack>
))}
</Stack>
{badge.approvedAt ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('explainer_approved_at', { date: formatShamsiDate(badge.approvedAt, locale) })}
</Typography>
) : null}
</Stack>
);
};
export default VerificationPanel;
@@ -0,0 +1,4 @@
import VerificationPanel from './VerificationPanel';
export default VerificationPanel;
export type { VerificationPanelProps } from './VerificationPanel';
@@ -1,14 +1,17 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Chip, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { Avatar, Chip, Paper, Skeleton, Stack, Typography } from '@mui/material';
import AccentCard from '@/components/common/AccentCard';
import AppButton from '@/components/common/AppButton';
import AppIcon from '@/components/common/AppIcon';
import { formatShamsiDate } from '@/utils';
import SurfaceCard from '@/components/common/SurfaceCard';
import { formatRelativeTime, formatShamsiDate, localeTag } from '@/utils';
import { useBookingDetail, useCareInstructions } from '@/services/bookings';
import {
isBookingConfirmedOrBeyond,
type BookingDetailDto,
type BookingSessionDto,
type BookingViewerRole,
} from '@/services/bookings/types';
import BookingStatusTimeline from '../BookingStatusTimeline';
@@ -16,6 +19,7 @@ import SessionList from '../SessionList';
import BookingMoneySummary from '../BookingMoneySummary';
import CareInstructionsCard from '../CareInstructionsCard';
import { useEvvController } from '../useEvvController';
import { buildSessionIcs, downloadIcsFile } from '../ics';
export interface BookingDetailViewProps {
bookingId: number;
@@ -33,6 +37,38 @@ function variantName(snapshotJson: string): string | null {
}
}
/**
* Best-effort read of the frozen address snapshot. The wire has no stable schema for this blob
* (confirmed against the seed fixtures — field names drift between `city`/`cityName`/`cityNameFa` and
* `line`/`addressLine` across bookings) — REQ-045 proposes a typed shape; until then this tries every
* candidate key and joins whatever resolves. `null` for the nurse view (masked server-side) or a
* genuinely empty snapshot.
*/
function addressSnapshotLabel(snapshotJson: string | null, locale: string): string | null {
if (!snapshotJson) return null;
try {
const parsed = JSON.parse(snapshotJson) as Record<string, string | undefined>;
const city = locale === 'en' ? (parsed.cityNameEn ?? parsed.cityName ?? parsed.city) : (parsed.cityNameFa ?? parsed.cityName ?? parsed.city);
const district = locale === 'en' ? (parsed.districtNameEn ?? parsed.district) : (parsed.districtNameFa ?? parsed.district);
const parts = [parsed.title, city, district, parsed.addressLine ?? parsed.line].filter(
(part): part is string => Boolean(part && part.trim()),
);
return parts.length > 0 ? parts.join(' · ') : null;
} catch {
return null;
}
}
/** The earliest not-yet-finished session — the "next visit" the hero headlines. */
function upcomingSession(sessions: BookingSessionDto[]): BookingSessionDto | undefined {
return sessions.find((session) => session.status === 'scheduled' || session.status === 'in_progress');
}
/** The session currently checked in (on-site) — drives the EVV presence headline. */
function checkedInSession(sessions: BookingSessionDto[]): BookingSessionDto | undefined {
return sessions.find((session) => session.evvStatus === 'checked_in');
}
/**
* The both-roles booking detail — the hinge screen. Fetches `useBookingDetail`, renders the server-truth
* `BookingStatusTimeline`, the `SessionList`, and the `BookingMoneySummary`. Role-conditioned: the
@@ -47,6 +83,7 @@ function variantName(snapshotJson: string): string | null {
*/
const BookingDetailView: FunctionComponent<BookingDetailViewProps> = ({ bookingId, viewerRole }) => {
const t = useTranslations('booking');
const locale = useLocale();
const isNurse = viewerRole === 'nurse';
const { data: booking, isLoading, isError } = useBookingDetail(bookingId, viewerRole);
@@ -58,10 +95,36 @@ const BookingDetailView: FunctionComponent<BookingDetailViewProps> = ({ bookingI
if (isError || !booking) return <NotFoundCard title={t('bd_not_found_title')} body={t('bd_not_found_body')} />;
const service = variantName(booking.variantSnapshotJson);
const addressLabel = addressSnapshotLabel(booking.addressSnapshotJson, locale);
const nextSession = upcomingSession(booking.sessions);
const onSiteCheckInAt = checkedInSession(booking.sessions)?.checkInAt ?? null;
const clockFmt = new Intl.DateTimeFormat(localeTag(locale), { hour: '2-digit', minute: '2-digit' });
const nextSessionDayLabel = nextSession
? formatRelativeTime(`${nextSession.scheduledDate}T${nextSession.scheduledTimeStart}`, locale, formatShamsiDate)
: null;
const nextSessionTimeLabel = nextSession
? clockFmt.format(new Date(`${nextSession.scheduledDate}T${nextSession.scheduledTimeStart}`))
: null;
const addToCalendar = () => {
if (!nextSession) return;
const start = new Date(`${nextSession.scheduledDate}T${nextSession.scheduledTimeStart}`);
const end = new Date(`${nextSession.scheduledDate}T${nextSession.scheduledTimeEnd}`);
const ics = buildSessionIcs({
uid: `balinyaar-booking-${booking.id}-session-${nextSession.id}@balinyaar`,
title: `${service ?? t('bd_title')}${t('session_index', { n: nextSession.sessionIndex })}`,
location: addressLabel ?? undefined,
start,
end,
});
downloadIcsFile(`balinyaar-booking-${booking.id}-visit-${nextSession.sessionIndex}.ics`, ics);
};
return (
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%' }} data-viewer-role={viewerRole}>
{/* Header — carries the terracotta accent + "نمای پرستار" chip on the nurse view. */}
{/* Hero — next-visit headline, address, nurse identity, add-to-calendar; terracotta accent + "نمای
پرستار" chip on the nurse view. */}
<Paper
elevation={0}
sx={{
@@ -72,11 +135,25 @@ const BookingDetailView: FunctionComponent<BookingDetailViewProps> = ({ bookingI
...(isNurse ? { borderTopWidth: 3, borderTopColor: 'var(--bal-secondary)' } : {}),
}}
>
<Stack sx={{ gap: 1 }}>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5, flexWrap: 'wrap' }}>
<Typography variant="h6" component="h1">
{service ?? t('bd_title')}
</Typography>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="h6" component="h1">
{nextSession ? (
<>
{t('session_index', { n: nextSession.sessionIndex })} · {nextSessionDayLabel}{' '}
<Typography component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>
{nextSessionTimeLabel}
</Typography>
</>
) : (
(service ?? t('bd_title'))
)}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('bd_ref', { id: booking.id })}
</Typography>
</Stack>
{isNurse ? (
<Chip
size="small"
@@ -85,19 +162,54 @@ const BookingDetailView: FunctionComponent<BookingDetailViewProps> = ({ bookingI
/>
) : null}
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('bd_ref', { id: booking.id })}
</Typography>
<Stack direction="row" sx={{ gap: 3, flexWrap: 'wrap', mt: 0.5 }}>
{onSiteCheckInAt ? (
<Stack
direction="row"
sx={{ gap: 1, alignItems: 'center', px: 1.5, py: 1, borderRadius: 2, bgcolor: 'var(--bal-success-soft)' }}
>
<AppIcon icon="location" size={18} color="var(--bal-success)" />
<Typography variant="body2" sx={{ fontWeight: 700, color: 'var(--bal-success)' }}>
{t('evv_presence_headline', { time: clockFmt.format(new Date(onSiteCheckInAt)) })}
</Typography>
</Stack>
) : null}
{addressLabel ? (
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<AppIcon icon="location" size={18} color="var(--bal-text-secondary)" />
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{addressLabel}
</Typography>
</Stack>
) : null}
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
<Avatar sx={{ width: 36, height: 36, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}>
{(booking.nurseName.trim() || t('unnamed_nurse')).charAt(0)}
</Avatar>
<HeaderFact label={t('bd_nurse_label')} value={booking.nurseName} />
<HeaderFact label={t('summary_patient')} value={booking.patientName} />
<HeaderFact label={t('unnamed_nurse')} value={booking.nurseName} />
</Stack>
{nextSession ? (
<AppButton
variant="outlined"
color="primary"
size="small"
startIcon="calendar"
onClick={addToCalendar}
sx={{ alignSelf: 'flex-start' }}
>
{t('bd_add_to_calendar')}
</AppButton>
) : null}
</Stack>
</Paper>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<SurfaceCard>
<BookingStatusTimeline status={booking.status} />
</Paper>
</SurfaceCard>
<StatusNote booking={booking} />
@@ -1,8 +1,8 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Paper, Stack, Typography } from '@mui/material';
import { AppButton, Money } from '@/components/common';
import { Stack, Typography } from '@mui/material';
import { AppButton, Money, SurfaceCard } from '@/components/common';
import StatusChip from '@/components/StatusChip';
import { formatShamsiDate } from '@/utils';
import type { BookingSessionStatus, VisitVerificationStatus } from '@/services/bookings/types';
@@ -76,11 +76,7 @@ const SessionCard: FunctionComponent<SessionCardProps> = ({
const busy = acquiringLocation || evvPending;
return (
<Paper
elevation={0}
data-session-status={status}
sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
>
<SurfaceCard padding="sm" data-session-status={status}>
<Stack sx={{ gap: 1.25 }}>
{title ? (
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
@@ -139,7 +135,7 @@ const SessionCard: FunctionComponent<SessionCardProps> = ({
</AppButton>
) : null}
</Stack>
</Paper>
</SurfaceCard>
);
};
+55
View File
@@ -0,0 +1,55 @@
/**
* A minimal client-side `.ics` (iCalendar) generator for the booking-detail "add to calendar"
* affordance no backend seam, the file is built entirely in the browser from already-served session
* data. Times are emitted in **Gregorian UTC** (the iCalendar spec's wire format); the UI around this
* stays Shamsi this is purely the download artifact.
*/
function toIcsUtc(date: Date): string {
return `${date.toISOString().replace(/[-:]/g, '').split('.')[0]}Z`;
}
function escapeIcsText(text: string): string {
return text.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
}
export interface IcsEventInput {
uid: string;
title: string;
description?: string;
location?: string;
start: Date;
end: Date;
}
/** Builds a single-event `VCALENDAR` document as a CRLF-joined string, per RFC 5545. */
export function buildSessionIcs({ uid, title, description, location, start, end }: IcsEventInput): string {
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Balinyaar//Booking//EN',
'BEGIN:VEVENT',
`UID:${uid}`,
`DTSTAMP:${toIcsUtc(new Date())}`,
`DTSTART:${toIcsUtc(start)}`,
`DTEND:${toIcsUtc(end)}`,
`SUMMARY:${escapeIcsText(title)}`,
];
if (description) lines.push(`DESCRIPTION:${escapeIcsText(description)}`);
if (location) lines.push(`LOCATION:${escapeIcsText(location)}`);
lines.push('END:VEVENT', 'END:VCALENDAR');
return lines.join('\r\n');
}
/** Triggers a browser download of the `.ics` content — call only from a client event handler. */
export function downloadIcsFile(filename: string, content: string): void {
const blob = new Blob([content], { type: 'text/calendar;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
@@ -0,0 +1,41 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
jest.mock('next-intl', () => ({
useLocale: () => 'en',
}));
import JalaliDateIntentPicker from './JalaliDateIntentPicker';
import { todayIso } from '../JalaliDatePicker/calendarEngine';
const LABELS = { todayLabel: 'Today', tomorrowLabel: 'Tomorrow', pickOtherLabel: 'Pick another date' };
function wrap(ui: React.ReactNode) {
return render(<ThemeProvider>{ui}</ThemeProvider>);
}
describe('<JalaliDateIntentPicker/> component', () => {
it('renders the near-date chip strip and emits the ISO date on click', () => {
const onChange = jest.fn();
const { container } = wrap(<JalaliDateIntentPicker value="" onChange={onChange} min={todayIso()} {...LABELS} />);
const chips = container.querySelectorAll('[data-day]');
expect(chips.length).toBeGreaterThan(0);
fireEvent.click(chips[0]);
expect(onChange).toHaveBeenCalledWith(todayIso());
});
it('opens a full-grid popover from the calendar-icon button', () => {
wrap(<JalaliDateIntentPicker value="" onChange={jest.fn()} min={todayIso()} {...LABELS} />);
fireEvent.click(screen.getByLabelText('Pick another date'));
expect(screen.getByText(/\d{4}/)).toBeInTheDocument();
});
it('closes the popover and emits the picked date when a grid day is clicked', () => {
const onChange = jest.fn();
wrap(<JalaliDateIntentPicker value={todayIso()} onChange={onChange} {...LABELS} />);
fireEvent.click(screen.getByLabelText('Pick another date'));
const gridDay = screen.getAllByText('15')[0];
fireEvent.click(gridDay);
expect(onChange).toHaveBeenCalled();
});
});
@@ -0,0 +1,83 @@
'use client';
import { FunctionComponent, useState } from 'react';
import Box from '@mui/material/Box';
import Popover from '@mui/material/Popover';
import Stack from '@mui/material/Stack';
import AppIconButton from '../AppIconButton';
import JalaliDatePicker from '../JalaliDatePicker';
export interface JalaliDateIntentPickerProps {
/** The selected date as a wire ISO (Gregorian) `YYYY-MM-DD` string, or `''` for no selection. */
value: string;
/** Fired with the wire ISO (Gregorian) date of the day the user picked. */
onChange: (iso: string) => void;
/** Inclusive ISO lower bound (typically `todayIso()`). */
min?: string;
/** Number of day-chips in the near strip (default 7). */
chipDayCount?: number;
/** Already-translated relative labels for the first two chips when they land on today/tomorrow. */
todayLabel: string;
tomorrowLabel: string;
/** Already-translated accessible label for the "pick another date" calendar-icon entry. */
pickOtherLabel: string;
}
/**
* A horizontal near-date chip strip (`JalaliDatePicker` `chips` variant) plus a calendar-icon entry into
* the full Jalali grid (a `Popover`) for dates beyond the strip the shape both C1 search (date-intent,
* never hard-filters) and C4 booking-request (a real required field) need. Presentational, caller-owned
* copy, per the `components/common` convention.
* @component JalaliDateIntentPicker
*/
const JalaliDateIntentPicker: FunctionComponent<JalaliDateIntentPickerProps> = ({
value,
onChange,
min,
chipDayCount = 7,
todayLabel,
tomorrowLabel,
pickOtherLabel,
}) => {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
return (
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<JalaliDatePicker
variant="chips"
chipDayCount={chipDayCount}
value={value || null}
onChange={onChange}
min={min}
todayLabel={todayLabel}
tomorrowLabel={tomorrowLabel}
/>
</Box>
<AppIconButton
icon="calendar"
title={pickOtherLabel}
onClick={(event) => setAnchorEl(event.currentTarget)}
/>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
>
<Box sx={{ p: 2, width: 320 }}>
<JalaliDatePicker
value={value || null}
min={min}
onChange={(iso) => {
onChange(iso);
setAnchorEl(null);
}}
/>
</Box>
</Popover>
</Stack>
);
};
export default JalaliDateIntentPicker;
@@ -0,0 +1,4 @@
import JalaliDateIntentPicker from './JalaliDateIntentPicker';
export default JalaliDateIntentPicker;
export type { JalaliDateIntentPickerProps } from './JalaliDateIntentPicker';
@@ -26,6 +26,10 @@ export interface JalaliDatePickerProps {
/** Already-translated accessible label for the previous/next-month buttons. */
prevMonthLabel?: string;
nextMonthLabel?: string;
/** `chips` variant only: overrides the weekday label of the first chip with "today"/"tomorrow" copy
* when that chip's date genuinely is today/tomorrow (never forced when a `min` pushes the strip later). */
todayLabel?: string;
tomorrowLabel?: string;
}
const GRID_COLUMNS = 7;
@@ -47,6 +51,8 @@ const JalaliDatePicker: FunctionComponent<JalaliDatePickerProps> = ({
chipDayCount = 14,
prevMonthLabel,
nextMonthLabel,
todayLabel,
tomorrowLabel,
}) => {
const locale = useLocale();
const engine = useMemo(() => engineForLocale(locale), [locale]);
@@ -77,6 +83,9 @@ const JalaliDatePicker: FunctionComponent<JalaliDatePickerProps> = ({
const weekday = new Intl.DateTimeFormat(localeTag(locale), { weekday: 'short' }).format(
new Date(`${dayIso}T00:00:00`),
);
const relativeLabel =
dayIso === todayIso() ? todayLabel : dayIso === addDaysIso(todayIso(), 1) ? tomorrowLabel : undefined;
const topLabel = relativeLabel ?? weekday;
return (
<Box
key={dayIso}
@@ -104,8 +113,8 @@ const JalaliDatePicker: FunctionComponent<JalaliDatePickerProps> = ({
opacity: disabled ? 0.5 : 1,
}}
>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{weekday}
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: relativeLabel ? 700 : 400 }}>
{topLabel}
</Typography>
<Typography variant="body2" sx={{ fontWeight: selected ? 700 : 500 }}>
{formatNumber(cell.day, locale)}
@@ -0,0 +1,19 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
import StickyActionBar from './StickyActionBar';
describe('<StickyActionBar/> component', () => {
it('renders its children inside a sticky-bottom container', () => {
const { container } = render(
<ThemeProvider>
<StickyActionBar>
<button type="button">مشاهده ۱۲ پرستار</button>
</StickyActionBar>
</ThemeProvider>,
);
expect(screen.getByText('مشاهده ۱۲ پرستار')).toBeInTheDocument();
const bar = container.querySelector('[data-sticky-action-bar]');
expect(bar).toBeInTheDocument();
expect(bar).toHaveStyle({ position: 'sticky', bottom: '0px' });
});
});
@@ -0,0 +1,35 @@
import { FunctionComponent, ReactNode } from 'react';
import Box from '@mui/material/Box';
export interface StickyActionBarProps {
children: ReactNode;
}
/**
* A bottom-pinned action bar for a scrolling screen the C1 live-count CTA and the C3 booking CTA.
* Rendered as the last child of a page's scrolling content, `position: sticky` pins it to the bottom
* of the nearest scrolling ancestor (the shell's `main`), so on mobile it naturally sits directly above
* `BottomBar` (a separate flex sibling below `main`, which already owns the `env(safe-area-inset-bottom)`
* padding this component does not re-implement it) and on desktop it sits at the viewport bottom.
* @component StickyActionBar
*/
const StickyActionBar: FunctionComponent<StickyActionBarProps> = ({ children }) => (
<Box
data-sticky-action-bar
sx={{
position: 'sticky',
bottom: 0,
zIndex: 1,
mt: 2,
pt: 1.5,
pb: 1.5,
bgcolor: 'background.default',
borderTop: '1px solid',
borderColor: 'divider',
}}
>
{children}
</Box>
);
export default StickyActionBar;
@@ -0,0 +1,4 @@
import StickyActionBar from './StickyActionBar';
export default StickyActionBar;
export type { StickyActionBarProps } from './StickyActionBar';
+6
View File
@@ -16,7 +16,9 @@ import Money from './Money';
import StatusTimeline from './StatusTimeline';
import JalaliDatePicker from './JalaliDatePicker';
import JalaliDateField from './JalaliDateField';
import JalaliDateIntentPicker from './JalaliDateIntentPicker';
import LocaleSwitcher from './LocaleSwitcher';
import StickyActionBar from './StickyActionBar';
export {
ErrorBoundary,
@@ -37,7 +39,9 @@ export {
StatusTimeline,
JalaliDatePicker,
JalaliDateField,
JalaliDateIntentPicker,
LocaleSwitcher,
StickyActionBar,
};
export type { EmptyStateProps } from './EmptyState';
export type { ErrorStateProps } from './ErrorState';
@@ -50,3 +54,5 @@ export type { MoneyProps } from './Money';
export type { StatusTimelineProps, TimelineNode, TimelineNodeState } from './StatusTimeline';
export type { JalaliDatePickerProps } from './JalaliDatePicker';
export type { JalaliDateFieldProps } from './JalaliDateField';
export type { JalaliDateIntentPickerProps } from './JalaliDateIntentPicker';
export type { StickyActionBarProps } from './StickyActionBar';
+3
View File
@@ -33,6 +33,7 @@ import RatingInput from './RatingInput';
import ReviewTagSelector from './ReviewTagSelector';
import VisitNoteCard from './VisitNoteCard';
import PatientHeader from './PatientHeader';
import VerificationPanel from './VerificationPanel';
export {
ProfileSummary,
@@ -68,6 +69,7 @@ export {
ReviewTagSelector,
VisitNoteCard,
PatientHeader,
VerificationPanel,
};
export type { PlaceholderScreenProps } from './PlaceholderScreen';
export type { OtpInputProps } from './OtpInput';
@@ -100,3 +102,4 @@ export type { RatingInputProps } from './RatingInput';
export type { ReviewTagSelectorProps } from './ReviewTagSelector';
export type { VisitNoteCardProps } from './VisitNoteCard';
export type { PatientHeaderProps } from './PatientHeader';
export type { VerificationPanelProps } from './VerificationPanel';
@@ -32,6 +32,8 @@ interface NurseSearchResultDto {
nurseName: string | null;
avatarUrl: string | null;
distanceKm: number | null;
/** REQ-040 (proposed) — not yet served; absent until the backend lands it. */
topReviewTag?: string | null;
}
/** The b6/b7 aggregated `NursePublicProfileDto` (REQ-012) — the C3 profile payload. */
@@ -106,6 +108,7 @@ export const searchClientApi: SearchApi = {
nurseGender: dto.nurseGender,
cityId: dto.cityId,
districtId: dto.districtId,
topReviewTag: dto.topReviewTag ?? null,
})),
};
},
+3 -1
View File
@@ -16,7 +16,9 @@ interface ParamReader {
const GENDERS: readonly NurseGender[] = ['male', 'female'];
function parsePositiveInt(raw: string | null): number | undefined {
/** Exported so callers that carry client-only params alongside the filter set (e.g. `province_id`,
* a UI-only cascading-select prefill hint not part of `NurseSearchFilters`) can reuse the same parse. */
export function parsePositiveInt(raw: string | null): number | undefined {
if (raw == null) return undefined;
const value = Number(raw);
return Number.isInteger(value) && value > 0 ? value : undefined;
+3
View File
@@ -74,6 +74,9 @@ export interface NurseSearchResult {
cityId: number;
/** `null` = the nurse covers the whole city. */
districtId: number | null;
/** One-line top review tag (e.g. «منظم و دقیق») optional, not yet served (REQ-040); `null`/absent
* hides the card's review-tag line. */
topReviewTag?: string | null;
}
/** One offered variant on the C3 profile — the bookable unit; reused by the ServicePriceRow. */