Add FuneralFinder organism — hero search with stepped flow

Procedural stepped search widget for the homepage:
1. "I'm here to" — Arrange now / Pre-plan (radiogroup)
2. "I'm planning for" — Myself / Someone else (conditional, pre-plan only)
3. "Type of funeral" — dynamic Chip list from prop
4. Location — suburb or postcode input
5. CTA: "Find funeral directors" (disabled until complete)

Features:
- Progressive disclosure: steps unlock sequentially
- Brand checkmarks + edit icon on completed steps
- Click completed value to revert (only resets dependents)
- Step numbering adjusts when conditional step hidden
- Trust signal: "Free to use · No obligation"

Audit: 17/20 (Good). P1/P2 fixes applied:
- Location input aria-label for screen readers
- Option groups wrapped in role="radiogroup"
- Completed values have edit icon affordance
- Heading uses display font CSS variable
- CheckCircleIcon aria-hidden

5 stories: Default, FewerTypes, CustomHeading, InHeroDesktop, InHeroMobile

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 09:15:07 +11:00
parent 83e3809752
commit d7dddb0773
4 changed files with 705 additions and 0 deletions

View File

@@ -54,6 +54,7 @@ duplicates) and MUST update it after completing one.
| ServiceSelector | done | ServiceOption × n + Typography + Button | Single-select service panel for arrangement flow. Heading + subheading + ServiceOption list (radiogroup) + optional continue Button. Manages selection state via selectedId/onSelect. maxDescriptionLines pass-through. |
| PricingTable | planned | PriceCard × n + Typography | Comparative pricing display |
| PackageDetail | done | LineItem × n + Typography + Button + Divider | Right-side package detail panel. Warm header band (surface.warm) with "Package" overline, name, price (brand colour), Make Arrangement + Compare (with loading) buttons. Sections (before total) + total + extras (after total, with subtext). T&C grey footer. Audit: 19/20. Maps to Figma Package Select (5405:181955). |
| FuneralFinder | review | Typography + Button + Chip + Input + custom OptionCard | Hero search widget. Procedural stepped flow: Intent → Planning For (conditional) → Funeral Type (chips, dynamic) → Location → CTA. Brand checkmarks on completion, edit icon to revert, radiogroup semantics. Audit: 17/20 → fixes applied. |
| ArrangementForm | planned | StepIndicator + ServiceSelector + AddOnOption + Button + Typography | Multi-step arrangement wizard. Deferred — build remaining atoms/molecules first. |
| Navigation | done | AppBar + Link + IconButton + Button + Divider + Drawer | Responsive site header. Desktop: logo left, links right, optional CTA. Mobile: hamburger + drawer with nav items, CTA, help footer. Sticky, grey surface bg (surface.subtle). Real FA logo from brandassets/. Maps to Figma Main Nav (14:108) + Mobile Header (2391:41508). |
| Footer | done | Link × n + Typography + Divider + Container + Grid | Dark espresso (brand.950) site footer. Logo + tagline + contact (phone/email) + link group columns + legal bar. Semantic HTML (footer, nav, ul). Critique: 38/40 (Excellent). |

View File

