ProvidersStep structured filters: tradition, type, verified, online, price

Replace generic ProviderFilter[] chip array with structured filter state:
- Service tradition: Autocomplete with type-to-search (20 options)
- Funeral type: Chip multi-select (6 types from spec)
- Verified providers only: Switch toggle
- Online arrangements available: Switch toggle
- Price range: Dual-knob slider with editable inputs (matches CoffinsStep)

New ProviderFilterValues interface, EMPTY_FILTER_VALUES export.
New story: WithActiveFilters (pre-applied Catholic + cremation + $0-2k).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 15:56:31 +11:00
parent 23bcf31c87
commit 7dea9f5855
2 changed files with 319 additions and 45 deletions

View File

@@ -1,7 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { ProvidersStep } from './ProvidersStep'; import { ProvidersStep } from './ProvidersStep';
import type { ProviderData, ProviderFilter } from './ProvidersStep'; import type { ProviderData, ProviderFilterValues } from './ProvidersStep';
import { EMPTY_FILTER_VALUES } from './ProvidersStep';
import { Navigation } from '../../organisms/Navigation'; import { Navigation } from '../../organisms/Navigation';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
@@ -96,12 +97,6 @@ const mockProviders: ProviderData[] = [
}, },
]; ];
const defaultFilters: ProviderFilter[] = [
{ label: 'Funeral Type', active: false },
{ label: 'Verified Only', active: false },
{ label: 'Under $1,500', active: false },
];
// ─── Meta ──────────────────────────────────────────────────────────────────── // ─── Meta ────────────────────────────────────────────────────────────────────
const meta: Meta<typeof ProvidersStep> = { const meta: Meta<typeof ProvidersStep> = {
@@ -118,11 +113,11 @@ type Story = StoryObj<typeof ProvidersStep>;
// ─── Interactive (default) ────────────────────────────────────────────────── // ─── Interactive (default) ──────────────────────────────────────────────────
/** Click-to-navigate — clicking a provider triggers navigation (D-D) */ /** Full filter panel — tradition, funeral type, verified, online, price range */
export const Default: Story = { export const Default: Story = {
render: () => { render: () => {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [filters, setFilters] = useState(defaultFilters); const [filters, setFilters] = useState<ProviderFilterValues>(EMPTY_FILTER_VALUES);
const filtered = mockProviders.filter((p) => const filtered = mockProviders.filter((p) =>
p.location.toLowerCase().includes(query.toLowerCase()), p.location.toLowerCase().includes(query.toLowerCase()),
@@ -134,11 +129,41 @@ export const Default: Story = {
onSelectProvider={(id) => alert(`Navigate to provider: ${id}`)} onSelectProvider={(id) => alert(`Navigate to provider: ${id}`)}
searchQuery={query} searchQuery={query}
onSearchChange={setQuery} onSearchChange={setQuery}
filters={filters} filterValues={filters}
onFilterToggle={(i) => onFilterChange={setFilters}
setFilters((prev) => prev.map((f, idx) => (idx === i ? { ...f, active: !f.active } : f))) onBack={() => alert('Back')}
} navigation={nav}
onFilterClear={() => setFilters((prev) => prev.map((f) => ({ ...f, active: false })))} />
);
},
};
// ─── With active filters ────────────────────────────────────────────────────
/** Filters pre-applied — verified only + price cap */
export const WithActiveFilters: Story = {
render: () => {
const [query, setQuery] = useState('');
const [filters, setFilters] = useState<ProviderFilterValues>({
tradition: 'Catholic',
funeralTypes: ['service_and_cremation'],
verifiedOnly: true,
onlineArrangements: false,
priceRange: [0, 2000],
});
const filtered = mockProviders.filter((p) =>
p.location.toLowerCase().includes(query.toLowerCase()),
);
return (
<ProvidersStep
providers={filtered}
onSelectProvider={(id) => alert(`Navigate to provider: ${id}`)}
searchQuery={query}
onSearchChange={setQuery}
filterValues={filters}
onFilterChange={setFilters}
onBack={() => alert('Back')} onBack={() => alert('Back')}
navigation={nav} navigation={nav}
/> />
@@ -152,6 +177,7 @@ export const Default: Story = {
export const PrePlanning: Story = { export const PrePlanning: Story = {
render: () => { render: () => {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [filters, setFilters] = useState<ProviderFilterValues>(EMPTY_FILTER_VALUES);
return ( return (
<ProvidersStep <ProvidersStep
@@ -159,6 +185,8 @@ export const PrePlanning: Story = {
onSelectProvider={(id) => alert(`Navigate to provider: ${id}`)} onSelectProvider={(id) => alert(`Navigate to provider: ${id}`)}
searchQuery={query} searchQuery={query}
onSearchChange={setQuery} onSearchChange={setQuery}
filterValues={filters}
onFilterChange={setFilters}
onBack={() => alert('Back')} onBack={() => alert('Back')}
navigation={nav} navigation={nav}
isPrePlanning isPrePlanning
@@ -173,6 +201,7 @@ export const PrePlanning: Story = {
export const EmptyResults: Story = { export const EmptyResults: Story = {
render: () => { render: () => {
const [query, setQuery] = useState('xyz'); const [query, setQuery] = useState('xyz');
const [filters, setFilters] = useState<ProviderFilterValues>(EMPTY_FILTER_VALUES);
return ( return (
<ProvidersStep <ProvidersStep
@@ -180,6 +209,8 @@ export const EmptyResults: Story = {
onSelectProvider={() => {}} onSelectProvider={() => {}}
searchQuery={query} searchQuery={query}
onSearchChange={setQuery} onSearchChange={setQuery}
filterValues={filters}
onFilterChange={setFilters}
onBack={() => alert('Back')} onBack={() => alert('Back')}
navigation={nav} navigation={nav}
/> />
@@ -193,6 +224,7 @@ export const EmptyResults: Story = {
export const SingleProvider: Story = { export const SingleProvider: Story = {
render: () => { render: () => {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [filters, setFilters] = useState<ProviderFilterValues>(EMPTY_FILTER_VALUES);
return ( return (
<ProvidersStep <ProvidersStep
@@ -200,6 +232,8 @@ export const SingleProvider: Story = {
onSelectProvider={(id) => alert(`Navigate to provider: ${id}`)} onSelectProvider={(id) => alert(`Navigate to provider: ${id}`)}
searchQuery={query} searchQuery={query}
onSearchChange={setQuery} onSearchChange={setQuery}
filterValues={filters}
onFilterChange={setFilters}
onBack={() => alert('Back')} onBack={() => alert('Back')}
navigation={nav} navigation={nav}
/> />

View File

@@ -2,15 +2,20 @@ import React from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import InputAdornment from '@mui/material/InputAdornment'; 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 LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined'; import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import type { SxProps, Theme } from '@mui/material/styles'; import type { SxProps, Theme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout'; import { WizardLayout } from '../../templates/WizardLayout';
import { ProviderCard } from '../../molecules/ProviderCard'; import { ProviderCard } from '../../molecules/ProviderCard';
import { FilterPanel } from '../../molecules/FilterPanel'; import { FilterPanel } from '../../molecules/FilterPanel';
import { Chip } from '../../atoms/Chip'; import { Chip } from '../../atoms/Chip';
import { Switch } from '../../atoms/Switch';
import { Typography } from '../../atoms/Typography'; import { Typography } from '../../atoms/Typography';
import { Divider } from '../../atoms/Divider';
// ── Types ────────────────────────────────────────────────────────────────── // ──<EFBFBD><EFBFBD> Types ─────────────────<EFBFBD><EFBFBD><EFBFBD>─────────────────────────────────────────────────
/** Provider data for display in the list */ /** Provider data for display in the list */
export interface ProviderData { export interface ProviderData {
@@ -38,12 +43,26 @@ export interface ProviderData {
description?: string; description?: string;
} }
/** Filter chip state */ /** A funeral type option for the filter */
export interface ProviderFilter { export interface FuneralTypeOption {
/** Filter label */ /** Machine-readable value (e.g. "service_and_cremation") */
value: string;
/** Human-readable label (e.g. "Service & Cremation") */
label: string; label: string;
/** Whether this filter is active */ }
active: boolean;
/** 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];
} }
/** Props for the ProvidersStep page component */ /** Props for the ProvidersStep page component */
@@ -58,25 +77,77 @@ export interface ProvidersStepProps {
onSearchChange: (query: string) => void; onSearchChange: (query: string) => void;
/** Callback when search is submitted */ /** Callback when search is submitted */
onSearch?: (query: string) => void; onSearch?: (query: string) => void;
/** Filter chips */ /** Current filter state */
filters?: ProviderFilter[]; filterValues: ProviderFilterValues;
/** Callback when a filter chip is toggled */ /** Callback when any filter changes */
onFilterToggle?: (index: number) => void; onFilterChange: (values: ProviderFilterValues) => void;
/** Callback to clear all filters */ /** Available service tradition options for the autocomplete */
onFilterClear?: () => void; 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;
/** Callback for the Back button */ /** Callback for the Back button */
onBack: () => void; onBack: () => void;
/** Map panel content — slot for future map integration */ /** Map panel content — slot for future map integration */
mapPanel?: React.ReactNode; mapPanel?: React.ReactNode;
/** Navigation bar — passed through to WizardLayout */ /** Navigation bar — passed through to WizardLayout */
navigation?: React.ReactNode; 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) */ /** Whether this is a pre-planning flow (shows softer copy) */
isPrePlanning?: boolean; isPrePlanning?: boolean;
/** MUI sx prop for the root */ /** MUI sx prop for the root */
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
} }
// ─── Component ─────────────────────────────────────────────────────────────── // ─── Defaults ────<E29480><E29480>──────────────────────<EFBFBD><EFBFBD>───────────────────────────────────
const DEFAULT_TRADITIONS = [
'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' },
];
export const EMPTY_FILTER_VALUES: ProviderFilterValues = {
tradition: null,
funeralTypes: [],
verifiedOnly: false,
onlineArrangements: false,
priceRange: [0, 15000],
};
// ──<E29480><E29480><EFBFBD> Component ─────────────────────────────────────────────────────<E29480><E29480><EFBFBD>─────────
/** /**
* Step 2 — Provider selection page for the FA arrangement wizard. * Step 2 — Provider selection page for the FA arrangement wizard.
@@ -88,6 +159,10 @@ export interface ProvidersStepProps {
* Click-to-navigate (D-D): clicking a provider card triggers * Click-to-navigate (D-D): clicking a provider card triggers
* navigation directly — no selection state or Continue button. * navigation directly — no selection state or Continue button.
* *
* Filters: service tradition (autocomplete), funeral type (chips),
* verified only (switch), online arrangements (switch), price range
* (dual-knob slider with editable inputs).
*
* Pure presentation component — props in, callbacks out. * Pure presentation component — props in, callbacks out.
* *
* Spec: documentation/steps/steps/02_providers.yaml * Spec: documentation/steps/steps/02_providers.yaml
@@ -98,12 +173,17 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
searchQuery, searchQuery,
onSearchChange, onSearchChange,
onSearch, onSearch,
filters, filterValues,
onFilterToggle, onFilterChange,
onFilterClear, traditionOptions = DEFAULT_TRADITIONS,
funeralTypeOptions = DEFAULT_FUNERAL_TYPES,
minPrice = 0,
maxPrice = 15000,
onBack, onBack,
mapPanel, mapPanel,
navigation, navigation,
progressStepper,
runningTotal,
isPrePlanning = false, isPrePlanning = false,
sx, sx,
}) => { }) => {
@@ -111,10 +191,59 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
? 'Take your time exploring providers. You can always come back and choose a different one.' ? '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.'; : 'These providers are near your location. Each has their own packages and pricing.';
// ─── 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 =
(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 = () => {
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 ( return (
<WizardLayout <WizardLayout
variant="list-map" variant="list-map"
navigation={navigation} navigation={navigation}
progressStepper={progressStepper}
runningTotal={runningTotal}
showBackLink showBackLink
backLabel="Back" backLabel="Back"
onBack={onBack} onBack={onBack}
@@ -187,27 +316,138 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
/> />
{/* Filters — right-aligned below search */} {/* Filters — right-aligned below search */}
{filters && filters.length > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}> <Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
<FilterPanel <FilterPanel activeCount={activeCount} onClear={handleClear}>
activeCount={filters.filter((f) => f.active).length} {/* ── Service tradition ── */}
onClear={onFilterClear} <Box>
> <Typography variant="label" sx={{ mb: 1, display: 'block' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> Service tradition
{filters.map((filter, index) => ( </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="label" sx={{ mb: 1.5, display: 'block' }}>
Funeral type
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{funeralTypeOptions.map((option) => (
<Chip <Chip
key={filter.label} key={option.value}
label={filter.label} label={option.label}
selected={filter.active} selected={filterValues.funeralTypes.includes(option.value)}
onClick={onFilterToggle ? () => onFilterToggle(index) : undefined} onClick={() => handleFuneralTypeToggle(option.value)}
variant="outlined" variant="outlined"
size="small" size="small"
/> />
))} ))}
</Box> </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="label" sx={{ mb: 1.5, display: 'block' }}>
Price range
</Typography>
<Box sx={{ px: 1, mb: 1.5 }}>
<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 }}>
<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',
}}
sx={{ flex: 1 }}
/>
<Typography variant="body2" color="text.secondary" sx={{ alignSelf: 'center' }}>
</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',
}}
sx={{ flex: 1 }}
/>
</Box>
</Box>
</FilterPanel> </FilterPanel>
</Box> </Box>
)}
{/* Results count */} {/* Results count */}
<Typography <Typography