Refine FuneralFinder v1 — full stepped flow, always-visible CTA

- Extend one-question-at-a-time pattern through all steps (type, service, location)
- CTA + location always visible at bottom; smart defaults for missing optional fields
- Minimum search requirements: intent + location; type/service/themes default to "all"
- Funeral types: Cremation, Burial, Water Burial (QLD only) + Explore All as TypeCard
- Service preference step (conditional): With a service / No service / I'm flexible
- Theme preferences (eco-friendly, budget-friendly, religious specialisation) as optional
  sub-option within type step
- StepHeading sub-component: bodyLg centered, distinct from card labels
- CompletedRows: generous py:1.5 spacing, caption-size "Change" with aria-label
- Loading prop on CTA button, location validation (3+ chars)
- Divider under subheading for visual structure
- Main heading upgraded to h2 with display font
- Audit: 14/20 (Good), Critique: 29/40 (Good)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 11:39:57 +11:00
parent e6f7817c18
commit a655245842
2 changed files with 402 additions and 218 deletions

View File

@@ -4,26 +4,31 @@ import { FuneralFinder } from './FuneralFinder';
import { Navigation } from '../Navigation'; import { Navigation } from '../Navigation';
import { Typography } from '../../atoms/Typography'; import { Typography } from '../../atoms/Typography';
// ─── Shared data ────────────────────────────────────────────────────────────
const funeralTypes = [ const funeralTypes = [
{ id: 'cremation', label: 'Cremation' }, { id: 'cremation', label: 'Cremation', hasServiceOption: true },
{ id: 'burial', label: 'Burial' }, { id: 'burial', label: 'Burial', hasServiceOption: true },
{ id: 'memorial', label: 'Memorial' }, { id: 'water-burial', label: 'Water Burial', note: 'Available in QLD only', hasServiceOption: false },
{ id: 'catholic', label: 'Catholic' }, ];
{ id: 'direct-cremation', label: 'Direct Cremation' },
{ id: 'natural-burial', label: 'Natural Burial' }, const themeOptions = [
{ id: 'eco-friendly', label: 'Eco-friendly' },
{ id: 'budget-friendly', label: 'Budget-friendly' },
{ id: 'religious', label: 'Religious specialisation' },
]; ];
const FALogoNav = () => ( const FALogoNav = () => (
<Box component="img" src="/brandlogo/logo-full.svg" alt="Funeral Arranger" sx={{ height: 28 }} /> <Box component="img" src="/brandlogo/logo-full.svg" alt="Funeral Arranger" sx={{ height: 28 }} />
); );
// ─── Meta ───────────────────────────────────────────────────────────────────
const meta: Meta<typeof FuneralFinder> = { const meta: Meta<typeof FuneralFinder> = {
title: 'Organisms/FuneralFinder', title: 'Organisms/FuneralFinder',
component: FuneralFinder, component: FuneralFinder,
tags: ['autodocs'], tags: ['autodocs'],
parameters: { parameters: { layout: 'centered' },
layout: 'centered',
},
decorators: [ decorators: [
(Story) => ( (Story) => (
<Box sx={{ maxWidth: 520, width: '100%' }}> <Box sx={{ maxWidth: 520, width: '100%' }}>
@@ -36,41 +41,67 @@ const meta: Meta<typeof FuneralFinder> = {
export default meta; export default meta;
type Story = StoryObj<typeof FuneralFinder>; type Story = StoryObj<typeof FuneralFinder>;
// --- Default ----------------------------------------------------------------- // ─── Default ────────────────────────────────────────────────────────────────
/** Initial state — step 1 active, all others locked */ /** Initial state — full feature set with types, themes, and explore-all. */
export const Default: Story = { export const Default: Story = {
args: { args: {
funeralTypes, funeralTypes,
themeOptions,
onSearch: (params) => alert(JSON.stringify(params, null, 2)), onSearch: (params) => alert(JSON.stringify(params, null, 2)),
}, },
}; };
// --- Fewer Funeral Types ----------------------------------------------------- // ─── Without Themes ─────────────────────────────────────────────────────────
/** With only 3 funeral types — shows compact chip row */ /** No theme options — skips the preferences section on the final step. */
export const FewerTypes: Story = { export const WithoutThemes: Story = {
args: { args: {
funeralTypes: funeralTypes.slice(0, 3), funeralTypes,
onSearch: (params) => alert(JSON.stringify(params, null, 2)), onSearch: (params) => alert(JSON.stringify(params, null, 2)),
}, },
}; };
// --- Custom Heading ---------------------------------------------------------- // ─── Without Explore All ────────────────────────────────────────────────────
/** With custom heading and subheading */ /** Explore-all option hidden — users must pick a specific type. */
export const WithoutExploreAll: Story = {
args: {
funeralTypes,
themeOptions,
showExploreAll: false,
onSearch: (params) => alert(JSON.stringify(params, null, 2)),
},
};
// ─── Loading State ──────────────────────────────────────────────────────────
/** CTA in loading state — shows spinner, button disabled. */
export const Loading: Story = {
args: {
funeralTypes,
themeOptions,
loading: true,
onSearch: (params) => alert(JSON.stringify(params, null, 2)),
},
};
// ─── Custom Heading ─────────────────────────────────────────────────────────
/** Custom heading and subheading for alternate page contexts. */
export const CustomHeading: Story = { export const CustomHeading: Story = {
args: { args: {
funeralTypes, funeralTypes,
themeOptions,
heading: 'Compare funeral directors in your area', heading: 'Compare funeral directors in your area',
subheading: 'Transparent pricing · No hidden fees · 24/7', subheading: 'Transparent pricing · No hidden fees · 24/7',
onSearch: (params) => alert(JSON.stringify(params, null, 2)), onSearch: (params) => alert(JSON.stringify(params, null, 2)),
}, },
}; };
// --- In Hero Context (Desktop) ----------------------------------------------- // ─── In Hero Context (Desktop) ──────────────────────────────────────────────
/** As it appears in the homepage hero — desktop layout */ /** As it appears in the homepage hero — desktop layout. */
export const InHeroDesktop: Story = { export const InHeroDesktop: Story = {
decorators: [ decorators: [
(Story) => ( (Story) => (
@@ -90,8 +121,6 @@ export const InHeroDesktop: Story = {
{ label: 'Log in', href: '/login' }, { label: 'Log in', href: '/login' },
]} ]}
/> />
{/* Hero section */}
<Box <Box
sx={{ sx={{
display: 'grid', display: 'grid',
@@ -100,7 +129,6 @@ export const InHeroDesktop: Story = {
bgcolor: 'var(--fa-color-brand-100)', bgcolor: 'var(--fa-color-brand-100)',
}} }}
> >
{/* Left: heading + search widget */}
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
@@ -115,11 +143,7 @@ export const InHeroDesktop: Story = {
<Typography <Typography
variant="displaySm" variant="displaySm"
component="h1" component="h1"
sx={{ sx={{ textAlign: 'center', mb: 2, color: 'var(--fa-color-brand-950)' }}
textAlign: 'center',
mb: 2,
color: 'var(--fa-color-brand-950)',
}}
> >
Discover, Explore, and Plan Funerals in Minutes Discover, Explore, and Plan Funerals in Minutes
</Typography> </Typography>
@@ -131,20 +155,19 @@ export const InHeroDesktop: Story = {
Whether you're thinking ahead or arranging for a loved one, find Whether you're thinking ahead or arranging for a loved one, find
trusted local providers with transparent pricing. trusted local providers with transparent pricing.
</Typography> </Typography>
<FuneralFinder <FuneralFinder
funeralTypes={funeralTypes} funeralTypes={funeralTypes}
themeOptions={themeOptions}
onSearch={(params) => alert(JSON.stringify(params, null, 2))} onSearch={(params) => alert(JSON.stringify(params, null, 2))}
/> />
</Box> </Box>
</Box> </Box>
{/* Right: hero image placeholder */}
<Box <Box
sx={{ sx={{
display: { xs: 'none', md: 'block' }, display: { xs: 'none', md: 'block' },
bgcolor: 'var(--fa-color-brand-200)', bgcolor: 'var(--fa-color-brand-200)',
backgroundImage: 'url(https://images.unsplash.com/photo-1516733968668-dbdce39c0571?w=800&h=600&fit=crop)', backgroundImage:
'url(https://images.unsplash.com/photo-1516733968668-dbdce39c0571?w=800&h=600&fit=crop)',
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center', backgroundPosition: 'center',
}} }}
@@ -154,9 +177,9 @@ export const InHeroDesktop: Story = {
), ),
}; };
// --- In Hero Context (Mobile) ------------------------------------------------ // ─── In Hero Context (Mobile) ───────────────────────────────────────────────
/** Mobile viewport — stacked layout with image above search */ /** Mobile viewport — stacked layout with image above search. */
export const InHeroMobile: Story = { export const InHeroMobile: Story = {
decorators: [ decorators: [
(Story) => ( (Story) => (
@@ -175,29 +198,14 @@ export const InHeroMobile: Story = {
{ label: 'Log in', href: '/login' }, { label: 'Log in', href: '/login' },
]} ]}
/> />
<Box sx={{ bgcolor: 'var(--fa-color-brand-100)', px: 3, py: 4, textAlign: 'center' }}>
{/* Hero heading */} <Typography variant="h3" component="h1" sx={{ mb: 1.5, color: 'var(--fa-color-brand-950)' }}>
<Box
sx={{
bgcolor: 'var(--fa-color-brand-100)',
px: 3,
py: 4,
textAlign: 'center',
}}
>
<Typography
variant="h3"
component="h1"
sx={{ mb: 1.5, color: 'var(--fa-color-brand-950)' }}
>
Discover, Explore, and Plan Funerals in Minutes Discover, Explore, and Plan Funerals in Minutes
</Typography> </Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Find trusted local providers with transparent pricing, at your own pace. Find trusted local providers with transparent pricing, at your own pace.
</Typography> </Typography>
</Box> </Box>
{/* Hero image */}
<Box <Box
sx={{ sx={{
height: 180, height: 180,
@@ -207,11 +215,10 @@ export const InHeroMobile: Story = {
backgroundPosition: 'center', backgroundPosition: 'center',
}} }}
/> />
{/* Search widget — overlaps image slightly */}
<Box sx={{ px: 2, mt: -3, pb: 4, bgcolor: 'var(--fa-color-brand-100)' }}> <Box sx={{ px: 2, mt: -3, pb: 4, bgcolor: 'var(--fa-color-brand-100)' }}>
<FuneralFinder <FuneralFinder
funeralTypes={funeralTypes} funeralTypes={funeralTypes}
themeOptions={themeOptions}
onSearch={(params) => alert(JSON.stringify(params, null, 2))} onSearch={(params) => alert(JSON.stringify(params, null, 2))}
/> />
</Box> </Box>

View File

@@ -1,10 +1,13 @@
import React from 'react'; import React from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Collapse from '@mui/material/Collapse';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import type { SxProps, Theme } from '@mui/material/styles'; import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography'; import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button'; import { Button } from '../../atoms/Button';
import { Input } from '../../atoms/Input'; import { Input } from '../../atoms/Input';
import { Chip } from '../../atoms/Chip';
import { Divider } from '../../atoms/Divider';
import { Link } from '../../atoms/Link'; import { Link } from '../../atoms/Link';
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
@@ -15,6 +18,20 @@ export interface FuneralTypeOption {
id: string; id: string;
/** Display label */ /** Display label */
label: string; label: string;
/** Brief description shown below the label */
description?: string;
/** Availability note, e.g., "Available in QLD only" */
note?: string;
/** Whether this type supports with/without service toggle */
hasServiceOption?: boolean;
}
/** A thematic filter option */
export interface ThemeOption {
/** Unique identifier */
id: string;
/** Display label */
label: string;
} }
/** Search parameters returned when the user submits */ /** Search parameters returned when the user submits */
@@ -23,8 +40,12 @@ export interface FuneralSearchParams {
intent: 'arrange' | 'preplan'; intent: 'arrange' | 'preplan';
/** Only present when intent is "preplan" */ /** Only present when intent is "preplan" */
planningFor?: 'myself' | 'someone-else'; planningFor?: 'myself' | 'someone-else';
/** Selected funeral type ID */ /** Selected funeral type ID, or null if "Explore all" / not specified */
funeralTypeId: string; funeralTypeId: string | null;
/** "with-service", "without-service", or "either" */
servicePreference: 'with-service' | 'without-service' | 'either';
/** Selected theme filter IDs (may be empty) */
themes: string[];
/** Suburb or postcode entered */ /** Suburb or postcode entered */
location: string; location: string;
} }
@@ -33,12 +54,18 @@ export interface FuneralSearchParams {
export interface FuneralFinderProps { export interface FuneralFinderProps {
/** Available funeral types — dynamic list from API */ /** Available funeral types — dynamic list from API */
funeralTypes: FuneralTypeOption[]; funeralTypes: FuneralTypeOption[];
/** Called when the user clicks "Find funeral directors" */ /** Optional thematic filter options (e.g., eco-friendly, budget-friendly) */
themeOptions?: ThemeOption[];
/** Called when the user clicks "Find funeral providers" */
onSearch?: (params: FuneralSearchParams) => void; onSearch?: (params: FuneralSearchParams) => void;
/** Whether a search is in progress — shows loading state on the CTA */
loading?: boolean;
/** Optional heading override */ /** Optional heading override */
heading?: string; heading?: string;
/** Optional subheading override */ /** Optional subheading override */
subheading?: string; subheading?: string;
/** Show "Explore all options" choice. Default true. */
showExploreAll?: boolean;
/** MUI sx prop for the root card */ /** MUI sx prop for the root card */
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
} }
@@ -47,9 +74,21 @@ export interface FuneralFinderProps {
type Intent = 'arrange' | 'preplan' | null; type Intent = 'arrange' | 'preplan' | null;
type PlanningFor = 'myself' | 'someone-else' | null; type PlanningFor = 'myself' | 'someone-else' | null;
type ServicePref = 'with-service' | 'without-service' | 'either';
// ─── Sub-components ────────────────────────────────────────────────────────── // ─── Sub-components ──────────────────────────────────────────────────────────
/** Step question heading — centered, larger than card labels */
function StepHeading({ children }: { children: React.ReactNode }) {
return (
<Box sx={{ mb: 2.5 }}>
<Typography variant="bodyLg" sx={{ fontWeight: 600, textAlign: 'center' }}>
{children}
</Typography>
</Box>
);
}
/** Large tappable option for binary choices (intent, planning-for) */ /** Large tappable option for binary choices (intent, planning-for) */
function ChoiceCard({ function ChoiceCard({
label, label,
@@ -69,8 +108,7 @@ function ChoiceCard({
aria-checked={selected} aria-checked={selected}
onClick={onClick} onClick={onClick}
sx={{ sx={{
flex: 1, width: '100%',
minWidth: 0,
px: 2.5, px: 2.5,
py: 2, py: 2,
border: '2px solid', border: '2px solid',
@@ -85,6 +123,7 @@ function ChoiceCard({
borderColor: 'var(--fa-color-brand-400)', borderColor: 'var(--fa-color-brand-400)',
bgcolor: selected ? 'var(--fa-color-surface-warm)' : 'var(--fa-color-brand-50)', bgcolor: selected ? 'var(--fa-color-surface-warm)' : 'var(--fa-color-brand-50)',
}, },
'&:active': { filter: 'brightness(0.95)' },
'&:focus-visible': { '&:focus-visible': {
outline: '2px solid var(--fa-color-brand-500)', outline: '2px solid var(--fa-color-brand-500)',
outlineOffset: 2, outlineOffset: 2,
@@ -107,16 +146,7 @@ function ChoiceCard({
</Typography> </Typography>
</Box> </Box>
{description && ( {description && (
<Typography <Typography variant="caption" component="span" sx={{ display: 'block', mt: 0.5, color: 'text.secondary' }}>
variant="caption"
component="span"
sx={{
display: 'block',
mt: 0.5,
color: 'text.secondary',
ml: selected ? 3.5 : 0,
}}
>
{description} {description}
</Typography> </Typography>
)} )}
@@ -124,13 +154,17 @@ function ChoiceCard({
); );
} }
/** Funeral type option — generous pill button */ /** Funeral type card — compact selectable card */
function TypePill({ function TypeCard({
label, label,
description,
note,
selected, selected,
onClick, onClick,
}: { }: {
label: string; label: string;
description?: string;
note?: string;
selected: boolean; selected: boolean;
onClick: () => void; onClick: () => void;
}) { }) {
@@ -141,30 +175,54 @@ function TypePill({
aria-checked={selected} aria-checked={selected}
onClick={onClick} onClick={onClick}
sx={{ sx={{
px: 2.5, width: '100%',
py: 1.25, minHeight: 44,
px: 2,
py: 2,
border: '2px solid', border: '2px solid',
borderColor: selected ? 'var(--fa-color-brand-500)' : 'var(--fa-color-neutral-200)', borderColor: selected ? 'var(--fa-color-brand-500)' : 'var(--fa-color-neutral-200)',
borderRadius: 'var(--fa-border-radius-full)', borderRadius: 'var(--fa-border-radius-md)',
bgcolor: selected ? 'var(--fa-color-surface-warm)' : 'var(--fa-color-surface-default)', bgcolor: selected ? 'var(--fa-color-surface-warm)' : 'var(--fa-color-surface-default)',
cursor: 'pointer', cursor: 'pointer',
fontFamily: 'inherit', fontFamily: 'inherit',
fontSize: '0.875rem', textAlign: 'left',
fontWeight: selected ? 600 : 500,
color: selected ? 'var(--fa-color-brand-700)' : 'text.primary',
whiteSpace: 'nowrap',
transition: 'border-color 150ms ease-in-out, background-color 150ms ease-in-out', transition: 'border-color 150ms ease-in-out, background-color 150ms ease-in-out',
'&:hover': { '&:hover': {
borderColor: 'var(--fa-color-brand-400)', borderColor: 'var(--fa-color-brand-400)',
bgcolor: selected ? 'var(--fa-color-surface-warm)' : 'var(--fa-color-brand-50)', bgcolor: selected ? 'var(--fa-color-surface-warm)' : 'var(--fa-color-brand-50)',
}, },
'&:active': { filter: 'brightness(0.95)' },
'&:focus-visible': { '&:focus-visible': {
outline: '2px solid var(--fa-color-brand-500)', outline: '2px solid var(--fa-color-brand-500)',
outlineOffset: 2, outlineOffset: 2,
}, },
}} }}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{selected && (
<CheckCircleOutlineIcon
aria-hidden="true"
sx={{ fontSize: 20, color: 'var(--fa-color-brand-500)', flexShrink: 0 }}
/>
)}
<Typography
variant="body2"
component="span"
sx={{ fontWeight: 600, color: selected ? 'var(--fa-color-brand-700)' : 'text.primary' }}
> >
{label} {label}
</Typography>
</Box>
{description && (
<Typography variant="caption" component="span" sx={{ display: 'block', mt: 0.25, color: 'text.secondary' }}>
{description}
</Typography>
)}
{note && (
<Typography variant="captionSm" component="span" sx={{ display: 'block', mt: 0.5, color: 'text.secondary', fontWeight: 500 }}>
{note}
</Typography>
)}
</Box> </Box>
); );
} }
@@ -186,7 +244,7 @@ function CompletedRow({
alignItems: 'baseline', alignItems: 'baseline',
flexWrap: 'wrap', flexWrap: 'wrap',
gap: 0.75, gap: 0.75,
py: 1, py: 1.5,
borderBottom: '1px solid', borderBottom: '1px solid',
borderColor: 'var(--fa-color-neutral-100)', borderColor: 'var(--fa-color-neutral-100)',
}} }}
@@ -201,7 +259,9 @@ function CompletedRow({
component="button" component="button"
variant="caption" variant="caption"
onClick={onChangeClick} onClick={onChangeClick}
sx={{ color: 'text.secondary', ml: 'auto' }} underline="hover"
aria-label={`Change ${question.toLowerCase()}`}
sx={{ color: 'text.secondary', ml: 'auto', minHeight: 44, display: 'inline-flex', alignItems: 'center' }}
> >
Change Change
</Link> </Link>
@@ -209,63 +269,94 @@ function CompletedRow({
); );
} }
// ─── Service preference options ──────────────────────────────────────────────
const SERVICE_OPTIONS: { value: ServicePref; label: string }[] = [
{ value: 'with-service', label: 'With a service' },
{ value: 'without-service', label: 'No service' },
{ value: 'either', label: "I'm flexible" },
];
// ─── Component ─────────────────────────────────────────────────────────────── // ─── Component ───────────────────────────────────────────────────────────────
/** /**
* Hero search widget for the FA homepage. * Hero search widget for the FA homepage.
* *
* Guides users through a conversational stepped flow to find funeral directors. * Guides users through a conversational stepped flow to find funeral providers.
* Each question appears naturally after the previous is answered. Completed * Every question is its own step that collapses to a compact summary row once
* answers collapse to a compact row with a "Change" link to revert. * answered. The location input and CTA are always visible at the bottom —
* minimum search requirements are intent + location; all other fields default
* to "show all" if not explicitly answered.
* *
* Flow: * Flow:
* 1. "How can we help?" → Arrange now / Pre-plan * 1. "How can we help?" → Arrange now / Pre-plan
* 2. "Who is this for?" (pre-plan only) → Myself / Someone else * 2. "Who is this for?" (pre-plan only) → Myself / Someone else
* 3. "What type of funeral?" → dynamic pill buttons * 3. "What type of funeral?" → type cards + "Explore all" + optional theme chips
* 4. "Where are you located?" → suburb/postcode input * 4. "Would you like a service?" (conditional) → chips (auto-advance)
* 5. CTA: "Find funeral directors" * 5. Location + CTA (always visible)
*
* Composes Typography + Button + Input + Link + custom ChoiceCard/TypePill.
*/ */
export const FuneralFinder = React.forwardRef<HTMLDivElement, FuneralFinderProps>( export const FuneralFinder = React.forwardRef<HTMLDivElement, FuneralFinderProps>(
( (
{ {
funeralTypes, funeralTypes,
themeOptions = [],
onSearch, onSearch,
loading = false,
heading = 'Find funeral directors near you', heading = 'Find funeral directors near you',
subheading = "Tell us a little about what you're looking for and we'll show you options in your area.", subheading = "Tell us a little about what you're looking for and we'll show you options in your area.",
showExploreAll = true,
sx, sx,
}, },
ref, ref,
) => { ) => {
const [intent, setIntent] = React.useState<Intent>(null); const [intent, setIntent] = React.useState<Intent>(null);
const [planningFor, setPlanningFor] = React.useState<PlanningFor>(null); const [planningFor, setPlanningFor] = React.useState<PlanningFor>(null);
const [funeralTypeId, setFuneralTypeId] = React.useState<string | null>(null); const [typeSelection, setTypeSelection] = React.useState<string | null>(null);
const [servicePref, setServicePref] = React.useState<ServicePref>('either');
const [serviceAnswered, setServiceAnswered] = React.useState(false);
const [selectedThemes, setSelectedThemes] = React.useState<string[]>([]);
const [location, setLocation] = React.useState(''); const [location, setLocation] = React.useState('');
const [locationError, setLocationError] = React.useState('');
const [showIntentPrompt, setShowIntentPrompt] = React.useState(false);
const [editingStep, setEditingStep] = React.useState<number | null>(null); const [editingStep, setEditingStep] = React.useState<number | null>(null);
const needsPlanningFor = intent === 'preplan'; const needsPlanningFor = intent === 'preplan';
const funeralTypeLabel = funeralTypes.find((ft) => ft.id === funeralTypeId)?.label; const isExploreAll = typeSelection === 'all';
const selectedType = funeralTypes.find((ft) => ft.id === typeSelection);
const showServiceStep = !isExploreAll && selectedType?.hasServiceOption === true;
const typeSelected = typeSelection !== null;
// Which step is currently active? // Which step is currently active? (0 = all complete)
const activeStep = (() => { const activeStep = (() => {
if (editingStep) return editingStep; if (editingStep !== null) return editingStep;
if (!intent) return 1; if (!intent) return 1;
if (needsPlanningFor && !planningFor) return 2; if (needsPlanningFor && !planningFor) return 2;
if (!funeralTypeId) return 3; if (!typeSelection) return 3;
return 4; if (showServiceStep && !serviceAnswered) return 4;
return 0;
})(); })();
const canSubmit = // ─── Labels ─────────────────────────────────────────────────────
intent !== null && const intentLabel = intent === 'arrange' ? 'Arrange a funeral now' : 'Pre-plan a funeral';
(!needsPlanningFor || planningFor !== null) && const planningForLabel = planningFor === 'myself' ? 'Myself' : 'Someone else';
funeralTypeId !== null &&
location.trim().length > 0;
// ─── Handlers ──────────────────────────────────────────────────── const typeLabel = isExploreAll ? 'All options' : (selectedType?.label ?? '');
const themeSuffix = selectedThemes
.map((id) => themeOptions.find((t) => t.id === id)?.label?.toLowerCase())
.filter(Boolean)
.join(', ');
const typeSummary = [typeLabel, themeSuffix].filter(Boolean).join(', ');
const serviceLabel =
servicePref === 'with-service' ? 'With a service'
: servicePref === 'without-service' ? 'No service'
: 'Flexible';
// ─── Handlers ───────────────────────────────────────────────────
const selectIntent = (value: Intent) => { const selectIntent = (value: Intent) => {
setIntent(value); setIntent(value);
if (value === 'arrange') setPlanningFor(null); if (value === 'arrange') setPlanningFor(null);
setShowIntentPrompt(false);
setEditingStep(null); setEditingStep(null);
}; };
@@ -274,26 +365,57 @@ export const FuneralFinder = React.forwardRef<HTMLDivElement, FuneralFinderProps
setEditingStep(null); setEditingStep(null);
}; };
const selectFuneralType = (id: string) => { const selectType = (id: string) => {
setFuneralTypeId(id); setTypeSelection(id);
if (id === 'all' || !funeralTypes.find((ft) => ft.id === id)?.hasServiceOption) {
setServicePref('either');
setServiceAnswered(false);
}
setEditingStep(null); setEditingStep(null);
}; };
const selectService = (value: ServicePref) => {
setServicePref(value);
setServiceAnswered(true);
setEditingStep(null);
};
const toggleTheme = (id: string) => {
setSelectedThemes((prev) =>
prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id],
);
};
const revertTo = (step: number) => { const revertTo = (step: number) => {
setEditingStep(step); setEditingStep(step);
}; };
const handleSubmit = () => { const handleSubmit = () => {
if (!canSubmit || !intent || !funeralTypeId) return; // Minimum: intent + location
if (!intent) {
setEditingStep(null);
setShowIntentPrompt(true);
return;
}
if (location.trim().length < 3) {
setLocationError('Please enter a suburb or postcode');
return;
}
setLocationError('');
setShowIntentPrompt(false);
// Smart defaults — missing optional fields default to "all"
onSearch?.({ onSearch?.({
intent, intent,
planningFor: needsPlanningFor ? (planningFor ?? undefined) : undefined, planningFor: needsPlanningFor ? (planningFor ?? undefined) : undefined,
funeralTypeId, funeralTypeId: isExploreAll ? null : (typeSelection ?? null),
servicePreference: (showServiceStep && serviceAnswered) ? servicePref : 'either',
themes: selectedThemes,
location: location.trim(), location: location.trim(),
}); });
}; };
// ─── Render ───────────────────────────────────────────────────── // ─── Render ─────────────────────────────────────────────────────
return ( return (
<Box <Box
ref={ref} ref={ref}
@@ -309,52 +431,49 @@ export const FuneralFinder = React.forwardRef<HTMLDivElement, FuneralFinderProps
> >
{/* Header */} {/* Header */}
<Typography <Typography
variant="h4" variant="h2"
component="h2" component="h2"
sx={{ fontFamily: 'var(--fa-font-family-display)', textAlign: 'center', mb: 1 }} sx={{ fontFamily: 'var(--fa-font-family-display)', textAlign: 'center', mb: 1.5 }}
> >
{heading} {heading}
</Typography> </Typography>
<Typography <Typography
variant="body2" variant="body2"
color="text.secondary" color="text.secondary"
sx={{ textAlign: 'center', mb: 3.5, maxWidth: 400, mx: 'auto' }} sx={{ textAlign: 'center', maxWidth: 400, mx: 'auto' }}
> >
{subheading} {subheading}
</Typography> </Typography>
<Divider sx={{ mt: 3, mb: 0 }} />
{/* Completed answers */} {/* ── Completed rows ─────────────────────────────────────── */}
{intent && activeStep > 1 && editingStep !== 1 && ( <Collapse in={intent !== null && activeStep !== 1} timeout={250}>
<CompletedRow <CompletedRow question="I'm here to" answer={intentLabel} onChangeClick={() => revertTo(1)} />
question="I'm here to" </Collapse>
answer={intent === 'arrange' ? 'Arrange a funeral now' : 'Pre-plan a funeral'} <Collapse in={needsPlanningFor && planningFor !== null && activeStep !== 2} timeout={250}>
onChangeClick={() => revertTo(1)} <CompletedRow question="Planning for" answer={planningForLabel} onChangeClick={() => revertTo(2)} />
/> </Collapse>
)} <Collapse in={typeSelected && activeStep !== 3} timeout={250}>
{needsPlanningFor && planningFor && activeStep > 2 && editingStep !== 2 && ( <CompletedRow question="Looking for" answer={typeSummary} onChangeClick={() => revertTo(3)} />
<CompletedRow </Collapse>
question="Planning for" <Collapse in={showServiceStep && serviceAnswered && activeStep !== 4} timeout={250}>
answer={planningFor === 'myself' ? 'Myself' : 'Someone else'} <CompletedRow question="Service" answer={serviceLabel} onChangeClick={() => revertTo(4)} />
onChangeClick={() => revertTo(2)} </Collapse>
/>
)}
{funeralTypeId && funeralTypeLabel && activeStep > 3 && editingStep !== 3 && (
<CompletedRow
question="Type of funeral"
answer={funeralTypeLabel}
onChangeClick={() => revertTo(3)}
/>
)}
{/* Active question */} {/* ── Step 1: Intent ─────────────────────────────────────── */}
<Box sx={{ mt: intent && activeStep > 1 && editingStep !== 1 ? 3 : 0 }}> <Collapse in={activeStep === 1} timeout={250}>
{/* Step 1: Intent */} <Box sx={{ mt: 3 }}>
{activeStep === 1 && ( {showIntentPrompt && (
<Box> <Typography
<Typography variant="body1" sx={{ fontWeight: 600, mb: 2 }}> variant="caption"
How can we help you today? role="alert"
sx={{ color: 'var(--fa-color-brand-600)', textAlign: 'center', display: 'block', mb: 1.5 }}
>
Please let us know how we can help
</Typography> </Typography>
<Box role="radiogroup" aria-label="How can we help" sx={{ display: 'flex', gap: 1.5, flexDirection: { xs: 'column', sm: 'row' } }}> )}
<StepHeading>How can we help you today?</StepHeading>
<Box role="radiogroup" aria-label="How can we help" sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<ChoiceCard <ChoiceCard
label="Arrange a funeral now" label="Arrange a funeral now"
description="Someone has passed and I need to make arrangements" description="Someone has passed and I need to make arrangements"
@@ -369,15 +488,13 @@ export const FuneralFinder = React.forwardRef<HTMLDivElement, FuneralFinderProps
/> />
</Box> </Box>
</Box> </Box>
)} </Collapse>
{/* Step 2: Planning for (conditional) */} {/* ── Step 2: Planning for (conditional) ─────────────────── */}
{activeStep === 2 && needsPlanningFor && ( <Collapse in={activeStep === 2 && needsPlanningFor} timeout={250}>
<Box> <Box sx={{ mt: 3 }}>
<Typography variant="body1" sx={{ fontWeight: 600, mb: 2 }}> <StepHeading>Who are you planning for?</StepHeading>
Who are you planning for? <Box role="radiogroup" aria-label="Who are you planning for" sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
</Typography>
<Box role="radiogroup" aria-label="Who are you planning for" sx={{ display: 'flex', gap: 1.5, flexDirection: { xs: 'column', sm: 'row' } }}>
<ChoiceCard <ChoiceCard
label="Myself" label="Myself"
description="I want to plan my own funeral in advance" description="I want to plan my own funeral in advance"
@@ -392,59 +509,119 @@ export const FuneralFinder = React.forwardRef<HTMLDivElement, FuneralFinderProps
/> />
</Box> </Box>
</Box> </Box>
)} </Collapse>
{/* Step 3: Funeral type */} {/* ── Step 3: Type + Preferences ─────────────────────────── */}
{activeStep === 3 && ( <Collapse in={activeStep === 3} timeout={250}>
<Box> <Box sx={{ mt: 3 }}>
<Typography variant="body1" sx={{ fontWeight: 600, mb: 2 }}> <StepHeading>What type of funeral are you considering?</StepHeading>
What type of funeral are you considering? <Box role="radiogroup" aria-label="Type of funeral" sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
</Typography>
<Box role="radiogroup" aria-label="Type of funeral" sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{funeralTypes.map((ft) => ( {funeralTypes.map((ft) => (
<TypePill <TypeCard
key={ft.id} key={ft.id}
label={ft.label} label={ft.label}
selected={funeralTypeId === ft.id} description={ft.description}
onClick={() => selectFuneralType(ft.id)} note={ft.note}
selected={typeSelection === ft.id}
onClick={() => selectType(ft.id)}
/>
))}
{showExploreAll && (
<TypeCard
label="Explore all options"
description="Browse everything available in your area"
selected={isExploreAll}
onClick={() => selectType('all')}
/>
)}
</Box>
{/* Theme preferences — optional, inside type step */}
{themeOptions.length > 0 && (
<Box sx={{ mt: 3 }}>
<Box sx={{ mb: 1.5 }}>
<Typography variant="body2" component="span" sx={{ fontWeight: 600 }}>
Any preferences?
</Typography>
<Typography variant="caption" component="span" color="text.secondary" sx={{ ml: 0.75 }}>
(optional)
</Typography>
</Box>
<Box role="group" aria-label="Preferences" sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{themeOptions.map((theme) => {
const isSelected = selectedThemes.includes(theme.id);
return (
<Chip
key={theme.id}
label={theme.label}
variant="outlined"
selected={isSelected}
onClick={() => toggleTheme(theme.id)}
clickable
aria-pressed={isSelected}
sx={{ height: 44 }}
/>
);
})}
</Box>
</Box>
)}
</Box>
</Collapse>
{/* ── Step 4: Service (conditional, auto-advance) ────────── */}
<Collapse in={activeStep === 4 && showServiceStep} timeout={250}>
<Box sx={{ mt: 3 }}>
<StepHeading>Would you like a service?</StepHeading>
<Box role="group" aria-label="Service preference" sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{SERVICE_OPTIONS.map((opt) => (
<Chip
key={opt.value}
label={opt.label}
variant="outlined"
selected={serviceAnswered && servicePref === opt.value}
onClick={() => selectService(opt.value)}
clickable
aria-pressed={serviceAnswered && servicePref === opt.value}
sx={{ justifyContent: 'flex-start', height: 44, borderRadius: 'var(--fa-border-radius-md)' }}
/> />
))} ))}
</Box> </Box>
</Box> </Box>
)} </Collapse>
{/* Step 4: Location */} {/* ── Always visible: Location + CTA ─────────────────────── */}
{activeStep === 4 && ( <Box sx={{ mt: 3 }}>
<Box> <Typography variant="body1" sx={{ fontWeight: 600, mb: 1.5 }}>
<Typography variant="body1" sx={{ fontWeight: 600, mb: 2 }}> Where are you looking?
Where are you located?
</Typography> </Typography>
<Input <Input
placeholder="Suburb or postcode" placeholder="Suburb or postcode"
value={location} value={location}
onChange={(e) => setLocation(e.target.value)} onChange={(e) => {
setLocation(e.target.value);
if (locationError) setLocationError('');
}}
size="small" size="small"
fullWidth fullWidth
inputProps={{ 'aria-label': 'Location — suburb or postcode' }} error={!!locationError}
helperText={locationError || 'Enter a suburb name or 4-digit postcode'}
inputProps={{ 'aria-label': 'Where are you looking — suburb or postcode' }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && canSubmit) handleSubmit(); if (e.key === 'Enter') handleSubmit();
}} }}
/> />
</Box>
)}
</Box>
{/* CTA — only visible once we're on the location step or beyond */}
{activeStep >= 4 && (
<Box sx={{ mt: 3 }}> <Box sx={{ mt: 3 }}>
<Button <Button
variant="contained" variant="contained"
size="large" size="large"
fullWidth fullWidth
disabled={!canSubmit} loading={loading}
disabled={loading}
onClick={handleSubmit} onClick={handleSubmit}
> >
Find funeral directors Find funeral providers
</Button> </Button>
<Typography <Typography
variant="captionSm" variant="captionSm"
@@ -454,7 +631,7 @@ export const FuneralFinder = React.forwardRef<HTMLDivElement, FuneralFinderProps
Free to use · No obligation Free to use · No obligation
</Typography> </Typography>
</Box> </Box>
)} </Box>
</Box> </Box>
); );
}, },