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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user