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; } // ─── 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 = ({ 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); // 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 ( {/* Floating view toggle */} 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)' }, }, }, }} > List Map {/* Map content */} {mapPanel || ( Map coming soon )} } > {/* Heading — scrolls with listings */} Find a funeral director {subheading} {/* Sticky controls — search + filters pinned while listings scroll */} {/* 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. */} { // 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 ( ); }) } renderInput={(params) => ( {params.InputProps.startAdornment} ), endAdornment: ( commitSearch(searchDraft)} sx={{ width: 28, height: 28, borderRadius: '50%', bgcolor: 'primary.main', color: 'primary.contrastText', '&:hover': { bgcolor: 'primary.dark' }, '&:focus-visible': { outline: 'none' }, }} > ), }} /> )} 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 */} {/* Filters */} {/* ── Location ── */} Location { // Take the last entered value as the active search const last = newValue[newValue.length - 1] ?? ''; onSearchChange(typeof last === 'string' ? last : ''); }} options={[]} renderInput={(params) => ( {params.InputProps.startAdornment} ), }} /> )} size="small" /> {/* ── Service tradition ── */} Service tradition onFilterChange({ ...filterValues, tradition: newValue })} options={traditionOptions} renderInput={(params) => ( )} clearOnEscape size="small" /> {/* ── Funeral type ── */} Funeral type {funeralTypeOptions.map((option) => ( 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', style: { padding: '6px 0' }, }} sx={{ flex: 1, '& .MuiOutlinedInput-root': { fontSize: '0.875rem' } }} /> 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', style: { padding: '6px 0' }, }} sx={{ flex: 1, '& .MuiOutlinedInput-root': { fontSize: '0.875rem' } }} /> {/* Sort — compact menu button, pushed right */} setSortAnchor(null)} anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} transformOrigin={{ vertical: 'top', horizontal: 'right' }} > {SORT_OPTIONS.map((opt) => ( { onSortChange?.(opt.value); setSortAnchor(null); }} sx={{ fontSize: '0.813rem' }} > {opt.label} ))} {/* Results count — below controls */} {providers.length} {' '} provider{providers.length !== 1 ? 's' : ''} found {/* Provider list — click-to-navigate (D-D) */} {providers.map((provider) => ( onSelectProvider(provider.id)} aria-label={`${provider.name}, ${provider.location}${provider.rating ? `, rated ${provider.rating}` : ''}${provider.startingPrice ? `, from $${provider.startingPrice}` : ''}`} /> ))} {providers.length === 0 && ( No providers found matching your search. Try adjusting your search or clearing filters. )} ); }; ProvidersStep.displayName = 'ProvidersStep'; export default ProvidersStep;