Redesign FuneralFinder — conversational flow, not bureaucratic form

Complete rewrite of the interaction model:

Before: numbered steps + uppercase overline labels + cramped option cards
  → felt like a government form, not gentle guidance

After: conversational questions with generous choice cards
  → "How can we help you today?" not "1. I'M HERE TO"

Key changes:
- Questions as warm sentences, not uppercase labels
- ChoiceCards: generous padding (20px × 16px), descriptions explaining
  each option, check icon on selection
- TypePills: pill-shaped buttons with proper touch targets for funeral types
- CompletedRow: "I'm here to: **Arrange a funeral now** · Change"
  — explicit, accessible, no hidden pencil icon
- Only shows one question at a time — true progressive disclosure
- CTA only appears once location step is reached
- No step numbers, no badges — the vertical flow IS the progression

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 09:19:29 +11:00
parent d7dddb0773
commit 1e8cd96648

View File

@@ -1,12 +1,11 @@
import React from 'react'; import React from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
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 { Chip } from '../../atoms/Chip';
import { Input } from '../../atoms/Input'; import { Input } from '../../atoms/Input';
import { Link } from '../../atoms/Link';
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
@@ -44,61 +43,24 @@ export interface FuneralFinderProps {
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
} }
// ─── Step state types ──────────────────────────────────────────────────────── // ─── Internal types ──────────────────────────────────────────────────────────
type Intent = 'arrange' | 'preplan' | null; type Intent = 'arrange' | 'preplan' | null;
type PlanningFor = 'myself' | 'someone-else' | null; type PlanningFor = 'myself' | 'someone-else' | null;
type StepStatus = 'active' | 'completed' | 'locked';
// ─── Sub-components ────────────────────────────────────────────────────────── // ─── Sub-components ──────────────────────────────────────────────────────────
/** Step number badge or completed checkmark */ /** Large tappable option for binary choices (intent, planning-for) */
function StepBadge({ step, status }: { step: number; status: StepStatus }) { function ChoiceCard({
if (status === 'completed') {
return (
<CheckCircleIcon
aria-hidden="true"
sx={{
fontSize: 24,
color: 'var(--fa-color-brand-500)',
flexShrink: 0,
}}
/>
);
}
return (
<Box
sx={{
width: 24,
height: 24,
borderRadius: '50%',
bgcolor: status === 'active' ? 'var(--fa-color-brand-500)' : 'var(--fa-color-neutral-300)',
color: status === 'active' ? 'var(--fa-color-white)' : 'var(--fa-color-neutral-500)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.75rem',
fontWeight: 600,
flexShrink: 0,
}}
>
{step}
</Box>
);
}
/** A selectable option card within a step — uses radio semantics */
function OptionCard({
label, label,
description,
selected, selected,
onClick, onClick,
disabled,
}: { }: {
label: string; label: string;
description?: string;
selected: boolean; selected: boolean;
onClick: () => void; onClick: () => void;
disabled?: boolean;
}) { }) {
return ( return (
<Box <Box
@@ -106,27 +68,95 @@ function OptionCard({
role="radio" role="radio"
aria-checked={selected} aria-checked={selected}
onClick={onClick} onClick={onClick}
disabled={disabled}
sx={{ sx={{
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
px: 2, px: 2.5,
py: 1.5, py: 2,
border: '2px solid', border: '2px solid',
borderColor: selected ? 'var(--fa-color-brand-500)' : 'divider', borderColor: selected ? 'var(--fa-color-brand-500)' : 'var(--fa-color-neutral-200)',
borderRadius: 'var(--fa-border-radius-md)', borderRadius: 'var(--fa-border-radius-md)',
bgcolor: selected ? 'var(--fa-color-surface-warm)' : 'transparent', bgcolor: selected ? 'var(--fa-color-surface-warm)' : 'var(--fa-color-surface-default)',
cursor: disabled ? 'default' : 'pointer', cursor: 'pointer',
opacity: disabled ? 0.4 : 1, fontFamily: 'inherit',
textAlign: 'left',
transition: 'border-color 150ms ease-in-out, background-color 150ms ease-in-out',
'&:hover': {
borderColor: 'var(--fa-color-brand-400)',
bgcolor: selected ? 'var(--fa-color-surface-warm)' : 'var(--fa-color-brand-50)',
},
'&:focus-visible': {
outline: '2px solid var(--fa-color-brand-500)',
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="body1"
component="span"
sx={{ fontWeight: 600, color: selected ? 'var(--fa-color-brand-700)' : 'text.primary' }}
>
{label}
</Typography>
</Box>
{description && (
<Typography
variant="caption"
component="span"
sx={{
display: 'block',
mt: 0.5,
color: 'text.secondary',
ml: selected ? 3.5 : 0,
}}
>
{description}
</Typography>
)}
</Box>
);
}
/** Funeral type option — generous pill button */
function TypePill({
label,
selected,
onClick,
}: {
label: string;
selected: boolean;
onClick: () => void;
}) {
return (
<Box
component="button"
role="radio"
aria-checked={selected}
onClick={onClick}
sx={{
px: 2.5,
py: 1.25,
border: '2px solid',
borderColor: selected ? 'var(--fa-color-brand-500)' : 'var(--fa-color-neutral-200)',
borderRadius: 'var(--fa-border-radius-full)',
bgcolor: selected ? 'var(--fa-color-surface-warm)' : 'var(--fa-color-surface-default)',
cursor: 'pointer',
fontFamily: 'inherit', fontFamily: 'inherit',
fontSize: '0.875rem', fontSize: '0.875rem',
fontWeight: 500, fontWeight: selected ? 600 : 500,
color: selected ? 'var(--fa-color-brand-700)' : 'text.primary', color: selected ? 'var(--fa-color-brand-700)' : 'text.primary',
textAlign: 'left', whiteSpace: 'nowrap',
transition: 'all 150ms ease-in-out', transition: 'border-color 150ms ease-in-out, background-color 150ms ease-in-out',
'&:hover:not(:disabled)': { '&:hover': {
borderColor: 'var(--fa-color-brand-400)', borderColor: 'var(--fa-color-brand-400)',
bgcolor: 'var(--fa-color-brand-50)', bgcolor: selected ? 'var(--fa-color-surface-warm)' : 'var(--fa-color-brand-50)',
}, },
'&:focus-visible': { '&:focus-visible': {
outline: '2px solid var(--fa-color-brand-500)', outline: '2px solid var(--fa-color-brand-500)',
@@ -139,24 +169,63 @@ function OptionCard({
); );
} }
/** Completed answer row — question + answer + change link */
function CompletedRow({
question,
answer,
onChangeClick,
}: {
question: string;
answer: string;
onChangeClick: () => void;
}) {
return (
<Box
sx={{
display: 'flex',
alignItems: 'baseline',
flexWrap: 'wrap',
gap: 0.75,
py: 1,
borderBottom: '1px solid',
borderColor: 'var(--fa-color-neutral-100)',
}}
>
<Typography variant="body2" color="text.secondary" component="span">
{question}
</Typography>
<Typography variant="body2" component="span" sx={{ fontWeight: 600 }}>
{answer}
</Typography>
<Link
component="button"
variant="caption"
onClick={onChangeClick}
sx={{ color: 'text.secondary', ml: 'auto' }}
>
Change
</Link>
</Box>
);
}
// ─── Component ─────────────────────────────────────────────────────────────── // ─── Component ───────────────────────────────────────────────────────────────
/** /**
* Hero search widget for the FA homepage. * Hero search widget for the FA homepage.
* *
* Guides users through a procedural stepped flow to find funeral directors: * Guides users through a conversational stepped flow to find funeral directors.
* 1. "I'm here to" — Arrange a funeral now / Pre-plan a funeral * Each question appears naturally after the previous is answered. Completed
* 2. "I'm planning for" (conditional, only for pre-plan) — Myself / Someone else * answers collapse to a compact row with a "Change" link to revert.
* 3. "Type of funeral" — dynamic list of funeral types (Cremation, Burial, etc.) *
* 4. Location — suburb or postcode input * Flow:
* 1. "How can we help?" → Arrange now / Pre-plan
* 2. "Who is this for?" (pre-plan only) → Myself / Someone else
* 3. "What type of funeral?" → dynamic pill buttons
* 4. "Where are you located?" → suburb/postcode input
* 5. CTA: "Find funeral directors" * 5. CTA: "Find funeral directors"
* *
* Each step reveals progressively. Completed steps collapse to show the * Composes Typography + Button + Input + Link + custom ChoiceCard/TypePill.
* selected value with a brand checkmark. Click a completed step to re-edit.
* Reverting a step only resets dependent steps (e.g., changing intent from
* "Pre-plan" to "Arrange now" removes the "I'm planning for" step).
*
* Composes Typography + Button + Chip + Input.
*/ */
export const FuneralFinder = React.forwardRef<HTMLDivElement, FuneralFinderProps>( export const FuneralFinder = React.forwardRef<HTMLDivElement, FuneralFinderProps>(
( (
@@ -169,77 +238,48 @@ export const FuneralFinder = React.forwardRef<HTMLDivElement, FuneralFinderProps
}, },
ref, ref,
) => { ) => {
// ─── State ───────────────────────────────────────────────────────
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 [funeralTypeId, setFuneralTypeId] = React.useState<string | null>(null);
const [location, setLocation] = React.useState(''); const [location, setLocation] = React.useState('');
// Track which step the user is currently editing (null = auto-advance)
const [editingStep, setEditingStep] = React.useState<number | null>(null); const [editingStep, setEditingStep] = React.useState<number | null>(null);
// ─── Derived state ──────────────────────────────────────────────
const needsPlanningFor = intent === 'preplan'; const needsPlanningFor = intent === 'preplan';
const funeralTypeLabel = funeralTypes.find((ft) => ft.id === funeralTypeId)?.label; const funeralTypeLabel = funeralTypes.find((ft) => ft.id === funeralTypeId)?.label;
// Determine step statuses // Which step is currently active?
const getStepStatus = (step: number): StepStatus => { const activeStep = (() => {
if (editingStep === step) return 'active'; if (editingStep) return editingStep;
if (!intent) return 1;
if (needsPlanningFor && !planningFor) return 2;
if (!funeralTypeId) return 3;
return 4;
})();
switch (step) {
case 1:
return intent ? 'completed' : 'active';
case 2: // planning for (conditional)
if (!needsPlanningFor) return 'locked';
if (planningFor) return 'completed';
return intent ? 'active' : 'locked';
case 3: // funeral type
if (funeralTypeId) return 'completed';
if (!intent) return 'locked';
if (needsPlanningFor && !planningFor) return 'locked';
return 'active';
case 4: // location
if (!funeralTypeId) return 'locked';
return 'active';
default:
return 'locked';
}
};
// Step numbering adjusts when planning-for step is hidden
const getVisibleStepNumber = (step: number): number => {
if (!needsPlanningFor && step > 2) return step - 1;
return step;
};
// Can submit?
const canSubmit = const canSubmit =
intent !== null && intent !== null &&
(!needsPlanningFor || planningFor !== null) && (!needsPlanningFor || planningFor !== null) &&
funeralTypeId !== null && funeralTypeId !== null &&
location.trim().length > 0; location.trim().length > 0;
// ─── Handlers ─────────────────────────────────────────────────── // ─── Handlers ───────────────────────────────────────────────────
const handleIntentSelect = (value: Intent) => { const selectIntent = (value: Intent) => {
setIntent(value); setIntent(value);
// If switching from preplan to arrange, clear planningFor if (value === 'arrange') setPlanningFor(null);
if (value === 'arrange') {
setPlanningFor(null);
}
setEditingStep(null); setEditingStep(null);
}; };
const handlePlanningForSelect = (value: PlanningFor) => { const selectPlanningFor = (value: PlanningFor) => {
setPlanningFor(value); setPlanningFor(value);
setEditingStep(null); setEditingStep(null);
}; };
const handleFuneralTypeSelect = (id: string) => { const selectFuneralType = (id: string) => {
setFuneralTypeId(id); setFuneralTypeId(id);
setEditingStep(null); setEditingStep(null);
}; };
const handleRevert = (step: number) => { const revertTo = (step: number) => {
setEditingStep(step); setEditingStep(step);
}; };
@@ -253,90 +293,7 @@ export const FuneralFinder = React.forwardRef<HTMLDivElement, FuneralFinderProps
}); });
}; };
// ─── Render helpers ───────────────────────────────────────────── // ─── Render ──────────────────────────────────────────────────────
const renderStep = (
stepNum: number,
label: string,
status: StepStatus,
completedValue: string | undefined,
content: React.ReactNode,
) => {
const visibleNum = getVisibleStepNumber(stepNum);
const isCompleted = status === 'completed' && editingStep !== stepNum;
const isActive = status === 'active' || editingStep === stepNum;
const isLocked = status === 'locked';
return (
<Box
key={stepNum}
sx={{
display: 'flex',
gap: 1.5,
alignItems: 'flex-start',
opacity: isLocked ? 0.5 : 1,
transition: 'opacity 150ms',
}}
>
<Box sx={{ pt: 0.25 }}>
<StepBadge step={visibleNum} status={isCompleted ? 'completed' : isActive ? 'active' : 'locked'} />
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="overlineSm"
sx={{
display: 'block',
color: isLocked ? 'text.disabled' : 'text.secondary',
mb: isCompleted ? 0 : 1,
}}
>
{label}
</Typography>
{isCompleted && completedValue && (
<Box
component="button"
onClick={() => handleRevert(stepNum)}
sx={{
background: 'none',
border: 'none',
p: 0,
cursor: 'pointer',
fontFamily: 'inherit',
fontSize: '0.9375rem',
fontWeight: 600,
color: 'text.primary',
textAlign: 'left',
display: 'flex',
alignItems: 'center',
gap: 0.75,
'&:hover': { color: 'primary.main' },
'&:focus-visible': {
outline: '2px solid var(--fa-color-brand-500)',
outlineOffset: 2,
borderRadius: '2px',
},
}}
aria-label={`${label}: ${completedValue}. Click to change.`}
>
{completedValue}
<EditOutlinedIcon sx={{ fontSize: 14, opacity: 0.5 }} />
</Box>
)}
{isActive && content}
{isLocked && (
<Typography variant="caption" color="text.disabled">
Complete the step above
</Typography>
)}
</Box>
</Box>
);
};
// ─── Render ─────────────────────────────────────────────────────
return ( return (
<Box <Box
ref={ref} ref={ref}
@@ -361,114 +318,143 @@ export const FuneralFinder = React.forwardRef<HTMLDivElement, FuneralFinderProps
<Typography <Typography
variant="body2" variant="body2"
color="text.secondary" color="text.secondary"
sx={{ textAlign: 'center', mb: 3, maxWidth: 400, mx: 'auto' }} sx={{ textAlign: 'center', mb: 3.5, maxWidth: 400, mx: 'auto' }}
> >
{subheading} {subheading}
</Typography> </Typography>
{/* Steps */} {/* Completed answers */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}> {intent && activeStep > 1 && editingStep !== 1 && (
<CompletedRow
question="I'm here to"
answer={intent === 'arrange' ? 'Arrange a funeral now' : 'Pre-plan a funeral'}
onChangeClick={() => revertTo(1)}
/>
)}
{needsPlanningFor && planningFor && activeStep > 2 && editingStep !== 2 && (
<CompletedRow
question="Planning for"
answer={planningFor === 'myself' ? 'Myself' : 'Someone else'}
onChangeClick={() => revertTo(2)}
/>
)}
{funeralTypeId && funeralTypeLabel && activeStep > 3 && editingStep !== 3 && (
<CompletedRow
question="Type of funeral"
answer={funeralTypeLabel}
onChangeClick={() => revertTo(3)}
/>
)}
{/* Active question */}
<Box sx={{ mt: intent && activeStep > 1 && editingStep !== 1 ? 3 : 0 }}>
{/* Step 1: Intent */} {/* Step 1: Intent */}
{renderStep( {activeStep === 1 && (
1, <Box>
"I'M HERE TO", <Typography variant="body1" sx={{ fontWeight: 600, mb: 2 }}>
getStepStatus(1), How can we help you today?
intent === 'arrange' ? 'Arrange a funeral now' : intent === 'preplan' ? 'Pre-plan a funeral' : undefined, </Typography>
<Box role="radiogroup" aria-label="I'm here to" sx={{ display: 'flex', gap: 1.5, flexDirection: { xs: 'column', sm: 'row' } }}> <Box role="radiogroup" aria-label="How can we help" sx={{ display: 'flex', gap: 1.5, flexDirection: { xs: 'column', sm: 'row' } }}>
<OptionCard <ChoiceCard
label="Arrange a funeral now" label="Arrange a funeral now"
selected={intent === 'arrange'} description="Someone has passed and I need to make arrangements"
onClick={() => handleIntentSelect('arrange')} selected={intent === 'arrange'}
/> onClick={() => selectIntent('arrange')}
<OptionCard />
label="Pre-plan a funeral" <ChoiceCard
selected={intent === 'preplan'} label="Pre-plan a funeral"
onClick={() => handleIntentSelect('preplan')} description="I'd like to plan ahead for the future"
/> selected={intent === 'preplan'}
</Box>, onClick={() => selectIntent('preplan')}
/>
</Box>
</Box>
)} )}
{/* Step 2: Planning for (conditional) */} {/* Step 2: Planning for (conditional) */}
{needsPlanningFor && {activeStep === 2 && needsPlanningFor && (
renderStep( <Box>
2, <Typography variant="body1" sx={{ fontWeight: 600, mb: 2 }}>
"I'M PLANNING FOR", Who are you planning for?
getStepStatus(2), </Typography>
planningFor === 'myself' ? 'Myself' : planningFor === 'someone-else' ? 'Someone else' : undefined, <Box role="radiogroup" aria-label="Who are you planning for" sx={{ display: 'flex', gap: 1.5, flexDirection: { xs: 'column', sm: 'row' } }}>
<Box role="radiogroup" aria-label="I'm planning for" sx={{ display: 'flex', gap: 1.5, flexDirection: { xs: 'column', sm: 'row' } }}> <ChoiceCard
<OptionCard
label="Myself" label="Myself"
description="I want to plan my own funeral in advance"
selected={planningFor === 'myself'} selected={planningFor === 'myself'}
onClick={() => handlePlanningForSelect('myself')} onClick={() => selectPlanningFor('myself')}
/> />
<OptionCard <ChoiceCard
label="Someone else" label="Someone else"
description="I'm helping a family member or friend plan ahead"
selected={planningFor === 'someone-else'} selected={planningFor === 'someone-else'}
onClick={() => handlePlanningForSelect('someone-else')} onClick={() => selectPlanningFor('someone-else')}
/> />
</Box>, </Box>
)} </Box>
)}
{/* Step 3: Funeral type */} {/* Step 3: Funeral type */}
{renderStep( {activeStep === 3 && (
3, <Box>
'TYPE OF FUNERAL', <Typography variant="body1" sx={{ fontWeight: 600, mb: 2 }}>
getStepStatus(3), What type of funeral are you considering?
funeralTypeLabel, </Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}> <Box role="radiogroup" aria-label="Type of funeral" sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{funeralTypes.map((ft) => ( {funeralTypes.map((ft) => (
<Chip <TypePill
key={ft.id} key={ft.id}
label={ft.label} label={ft.label}
variant={funeralTypeId === ft.id ? 'filled' : 'outlined'} selected={funeralTypeId === ft.id}
selected={funeralTypeId === ft.id} onClick={() => selectFuneralType(ft.id)}
onClick={() => handleFuneralTypeSelect(ft.id)} />
/> ))}
))} </Box>
</Box>, </Box>
)} )}
{/* Step 4: Location */} {/* Step 4: Location */}
{renderStep( {activeStep === 4 && (
4, <Box>
'LOCATION', <Typography variant="body1" sx={{ fontWeight: 600, mb: 2 }}>
getStepStatus(4), Where are you located?
undefined, // Location doesn't collapse — always editable when unlocked </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)}
size="small" size="small"
fullWidth fullWidth
inputProps={{ 'aria-label': 'Location — suburb or postcode' }} inputProps={{ 'aria-label': 'Location — suburb or postcode' }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && canSubmit) handleSubmit(); if (e.key === 'Enter' && canSubmit) handleSubmit();
}} }}
/>, />
</Box>
)} )}
</Box> </Box>
{/* CTA */} {/* CTA — only visible once we're on the location step or beyond */}
<Box sx={{ mt: 3 }}> {activeStep >= 4 && (
<Button <Box sx={{ mt: 3 }}>
variant="contained" <Button
size="large" variant="contained"
fullWidth size="large"
disabled={!canSubmit} fullWidth
onClick={handleSubmit} disabled={!canSubmit}
> onClick={handleSubmit}
Find funeral directors >
</Button> Find funeral directors
</Box> </Button>
<Typography
{/* Trust signal */} variant="captionSm"
<Typography color="text.secondary"
variant="captionSm" sx={{ textAlign: 'center', display: 'block', mt: 1.5 }}
color="text.secondary" >
sx={{ textAlign: 'center', display: 'block', mt: 1.5 }} Free to use · No obligation
> </Typography>
Free to use · No obligation </Box>
</Typography> )}
</Box> </Box>
); );
}, },