diff --git a/src/components/pages/ProvidersStep/ProvidersStep.stories.tsx b/src/components/pages/ProvidersStep/ProvidersStep.stories.tsx index 31ea648..c07ead0 100644 --- a/src/components/pages/ProvidersStep/ProvidersStep.stories.tsx +++ b/src/components/pages/ProvidersStep/ProvidersStep.stories.tsx @@ -1,7 +1,8 @@ import { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; 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 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 ──────────────────────────────────────────────────────────────────── const meta: Meta = { @@ -118,11 +113,11 @@ type Story = StoryObj; // ─── 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 = { render: () => { const [query, setQuery] = useState(''); - const [filters, setFilters] = useState(defaultFilters); + const [filters, setFilters] = useState(EMPTY_FILTER_VALUES); const filtered = mockProviders.filter((p) => p.location.toLowerCase().includes(query.toLowerCase()), @@ -134,11 +129,41 @@ export const Default: Story = { onSelectProvider={(id) => alert(`Navigate to provider: ${id}`)} searchQuery={query} onSearchChange={setQuery} - filters={filters} - onFilterToggle={(i) => - setFilters((prev) => prev.map((f, idx) => (idx === i ? { ...f, active: !f.active } : f))) - } - onFilterClear={() => setFilters((prev) => prev.map((f) => ({ ...f, active: false })))} + filterValues={filters} + onFilterChange={setFilters} + onBack={() => alert('Back')} + navigation={nav} + /> + ); + }, +}; + +// ─── With active filters ──────────────────────────────────────────────────── + +/** Filters pre-applied — verified only + price cap */ +export const WithActiveFilters: Story = { + render: () => { + const [query, setQuery] = useState(''); + const [filters, setFilters] = useState({ + 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 ( + alert(`Navigate to provider: ${id}`)} + searchQuery={query} + onSearchChange={setQuery} + filterValues={filters} + onFilterChange={setFilters} onBack={() => alert('Back')} navigation={nav} /> @@ -152,6 +177,7 @@ export const Default: Story = { export const PrePlanning: Story = { render: () => { const [query, setQuery] = useState(''); + const [filters, setFilters] = useState(EMPTY_FILTER_VALUES); return ( alert(`Navigate to provider: ${id}`)} searchQuery={query} onSearchChange={setQuery} + filterValues={filters} + onFilterChange={setFilters} onBack={() => alert('Back')} navigation={nav} isPrePlanning @@ -173,6 +201,7 @@ export const PrePlanning: Story = { export const EmptyResults: Story = { render: () => { const [query, setQuery] = useState('xyz'); + const [filters, setFilters] = useState(EMPTY_FILTER_VALUES); return ( {}} searchQuery={query} onSearchChange={setQuery} + filterValues={filters} + onFilterChange={setFilters} onBack={() => alert('Back')} navigation={nav} /> @@ -193,6 +224,7 @@ export const EmptyResults: Story = { export const SingleProvider: Story = { render: () => { const [query, setQuery] = useState(''); + const [filters, setFilters] = useState(EMPTY_FILTER_VALUES); return ( alert(`Navigate to provider: ${id}`)} searchQuery={query} onSearchChange={setQuery} + filterValues={filters} + onFilterChange={setFilters} onBack={() => alert('Back')} navigation={nav} /> diff --git a/src/components/pages/ProvidersStep/ProvidersStep.tsx b/src/components/pages/ProvidersStep/ProvidersStep.tsx index f8767cd..3ee517b 100644 --- a/src/components/pages/ProvidersStep/ProvidersStep.tsx +++ b/src/components/pages/ProvidersStep/ProvidersStep.tsx @@ -2,15 +2,20 @@ 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 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 { Chip } from '../../atoms/Chip'; +import { Switch } from '../../atoms/Switch'; import { Typography } from '../../atoms/Typography'; +import { Divider } from '../../atoms/Divider'; -// ─── Types ─────────────────────────────────────────────────────────────────── +// ──�� Types ─────────────────���───────────────────────────────────────────────── /** Provider data for display in the list */ export interface ProviderData { @@ -38,12 +43,26 @@ export interface ProviderData { description?: string; } -/** Filter chip state */ -export interface ProviderFilter { - /** Filter label */ +/** 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; - /** 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 */ @@ -58,25 +77,77 @@ export interface ProvidersStepProps { onSearchChange: (query: string) => void; /** Callback when search is submitted */ onSearch?: (query: string) => void; - /** Filter chips */ - filters?: ProviderFilter[]; - /** Callback when a filter chip is toggled */ - onFilterToggle?: (index: number) => void; - /** Callback to clear all filters */ - onFilterClear?: () => 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; /** 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; } -// ─── Component ─────────────────────────────────────────────────────────────── +// ─── Defaults ────��──────────────────────��─────────────────────────────────── + +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], +}; + +// ──��� Component ─────────────────────────────────────────────────────���───────── /** * 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 * 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. * * Spec: documentation/steps/steps/02_providers.yaml @@ -98,12 +173,17 @@ export const ProvidersStep: React.FC = ({ searchQuery, onSearchChange, onSearch, - filters, - onFilterToggle, - onFilterClear, + filterValues, + onFilterChange, + traditionOptions = DEFAULT_TRADITIONS, + funeralTypeOptions = DEFAULT_FUNERAL_TYPES, + minPrice = 0, + maxPrice = 15000, onBack, mapPanel, navigation, + progressStepper, + runningTotal, isPrePlanning = false, sx, }) => { @@ -111,10 +191,59 @@ export const ProvidersStep: React.FC = ({ ? '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.'; + // ─── 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 ( = ({ /> {/* Filters — right-aligned below search */} - {filters && filters.length > 0 && ( - - f.active).length} - onClear={onFilterClear} - > - - {filters.map((filter, index) => ( + + + {/* ── Service tradition ── */} + + + Service tradition + + onFilterChange({ ...filterValues, tradition: newValue })} + options={traditionOptions} + renderInput={(params) => ( + + )} + clearOnEscape + size="small" + /> + + + + + {/* ── Funeral type ── */} + + + Funeral type + + + {funeralTypeOptions.map((option) => ( onFilterToggle(index) : undefined} + key={option.value} + label={option.label} + selected={filterValues.funeralTypes.includes(option.value)} + onClick={() => handleFuneralTypeToggle(option.value)} variant="outlined" size="small" /> ))} - - - )} + + + + + {/* ── Provider features ── */} + + + onFilterChange({ ...filterValues, verifiedOnly: checked }) + } + /> + } + label="Verified providers only" + sx={{ mx: 0 }} + /> + + onFilterChange({ ...filterValues, onlineArrangements: checked }) + } + /> + } + label="Online arrangements available" + sx={{ mx: 0 }} + /> + + + + + {/* ── Price range ── */} + + + Price range + + + + onFilterChange({ + ...filterValues, + priceRange: newValue as [number, number], + }) + } + min={minPrice} + max={maxPrice} + step={100} + valueLabelDisplay="auto" + valueLabelFormat={(v) => `$${v.toLocaleString('en-AU')}`} + color="primary" + /> + + + setPriceMinInput(e.target.value.replace(/[^0-9]/g, ''))} + onBlur={commitPriceRange} + onKeyDown={(e) => e.key === 'Enter' && commitPriceRange()} + InputProps={{ + startAdornment: $, + }} + inputProps={{ + inputMode: 'numeric', + 'aria-label': 'Minimum price', + }} + sx={{ flex: 1 }} + /> + + – + + setPriceMaxInput(e.target.value.replace(/[^0-9]/g, ''))} + onBlur={commitPriceRange} + onKeyDown={(e) => e.key === 'Enter' && commitPriceRange()} + InputProps={{ + startAdornment: $, + }} + inputProps={{ + inputMode: 'numeric', + 'aria-label': 'Maximum price', + }} + sx={{ flex: 1 }} + /> + + + + {/* Results count */}