ui phase 5
This commit is contained in:
@@ -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 — 5–6
|
||||
* 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 }}>
|
||||
|
||||
+134
-61
@@ -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,
|
||||
|
||||
+98
-20
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user