Files
Parsons/src/components/pages/ProvidersStep/ProvidersStep.tsx
Richie 3bf5f72b4f ProvidersStep search: lock button, primary-circle, drop focus rings, grey chip
- Suppress Autocomplete's own popup/clear indicators (forcePopupIcon,
  clearIcon) so the search IconButton stays anchored in the same spot
  across empty, draft, and chip states
- Search button is a primary-filled circle at default strength in every
  state (no disabled dimming) — a clear affordance, handler already
  guards for empty drafts
- Drop the brand-gold focus ring on the search bar; keep the default
  neutral border on focus
- Drop the copper 2px focus outline on Filters and Sort (outline: none
  under :focus-visible)
- Committed location chip now uses the default neutral tonal fill
  instead of the promoted brand colour

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 10:53:04 +10:00

798 lines
28 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 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={[]}
forcePopupIcon={false}
clearIcon={null}
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"
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.5 }}>
<IconButton
aria-label="Search"
onClick={() => commitSearch(searchDraft)}
sx={{
width: 28,
height: 28,
borderRadius: '50%',
bgcolor: 'primary.main',
color: 'primary.contrastText',
'&:hover': { bgcolor: 'primary.dark' },
'&:focus-visible': { outline: 'none' },
}}
>
<SearchIcon sx={{ fontSize: 16 }} />
</IconButton>
</InputAdornment>
),
}}
/>
)}
sx={{
mb: 1.5,
// Kill the custom brand focus ring + border colour change on focus
'& .MuiOutlinedInput-root.Mui-focused': {
boxShadow: 'none',
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'var(--fa-color-neutral-300)',
borderWidth: 1,
},
},
}}
/>
{/* Control bar — filters + sort */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
{/* Filters */}
<FilterPanel
activeCount={activeCount}
onClear={handleClear}
sx={{ '& .MuiButton-root:focus-visible': { outline: 'none' } }}
>
{/* ── 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', '&:focus-visible': { outline: '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;