@@ -0,0 +1,220 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { FuneralFinder } from './FuneralFinder';
import { Navigation } from '../Navigation';
import { Typography } from '../../atoms/Typography';
const funeralTypes = [
{ id: 'cremation', label: 'Cremation' },
{ id: 'burial', label: 'Burial' },
{ id: 'memorial', label: 'Memorial' },
{ id: 'catholic', label: 'Catholic' },
{ id: 'direct-cremation', label: 'Direct Cremation' },
{ id: 'natural-burial', label: 'Natural Burial' },
];
const FALogoNav = () => (
<Box component="img" src="/brandlogo/logo-full.svg" alt="Funeral Arranger" sx={{ height: 28 }} />
);
const meta: Meta<typeof FuneralFinder> = {
title: 'Organisms/FuneralFinder',
component: FuneralFinder,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
decorators: [
(Story) => (
<Box sx={{ maxWidth: 520, width: '100%' }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof FuneralFinder>;
// --- Default -----------------------------------------------------------------
/** Initial state — step 1 active, all others locked */
export const Default: Story = {
args: {
funeralTypes,
onSearch: (params) => alert(JSON.stringify(params, null, 2)),
},
};
// --- Fewer Funeral Types -----------------------------------------------------
/** With only 3 funeral types — shows compact chip row */
export const FewerTypes: Story = {
args: {
funeralTypes: funeralTypes.slice(0, 3),
onSearch: (params) => alert(JSON.stringify(params, null, 2)),
},
};
// --- Custom Heading ----------------------------------------------------------
/** With custom heading and subheading */
export const CustomHeading: Story = {
args: {
funeralTypes,
heading: 'Compare funeral directors in your area',
subheading: 'Transparent pricing · No hidden fees · 24/7',
onSearch: (params) => alert(JSON.stringify(params, null, 2)),
},
};
// --- In Hero Context (Desktop) -----------------------------------------------
/** As it appears in the homepage hero — desktop layout */
export const InHeroDesktop: Story = {
decorators: [
(Story) => (
<Box sx={{ maxWidth: 'none', width: '100%' }}>
<Story />
</Box>
),
],
render: () => (
<Box>
<Navigation
logo={<FALogoNav />}
items={[
{ label: 'Provider Portal', href: '/provider-portal' },
{ label: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' },
{ label: 'Log in', href: '/login' },
]}
/>
{/* Hero section */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' },
minHeight: { md: 600 },
bgcolor: 'var(--fa-color-brand-100)',
}}
>
{/* Left: heading + search widget */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
px: { xs: 2, md: 6 },
py: { xs: 4, md: 6 },
}}
>
<Box sx={{ maxWidth: 520, width: '100%' }}>
<Typography
variant="displaySm"
component="h1"
sx={{
textAlign: 'center',
mb: 2,
color: 'var(--fa-color-brand-950)',
}}
>
Discover, Explore, and Plan Funerals in Minutes
</Typography>
<Typography
variant="body1"
color="text.secondary"
sx={{ textAlign: 'center', mb: 4, maxWidth: 440, mx: 'auto' }}
>
Whether you're thinking ahead or arranging for a loved one, find
trusted local providers with transparent pricing.
</Typography>
<FuneralFinder
funeralTypes={funeralTypes}
onSearch={(params) => alert(JSON.stringify(params, null, 2))}
/>
</Box>
</Box>
{/* Right: hero image placeholder */}
<Box
sx={{
display: { xs: 'none', md: 'block' },
bgcolor: 'var(--fa-color-brand-200)',
backgroundImage: 'url(https://images.unsplash.com/photo-1516733968668-dbdce39c0571?w=800&h=600&fit=crop)',
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
</Box>
</Box>
),
};
// --- In Hero Context (Mobile) ------------------------------------------------
/** Mobile viewport — stacked layout with image above search */
export const InHeroMobile: Story = {
decorators: [
(Story) => (
<Box sx={{ maxWidth: 420, width: '100%', mx: 'auto' }}>
<Story />
</Box>
),
],
render: () => (
<Box>
<Navigation
logo={<FALogoNav />}
items={[
{ label: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' },
{ label: 'Log in', href: '/login' },
]}
/>
{/* Hero heading */}
<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
</Typography>
<Typography variant="body2" color="text.secondary">
Find trusted local providers with transparent pricing, at your own pace.
</Typography>
</Box>
{/* Hero image */}
<Box
sx={{
height: 180,
bgcolor: 'var(--fa-color-brand-200)',
backgroundImage: 'url(https://images.unsplash.com/photo-1516733968668-dbdce39c0571?w=800&h=400&fit=crop)',
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
{/* Search widget — overlaps image slightly */}
<Box sx={{ px: 2, mt: -3, pb: 4, bgcolor: 'var(--fa-color-brand-100)' }}>
<FuneralFinder
funeralTypes={funeralTypes}
onSearch={(params) => alert(JSON.stringify(params, null, 2))}
/>
</Box>
</Box>
),
};

View File

@@ -0,0 +1,478 @@
import React from 'react';
import Box from '@mui/material/Box';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Chip } from '../../atoms/Chip';
import { Input } from '../../atoms/Input';
// ─── Types ───────────────────────────────────────────────────────────────────
/** A funeral type option (dynamic, from API) */
export interface FuneralTypeOption {
/** Unique identifier */
id: string;
/** Display label */
label: string;
}
/** Search parameters returned when the user submits */
export interface FuneralSearchParams {
/** "arrange" (immediate need) or "preplan" */
intent: 'arrange' | 'preplan';
/** Only present when intent is "preplan" */
planningFor?: 'myself' | 'someone-else';
/** Selected funeral type ID */
funeralTypeId: string;
/** Suburb or postcode entered */
location: string;
}
/** Props for the FA FuneralFinder organism */
export interface FuneralFinderProps {
/** Available funeral types — dynamic list from API */
funeralTypes: FuneralTypeOption[];
/** Called when the user clicks "Find funeral directors" */
onSearch?: (params: FuneralSearchParams) => void;
/** Optional heading override */
heading?: string;
/** Optional subheading override */
subheading?: string;
/** MUI sx prop for the root card */
sx?: SxProps<Theme>;
}
// ─── Step state types ────────────────────────────────────────────────────────
type Intent = 'arrange' | 'preplan' | null;
type PlanningFor = 'myself' | 'someone-else' | null;
type StepStatus = 'active' | 'completed' | 'locked';
// ─── Sub-components ──────────────────────────────────────────────────────────
/** Step number badge or completed checkmark */
function StepBadge({ step, status }: { step: number; status: StepStatus }) {
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,
selected,
onClick,
disabled,
}: {
label: string;
selected: boolean;
onClick: () => void;
disabled?: boolean;
}) {
return (
<Box
component="button"
role="radio"
aria-checked={selected}
onClick={onClick}
disabled={disabled}
sx={{
flex: 1,
minWidth: 0,
px: 2,
py: 1.5,
border: '2px solid',
borderColor: selected ? 'var(--fa-color-brand-500)' : 'divider',
borderRadius: 'var(--fa-border-radius-md)',
bgcolor: selected ? 'var(--fa-color-surface-warm)' : 'transparent',
cursor: disabled ? 'default' : 'pointer',
opacity: disabled ? 0.4 : 1,
fontFamily: 'inherit',
fontSize: '0.875rem',
fontWeight: 500,
color: selected ? 'var(--fa-color-brand-700)' : 'text.primary',
textAlign: 'left',
transition: 'all 150ms ease-in-out',
'&:hover:not(:disabled)': {
borderColor: 'var(--fa-color-brand-400)',
bgcolor: 'var(--fa-color-brand-50)',
},
'&:focus-visible': {
outline: '2px solid var(--fa-color-brand-500)',
outlineOffset: 2,
},
}}
>
{label}
</Box>
);
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Hero search widget for the FA homepage.
*
* Guides users through a procedural stepped flow to find funeral directors:
* 1. "I'm here to" — Arrange a funeral now / Pre-plan a funeral
* 2. "I'm planning for" (conditional, only for pre-plan) — Myself / Someone else
* 3. "Type of funeral" — dynamic list of funeral types (Cremation, Burial, etc.)
* 4. Location — suburb or postcode input
* 5. CTA: "Find funeral directors"
*
* Each step reveals progressively. Completed steps collapse to show the
* 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>(
(
{
funeralTypes,
onSearch,
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.",
sx,
},
ref,
) => {
// ─── State ───────────────────────────────────────────────────────
const [intent, setIntent] = React.useState<Intent>(null);
const [planningFor, setPlanningFor] = React.useState<PlanningFor>(null);
const [funeralTypeId, setFuneralTypeId] = React.useState<string | null>(null);
const [location, setLocation] = React.useState('');
// Track which step the user is currently editing (null = auto-advance)
const [editingStep, setEditingStep] = React.useState<number | null>(null);
// ─── Derived state ──────────────────────────────────────────────
const needsPlanningFor = intent === 'preplan';
const funeralTypeLabel = funeralTypes.find((ft) => ft.id === funeralTypeId)?.label;
// Determine step statuses
const getStepStatus = (step: number): StepStatus => {
if (editingStep === step) return 'active';
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 =
intent !== null &&
(!needsPlanningFor || planningFor !== null) &&
funeralTypeId !== null &&
location.trim().length > 0;
// ─── Handlers ───────────────────────────────────────────────────
const handleIntentSelect = (value: Intent) => {
setIntent(value);
// If switching from preplan to arrange, clear planningFor
if (value === 'arrange') {
setPlanningFor(null);
}
setEditingStep(null);
};
const handlePlanningForSelect = (value: PlanningFor) => {
setPlanningFor(value);
setEditingStep(null);
};
const handleFuneralTypeSelect = (id: string) => {
setFuneralTypeId(id);
setEditingStep(null);
};
const handleRevert = (step: number) => {
setEditingStep(step);
};
const handleSubmit = () => {
if (!canSubmit || !intent || !funeralTypeId) return;
onSearch?.({
intent,
planningFor: needsPlanningFor ? (planningFor ?? undefined) : undefined,
funeralTypeId,
location: location.trim(),
});
};
// ─── Render helpers ─────────────────────────────────────────────
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 (
<Box
ref={ref}
sx={[
{
bgcolor: 'background.paper',
borderRadius: 'var(--fa-card-border-radius-default)',
boxShadow: 'var(--fa-shadow-md)',
p: { xs: 3, sm: 4 },
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Header */}
<Typography
variant="h4"
component="h2"
sx={{ fontFamily: 'var(--fa-font-family-display)', textAlign: 'center', mb: 1 }}
>
{heading}
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ textAlign: 'center', mb: 3, maxWidth: 400, mx: 'auto' }}
>
{subheading}
</Typography>
{/* Steps */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* Step 1: Intent */}
{renderStep(
1,
"I'M HERE TO",
getStepStatus(1),
intent === 'arrange' ? 'Arrange a funeral now' : intent === 'preplan' ? 'Pre-plan a funeral' : undefined,
<Box role="radiogroup" aria-label="I'm here to" sx={{ display: 'flex', gap: 1.5, flexDirection: { xs: 'column', sm: 'row' } }}>
<OptionCard
label="Arrange a funeral now"
selected={intent === 'arrange'}
onClick={() => handleIntentSelect('arrange')}
/>
<OptionCard
label="Pre-plan a funeral"
selected={intent === 'preplan'}
onClick={() => handleIntentSelect('preplan')}
/>
</Box>,
)}
{/* Step 2: Planning for (conditional) */}
{needsPlanningFor &&
renderStep(
2,
"I'M PLANNING FOR",
getStepStatus(2),
planningFor === 'myself' ? 'Myself' : planningFor === 'someone-else' ? 'Someone else' : undefined,
<Box role="radiogroup" aria-label="I'm planning for" sx={{ display: 'flex', gap: 1.5, flexDirection: { xs: 'column', sm: 'row' } }}>
<OptionCard
label="Myself"
selected={planningFor === 'myself'}
onClick={() => handlePlanningForSelect('myself')}
/>
<OptionCard
label="Someone else"
selected={planningFor === 'someone-else'}
onClick={() => handlePlanningForSelect('someone-else')}
/>
</Box>,
)}
{/* Step 3: Funeral type */}
{renderStep(
3,
'TYPE OF FUNERAL',
getStepStatus(3),
funeralTypeLabel,
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{funeralTypes.map((ft) => (
<Chip
key={ft.id}
label={ft.label}
variant={funeralTypeId === ft.id ? 'filled' : 'outlined'}
selected={funeralTypeId === ft.id}
onClick={() => handleFuneralTypeSelect(ft.id)}
/>
))}
</Box>,
)}
{/* Step 4: Location */}
{renderStep(
4,
'LOCATION',
getStepStatus(4),
undefined, // Location doesn't collapse — always editable when unlocked
<Input
placeholder="Suburb or postcode"
value={location}
onChange={(e) => setLocation(e.target.value)}
size="small"
fullWidth
inputProps={{ 'aria-label': 'Location — suburb or postcode' }}
onKeyDown={(e) => {
if (e.key === 'Enter' && canSubmit) handleSubmit();
}}
/>,
)}
</Box>
{/* CTA */}
<Box sx={{ mt: 3 }}>
<Button
variant="contained"
size="large"
fullWidth
disabled={!canSubmit}
onClick={handleSubmit}
>
Find funeral directors
</Button>
</Box>
{/* Trust signal */}
<Typography
variant="captionSm"
color="text.secondary"
sx={{ textAlign: 'center', display: 'block', mt: 1.5 }}
>
Free to use · No obligation
</Typography>
</Box>
);
},
);
FuneralFinder.displayName = 'FuneralFinder';
export default FuneralFinder;

View File

@@ -0,0 +1,6 @@
export {
FuneralFinder,
type FuneralFinderProps,
type FuneralTypeOption,
type FuneralSearchParams,
} from './FuneralFinder';