Sticky search now uses Autocomplete (multiple+freeSolo capped to 1) instead of a plain TextField: - Pin icon tightened to the left edge and to the placeholder - Committed location renders inside the input as an FA Chip with an X delete (clears the committed filter) - Primary-coloured magnifying-glass IconButton on the right commits the draft; disabled while the draft is empty - Typing no longer filters live — Enter or the search button promotes the draft to a chip, matching the chip mental model The FilterPanel dialog's Location autocomplete already read from the same searchQuery state, so it continues to display the committed chip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
777 lines
27 KiB
TypeScript
777 lines
27 KiB
TypeScript
import React from 'react';
|
||
import Box from '@mui/material/Box';
|
||
import TextField from '@mui/material/TextField';
|
||
import InputAdornment from '@mui/material/InputAdornment';
|
||
import Autocomplete from '@mui/material/Autocomplete';
|
||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||
import Slider from '@mui/material/Slider';
|
||
import MenuItem from '@mui/material/MenuItem';
|
||
import Menu from '@mui/material/Menu';
|
||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||
import ToggleButton from '@mui/material/ToggleButton';
|
||
import SwapVertIcon from '@mui/icons-material/SwapVert';
|
||
import SearchIcon from '@mui/icons-material/Search';
|
||
import ViewListOutlinedIcon from '@mui/icons-material/ViewListOutlined';
|
||
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
|
||
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
|
||
import type { SxProps, Theme } from '@mui/material/styles';
|
||
import { WizardLayout } from '../../templates/WizardLayout';
|
||
import { ProviderCard } from '../../molecules/ProviderCard';
|
||
import { FilterPanel } from '../../molecules/FilterPanel';
|
||
import { Button } from '../../atoms/Button';
|
||
import { Chip } from '../../atoms/Chip';
|
||
import { IconButton } from '../../atoms/IconButton';
|
||
import { Switch } from '../../atoms/Switch';
|
||
import { Typography } from '../../atoms/Typography';
|
||
import { Divider } from '../../atoms/Divider';
|
||
|
||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||
|
||
/** Provider data for display in the list */
|
||
export interface ProviderData {
|
||
/** Unique provider ID (funeralBrandID) */
|
||
id: string;
|
||
/** Provider display name */
|
||
name: string;
|
||
/** Location text (suburb, city) */
|
||
location: string;
|
||
/** Whether this is a verified/trusted partner */
|
||
verified?: boolean;
|
||
/** Hero image URL */
|
||
imageUrl?: string;
|
||
/** Provider logo URL */
|
||
logoUrl?: string;
|
||
/** Average rating (e.g. 4.8) */
|
||
rating?: number;
|
||
/** Number of reviews */
|
||
reviewCount?: number;
|
||
/** Starting price in dollars */
|
||
startingPrice?: number;
|
||
/** Distance from user in km */
|
||
distanceKm?: number;
|
||
/** Brief description */
|
||
description?: string;
|
||
/** Geographic coordinates for map display */
|
||
coords?: { lat: number; lng: number };
|
||
}
|
||
|
||
/** A funeral type option for the filter */
|
||
export interface FuneralTypeOption {
|
||
/** Machine-readable value (e.g. "service_and_cremation") */
|
||
value: string;
|
||
/** Human-readable label (e.g. "Service & Cremation") */
|
||
label: string;
|
||
}
|
||
|
||
/** Structured filter state for the providers list */
|
||
export interface ProviderFilterValues {
|
||
/** Selected service tradition (null = any) */
|
||
tradition: string | null;
|
||
/** Selected funeral type values (empty = all) */
|
||
funeralTypes: string[];
|
||
/** Show only verified providers */
|
||
verifiedOnly: boolean;
|
||
/** Show only providers offering online arrangements */
|
||
onlineArrangements: boolean;
|
||
/** Price range [min, max] */
|
||
priceRange: [number, number];
|
||
}
|
||
|
||
/** Sort options for the provider list */
|
||
export type ProviderSortBy = 'recommended' | 'nearest' | 'price_low' | 'price_high';
|
||
|
||
/** View mode for the listing */
|
||
export type ListViewMode = 'list' | 'map';
|
||
|
||
/** Props for the ProvidersStep page component */
|
||
export interface ProvidersStepProps {
|
||
/** List of providers to display */
|
||
providers: ProviderData[];
|
||
/** Callback when a provider card is clicked — triggers navigation (D-D) */
|
||
onSelectProvider: (id: string) => void;
|
||
/** Search query value */
|
||
searchQuery: string;
|
||
/** Callback when search query changes */
|
||
onSearchChange: (query: string) => void;
|
||
/** Callback when search is submitted */
|
||
onSearch?: (query: string) => void;
|
||
/** Current filter state */
|
||
filterValues: ProviderFilterValues;
|
||
/** Callback when any filter changes */
|
||
onFilterChange: (values: ProviderFilterValues) => void;
|
||
/** Available service tradition options for the autocomplete */
|
||
traditionOptions?: string[];
|
||
/** Available funeral type options */
|
||
funeralTypeOptions?: FuneralTypeOption[];
|
||
/** Minimum price for the slider (default 0) */
|
||
minPrice?: number;
|
||
/** Maximum price for the slider (default 15000) */
|
||
maxPrice?: number;
|
||
/** Current sort order */
|
||
sortBy?: ProviderSortBy;
|
||
/** Callback when sort order changes */
|
||
onSortChange?: (sort: ProviderSortBy) => void;
|
||
/** Current view mode */
|
||
viewMode?: ListViewMode;
|
||
/** Callback when view mode changes */
|
||
onViewModeChange?: (mode: ListViewMode) => void;
|
||
/** Callback for the Back button */
|
||
onBack: () => void;
|
||
/** Map panel content — slot for future map integration */
|
||
mapPanel?: React.ReactNode;
|
||
/** Navigation bar — passed through to WizardLayout */
|
||
navigation?: React.ReactNode;
|
||
/** Progress stepper — passed through to WizardLayout */
|
||
progressStepper?: React.ReactNode;
|
||
/** Running total — passed through to WizardLayout */
|
||
runningTotal?: React.ReactNode;
|
||
/** Whether this is a pre-planning flow (shows softer copy) */
|
||
isPrePlanning?: boolean;
|
||
/** MUI sx prop for the root */
|
||
sx?: SxProps<Theme>;
|
||
}
|
||
|
||
// ─── Defaults ────────────────────────────────────────────────────────────────
|
||
|
||
const DEFAULT_TRADITIONS = [
|
||
'None',
|
||
'Anglican',
|
||
"Bahá'í",
|
||
'Baptist',
|
||
'Buddhist',
|
||
'Catholic',
|
||
'Eastern Orthodox',
|
||
'Hindu',
|
||
'Humanist',
|
||
'Indigenous Australian',
|
||
'Jewish',
|
||
'Lutheran',
|
||
'Methodist',
|
||
'Muslim',
|
||
'Non-religious',
|
||
'Pentecostal',
|
||
'Presbyterian',
|
||
'Salvation Army',
|
||
'Secular',
|
||
'Sikh',
|
||
'Uniting Church',
|
||
];
|
||
|
||
const DEFAULT_FUNERAL_TYPES: FuneralTypeOption[] = [
|
||
{ value: 'service_and_cremation', label: 'Service & Cremation' },
|
||
{ value: 'service_and_burial', label: 'Service & Burial' },
|
||
{ value: 'cremation_only', label: 'Cremation Only' },
|
||
{ value: 'graveside_burial', label: 'Graveside Burial' },
|
||
{ value: 'water_cremation', label: 'Water Cremation' },
|
||
{ value: 'burial_only', label: 'Burial Only' },
|
||
];
|
||
|
||
const SORT_OPTIONS: { value: ProviderSortBy; label: string }[] = [
|
||
{ value: 'recommended', label: 'Recommended' },
|
||
{ value: 'nearest', label: 'Nearest' },
|
||
{ value: 'price_low', label: 'Price low to high' },
|
||
{ value: 'price_high', label: 'Price high to low' },
|
||
];
|
||
|
||
export const EMPTY_FILTER_VALUES: ProviderFilterValues = {
|
||
tradition: null,
|
||
funeralTypes: [],
|
||
verifiedOnly: false,
|
||
onlineArrangements: false,
|
||
priceRange: [0, 15000],
|
||
};
|
||
|
||
// ─── Shared styles ───────────────────────────────────────────────────────────
|
||
|
||
/** Section heading inside the filter panel */
|
||
const sectionHeadingSx = {
|
||
mb: 1.5,
|
||
display: 'block',
|
||
fontWeight: 600,
|
||
color: 'text.primary',
|
||
} as const;
|
||
|
||
/** Wrapping chip row */
|
||
const chipWrapSx = {
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: 1,
|
||
} as const;
|
||
|
||
// ─── Component ───────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Step 2 — Provider selection page for the FA arrangement wizard.
|
||
*
|
||
* List + Map split layout. Left panel shows a scrollable list of
|
||
* provider cards with search and filter button. Right panel is a
|
||
* slot for future map integration.
|
||
*
|
||
* Click-to-navigate (D-D): clicking a provider card triggers
|
||
* navigation directly — no selection state or Continue button.
|
||
*
|
||
* Filters: location (chip + search), service tradition (autocomplete),
|
||
* funeral type (horizontal scroll chips), verified only (switch),
|
||
* online arrangements (switch), price range (slider + compact inputs).
|
||
*
|
||
* Pure presentation component — props in, callbacks out.
|
||
*
|
||
* Spec: documentation/steps/steps/02_providers.yaml
|
||
*/
|
||
export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
||
providers,
|
||
onSelectProvider,
|
||
searchQuery,
|
||
onSearchChange,
|
||
onSearch,
|
||
filterValues,
|
||
onFilterChange,
|
||
traditionOptions = DEFAULT_TRADITIONS,
|
||
funeralTypeOptions = DEFAULT_FUNERAL_TYPES,
|
||
minPrice = 0,
|
||
maxPrice = 15000,
|
||
sortBy = 'recommended',
|
||
onSortChange,
|
||
viewMode = 'list',
|
||
onViewModeChange,
|
||
onBack,
|
||
mapPanel,
|
||
navigation,
|
||
progressStepper,
|
||
runningTotal,
|
||
isPrePlanning = false,
|
||
sx,
|
||
}) => {
|
||
const subheading = isPrePlanning
|
||
? 'Take your time exploring providers. You can always come back and choose a different one.'
|
||
: 'These providers are near your location. Each has their own packages and pricing.';
|
||
|
||
// ─── Local state ───
|
||
const [sortAnchor, setSortAnchor] = React.useState<null | HTMLElement>(null);
|
||
// Draft value for the sticky search input — only committed (promoted to a
|
||
// chip) on Enter or when the search button is clicked. searchQuery is the
|
||
// committed filter value; the draft lives here until the user confirms.
|
||
const [searchDraft, setSearchDraft] = React.useState('');
|
||
|
||
const commitSearch = (next: string) => {
|
||
const trimmed = next.trim();
|
||
if (!trimmed) return;
|
||
onSearchChange(trimmed);
|
||
onSearch?.(trimmed);
|
||
setSearchDraft('');
|
||
};
|
||
|
||
// ─── Price input local state (commits on blur / Enter) ───
|
||
const [priceMinInput, setPriceMinInput] = React.useState(String(filterValues.priceRange[0]));
|
||
const [priceMaxInput, setPriceMaxInput] = React.useState(String(filterValues.priceRange[1]));
|
||
|
||
// Sync local state when slider (or clear) changes the value
|
||
const rangeMin = filterValues.priceRange[0];
|
||
const rangeMax = filterValues.priceRange[1];
|
||
React.useEffect(() => {
|
||
setPriceMinInput(String(rangeMin));
|
||
setPriceMaxInput(String(rangeMax));
|
||
}, [rangeMin, rangeMax]);
|
||
|
||
const commitPriceRange = () => {
|
||
let lo = parseInt(priceMinInput, 10);
|
||
let hi = parseInt(priceMaxInput, 10);
|
||
if (isNaN(lo)) lo = minPrice;
|
||
if (isNaN(hi)) hi = maxPrice;
|
||
lo = Math.max(minPrice, Math.min(lo, maxPrice));
|
||
hi = Math.max(minPrice, Math.min(hi, maxPrice));
|
||
if (lo > hi) [lo, hi] = [hi, lo];
|
||
const newRange: [number, number] = [lo, hi];
|
||
if (newRange[0] !== filterValues.priceRange[0] || newRange[1] !== filterValues.priceRange[1]) {
|
||
onFilterChange({ ...filterValues, priceRange: newRange });
|
||
}
|
||
};
|
||
|
||
// ─── Active filter count ───
|
||
const activeCount =
|
||
(searchQuery.trim() ? 1 : 0) +
|
||
(filterValues.tradition ? 1 : 0) +
|
||
filterValues.funeralTypes.length +
|
||
(filterValues.verifiedOnly ? 1 : 0) +
|
||
(filterValues.onlineArrangements ? 1 : 0) +
|
||
(filterValues.priceRange[0] !== minPrice || filterValues.priceRange[1] !== maxPrice ? 1 : 0);
|
||
|
||
const handleClear = () => {
|
||
onSearchChange('');
|
||
onFilterChange({
|
||
...EMPTY_FILTER_VALUES,
|
||
priceRange: [minPrice, maxPrice],
|
||
});
|
||
};
|
||
|
||
const handleFuneralTypeToggle = (value: string) => {
|
||
const current = filterValues.funeralTypes;
|
||
const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value];
|
||
onFilterChange({ ...filterValues, funeralTypes: next });
|
||
};
|
||
|
||
return (
|
||
<WizardLayout
|
||
variant="list-map"
|
||
navigation={navigation}
|
||
progressStepper={progressStepper}
|
||
runningTotal={runningTotal}
|
||
showBackLink
|
||
backLabel="Back"
|
||
onBack={onBack}
|
||
sx={sx}
|
||
secondaryPanel={
|
||
<Box sx={{ position: 'relative', flex: 1, display: 'flex' }}>
|
||
{/* Floating view toggle */}
|
||
<ToggleButtonGroup
|
||
value={viewMode}
|
||
exclusive
|
||
onChange={(_, val) => val && onViewModeChange?.(val as ListViewMode)}
|
||
size="small"
|
||
aria-label="View mode"
|
||
sx={{
|
||
position: 'absolute',
|
||
top: 12,
|
||
left: 12,
|
||
zIndex: 1,
|
||
bgcolor: 'background.paper',
|
||
boxShadow: 'var(--fa-shadow-md)',
|
||
borderRadius: 1,
|
||
'& .MuiToggleButton-root': {
|
||
px: 1.5,
|
||
py: 0.5,
|
||
fontSize: '0.75rem',
|
||
fontWeight: 500,
|
||
gap: 0.5,
|
||
border: '1px solid',
|
||
borderColor: 'divider',
|
||
textTransform: 'none',
|
||
'&.Mui-selected': {
|
||
bgcolor: 'var(--fa-color-brand-100)',
|
||
color: 'primary.main',
|
||
borderColor: 'primary.main',
|
||
'&:hover': { bgcolor: 'var(--fa-color-brand-200)' },
|
||
},
|
||
},
|
||
}}
|
||
>
|
||
<ToggleButton value="list" aria-label="List view">
|
||
<ViewListOutlinedIcon sx={{ fontSize: 16 }} />
|
||
List
|
||
</ToggleButton>
|
||
<ToggleButton value="map" aria-label="Map view">
|
||
<MapOutlinedIcon sx={{ fontSize: 16 }} />
|
||
Map
|
||
</ToggleButton>
|
||
</ToggleButtonGroup>
|
||
|
||
{/* Map content */}
|
||
{mapPanel || (
|
||
<Box
|
||
sx={{
|
||
flex: 1,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
bgcolor: 'var(--fa-color-surface-cool)',
|
||
borderLeft: '1px solid',
|
||
borderColor: 'divider',
|
||
}}
|
||
>
|
||
<Typography variant="body1" color="text.secondary">
|
||
Map coming soon
|
||
</Typography>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
}
|
||
>
|
||
{/* Heading — scrolls with listings */}
|
||
<Typography variant="h4" component="h1" sx={{ mb: 0.5, pt: 2 }} tabIndex={-1}>
|
||
Find a funeral director
|
||
</Typography>
|
||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||
{subheading}
|
||
</Typography>
|
||
|
||
{/* Sticky controls — search + filters pinned while listings scroll */}
|
||
<Box
|
||
sx={{
|
||
position: 'sticky',
|
||
top: 0,
|
||
zIndex: 1,
|
||
bgcolor: 'background.default',
|
||
pt: 3,
|
||
pb: 1.5,
|
||
mx: { xs: -2, md: -3 },
|
||
px: { xs: 2, md: 3 },
|
||
borderBottom: '1px solid',
|
||
borderColor: 'divider',
|
||
}}
|
||
>
|
||
{/* Location search — committed location renders as a chip inside the
|
||
input. Typing produces a draft; Enter or the search button commit
|
||
it. Deleting the chip clears the committed filter. */}
|
||
<Autocomplete
|
||
multiple
|
||
freeSolo
|
||
options={[]}
|
||
value={searchQuery.trim() ? [searchQuery.trim()] : []}
|
||
inputValue={searchDraft}
|
||
onInputChange={(_, newDraft, reason) => {
|
||
// Ignore the 'reset' input-change Autocomplete fires after a value
|
||
// commit (it echoes the committed value back into the input and
|
||
// would otherwise re-populate the draft we just cleared).
|
||
if (reason === 'reset') return;
|
||
setSearchDraft(newDraft);
|
||
}}
|
||
onChange={(_, newValue) => {
|
||
if (newValue.length === 0) {
|
||
// Chip removed — clear the committed filter
|
||
onSearchChange('');
|
||
return;
|
||
}
|
||
// Commit the most-recent entry (cap at 1 location)
|
||
const last = newValue[newValue.length - 1];
|
||
if (typeof last === 'string') commitSearch(last);
|
||
}}
|
||
renderTags={(value, getTagProps) =>
|
||
value.map((option, index) => {
|
||
const { key, ...chipProps } = getTagProps({ index });
|
||
return (
|
||
<Chip
|
||
key={key}
|
||
label={option}
|
||
size="small"
|
||
selected
|
||
aria-label={`Current location: ${option}. Press delete to clear.`}
|
||
{...chipProps}
|
||
/>
|
||
);
|
||
})
|
||
}
|
||
renderInput={(params) => (
|
||
<TextField
|
||
{...params}
|
||
placeholder={searchQuery.trim() ? '' : 'Search a town or suburb...'}
|
||
size="small"
|
||
inputProps={{
|
||
...params.inputProps,
|
||
'aria-label': 'Search providers by town or suburb',
|
||
}}
|
||
InputProps={{
|
||
...params.InputProps,
|
||
startAdornment: (
|
||
<>
|
||
<InputAdornment position="start" sx={{ ml: 0.5, mr: 0.5 }}>
|
||
<LocationOnOutlinedIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
|
||
</InputAdornment>
|
||
{params.InputProps.startAdornment}
|
||
</>
|
||
),
|
||
endAdornment: (
|
||
<InputAdornment position="end" sx={{ mr: 0.25 }}>
|
||
<IconButton
|
||
aria-label="Search"
|
||
color="primary"
|
||
size="small"
|
||
onClick={() => commitSearch(searchDraft)}
|
||
disabled={!searchDraft.trim()}
|
||
>
|
||
<SearchIcon fontSize="small" />
|
||
</IconButton>
|
||
</InputAdornment>
|
||
),
|
||
}}
|
||
/>
|
||
)}
|
||
sx={{ mb: 1.5 }}
|
||
/>
|
||
|
||
{/* Control bar — filters + sort */}
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 1,
|
||
}}
|
||
>
|
||
{/* Filters */}
|
||
<FilterPanel activeCount={activeCount} onClear={handleClear}>
|
||
{/* ── Location ── */}
|
||
<Box>
|
||
<Typography variant="labelLg" sx={sectionHeadingSx}>
|
||
Location
|
||
</Typography>
|
||
<Autocomplete
|
||
multiple
|
||
freeSolo
|
||
value={searchQuery.trim() ? [searchQuery.trim()] : []}
|
||
onChange={(_, newValue) => {
|
||
// Take the last entered value as the active search
|
||
const last = newValue[newValue.length - 1] ?? '';
|
||
onSearchChange(typeof last === 'string' ? last : '');
|
||
}}
|
||
options={[]}
|
||
renderInput={(params) => (
|
||
<TextField
|
||
{...params}
|
||
placeholder={searchQuery.trim() ? '' : 'Search a town or suburb...'}
|
||
size="small"
|
||
InputProps={{
|
||
...params.InputProps,
|
||
startAdornment: (
|
||
<>
|
||
<InputAdornment position="start" sx={{ ml: 0.5 }}>
|
||
<LocationOnOutlinedIcon
|
||
sx={{ color: 'text.secondary', fontSize: 18 }}
|
||
/>
|
||
</InputAdornment>
|
||
{params.InputProps.startAdornment}
|
||
</>
|
||
),
|
||
}}
|
||
/>
|
||
)}
|
||
size="small"
|
||
/>
|
||
</Box>
|
||
|
||
<Divider />
|
||
|
||
{/* ── Service tradition ── */}
|
||
<Box>
|
||
<Typography variant="labelLg" sx={sectionHeadingSx}>
|
||
Service tradition
|
||
</Typography>
|
||
<Autocomplete
|
||
value={filterValues.tradition}
|
||
onChange={(_, newValue) => onFilterChange({ ...filterValues, tradition: newValue })}
|
||
options={traditionOptions}
|
||
renderInput={(params) => (
|
||
<TextField {...params} placeholder="Search traditions..." size="small" />
|
||
)}
|
||
clearOnEscape
|
||
size="small"
|
||
/>
|
||
</Box>
|
||
|
||
<Divider />
|
||
|
||
{/* ── Funeral type ── */}
|
||
<Box>
|
||
<Typography variant="labelLg" sx={sectionHeadingSx}>
|
||
Funeral type
|
||
</Typography>
|
||
<Box sx={chipWrapSx}>
|
||
{funeralTypeOptions.map((option) => (
|
||
<Chip
|
||
key={option.value}
|
||
label={option.label}
|
||
selected={filterValues.funeralTypes.includes(option.value)}
|
||
onClick={() => handleFuneralTypeToggle(option.value)}
|
||
variant="outlined"
|
||
size="small"
|
||
/>
|
||
))}
|
||
</Box>
|
||
</Box>
|
||
|
||
<Divider />
|
||
|
||
{/* ── Provider features ── */}
|
||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||
<FormControlLabel
|
||
control={
|
||
<Switch
|
||
checked={filterValues.verifiedOnly}
|
||
onChange={(_, checked) =>
|
||
onFilterChange({ ...filterValues, verifiedOnly: checked })
|
||
}
|
||
/>
|
||
}
|
||
label="Verified providers only"
|
||
sx={{ mx: 0 }}
|
||
/>
|
||
<FormControlLabel
|
||
control={
|
||
<Switch
|
||
checked={filterValues.onlineArrangements}
|
||
onChange={(_, checked) =>
|
||
onFilterChange({ ...filterValues, onlineArrangements: checked })
|
||
}
|
||
/>
|
||
}
|
||
label="Online arrangements available"
|
||
sx={{ mx: 0 }}
|
||
/>
|
||
</Box>
|
||
|
||
<Divider />
|
||
|
||
{/* ── Price range ── */}
|
||
<Box>
|
||
<Typography variant="labelLg" sx={sectionHeadingSx}>
|
||
Price range
|
||
</Typography>
|
||
<Box sx={{ px: 2.5, mb: 1 }}>
|
||
<Slider
|
||
value={filterValues.priceRange}
|
||
onChange={(_, newValue) =>
|
||
onFilterChange({
|
||
...filterValues,
|
||
priceRange: newValue as [number, number],
|
||
})
|
||
}
|
||
min={minPrice}
|
||
max={maxPrice}
|
||
step={100}
|
||
valueLabelDisplay="auto"
|
||
valueLabelFormat={(v) => `$${v.toLocaleString('en-AU')}`}
|
||
color="primary"
|
||
/>
|
||
</Box>
|
||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||
<TextField
|
||
size="small"
|
||
value={priceMinInput}
|
||
onChange={(e) => setPriceMinInput(e.target.value.replace(/[^0-9]/g, ''))}
|
||
onBlur={commitPriceRange}
|
||
onKeyDown={(e) => e.key === 'Enter' && commitPriceRange()}
|
||
InputProps={{
|
||
startAdornment: <InputAdornment position="start">$</InputAdornment>,
|
||
}}
|
||
inputProps={{
|
||
inputMode: 'numeric',
|
||
'aria-label': 'Minimum price',
|
||
style: { padding: '6px 0' },
|
||
}}
|
||
sx={{ flex: 1, '& .MuiOutlinedInput-root': { fontSize: '0.875rem' } }}
|
||
/>
|
||
<Typography variant="caption" color="text.secondary">
|
||
–
|
||
</Typography>
|
||
<TextField
|
||
size="small"
|
||
value={priceMaxInput}
|
||
onChange={(e) => setPriceMaxInput(e.target.value.replace(/[^0-9]/g, ''))}
|
||
onBlur={commitPriceRange}
|
||
onKeyDown={(e) => e.key === 'Enter' && commitPriceRange()}
|
||
InputProps={{
|
||
startAdornment: <InputAdornment position="start">$</InputAdornment>,
|
||
}}
|
||
inputProps={{
|
||
inputMode: 'numeric',
|
||
'aria-label': 'Maximum price',
|
||
style: { padding: '6px 0' },
|
||
}}
|
||
sx={{ flex: 1, '& .MuiOutlinedInput-root': { fontSize: '0.875rem' } }}
|
||
/>
|
||
</Box>
|
||
</Box>
|
||
</FilterPanel>
|
||
|
||
{/* Sort — compact menu button, pushed right */}
|
||
<Box sx={{ ml: 'auto' }}>
|
||
<Button
|
||
variant="outlined"
|
||
color="secondary"
|
||
size="small"
|
||
startIcon={<SwapVertIcon sx={{ fontSize: 16 }} />}
|
||
onClick={(e) => setSortAnchor(e.currentTarget)}
|
||
aria-haspopup="listbox"
|
||
aria-label={`Sort by ${SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? 'Recommended'}`}
|
||
sx={{ textTransform: 'none' }}
|
||
>
|
||
<Box component="span" sx={{ color: 'text.secondary', fontWeight: 400, mr: 0.5 }}>
|
||
Sort:
|
||
</Box>
|
||
{SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? 'Recommended'}
|
||
</Button>
|
||
<Menu
|
||
anchorEl={sortAnchor}
|
||
open={Boolean(sortAnchor)}
|
||
onClose={() => setSortAnchor(null)}
|
||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||
>
|
||
{SORT_OPTIONS.map((opt) => (
|
||
<MenuItem
|
||
key={opt.value}
|
||
selected={opt.value === sortBy}
|
||
onClick={() => {
|
||
onSortChange?.(opt.value);
|
||
setSortAnchor(null);
|
||
}}
|
||
sx={{ fontSize: '0.813rem' }}
|
||
>
|
||
{opt.label}
|
||
</MenuItem>
|
||
))}
|
||
</Menu>
|
||
</Box>
|
||
</Box>
|
||
|
||
{/* Results count — below controls */}
|
||
<Typography
|
||
variant="caption"
|
||
color="text.secondary"
|
||
sx={{ mt: 3, display: 'block' }}
|
||
aria-live="polite"
|
||
>
|
||
<Box component="span" sx={{ fontWeight: 600, color: 'text.primary' }}>
|
||
{providers.length}
|
||
</Box>{' '}
|
||
provider{providers.length !== 1 ? 's' : ''} found
|
||
</Typography>
|
||
</Box>
|
||
|
||
{/* Provider list — click-to-navigate (D-D) */}
|
||
<Box
|
||
role="list"
|
||
aria-label="Funeral providers"
|
||
sx={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: 2,
|
||
pb: 3,
|
||
pt: 2,
|
||
px: { xs: 2, md: 3 },
|
||
mx: { xs: -2, md: -3 },
|
||
bgcolor: 'var(--fa-color-surface-subtle)',
|
||
}}
|
||
>
|
||
{providers.map((provider) => (
|
||
<ProviderCard
|
||
key={provider.id}
|
||
name={provider.name}
|
||
location={provider.location}
|
||
verified={provider.verified}
|
||
imageUrl={provider.imageUrl}
|
||
logoUrl={provider.logoUrl}
|
||
rating={provider.rating}
|
||
reviewCount={provider.reviewCount}
|
||
startingPrice={provider.startingPrice}
|
||
onClick={() => onSelectProvider(provider.id)}
|
||
aria-label={`${provider.name}, ${provider.location}${provider.rating ? `, rated ${provider.rating}` : ''}${provider.startingPrice ? `, from $${provider.startingPrice}` : ''}`}
|
||
/>
|
||
))}
|
||
|
||
{providers.length === 0 && (
|
||
<Box sx={{ py: 6, textAlign: 'center' }}>
|
||
<Typography variant="body1" color="text.secondary" sx={{ mb: 1 }}>
|
||
No providers found matching your search.
|
||
</Typography>
|
||
<Typography variant="body2" color="text.secondary">
|
||
Try adjusting your search or clearing filters.
|
||
</Typography>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
</WizardLayout>
|
||
);
|
||
};
|
||
|
||
ProvidersStep.displayName = 'ProvidersStep';
|
||
export default ProvidersStep;
|