Files
Parsons/src/components/pages/ProvidersStep/ProvidersStep.tsx
Richie 02510caf18 Match sort button size to filters, more spacing above results count
- Sort button: remove fontSize override so it inherits Button small size
- Results count: mt 2 → 3 for more breathing room below controls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:24:11 +11:00

699 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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 { 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;
}
/** 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);
// ─── 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 */}
<TextField
placeholder="Search a town or suburb..."
aria-label="Search providers by town or suburb"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && onSearch) {
e.preventDefault();
onSearch(searchQuery);
}
}}
fullWidth
size="small"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<LocationOnOutlinedIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
</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"
sx={{ textTransform: 'none' }}
>
{SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? 'Sort'}
</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"
>
{providers.length} 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;