backend phase 13 & frontend phase 6
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
import { Suspense } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppLoading, PlaceholderScreen } from '@/components';
|
||||
|
||||
/**
|
||||
* Booking-request handoff target — **DEFERRED → frontend-phase-7-b8**. C3's "درخواست رزرو" lands here
|
||||
* carrying the selected nurse + variant + the same-gender intent (`required_gender`, which becomes
|
||||
* `required_caregiver_gender` in b8) + city/category. f7 builds the actual request form; this placeholder
|
||||
* confirms the intent arrived so the CTA doesn't dead-end. `useSearchParams` needs a Suspense boundary.
|
||||
*/
|
||||
export default function BookingRequestPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<BookingRequestDeferred />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingRequestDeferred() {
|
||||
const t = useTranslations('booking');
|
||||
const params = useSearchParams();
|
||||
const gender = params.get('required_gender');
|
||||
const echo = t('handoff_echo', {
|
||||
nurse: params.get('nurse_id') ?? '—',
|
||||
variant: params.get('variant_id') ?? '—',
|
||||
gender: gender ? t(`gender_${gender}`) : t('gender_any'),
|
||||
});
|
||||
|
||||
return <PlaceholderScreen icon="bookings" title={t('request_title')} description={[t('deferred'), echo].join(' ')} />;
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
'use client';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Avatar, Box, Chip, Divider, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, ServicePriceRow, TrustBadge } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { useNurseProfile } from '@/services/search';
|
||||
import type { NurseProfile } from '@/services/search/types';
|
||||
|
||||
/**
|
||||
* C3 — Nurse profile (پروفایل پرستار): identity + trust badges (✓ تاییدشده, نظام پرستاری), attribute
|
||||
* chips, the priced services list (ServicePriceRow), and the latest-review snippet. The primary CTA
|
||||
* "درخواست رزرو" hands the selected nurse + variant + `required_caregiver_gender` + city/category to the
|
||||
* f7 booking route (the form itself is DEFERRED → f7). States: loading skeleton, not-found, error/retry.
|
||||
*/
|
||||
export default function NurseProfilePage() {
|
||||
const t = useTranslations('search');
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const routeParams = useParams<{ nurseId: string }>();
|
||||
const query = useSearchParams();
|
||||
|
||||
const nurseId = Number(routeParams.nurseId);
|
||||
const { data: profile, isLoading, isError, error, refetch } = useNurseProfile(
|
||||
Number.isInteger(nurseId) && nurseId > 0 ? nurseId : undefined,
|
||||
);
|
||||
|
||||
if (isLoading) return <ProfileSkeleton />;
|
||||
|
||||
if (isError) {
|
||||
const notFound = error instanceof ApiError && error.status === 404;
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
{notFound ? t('profile_not_found_title') : t('profile_error_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
||||
{notFound ? t('profile_not_found_body') : t('profile_error_body')}
|
||||
</Typography>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => (notFound ? router.push(`/${locale}${ROUTES.SEARCH}`) : refetch())}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{notFound ? t('profile_not_found_cta') : t('retry')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profile) return null;
|
||||
|
||||
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));
|
||||
if (variantId) params.set('variant_id', variantId);
|
||||
// The same-gender intent chosen on C1, carried BEFORE booking (becomes required_caregiver_gender in f7/b8).
|
||||
const requiredGender = query.get('required_gender');
|
||||
if (requiredGender) params.set('required_gender', requiredGender);
|
||||
const cityId = query.get('city_id');
|
||||
if (cityId) params.set('city_id', cityId);
|
||||
const categoryId = query.get('service_category_id');
|
||||
if (categoryId) params.set('service_category_id', categoryId);
|
||||
const date = query.get('date');
|
||||
if (date) params.set('date', date);
|
||||
router.push(`/${locale}${ROUTES.BOOKING_REQUEST}?${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 3 }}>
|
||||
<ProfileHeader profile={profile} />
|
||||
<AttributeChips profile={profile} />
|
||||
<ServicesSection profile={profile} />
|
||||
<LatestReview profile={profile} />
|
||||
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={requestBooking}
|
||||
startIcon="bookings"
|
||||
sx={{ m: 0, py: 1.5 }}
|
||||
>
|
||||
{t('request_booking')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileHeader({ profile }: { profile: NurseProfile }) {
|
||||
const t = useTranslations('search');
|
||||
const locale = useLocale();
|
||||
const name = profile.nurseName.trim() || t('unnamed_nurse');
|
||||
const rating = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(profile.averageRating);
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}>
|
||||
<Avatar
|
||||
src={profile.avatarUrl ?? undefined}
|
||||
sx={{ width: 72, height: 72, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700, fontSize: 28 }}
|
||||
>
|
||||
{name.charAt(0)}
|
||||
</Avatar>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="h5" component="h1">
|
||||
{name}
|
||||
</Typography>
|
||||
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
|
||||
<AppIcon icon="star" size={18} color="var(--bal-warning)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{rating}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('reviews_count', { count: profile.totalReviews })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||
<TrustBadge state="verified" />
|
||||
{profile.inoMembership ? (
|
||||
<Chip
|
||||
icon={<AppIcon icon="license" size={16} color="var(--bal-primary)" />}
|
||||
label={t('badge_ino')}
|
||||
sx={{ backgroundColor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{profile.bio ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{profile.bio}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function AttributeChips({ profile }: { profile: NurseProfile }) {
|
||||
const t = useTranslations('search');
|
||||
const locale = useLocale();
|
||||
const chips: string[] = [];
|
||||
if (profile.yearsExperience != null && profile.yearsExperience > 0) {
|
||||
const years = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(profile.yearsExperience);
|
||||
chips.push(t('years_experience', { years }));
|
||||
}
|
||||
for (const code of profile.attributeChips) {
|
||||
chips.push(t.has(`specialty_${code}`) ? t(`specialty_${code}`) : code);
|
||||
}
|
||||
if (chips.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||
{chips.map((label) => (
|
||||
<Chip key={label} label={label} variant="outlined" />
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function ServicesSection({ profile }: { profile: NurseProfile }) {
|
||||
const t = useTranslations('search');
|
||||
return (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('services_title')}
|
||||
</Typography>
|
||||
{profile.services.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('services_empty')}
|
||||
</Typography>
|
||||
) : (
|
||||
<Box>
|
||||
{profile.services.map((service) => (
|
||||
<ServicePriceRow
|
||||
key={service.variantId}
|
||||
displayName={service.displayName}
|
||||
priceIrr={service.priceIrr}
|
||||
priceUnit={service.priceUnit}
|
||||
sessionCount={service.sessionCount}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function LatestReview({ profile }: { profile: NurseProfile }) {
|
||||
const t = useTranslations('search');
|
||||
const locale = useLocale();
|
||||
const review = profile.latestReview;
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('latest_review_title')}
|
||||
</Typography>
|
||||
{!review ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('no_reviews')}
|
||||
</Typography>
|
||||
) : (
|
||||
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center', mb: 0.5 }}>
|
||||
<AppIcon icon="star" size={16} color="var(--bal-warning)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(review.rating)}
|
||||
</Typography>
|
||||
<Divider orientation="vertical" flexItem sx={{ mx: 1 }} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{review.authorMasked} · {formatShamsiDate(review.createdAt, locale)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2">{review.body}</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileSkeleton() {
|
||||
return (
|
||||
<Stack sx={{ gap: 3 }}>
|
||||
<Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}>
|
||||
<Skeleton variant="circular" width={72} height={72} />
|
||||
<Stack sx={{ gap: 1, flexGrow: 1 }}>
|
||||
<Skeleton variant="text" width="60%" height={32} />
|
||||
<Skeleton variant="text" width="40%" />
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Skeleton variant="rounded" height={72} />
|
||||
<Skeleton variant="rounded" height={120} />
|
||||
<Skeleton variant="rounded" height={96} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,220 @@
|
||||
'use client';
|
||||
import { Suspense } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppLoading, PlaceholderScreen } from '@/components';
|
||||
import { Suspense, type FunctionComponent, type ReactNode } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import {
|
||||
Box,
|
||||
InputAdornment,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
TextField,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading, CategoryTile } from '@/components';
|
||||
import CascadingRegionSelect from '@/components/geography/CascadingRegionSelect';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useServiceCategories } from '@/services/catalog';
|
||||
import { pickCatalogName } from '@/services/catalog/names';
|
||||
import { useNurseSearch } from '@/services/search';
|
||||
import { filtersToSearchParams } from '@/services/search/filterParams';
|
||||
import type { NurseGender } from '@/services/search/types';
|
||||
import { useSearchFilters } from './useSearchFilters';
|
||||
|
||||
/**
|
||||
* Search landing — **DEFERRED → frontend-phase-6-b7**. The A5 Home search bar and category tiles
|
||||
* navigate here carrying a `q` / `category_id`; f6 builds the actual results, filters, and nurse
|
||||
* cards. This placeholder just acknowledges the intent so the Home CTAs don't dead-end. `useSearchParams`
|
||||
* needs a Suspense boundary under static rendering.
|
||||
* C1 — Search & filter (جستجو و فیلتر): the discovery entry screen. Pick a care category (reusing the
|
||||
* f4 catalog grid), a city (reusing the f3 cascading region picker; district optional = whole city),
|
||||
* the **prominent same-gender facet**, and an optional Toman price range; a live result count drives the
|
||||
* "مشاهده N پرستار" CTA into C2. Availability (date) is intent-only at MVP — it is carried to booking,
|
||||
* never used to hard-filter results. `useSearchParams` needs a Suspense boundary under static rendering.
|
||||
*/
|
||||
export default function SearchPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<SearchDeferred />
|
||||
<SearchFilterScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchDeferred() {
|
||||
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 query = params.get('q');
|
||||
const categoryId = params.get('category_id');
|
||||
const echo = query ? t('query_echo', { query }) : categoryId ? t('category_echo') : undefined;
|
||||
|
||||
const initialCategoryRaw = Number(params.get('category_id'));
|
||||
const initialCategoryId = Number.isInteger(initialCategoryRaw) && initialCategoryRaw > 0 ? initialCategoryRaw : undefined;
|
||||
|
||||
const controller = useSearchFilters(initialCategoryId);
|
||||
const { data, isFetching } = useNurseSearch(controller.filters);
|
||||
const count = data?.total;
|
||||
|
||||
const goToResults = () => {
|
||||
const query = filtersToSearchParams(controller.filters);
|
||||
if (controller.dateIntent) query.set('date', controller.dateIntent);
|
||||
router.push(`/${locale}${ROUTES.SEARCH_RESULTS}?${query.toString()}`);
|
||||
};
|
||||
|
||||
const ctaLabel = !controller.isReady
|
||||
? t('cta_choose_category_city')
|
||||
: isFetching || count == null
|
||||
? t('cta_loading')
|
||||
: t('cta_view_results', { count });
|
||||
|
||||
return (
|
||||
<PlaceholderScreen
|
||||
icon="search"
|
||||
title={t('title')}
|
||||
description={[t('deferred'), echo].filter(Boolean).join(' ')}
|
||||
/>
|
||||
<Stack sx={{ gap: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<CategorySelect selectedId={controller.categoryId} onSelect={controller.setCategoryId} />
|
||||
|
||||
<FilterSection title={t('section_location')}>
|
||||
<CascadingRegionSelect value={controller.region} onChange={controller.setRegion} includeDistrict />
|
||||
</FilterSection>
|
||||
|
||||
<FilterSection title={t('section_gender')} hint={t('gender_hint')}>
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
fullWidth
|
||||
color="primary"
|
||||
value={controller.gender ?? 'any'}
|
||||
onChange={(_event, value: NurseGender | 'any' | null) => {
|
||||
if (value != null) controller.setGender(value === 'any' ? undefined : value);
|
||||
}}
|
||||
>
|
||||
{GENDER_OPTIONS.map((option) => (
|
||||
<ToggleButton key={option} value={option} sx={{ fontWeight: 700 }}>
|
||||
{t(`gender_${option}`)}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
</FilterSection>
|
||||
|
||||
<FilterSection title={t('section_date')} hint={t('date_hint')}>
|
||||
<TextField
|
||||
type="date"
|
||||
fullWidth
|
||||
value={controller.dateIntent}
|
||||
onChange={(event) => controller.setDateIntent(event.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
</FilterSection>
|
||||
|
||||
<FilterSection title={t('section_price')} hint={t('price_hint')}>
|
||||
<Stack direction="row" sx={{ gap: 2 }}>
|
||||
<PriceField
|
||||
label={t('price_min')}
|
||||
value={controller.priceMinToman}
|
||||
onChange={controller.setPriceMinToman}
|
||||
adornment={t('toman')}
|
||||
/>
|
||||
<PriceField
|
||||
label={t('price_max')}
|
||||
value={controller.priceMaxToman}
|
||||
onChange={controller.setPriceMaxToman}
|
||||
adornment={t('toman')}
|
||||
/>
|
||||
</Stack>
|
||||
</FilterSection>
|
||||
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={!controller.isReady}
|
||||
onClick={goToResults}
|
||||
startIcon="search"
|
||||
sx={{ m: 0, py: 1.5 }}
|
||||
>
|
||||
{ctaLabel}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const FilterSection: FunctionComponent<{ title: string; hint?: string; children: ReactNode }> = ({
|
||||
title,
|
||||
hint,
|
||||
children,
|
||||
}) => (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{hint ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{hint}
|
||||
</Typography>
|
||||
) : null}
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const PriceField: FunctionComponent<{
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
adornment: string;
|
||||
}> = ({ label, value, onChange, adornment }) => (
|
||||
<TextField
|
||||
label={label}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
inputMode="numeric"
|
||||
fullWidth
|
||||
slotProps={{
|
||||
input: { endAdornment: <InputAdornment position="end">{adornment}</InputAdornment> },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
/** The reused f4 category grid (data-driven from the cached catalog reference data), with selection. */
|
||||
const CategorySelect: FunctionComponent<{ selectedId: number | null; onSelect: (id: number) => void }> = ({
|
||||
selectedId,
|
||||
onSelect,
|
||||
}) => {
|
||||
const t = useTranslations('search');
|
||||
const locale = useLocale();
|
||||
const { data, isLoading, isError } = useServiceCategories();
|
||||
const categories = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<FilterSection title={t('section_category')}>
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
|
||||
{[0, 1, 2, 3].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
|
||||
))}
|
||||
</Box>
|
||||
) : isError ? (
|
||||
<Paper elevation={0} sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('categories_error')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
|
||||
{categories.map((category) => (
|
||||
<CategoryTile
|
||||
key={category.id}
|
||||
label={pickCatalogName(category, locale)}
|
||||
iconKey={category.iconKey}
|
||||
selected={category.id === selectedId}
|
||||
onClick={() => onSelect(category.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</FilterSection>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
import { Suspense, useCallback, useMemo, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, MenuItem, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading, NurseResultCard } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useNurseSearch } from '@/services/search';
|
||||
import { searchParamsToFilters } from '@/services/search/filterParams';
|
||||
import { SEARCH_PAGE_SIZE } from '@/services/search/constants';
|
||||
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.
|
||||
*/
|
||||
export default function SearchResultsPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<ResultsScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultsScreen() {
|
||||
const t = useTranslations('search');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
|
||||
const [pageSize, setPageSize] = useState(SEARCH_PAGE_SIZE);
|
||||
|
||||
// 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 { data, isLoading, isError, isFetching, refetch } = useNurseSearch(filters);
|
||||
const items = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const hasMore = items.length < total;
|
||||
|
||||
const openProfile = useCallback(
|
||||
(nurse: NurseSearchResult) => {
|
||||
const query = new URLSearchParams();
|
||||
query.set('variant_id', String(nurse.variantId));
|
||||
query.set('service_category_id', String(filters.serviceCategoryId));
|
||||
query.set('city_id', String(filters.cityId));
|
||||
if (filters.nurseGender) query.set('required_gender', filters.nurseGender);
|
||||
if (dateIntent) query.set('date', dateIntent);
|
||||
router.push(`/${locale}${ROUTES.SEARCH_NURSE}/${nurse.nurseId}?${query.toString()}`);
|
||||
},
|
||||
[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' }}>
|
||||
<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>
|
||||
</Stack>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{[0, 1, 2, 3].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={112} sx={{ borderRadius: 2 }} />
|
||||
))}
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
||||
{t('results_error')}
|
||||
</Typography>
|
||||
<AppButton variant="outlined" color="primary" onClick={() => refetch()} sx={{ m: 0 }}>
|
||||
{t('retry')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState onRelax={backToFilters} />
|
||||
) : (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{items.map((nurse) => (
|
||||
<NurseResultCard key={`${nurse.nurseId}-${nurse.variantId}`} nurse={nurse} onSelect={openProfile} />
|
||||
))}
|
||||
{hasMore ? (
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => setPageSize((size) => size + SEARCH_PAGE_SIZE)}
|
||||
disabled={isFetching}
|
||||
sx={{ m: 0, alignSelf: 'center' }}
|
||||
>
|
||||
{t('load_more')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** The "no nurses match → relax your filters" state with concrete, product-aligned suggestions. */
|
||||
function EmptyState({ onRelax }: { onRelax: () => void }) {
|
||||
const t = useTranslations('search');
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<AppIcon icon="search" size={40} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1 }}>
|
||||
{t('empty_title')}
|
||||
</Typography>
|
||||
<Stack sx={{ gap: 0.5, mt: 1, mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('empty_suggest_gender')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('empty_suggest_district')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('empty_suggest_city')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<AppButton variant="contained" color="primary" onClick={onRelax} startIcon="tune" sx={{ m: 0 }}>
|
||||
{t('empty_cta')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { toEnglishDigits, tomanToRial } from '@/utils';
|
||||
import { useDebouncedValue } from '@/services/search';
|
||||
import { SEARCH_FILTER_DEBOUNCE_MS, SEARCH_PAGE_SIZE } from '@/services/search/constants';
|
||||
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 };
|
||||
|
||||
/** Toman input → IRR-Rial digit-string at the field boundary; undefined for blank/invalid input. */
|
||||
function tomanInputToIrr(toman: string): string | undefined {
|
||||
const digits = toEnglishDigits(toman).trim();
|
||||
if (!/^\d+$/.test(digits)) return undefined;
|
||||
return tomanToRial(digits);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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('');
|
||||
|
||||
const debouncedMin = useDebouncedValue(priceMinToman, SEARCH_FILTER_DEBOUNCE_MS);
|
||||
const debouncedMax = useDebouncedValue(priceMaxToman, SEARCH_FILTER_DEBOUNCE_MS);
|
||||
|
||||
const filters: NurseSearchFilters = useMemo(
|
||||
() => ({
|
||||
serviceCategoryId: categoryId ?? 0,
|
||||
cityId: region.cityId ?? 0,
|
||||
districtId: region.districtId ?? undefined,
|
||||
nurseGender: gender,
|
||||
priceMin: tomanInputToIrr(debouncedMin),
|
||||
priceMax: tomanInputToIrr(debouncedMax),
|
||||
sort: 'rating',
|
||||
page: 1,
|
||||
pageSize: SEARCH_PAGE_SIZE,
|
||||
}),
|
||||
[categoryId, region.cityId, region.districtId, gender, debouncedMin, debouncedMax],
|
||||
);
|
||||
|
||||
const isReady = filters.serviceCategoryId > 0 && filters.cityId > 0;
|
||||
|
||||
return {
|
||||
categoryId,
|
||||
setCategoryId,
|
||||
region,
|
||||
setRegion,
|
||||
gender,
|
||||
setGender,
|
||||
priceMinToman,
|
||||
setPriceMinToman,
|
||||
priceMaxToman,
|
||||
setPriceMaxToman,
|
||||
dateIntent,
|
||||
setDateIntent,
|
||||
filters,
|
||||
isReady,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
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.
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import NurseResultCard from './NurseResultCard';
|
||||
|
||||
const NURSE: NurseSearchResult = {
|
||||
nurseId: 1,
|
||||
variantId: 11,
|
||||
serviceCategoryId: 1,
|
||||
nurseName: 'Maryam Rezaei',
|
||||
avatarUrl: null,
|
||||
isVerified: true,
|
||||
averageRating: 4.9,
|
||||
totalReviews: 37,
|
||||
totalCompletedBookings: 52,
|
||||
distanceKm: 2.4,
|
||||
priceFromIrr: '2800000',
|
||||
priceUnit: 'per_hour',
|
||||
nurseGender: 'female',
|
||||
cityId: 101,
|
||||
districtId: 1003,
|
||||
};
|
||||
|
||||
function renderCard(nurse: NurseSearchResult, onSelect = jest.fn()) {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<NurseResultCard nurse={nurse} onSelect={onSelect} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
return onSelect;
|
||||
}
|
||||
|
||||
describe('<NurseResultCard/> component', () => {
|
||||
it('renders the name, the reused verified badge, and the rating', () => {
|
||||
renderCard(NURSE);
|
||||
expect(screen.getByText('Maryam Rezaei')).toBeInTheDocument();
|
||||
expect(screen.getByText('badge_verified')).toBeInTheDocument();
|
||||
expect(screen.getByText('4.9')).toBeInTheDocument();
|
||||
expect(screen.getByText('reviews_count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the "from" price line as grouped Toman via the money util', () => {
|
||||
renderCard(NURSE);
|
||||
expect(screen.getByText('price_from')).toBeInTheDocument();
|
||||
// 2,800,000 IRR = 280,000 Toman.
|
||||
expect(screen.getByText(/280,000/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the distance chip only when distanceKm is present', () => {
|
||||
const { rerender } = render(
|
||||
<ThemeProvider>
|
||||
<NurseResultCard nurse={NURSE} onSelect={jest.fn()} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByText('distance_km')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ThemeProvider>
|
||||
<NurseResultCard nurse={{ ...NURSE, distanceKm: null }} onSelect={jest.fn()} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.queryByText('distance_km')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to a label when the name is missing (b7 join gap)', () => {
|
||||
renderCard({ ...NURSE, nurseName: '' });
|
||||
expect(screen.getByText('unnamed_nurse')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSelect with the nurse row when clicked', () => {
|
||||
const onSelect = renderCard(NURSE);
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(onSelect).toHaveBeenCalledWith(NURSE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { memo } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Avatar, Box, Paper, Stack, Typography } from '@mui/material';
|
||||
import AppIcon from '../common/AppIcon';
|
||||
import TrustBadge from '../TrustBadge';
|
||||
import PriceDisplay from '../PriceDisplay';
|
||||
import type { NurseSearchResult } from '@/services/search/types';
|
||||
|
||||
export interface NurseResultCardProps {
|
||||
/** One search-result row (a bookable variant in a covered area). */
|
||||
nurse: NurseSearchResult;
|
||||
/** Tapping the card opens the nurse profile (C3), carrying the row (nurse + variant + gender intent). */
|
||||
onSelect: (nurse: NurseSearchResult) => void;
|
||||
}
|
||||
|
||||
function ratingText(rating: number, locale: string): string {
|
||||
return new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(rating);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @component NurseResultCard
|
||||
*/
|
||||
const NurseResultCard = ({ nurse, onSelect }: NurseResultCardProps) => {
|
||||
const t = useTranslations('search');
|
||||
const locale = useLocale();
|
||||
|
||||
const name = nurse.nurseName.trim() || t('unnamed_nurse');
|
||||
const initial = name.charAt(0);
|
||||
const distance =
|
||||
nurse.distanceKm != null
|
||||
? new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { maximumFractionDigits: 1 }).format(
|
||||
nurse.distanceKm,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
onClick={() => onSelect(nurse)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onSelect(nurse);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
alignItems: 'flex-start',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 120ms ease',
|
||||
'&:hover': { borderColor: 'var(--bal-primary)' },
|
||||
'&:focus-visible': { outline: '2px solid var(--bal-primary)', outlineOffset: 2 },
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
src={nurse.avatarUrl ?? undefined}
|
||||
sx={{ width: 56, height: 56, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}
|
||||
>
|
||||
{initial}
|
||||
</Avatar>
|
||||
|
||||
<Stack sx={{ gap: 0.75, 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" />
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
|
||||
<AppIcon icon="star" size={16} color="var(--bal-warning)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{ratingText(nurse.averageRating, locale)}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('reviews_count', { count: nurse.totalReviews })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{distance != null ? (
|
||||
<Stack direction="row" sx={{ gap: 0.25, alignItems: 'center' }}>
|
||||
<AppIcon icon="location" size={16} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('distance_km', { km: distance })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('price_from')}
|
||||
</Typography>
|
||||
<PriceDisplay price={nurse.priceFromIrr} priceUnit={nurse.priceUnit} align="start" />
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(NurseResultCard);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './NurseResultCard';
|
||||
export type { NurseResultCardProps } from './NurseResultCard';
|
||||
@@ -0,0 +1,36 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
|
||||
// next-intl is mocked to echo keys; locale = en so the money util groups with ASCII digits we can assert on.
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import ServicePriceRow from './ServicePriceRow';
|
||||
|
||||
function renderRow(props: React.ComponentProps<typeof ServicePriceRow>) {
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<ServicePriceRow {...props} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('<ServicePriceRow/> component', () => {
|
||||
it('renders the service name', () => {
|
||||
renderRow({ displayName: 'Daytime elderly care', priceIrr: '2800000', priceUnit: 'per_hour' });
|
||||
expect(screen.getByText('Daytime elderly care')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the price as grouped Toman via the shared money display', () => {
|
||||
// 2,800,000 IRR = 280,000 Toman.
|
||||
renderRow({ displayName: 'Daytime elderly care', priceIrr: '2800000', priceUnit: 'per_hour' });
|
||||
expect(screen.getByText(/280,000/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the unit label off the price_unit code (never hardcoded)', () => {
|
||||
renderRow({ displayName: 'Live-in care', priceIrr: '85000000', priceUnit: 'per_24h' });
|
||||
expect(screen.getByText('unit_per_24h')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import PriceDisplay from '../PriceDisplay';
|
||||
import type { PriceUnit } from '@/services/catalog/types';
|
||||
|
||||
export interface ServicePriceRowProps {
|
||||
/** The variant/service name, already localised by the caller. */
|
||||
displayName: string;
|
||||
/** IRR Rials as a digit-string (wire shape); rendered as Toman via the money util in PriceDisplay. */
|
||||
priceIrr: string;
|
||||
/** Drives the unit label — an i18n key off the code, never hardcoded. */
|
||||
priceUnit: PriceUnit;
|
||||
/** Duration/count carried for later booking-summary reuse; not shown as a total here. */
|
||||
sessionCount?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One offered-service line: the service name on the start edge, the priced rate on the end edge. The
|
||||
* money + unit label render through the shared `PriceDisplay` (which uses the f0 money util and the
|
||||
* i18n `catalog` unit labels) — never re-implemented here. Used on the C3 nurse profile now and reused
|
||||
* by the booking summary (f7+).
|
||||
* @component ServicePriceRow
|
||||
*/
|
||||
const ServicePriceRow: FunctionComponent<ServicePriceRowProps> = ({
|
||||
displayName,
|
||||
priceIrr,
|
||||
priceUnit,
|
||||
sessionCount,
|
||||
}) => (
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
gap: 2,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
py: 1.5,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" sx={{ fontWeight: 600, flexGrow: 1 }}>
|
||||
{displayName}
|
||||
</Typography>
|
||||
<PriceDisplay price={priceIrr} priceUnit={priceUnit} sessionCount={sessionCount} align="start" />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export default ServicePriceRow;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './ServicePriceRow';
|
||||
export type { ServicePriceRowProps } from './ServicePriceRow';
|
||||
@@ -55,6 +55,9 @@ import RefreshIcon from '@mui/icons-material/RefreshOutlined';
|
||||
import IdentityIcon from '@mui/icons-material/BadgeOutlined';
|
||||
import LicenseIcon from '@mui/icons-material/WorkspacePremiumOutlined';
|
||||
import PublishIcon from '@mui/icons-material/RocketLaunchOutlined';
|
||||
// Search & discovery — the customer nurse-finding flow (f6/b7): rating star, filter controls
|
||||
import StarIcon from '@mui/icons-material/Star';
|
||||
import TuneIcon from '@mui/icons-material/TuneOutlined';
|
||||
|
||||
/**
|
||||
* List of all available Icon names
|
||||
@@ -123,4 +126,6 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
|
||||
identity: IdentityIcon,
|
||||
license: LicenseIcon,
|
||||
publish: PublishIcon,
|
||||
star: StarIcon,
|
||||
tune: TuneIcon,
|
||||
};
|
||||
|
||||
@@ -17,6 +17,8 @@ import PriceDisplay from './PriceDisplay';
|
||||
import VariantCard from './VariantCard';
|
||||
import TrustBadge from './TrustBadge';
|
||||
import DocumentUpload from './DocumentUpload';
|
||||
import NurseResultCard from './NurseResultCard';
|
||||
import ServicePriceRow from './ServicePriceRow';
|
||||
|
||||
export {
|
||||
UserInfo,
|
||||
@@ -36,6 +38,8 @@ export {
|
||||
VariantCard,
|
||||
TrustBadge,
|
||||
DocumentUpload,
|
||||
NurseResultCard,
|
||||
ServicePriceRow,
|
||||
};
|
||||
export type { PlaceholderScreenProps } from './PlaceholderScreen';
|
||||
export type { OtpInputProps } from './OtpInput';
|
||||
@@ -53,3 +57,5 @@ export type { PriceDisplayProps } from './PriceDisplay';
|
||||
export type { VariantCardProps } from './VariantCard';
|
||||
export type { TrustBadgeProps } from './TrustBadge';
|
||||
export type { DocumentUploadProps, UploadedDocInfo } from './DocumentUpload';
|
||||
export type { NurseResultCardProps } from './NurseResultCard';
|
||||
export type { ServicePriceRowProps } from './ServicePriceRow';
|
||||
|
||||
@@ -7,9 +7,15 @@ export const ROUTES = {
|
||||
HOME: '/',
|
||||
// First-login "who is care for?" flow (A3→A4); re-enterable from the patient list.
|
||||
ONBOARDING: '/onboarding',
|
||||
// Search & discovery — the Home search bar + category tiles navigate here (results built in f6).
|
||||
// Search & discovery (f6) — C1 filter screen; the Home search bar + category tiles navigate here.
|
||||
SEARCH: '/search',
|
||||
// C2 results list — C1 pushes here carrying the filter set as query params (the deep-linkable key).
|
||||
SEARCH_RESULTS: '/search/results',
|
||||
// C3 nurse profile base — append `/{nurseId}` (results cards + the booking handoff read this).
|
||||
SEARCH_NURSE: '/search/nurse',
|
||||
BOOKINGS: '/bookings',
|
||||
// Booking-request handoff target (f7 owns the form) — C3's "درخواست رزرو" lands here with intent.
|
||||
BOOKING_REQUEST: '/bookings/request',
|
||||
PATIENTS: '/patients',
|
||||
// Address book — cascading region dropdowns + map-pin picker; reached from the profile hub.
|
||||
ADDRESSES: '/addresses',
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { clientFetch } from '@/lib/api/client';
|
||||
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
|
||||
import type { PriceUnit } from '@/services/catalog/types';
|
||||
import type { TrustBadge } from '@/services/verification/types';
|
||||
import { SEARCH_PAGE_SIZE } from '../constants';
|
||||
import type {
|
||||
NurseGender,
|
||||
NurseProfile,
|
||||
NurseSearchFilters,
|
||||
NurseSearchResult,
|
||||
SearchApi,
|
||||
} from '../types';
|
||||
|
||||
const SEARCH_BASE = '/api/v1/search';
|
||||
const NURSES_BASE = '/api/v1/nurses';
|
||||
|
||||
/** The b7 `NurseSearchResultDto` (the projected index row) — the exact wire shape we map from. */
|
||||
interface NurseSearchResultDto {
|
||||
variantId: number;
|
||||
nurseId: number;
|
||||
serviceCategoryId: number;
|
||||
price: string;
|
||||
priceUnit: PriceUnit;
|
||||
nurseGender: NurseGender;
|
||||
averageRating: number;
|
||||
totalReviews: number;
|
||||
totalCompletedBookings: number;
|
||||
cityId: number;
|
||||
districtId: number | null;
|
||||
}
|
||||
|
||||
/** The INO-membership credential type code (see b6 verification). */
|
||||
const INO_MEMBERSHIP_CODE = 'ino_membership';
|
||||
|
||||
/**
|
||||
* Real HTTP implementation of the `SearchApi` seam (b7 `search/nurses`, b6 trust badge). Routes are
|
||||
* action-style + snake_case; query params are snake_case per the contract; JSON fields are camelCase and
|
||||
* `clientFetch` returns the raw envelope, so we `unwrap()`.
|
||||
*
|
||||
* NOT the primary implementation this phase (`USE_SEARCH_MOCK = true`): b7's index row omits the nurse
|
||||
* **display name, avatar, and distance** the C2 card renders, and there is **no** aggregated
|
||||
* nurse-profile endpoint (name/bio/specialties/full services list/latest review) for C3 — only the b6
|
||||
* trust badge is public. Both gaps are filed in
|
||||
* `dev/shared-working-context/frontend/requests/for-backend.md`. This client maps everything b7/b6
|
||||
* currently provide (leaving the missing fields blank) so the swap is a single config flip once the
|
||||
* backend lands the join + profile route.
|
||||
*/
|
||||
export const searchClientApi: SearchApi = {
|
||||
searchNurses: async (filters: NurseSearchFilters): Promise<Paginated<NurseSearchResult>> => {
|
||||
const query = new URLSearchParams();
|
||||
query.set('service_category_id', String(filters.serviceCategoryId));
|
||||
query.set('city_id', String(filters.cityId));
|
||||
if (filters.districtId != null) query.set('district_id', String(filters.districtId));
|
||||
if (filters.nurseGender) query.set('nurse_gender', filters.nurseGender);
|
||||
if (filters.priceMin) query.set('min_price', filters.priceMin);
|
||||
if (filters.priceMax) query.set('max_price', filters.priceMax);
|
||||
if (filters.priceUnit) query.set('price_unit', filters.priceUnit);
|
||||
query.set('page', String(filters.page || 1));
|
||||
query.set('page_size', String(filters.pageSize || SEARCH_PAGE_SIZE));
|
||||
|
||||
const paged = unwrap(
|
||||
await clientFetch<ApiEnvelope<Paginated<NurseSearchResultDto>>>(
|
||||
`${SEARCH_BASE}/nurses?${query.toString()}`,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
...paged,
|
||||
items: paged.items.map((dto) => ({
|
||||
nurseId: dto.nurseId,
|
||||
variantId: dto.variantId,
|
||||
serviceCategoryId: dto.serviceCategoryId,
|
||||
// Gap (filed): b7 does not yet join the nurse's name/avatar; the card falls back to a label.
|
||||
nurseName: '',
|
||||
avatarUrl: null,
|
||||
// Every returned row is searchable by the index invariant.
|
||||
isVerified: true,
|
||||
averageRating: dto.averageRating,
|
||||
totalReviews: dto.totalReviews,
|
||||
totalCompletedBookings: dto.totalCompletedBookings,
|
||||
// Gap (filed): no geo-distance in the index row yet.
|
||||
distanceKm: null,
|
||||
priceFromIrr: dto.price,
|
||||
priceUnit: dto.priceUnit,
|
||||
nurseGender: dto.nurseGender,
|
||||
cityId: dto.cityId,
|
||||
districtId: dto.districtId,
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
getNurseProfile: async (nurseId: number): Promise<NurseProfile> => {
|
||||
// Only the public trust badge is available today; the aggregated profile (name/bio/specialties/
|
||||
// services list/latest review) is filed for the backend. Compose what b6 exposes; leave the rest blank.
|
||||
const badge = unwrap(
|
||||
await clientFetch<ApiEnvelope<TrustBadge>>(`${NURSES_BASE}/${nurseId}/trust_badge`),
|
||||
);
|
||||
|
||||
return {
|
||||
nurseId: badge.nurseId,
|
||||
nurseName: '',
|
||||
avatarUrl: null,
|
||||
bio: null,
|
||||
yearsExperience: null,
|
||||
averageRating: 0,
|
||||
totalReviews: 0,
|
||||
totalCompletedBookings: 0,
|
||||
isVerified: badge.isVerified,
|
||||
inoMembership: badge.credentialTypes.includes(INO_MEMBERSHIP_CODE),
|
||||
attributeChips: badge.credentialTypes,
|
||||
services: [],
|
||||
latestReview: null,
|
||||
nurseGender: 'female',
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { USE_SEARCH_MOCK } from '../constants';
|
||||
import type { SearchApi } from '../types';
|
||||
import { searchClientApi } from './clientApi';
|
||||
import { searchMockApi } from './mockApi';
|
||||
|
||||
/**
|
||||
* The selected SearchApi implementation — the single seam the hooks import. Selection is by config
|
||||
* (USE_SEARCH_MOCK), never by scattered `if (mock)` checks.
|
||||
*/
|
||||
export const searchApi: SearchApi = USE_SEARCH_MOCK ? searchMockApi : searchClientApi;
|
||||
@@ -0,0 +1,130 @@
|
||||
import { sleep } from '@/utils';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import type { Paginated } from '@/lib/api/types';
|
||||
import { SEARCH_PAGE_SIZE } from '../constants';
|
||||
import type {
|
||||
NurseProfile,
|
||||
NurseProfileServiceRow,
|
||||
NurseSearchFilters,
|
||||
NurseSearchResult,
|
||||
SearchApi,
|
||||
} from '../types';
|
||||
import { SEED_NURSES, type SeedNurse, type SeedVariant } from './seed';
|
||||
|
||||
const MOCK_LATENCY_MS = 300;
|
||||
|
||||
/** Flatten every seeded nurse's variants into candidate search rows (one row per variant×area). */
|
||||
function allRows(): { nurse: SeedNurse; variant: SeedVariant }[] {
|
||||
return SEED_NURSES.flatMap((nurse) => nurse.variants.map((variant) => ({ nurse, variant })));
|
||||
}
|
||||
|
||||
/**
|
||||
* The b7 geography rule: a **city-only** search (no `districtId`) matches every row in the city; a
|
||||
* **district** search matches that district's rows **plus** whole-city (`null`) rows.
|
||||
*/
|
||||
function matchesDistrict(rowDistrictId: number | null, filterDistrictId?: number): boolean {
|
||||
if (filterDistrictId == null) return true;
|
||||
return rowDistrictId === filterDistrictId || rowDistrictId === null;
|
||||
}
|
||||
|
||||
function withinPrice(priceIrr: string, min?: string, max?: string): boolean {
|
||||
const value = BigInt(priceIrr);
|
||||
if (min != null && min !== '' && value < BigInt(min)) return false;
|
||||
if (max != null && max !== '' && value > BigInt(max)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function toResult(nurse: SeedNurse, variant: SeedVariant): NurseSearchResult {
|
||||
return {
|
||||
nurseId: nurse.nurseId,
|
||||
variantId: variant.variantId,
|
||||
serviceCategoryId: variant.serviceCategoryId,
|
||||
nurseName: nurse.nurseName,
|
||||
avatarUrl: nurse.avatarUrl,
|
||||
isVerified: true,
|
||||
averageRating: nurse.averageRating,
|
||||
totalReviews: nurse.totalReviews,
|
||||
totalCompletedBookings: nurse.totalCompletedBookings,
|
||||
distanceKm: variant.distanceKm,
|
||||
priceFromIrr: variant.priceIrr,
|
||||
priceUnit: variant.priceUnit,
|
||||
nurseGender: nurse.gender,
|
||||
cityId: variant.cityId,
|
||||
districtId: variant.districtId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory mock behind the `SearchApi` seam. Reproduces the b7 filter + geography + rating-sort
|
||||
* semantics over verified-only fixtures, so C1/C2/C3 (incl. the empty state and the caching revert)
|
||||
* demo end-to-end. Mirrors the real shapes for a one-line swap once the backend join/profile endpoints
|
||||
* land (`USE_SEARCH_MOCK = false`).
|
||||
*/
|
||||
export const searchMockApi: SearchApi = {
|
||||
searchNurses: async (filters: NurseSearchFilters): Promise<Paginated<NurseSearchResult>> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
|
||||
if (!(filters.serviceCategoryId > 0) || !(filters.cityId > 0)) {
|
||||
throw new ApiError(400, 'service_category_id and city_id are required', 'invalid_filters');
|
||||
}
|
||||
if (filters.priceMin && filters.priceMax && BigInt(filters.priceMin) > BigInt(filters.priceMax)) {
|
||||
throw new ApiError(400, 'min_price must not exceed max_price', 'invalid_price_range');
|
||||
}
|
||||
|
||||
const matched = allRows()
|
||||
.filter(({ nurse, variant }) => {
|
||||
if (variant.serviceCategoryId !== filters.serviceCategoryId) return false;
|
||||
if (variant.cityId !== filters.cityId) return false;
|
||||
if (!matchesDistrict(variant.districtId, filters.districtId)) return false;
|
||||
if (filters.nurseGender && nurse.gender !== filters.nurseGender) return false;
|
||||
if (filters.priceUnit && variant.priceUnit !== filters.priceUnit) return false;
|
||||
if (!withinPrice(variant.priceIrr, filters.priceMin, filters.priceMax)) return false;
|
||||
return true;
|
||||
})
|
||||
// Rating desc, tiebroken by review count then ids so paging is deterministic (contract order).
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.nurse.averageRating - a.nurse.averageRating ||
|
||||
b.nurse.totalReviews - a.nurse.totalReviews ||
|
||||
a.nurse.nurseId - b.nurse.nurseId ||
|
||||
a.variant.variantId - b.variant.variantId,
|
||||
)
|
||||
.map(({ nurse, variant }) => toResult(nurse, variant));
|
||||
|
||||
const pageSize = filters.pageSize || SEARCH_PAGE_SIZE;
|
||||
const page = filters.page || 1;
|
||||
const start = (page - 1) * pageSize;
|
||||
return { items: matched.slice(start, start + pageSize), total: matched.length, page, pageSize };
|
||||
},
|
||||
|
||||
getNurseProfile: async (nurseId: number): Promise<NurseProfile> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const nurse = SEED_NURSES.find((candidate) => candidate.nurseId === nurseId);
|
||||
if (!nurse) throw new ApiError(404, 'Nurse not found', 'not_found');
|
||||
|
||||
const services: NurseProfileServiceRow[] = nurse.variants.map((variant) => ({
|
||||
variantId: variant.variantId,
|
||||
displayName: variant.displayName,
|
||||
priceIrr: variant.priceIrr,
|
||||
priceUnit: variant.priceUnit,
|
||||
sessionCount: variant.sessionCount,
|
||||
}));
|
||||
|
||||
return {
|
||||
nurseId: nurse.nurseId,
|
||||
nurseName: nurse.nurseName,
|
||||
avatarUrl: nurse.avatarUrl,
|
||||
bio: nurse.bio,
|
||||
yearsExperience: nurse.yearsExperience,
|
||||
averageRating: nurse.averageRating,
|
||||
totalReviews: nurse.totalReviews,
|
||||
totalCompletedBookings: nurse.totalCompletedBookings,
|
||||
isVerified: true,
|
||||
inoMembership: nurse.inoMembership,
|
||||
attributeChips: nurse.attributeChips,
|
||||
services,
|
||||
latestReview: nurse.latestReview,
|
||||
nurseGender: nurse.gender,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { PriceUnit } from '@/services/catalog/types';
|
||||
import type { NurseGender, NurseReviewSnippet } from '../types';
|
||||
|
||||
/**
|
||||
* Canned discovery fixtures for the client-side mock — real-shaped verified nurses so C1/C2/C3 demo
|
||||
* before the backend join/profile endpoints land. Ids align with the sibling mocks so the end-to-end
|
||||
* flow works with the geo picker + category grid: `serviceCategoryId` uses the catalog seed
|
||||
* (1 = elderly, 2 = post-surgery, 3 = infant, 4 = chronic), `cityId`/`districtId` use the geography
|
||||
* seed (Tehran = 101 with districts 1001…1022, Karaj = 801, whole-city = `null`). Mashhad/Isfahan/Shiraz
|
||||
* are intentionally left with **no** nurses so the C2 "relax your filters" empty state is reachable.
|
||||
*
|
||||
* Every nurse here is verified + accepting by construction (the invariant the real index enforces), so
|
||||
* the mock never returns an unverified row. `price` is IRR Rials as a digit-string (Toman × 10).
|
||||
* Avatars are `null` on purpose (initials fallback) to keep the demo self-contained — no remote images.
|
||||
*/
|
||||
|
||||
/** One priced, bookable offering of a seeded nurse, matched in a covered area. */
|
||||
export interface SeedVariant {
|
||||
variantId: number;
|
||||
serviceCategoryId: number;
|
||||
displayName: string;
|
||||
priceIrr: string;
|
||||
priceUnit: PriceUnit;
|
||||
sessionCount: number | null;
|
||||
cityId: number;
|
||||
/** `null` = the nurse covers the whole city. */
|
||||
districtId: number | null;
|
||||
/** Approximate distance from the searched area (mock-only stand-in for a future geo-distance join). */
|
||||
distanceKm: number | null;
|
||||
}
|
||||
|
||||
/** A seeded verified nurse + their offerings and latest review (the mock's source of truth). */
|
||||
export interface SeedNurse {
|
||||
nurseId: number;
|
||||
nurseName: string;
|
||||
avatarUrl: string | null;
|
||||
bio: string;
|
||||
yearsExperience: number;
|
||||
gender: NurseGender;
|
||||
averageRating: number;
|
||||
totalReviews: number;
|
||||
totalCompletedBookings: number;
|
||||
inoMembership: boolean;
|
||||
/** Specialty codes → i18n labels (never rendered raw). */
|
||||
attributeChips: string[];
|
||||
variants: SeedVariant[];
|
||||
latestReview: NurseReviewSnippet | null;
|
||||
}
|
||||
|
||||
export const SEED_NURSES: SeedNurse[] = [
|
||||
{
|
||||
nurseId: 1,
|
||||
nurseName: 'مریم رضایی',
|
||||
avatarUrl: null,
|
||||
bio: 'پرستار سالمند با تمرکز بر مراقبتهای شبانهروزی و پانسمان زخم.',
|
||||
yearsExperience: 8,
|
||||
gender: 'female',
|
||||
averageRating: 4.9,
|
||||
totalReviews: 37,
|
||||
totalCompletedBookings: 52,
|
||||
inoMembership: true,
|
||||
attributeChips: ['elderly', 'wound_care'],
|
||||
variants: [
|
||||
{ variantId: 11, serviceCategoryId: 1, displayName: 'مراقبت روزانه سالمند', priceIrr: '2800000', priceUnit: 'per_hour', sessionCount: null, cityId: 101, districtId: 1003, distanceKm: 2.4 },
|
||||
{ variantId: 12, serviceCategoryId: 1, displayName: 'مراقبت شبانهروزی سالمند', priceIrr: '85000000', priceUnit: 'per_24h', sessionCount: null, cityId: 101, districtId: 1003, distanceKm: 2.4 },
|
||||
],
|
||||
latestReview: {
|
||||
rating: 5,
|
||||
body: 'بسیار دلسوز و منظم بودند. مادرم کاملاً راضی بود.',
|
||||
authorMasked: 'ز. م.',
|
||||
createdAt: '2026-06-20T09:30:00Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
nurseId: 2,
|
||||
nurseName: 'سارا احمدی',
|
||||
avatarUrl: null,
|
||||
bio: 'پرستار مراقبت از سالمند و بیماریهای مزمن، فعال در سراسر شهر تهران.',
|
||||
yearsExperience: 6,
|
||||
gender: 'female',
|
||||
averageRating: 4.7,
|
||||
totalReviews: 21,
|
||||
totalCompletedBookings: 33,
|
||||
inoMembership: true,
|
||||
attributeChips: ['elderly', 'icu'],
|
||||
variants: [
|
||||
{ variantId: 21, serviceCategoryId: 1, displayName: 'مراقبت روزانه سالمند', priceIrr: '2500000', priceUnit: 'per_hour', sessionCount: null, cityId: 101, districtId: null, distanceKm: 5.1 },
|
||||
{ variantId: 22, serviceCategoryId: 4, displayName: 'مدیریت بیماری مزمن', priceIrr: '30000000', priceUnit: 'per_day', sessionCount: null, cityId: 101, districtId: null, distanceKm: 5.1 },
|
||||
],
|
||||
latestReview: {
|
||||
rating: 5,
|
||||
body: 'برخورد حرفهای و بهموقع. حتماً دوباره درخواست میدهم.',
|
||||
authorMasked: 'م. ک.',
|
||||
createdAt: '2026-06-28T14:10:00Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
nurseId: 3,
|
||||
nurseName: 'زهرا موسوی',
|
||||
avatarUrl: null,
|
||||
bio: 'متخصص مراقبت پس از جراحی و پانسمان تخصصی زخم.',
|
||||
yearsExperience: 10,
|
||||
gender: 'female',
|
||||
averageRating: 4.8,
|
||||
totalReviews: 44,
|
||||
totalCompletedBookings: 61,
|
||||
inoMembership: true,
|
||||
attributeChips: ['post_surgery', 'wound_care'],
|
||||
variants: [
|
||||
{ variantId: 31, serviceCategoryId: 2, displayName: 'مراقبت پس از جراحی', priceIrr: '3200000', priceUnit: 'per_hour', sessionCount: null, cityId: 101, districtId: 1005, distanceKm: 3.8 },
|
||||
],
|
||||
latestReview: {
|
||||
rating: 4,
|
||||
body: 'مراقبت خوبی داشتند، فقط کمی دیر رسیدند.',
|
||||
authorMasked: 'ح. ر.',
|
||||
createdAt: '2026-05-30T11:00:00Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
nurseId: 4,
|
||||
nurseName: 'علی کریمی',
|
||||
avatarUrl: null,
|
||||
bio: 'پرستار مراقبتهای ویژه و مدیریت بیماریهای مزمن.',
|
||||
yearsExperience: 7,
|
||||
gender: 'male',
|
||||
averageRating: 4.6,
|
||||
totalReviews: 18,
|
||||
totalCompletedBookings: 27,
|
||||
inoMembership: false,
|
||||
attributeChips: ['icu'],
|
||||
variants: [
|
||||
{ variantId: 41, serviceCategoryId: 4, displayName: 'مدیریت بیماری مزمن', priceIrr: '2900000', priceUnit: 'per_hour', sessionCount: null, cityId: 101, districtId: 1002, distanceKm: 6.7 },
|
||||
],
|
||||
latestReview: {
|
||||
rating: 5,
|
||||
body: 'دقیق و مسئولیتپذیر. پیگیری داروها عالی بود.',
|
||||
authorMasked: 'ع. ن.',
|
||||
createdAt: '2026-06-15T08:45:00Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
nurseId: 5,
|
||||
nurseName: 'رضا حسینی',
|
||||
avatarUrl: null,
|
||||
bio: 'پرستار سالمند در کرج، فعال در تمام مناطق شهر.',
|
||||
yearsExperience: 5,
|
||||
gender: 'male',
|
||||
averageRating: 4.5,
|
||||
totalReviews: 12,
|
||||
totalCompletedBookings: 19,
|
||||
inoMembership: false,
|
||||
attributeChips: ['elderly'],
|
||||
variants: [
|
||||
{ variantId: 51, serviceCategoryId: 1, displayName: 'مراقبت روزانه سالمند', priceIrr: '2200000', priceUnit: 'per_hour', sessionCount: null, cityId: 801, districtId: null, distanceKm: null },
|
||||
],
|
||||
latestReview: null,
|
||||
},
|
||||
{
|
||||
nurseId: 6,
|
||||
nurseName: 'فاطمه صادقی',
|
||||
avatarUrl: null,
|
||||
bio: 'پرستار نوزاد با تجربه در مراقبت روزانه و شبانه.',
|
||||
yearsExperience: 9,
|
||||
gender: 'female',
|
||||
averageRating: 4.9,
|
||||
totalReviews: 29,
|
||||
totalCompletedBookings: 40,
|
||||
inoMembership: true,
|
||||
attributeChips: ['pediatric'],
|
||||
variants: [
|
||||
{ variantId: 61, serviceCategoryId: 3, displayName: 'مراقبت روزانه نوزاد', priceIrr: '3000000', priceUnit: 'per_hour', sessionCount: null, cityId: 101, districtId: 1008, distanceKm: 4.2 },
|
||||
],
|
||||
latestReview: {
|
||||
rating: 5,
|
||||
body: 'با نوزاد ما فوقالعاده مهربان بودند. بسیار حرفهای.',
|
||||
authorMasked: 'س. ط.',
|
||||
createdAt: '2026-07-01T16:20:00Z',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* When true, the search domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
|
||||
* `SearchApi` seam. **Mock is primary this phase:** b7's search-index row and the b5/b6 reads do not
|
||||
* yet expose the display name, avatar, distance, bio, specialties, full services list, or latest review
|
||||
* that C2/C3 render (gap filed in `dev/shared-working-context/frontend/requests/for-backend.md`). The
|
||||
* mock supplies real-shaped fixtures so C1/C2/C3 demo end-to-end. Flip to false once the backend fills
|
||||
* the gap — no hook/component changes (see `dev/shared-working-context/reports/frontend-phase-6-report.md`).
|
||||
*/
|
||||
export const USE_SEARCH_MOCK = true;
|
||||
|
||||
/**
|
||||
* Results are read-heavy and change slowly, so a revisit (or a filter **revert**) serves from cache
|
||||
* within the stale window instead of refetching — the headline caching behaviour of this phase. A
|
||||
* generous `gcTime` keeps prior filter sets warm so back/forward navigation is instant.
|
||||
*/
|
||||
export const SEARCH_RESULTS_STALE_TIME = 5 * 60 * 1000; // 5m
|
||||
export const SEARCH_PROFILE_STALE_TIME = 5 * 60 * 1000; // 5m
|
||||
export const SEARCH_GC_TIME = 30 * 60 * 1000; // 30m
|
||||
|
||||
/** api-conventions default/max page sizes (max 100 server-side); a page of result cards. */
|
||||
export const SEARCH_PAGE_SIZE = 20;
|
||||
|
||||
/** Debounce window for the price-range inputs so keystrokes don't fan out one request per character. */
|
||||
export const SEARCH_FILTER_DEBOUNCE_MS = 400;
|
||||
@@ -0,0 +1,69 @@
|
||||
import { PRICE_UNITS, type PriceUnit } from '@/services/catalog/types';
|
||||
import { SEARCH_PAGE_SIZE } from './constants';
|
||||
import type { NurseGender, NurseSearchFilters } from './types';
|
||||
|
||||
/**
|
||||
* Single source of truth for the C1 → C2 filter **query string** (snake_case, matching the b7 contract
|
||||
* params), so C1 (which writes the URL) and C2 (which reads it via `useSearchParams`) never drift. The
|
||||
* URL is the deep-linkable, back/forward-safe carrier of the filter set; C2 turns it back into a
|
||||
* `NurseSearchFilters`, which is what becomes the React Query cache key.
|
||||
*/
|
||||
|
||||
/** Minimal read surface shared by `URLSearchParams` and Next's `ReadonlyURLSearchParams`. */
|
||||
interface ParamReader {
|
||||
get(name: string): string | null;
|
||||
}
|
||||
|
||||
const GENDERS: readonly NurseGender[] = ['male', 'female'];
|
||||
|
||||
function parsePositiveInt(raw: string | null): number | undefined {
|
||||
if (raw == null) return undefined;
|
||||
const value = Number(raw);
|
||||
return Number.isInteger(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function parseGender(raw: string | null): NurseGender | undefined {
|
||||
return raw != null && GENDERS.includes(raw as NurseGender) ? (raw as NurseGender) : undefined;
|
||||
}
|
||||
|
||||
function parsePriceUnit(raw: string | null): PriceUnit | undefined {
|
||||
return raw != null && PRICE_UNITS.includes(raw as PriceUnit) ? (raw as PriceUnit) : undefined;
|
||||
}
|
||||
|
||||
/** IRR digit-string or undefined (never a float; leaves bogus input out). */
|
||||
function parseIrrString(raw: string | null): string | undefined {
|
||||
return raw != null && /^\d+$/.test(raw) ? raw : undefined;
|
||||
}
|
||||
|
||||
/** Serialise a filter set to snake_case URL params, omitting every absent optional filter. */
|
||||
export function filtersToSearchParams(filters: NurseSearchFilters): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
params.set('service_category_id', String(filters.serviceCategoryId));
|
||||
params.set('city_id', String(filters.cityId));
|
||||
if (filters.districtId != null) params.set('district_id', String(filters.districtId));
|
||||
if (filters.nurseGender) params.set('nurse_gender', filters.nurseGender);
|
||||
if (filters.priceMin) params.set('min_price', filters.priceMin);
|
||||
if (filters.priceMax) params.set('max_price', filters.priceMax);
|
||||
if (filters.priceUnit) params.set('price_unit', filters.priceUnit);
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild a `NurseSearchFilters` from URL params. `serviceCategoryId`/`cityId` fall back to `0` when
|
||||
* absent/invalid — the query hook is disabled until both are `> 0`, so an incomplete URL is inert
|
||||
* rather than an error. Sort is always rating (MVP); page/pageSize reset to the first page.
|
||||
*/
|
||||
export function searchParamsToFilters(params: ParamReader): NurseSearchFilters {
|
||||
return {
|
||||
serviceCategoryId: parsePositiveInt(params.get('service_category_id')) ?? 0,
|
||||
cityId: parsePositiveInt(params.get('city_id')) ?? 0,
|
||||
districtId: parsePositiveInt(params.get('district_id')),
|
||||
nurseGender: parseGender(params.get('nurse_gender')),
|
||||
priceMin: parseIrrString(params.get('min_price')),
|
||||
priceMax: parseIrrString(params.get('max_price')),
|
||||
priceUnit: parsePriceUnit(params.get('price_unit')),
|
||||
sort: 'rating',
|
||||
page: 1,
|
||||
pageSize: SEARCH_PAGE_SIZE,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Returns a debounced copy of `value` that only updates after `delayMs` of no changes. Used by the C1
|
||||
* filter controller for the price-range inputs so typing doesn't fan out one search request per
|
||||
* keystroke (phase §5 "Debounce input") — the debounced value is what becomes part of the query key.
|
||||
*/
|
||||
export function useDebouncedValue<T>(value: T, delayMs: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delayMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delayMs]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { searchApi } from '../apis';
|
||||
import { searchKeys } from '../keys';
|
||||
import { SEARCH_GC_TIME, SEARCH_PROFILE_STALE_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* The C3 nurse-profile query, keyed on `searchKeys.profile(nurseId)` and enabled only when an id is
|
||||
* present. Cached for the stale window so returning from the booking handoff serves from cache.
|
||||
*/
|
||||
export function useNurseProfile(nurseId: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: searchKeys.profile(nurseId ?? -1),
|
||||
queryFn: () => searchApi.getNurseProfile(nurseId as number),
|
||||
enabled: nurseId != null,
|
||||
staleTime: SEARCH_PROFILE_STALE_TIME,
|
||||
gcTime: SEARCH_GC_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { searchApi } from '../apis';
|
||||
import { searchKeys } from '../keys';
|
||||
import { SEARCH_GC_TIME, SEARCH_RESULTS_STALE_TIME } from '../constants';
|
||||
import type { NurseSearchFilters } from '../types';
|
||||
|
||||
/**
|
||||
* The C2 discovery query. **The filter object is the query key** (`searchKeys.results`), so an
|
||||
* identical filter set is served straight from cache — changing a filter and reverting to a previous
|
||||
* set is a cache hit with zero network calls. `placeholderData: keepPreviousData` keeps the previous
|
||||
* page/results on screen while a new filter loads, so the list never flashes empty. Enabled only once
|
||||
* the two required facets (category + city) are chosen.
|
||||
*/
|
||||
export function useNurseSearch(filters: NurseSearchFilters) {
|
||||
return useQuery({
|
||||
queryKey: searchKeys.results(filters),
|
||||
queryFn: () => searchApi.searchNurses(filters),
|
||||
enabled: filters.serviceCategoryId > 0 && filters.cityId > 0,
|
||||
staleTime: SEARCH_RESULTS_STALE_TIME,
|
||||
gcTime: SEARCH_GC_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { useNurseSearch } from './hooks/useNurseSearch';
|
||||
export { useNurseProfile } from './hooks/useNurseProfile';
|
||||
export { useDebouncedValue } from './hooks/useDebouncedValue';
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { NurseSearchFilters } from './types';
|
||||
|
||||
/**
|
||||
* React Query key factory for the search domain.
|
||||
*
|
||||
* **The filter object IS the query key** (phase §5, the caching contract). `results(filters)` keys on a
|
||||
* **canonical** serialization of the full filter object — a stable key order with every *absent* optional
|
||||
* filter omitted (never carried as `undefined`). Two filter sets that are semantically equal therefore
|
||||
* produce the identical key, so changing a filter and **reverting** to a previous set is a cache hit with
|
||||
* zero network calls (React Query hashes query keys deterministically; canonicalizing here makes the
|
||||
* intent explicit and keeps the URL/query-param serialization aligned with the cache key).
|
||||
*/
|
||||
|
||||
/** Canonical, order-stable filter object with absent optionals omitted (the cache key + query params). */
|
||||
export function canonicalizeSearchFilters(filters: NurseSearchFilters): Record<string, string | number> {
|
||||
const canonical: Record<string, string | number> = {
|
||||
serviceCategoryId: filters.serviceCategoryId,
|
||||
cityId: filters.cityId,
|
||||
sort: filters.sort,
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
};
|
||||
if (filters.districtId != null) canonical.districtId = filters.districtId;
|
||||
if (filters.nurseGender != null) canonical.nurseGender = filters.nurseGender;
|
||||
if (filters.priceMin != null && filters.priceMin !== '') canonical.priceMin = filters.priceMin;
|
||||
if (filters.priceMax != null && filters.priceMax !== '') canonical.priceMax = filters.priceMax;
|
||||
if (filters.priceUnit != null) canonical.priceUnit = filters.priceUnit;
|
||||
return canonical;
|
||||
}
|
||||
|
||||
export const searchKeys = {
|
||||
all: ['search'] as const,
|
||||
results: (filters: NurseSearchFilters) =>
|
||||
[...searchKeys.all, 'results', canonicalizeSearchFilters(filters)] as const,
|
||||
profiles: () => [...searchKeys.all, 'profile'] as const,
|
||||
profile: (nurseId: number) => [...searchKeys.profiles(), nurseId] as const,
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Paginated } from '@/lib/api/types';
|
||||
import type { PriceUnit } from '@/services/catalog/types';
|
||||
|
||||
/**
|
||||
* Search & discovery domain — the family-facing nurse-finding layer. Shapes are derived from the b7
|
||||
* contract (`dev/contracts/domains/search.md`) plus the b6 trust badge / b5 variant reads for the
|
||||
* profile. The wire is **camelCase** and `clientFetch` unwraps the `ApiResult<T>` envelope, so these
|
||||
* are the post-`unwrap()` payloads.
|
||||
*
|
||||
* Load-bearing semantics (see the contract "Key semantics" + phase §5):
|
||||
* - **Every returned row is already bookable.** The `nurse_search_index` invariant guarantees a hit
|
||||
* only when the nurse is verified + not suspended + accepting + the variant is active. The UI must
|
||||
* **never** re-filter for verification, and never surface an unverified/paused nurse.
|
||||
* - **The result unit is the variant, not the nurse** — a nurse with several variants/areas can appear
|
||||
* as several hits.
|
||||
* - **`districtId = null` ⇒ whole city**, both directions; the client omits `districtId` for a
|
||||
* whole-city search rather than sending a bogus value.
|
||||
* - **Same-gender is first-class** — `nurseGender` is an up-front filter, never silently defaulted or
|
||||
* dropped, and the chosen value is carried into the booking request as `required_caregiver_gender`
|
||||
* (f7), surfaced *before* booking.
|
||||
* - **Money is an IRR digit-string** (`price`) — rendered only via the money util, never parsed to a float.
|
||||
* - **Rating sort only (MVP).**
|
||||
*
|
||||
* @remarks b7's `NurseSearchResultDto` and the b5/b6 reads do **not** yet expose the nurse's display
|
||||
* name, avatar, distance, bio, specialties, full services list, or latest review that C2/C3 render. Those
|
||||
* gaps are served by the in-memory mock (`apis/mockApi.ts`, primary this phase) and filed for the backend
|
||||
* in `dev/shared-working-context/frontend/requests/for-backend.md`; the real client
|
||||
* (`apis/clientApi.ts`) maps what b7/b6/b5 currently provide and is swapped in when the endpoints land.
|
||||
*/
|
||||
|
||||
/** A caregiver's gender — the same-gender matching facet (`any` is expressed by omitting the filter). */
|
||||
export type NurseGender = 'male' | 'female';
|
||||
|
||||
/** The only MVP result ordering. Rendered as a control with one option; other sorts are DEFERRED. */
|
||||
export type SearchSort = 'rating';
|
||||
|
||||
/** The filter object — this **is** the React Query cache key (see `keys.ts`) and the C2 query string. */
|
||||
export interface NurseSearchFilters {
|
||||
serviceCategoryId: number;
|
||||
cityId: number;
|
||||
/** Omit for a whole-city search; "empty district = whole city" (never send a bogus district). */
|
||||
districtId?: number;
|
||||
/** Omit = فرقی ندارد / any gender. Never defaulted silently. */
|
||||
nurseGender?: NurseGender;
|
||||
/** Inclusive IRR-Rial digit-string bounds; compared like-for-like within a `priceUnit`. */
|
||||
priceMin?: string;
|
||||
priceMax?: string;
|
||||
/** Compare only like-for-like listings (e.g. only `per_hour`). */
|
||||
priceUnit?: PriceUnit;
|
||||
sort: SearchSort;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/** A single C2 result card row (one bookable variant matched in a covered area). */
|
||||
export interface NurseSearchResult {
|
||||
nurseId: number;
|
||||
variantId: number;
|
||||
serviceCategoryId: number;
|
||||
/** Display name (mock/future-backend; the real b7 row omits it — card falls back to a label). */
|
||||
nurseName: string;
|
||||
avatarUrl: string | null;
|
||||
/** Always `true` by the search-index invariant — the UI relies on this, never re-checks it. */
|
||||
isVerified: boolean;
|
||||
averageRating: number;
|
||||
totalReviews: number;
|
||||
totalCompletedBookings: number;
|
||||
/** Kilometres from the searched area; `null` when unknown — the card hides the distance chip. */
|
||||
distanceKm: number | null;
|
||||
/** The variant's `price` as an IRR-Rial digit-string; rendered via the money util only. */
|
||||
priceFromIrr: string;
|
||||
priceUnit: PriceUnit;
|
||||
nurseGender: NurseGender;
|
||||
cityId: number;
|
||||
/** `null` = the nurse covers the whole city. */
|
||||
districtId: number | null;
|
||||
}
|
||||
|
||||
/** One offered variant on the C3 profile — the bookable unit; reused by the ServicePriceRow. */
|
||||
export interface NurseProfileServiceRow {
|
||||
variantId: number;
|
||||
displayName: string;
|
||||
/** IRR-Rial digit-string; rendered via the money util + the localized `priceUnit` label. */
|
||||
priceIrr: string;
|
||||
priceUnit: PriceUnit;
|
||||
sessionCount?: number | null;
|
||||
}
|
||||
|
||||
/** A short latest-review snippet for C3 (the full reviews tab is DEFERRED → f13). */
|
||||
export interface NurseReviewSnippet {
|
||||
rating: number;
|
||||
body: string;
|
||||
/** Author name already masked server-side (PII rule); rendered verbatim. */
|
||||
authorMasked: string;
|
||||
/** UTC ISO-8601; displayed via the Shamsi date util. */
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** The C3 nurse-profile payload. */
|
||||
export interface NurseProfile {
|
||||
nurseId: number;
|
||||
nurseName: string;
|
||||
avatarUrl: string | null;
|
||||
bio: string | null;
|
||||
yearsExperience: number | null;
|
||||
averageRating: number;
|
||||
totalReviews: number;
|
||||
totalCompletedBookings: number;
|
||||
/** Always `true` for a discoverable nurse (invariant); drives the ✓ تاییدشده badge. */
|
||||
isVerified: boolean;
|
||||
/** نظام پرستاری (INO membership) — render the badge only when `true`. */
|
||||
inoMembership: boolean;
|
||||
/** Specialty **codes** (mapped to i18n labels, never rendered raw); the C3 attribute chips. */
|
||||
attributeChips: string[];
|
||||
services: NurseProfileServiceRow[];
|
||||
latestReview?: NurseReviewSnippet | null;
|
||||
nurseGender: NurseGender;
|
||||
}
|
||||
|
||||
/**
|
||||
* The search domain's API seam — the real HTTP client and the in-memory mock both implement this
|
||||
* interface; selection is by config (`USE_SEARCH_MOCK`), never scattered `if (mock)` checks.
|
||||
*/
|
||||
export interface SearchApi {
|
||||
/** The single family-facing discovery query over the maintained search index. */
|
||||
searchNurses(filters: NurseSearchFilters): Promise<Paginated<NurseSearchResult>>;
|
||||
/** The C3 nurse profile (identity + badges + services + latest review). */
|
||||
getNurseProfile(nurseId: number): Promise<NurseProfile>;
|
||||
}
|
||||
Reference in New Issue
Block a user