backend phase 13 & frontend phase 6
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user