Add FuneralFinder v2 — quick-form stepped search widget

- 4-step vertical form: intent, planning-for, type, location
- Sequential unlock: each step enables when the previous is filled
- Step circles (48px, brand-200) transition to brand-500 check on completion
- Connector lines between circles for visual progression
- Conditional logic: arrange-now auto-sets step 2 to "Someone else"
- CTA disabled until location filled, trust signal below
- Display serif heading + subheading with divider
- Critique 33/40 (Good), Audit 18/20 (Excellent)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 17:27:18 +11:00
parent fef27a2701
commit cc87827c39
3 changed files with 565 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { FuneralFinderV2 } from './FuneralFinderV2';
const meta: Meta<typeof FuneralFinderV2> = {
title: 'Organisms/FuneralFinderV2',
component: FuneralFinderV2,
parameters: {
layout: 'padded',
},
args: {
onSearch: (params) => {
console.log('Search params:', params);
},
},
};
export default meta;
type Story = StoryObj<typeof FuneralFinderV2>;
/** Default empty state — all 3 steps ready for input */
export const Default: Story = {};
/** Loading state — CTA shows spinner */
export const Loading: Story = {
args: { loading: true },
};
/** Placed below a masthead-style header to preview in context */
export const BelowMasthead: Story = {
decorators: [
(Story) => (
<Box>
{/* Simulated masthead */}
<Box
sx={{
bgcolor: 'var(--fa-color-sage-800, #4c5459)',
color: '#fff',
py: 6,
px: 4,
textAlign: 'center',
}}
>
<Box sx={{ fontSize: '2rem', fontWeight: 700, mb: 1 }}>
Funeral Arranger
</Box>
<Box sx={{ opacity: 0.8 }}>
Find trusted funeral directors near you
</Box>
</Box>
{/* Widget below masthead */}
<Box sx={{ maxWidth: 560, mx: 'auto', mt: -4, px: 2, position: 'relative', zIndex: 1 }}>
<Story />
</Box>
</Box>
),
],
};
/** Constrained width — typical sidebar or narrow column */
export const Narrow: Story = {
decorators: [
(Story) => (
<Box sx={{ maxWidth: 400, mx: 'auto' }}>
<Story />
</Box>
),
],
};

View File

@@ -0,0 +1,490 @@
import React from 'react';
import Box from '@mui/material/Box';
import Select, { type SelectChangeEvent } from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import CheckIcon from '@mui/icons-material/Check';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Input } from '../../atoms/Input';
import { Divider } from '../../atoms/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
type LookingTo = 'arrange-now' | 'expected' | 'future';
type PlanningFor = 'myself' | 'someone-else';
type FuneralType =
| 'cremation-funeral'
| 'cremation-only'
| 'burial-funeral'
| 'graveside-only'
| 'water-cremation';
/** Search parameters returned on form submission */
export interface FuneralFinderV2SearchParams {
/** User's situation — immediate, expected, or future need */
lookingTo: LookingTo;
/** Who the funeral is for */
planningFor: PlanningFor;
/** Type of funeral selected */
funeralType: FuneralType;
/** Suburb or postcode */
location: string;
}
/** Props for the FuneralFinder v2 quick-form organism */
export interface FuneralFinderV2Props {
/** Called when the user submits with valid data */
onSearch?: (params: FuneralFinderV2SearchParams) => void;
/** Shows loading state on the CTA */
loading?: boolean;
/** Optional heading override */
heading?: string;
/** Optional subheading override */
subheading?: string;
/** MUI sx override for the root container */
sx?: SxProps<Theme>;
}
// ─── Options ─────────────────────────────────────────────────────────────────
const LOOKING_TO_OPTIONS: { value: LookingTo; label: string }[] = [
{ value: 'arrange-now', label: 'Arrange a funeral for someone who has passed' },
{ value: 'expected', label: 'Plan ahead for someone who is unwell' },
{ value: 'future', label: "Plan for a future need that isn't expected soon" },
];
const PLANNING_FOR_OPTIONS: { value: PlanningFor; label: string }[] = [
{ value: 'someone-else', label: 'Someone else' },
{ value: 'myself', label: 'Myself' },
];
const FUNERAL_TYPE_OPTIONS: { value: FuneralType; label: string }[] = [
{ value: 'cremation-funeral', label: 'Cremation with funeral' },
{ value: 'cremation-only', label: 'Cremation only (no funeral, no attendance)' },
{ value: 'burial-funeral', label: 'Burial with funeral' },
{ value: 'graveside-only', label: 'Graveside burial only' },
{ value: 'water-cremation', label: 'Water cremation (QLD only)' },
];
// ─── Layout constants ────────────────────────────────────────────────────────
const STEP_CIRCLE_SIZE = 48;
// ─── Sub-components ──────────────────────────────────────────────────────────
/** Step number circle — transitions to a check icon when the step is completed */
function StepCircle({
step,
completed,
showConnector = false,
}: {
step: number;
completed: boolean;
/** Show a vertical connector line below this circle to the next step */
showConnector?: boolean;
}) {
return (
<Box
sx={{
width: STEP_CIRCLE_SIZE,
height: STEP_CIRCLE_SIZE,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
position: 'relative',
zIndex: 1,
transition: 'background-color 200ms ease, color 200ms ease',
...(completed
? { bgcolor: 'var(--fa-color-brand-500)', color: 'common.white' }
: { bgcolor: 'var(--fa-color-brand-200, #EBDAC8)', color: 'var(--fa-color-brand-700, #8B4E0D)' }),
// Connector line from bottom of this circle toward the next
...(showConnector && {
'&::after': {
content: '""',
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
width: 3,
height: 60,
backgroundColor: 'var(--fa-color-neutral-200)',
zIndex: -1,
},
}),
}}
>
{completed ? (
<CheckIcon sx={{ fontSize: 24 }} />
) : (
<Typography
variant="captionSm"
component="span"
sx={{ fontWeight: 700, fontSize: '1.375rem', lineHeight: 1, color: 'inherit' }}
>
{step}
</Typography>
)}
</Box>
);
}
// ─── Shared styles ───────────────────────────────────────────────────────────
const selectSx: SxProps<Theme> = {
width: '100%',
bgcolor: 'var(--fa-color-surface-default, #fff)',
'.MuiOutlinedInput-notchedOutline': {
borderColor: 'var(--fa-color-neutral-200)',
borderRadius: 'var(--fa-border-radius-md, 8px)',
},
'&:hover:not(.Mui-disabled) .MuiOutlinedInput-notchedOutline': {
borderColor: 'var(--fa-color-brand-400)',
},
'&.Mui-focused': {
boxShadow: 'none',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: 'var(--fa-color-brand-400)',
borderWidth: 1,
},
'&.Mui-disabled': {
opacity: 0.6,
'.MuiOutlinedInput-notchedOutline': {
borderStyle: 'dashed',
},
},
'&.Mui-error .MuiOutlinedInput-notchedOutline': {
borderColor: 'var(--fa-color-feedback-error-500, #d32f2f)',
},
'.MuiSelect-select': {
py: '14px',
px: 2,
fontSize: '0.875rem',
minHeight: 'unset !important',
},
'.MuiSelect-icon': {
color: 'var(--fa-color-neutral-400)',
},
};
const selectMenuProps = {
PaperProps: {
sx: {
mt: 0.5,
borderRadius: 'var(--fa-border-radius-md, 8px)',
boxShadow: 'var(--fa-shadow-md)',
minWidth: 280,
'& .MuiMenuItem-root': {
py: 1.5,
px: 2,
fontSize: '0.875rem',
whiteSpace: 'normal',
'&:hover': { bgcolor: 'var(--fa-color-brand-50)' },
'&.Mui-selected': {
bgcolor: 'var(--fa-color-surface-warm)',
fontWeight: 600,
'&:hover': { bgcolor: 'var(--fa-color-surface-warm)' },
},
},
},
},
};
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Quick-form funeral search widget for the homepage hero.
*
* Three dropdown selects (intent, planning-for, type), a location input,
* and a CTA — all always visible at a fixed height. Step numbers transition
* to check icons as the user completes each field.
*
* Conditional logic:
* - "Arrange a funeral for someone who has passed" auto-sets step 2
* to "Someone else" and disables it.
* - "Myself" is only available for pre-planning paths (expected / future).
*/
export const FuneralFinderV2 = React.forwardRef<HTMLDivElement, FuneralFinderV2Props>(
(
{
onSearch,
loading = false,
heading = 'Find funeral directors near you',
subheading = "Tell us what you need and we'll show options in your area.",
sx,
},
ref,
) => {
// ─── State ───────────────────────────────────────────────────
const [lookingTo, setLookingTo] = React.useState<LookingTo | ''>('');
const [planningFor, setPlanningFor] = React.useState<PlanningFor | ''>('');
const [funeralType, setFuneralType] = React.useState<FuneralType | ''>('');
const [location, setLocation] = React.useState('');
const [submitted, setSubmitted] = React.useState(false);
// ─── Derived ─────────────────────────────────────────────────
const isArrangeNow = lookingTo === 'arrange-now';
const step2Disabled = !lookingTo || isArrangeNow;
const step3Disabled = !planningFor;
const step4Disabled = !funeralType;
// Errors only show after first submit attempt, then clear as fields fill
const errs = submitted
? {
lookingTo: !lookingTo,
planningFor: !planningFor,
funeralType: !funeralType,
location: location.trim().length < 3,
}
: { lookingTo: false, planningFor: false, funeralType: false, location: false };
const hasErrors = submitted && (errs.lookingTo || errs.planningFor || errs.funeralType || errs.location);
// ─── Handlers ────────────────────────────────────────────────
const handleLookingTo = (e: SelectChangeEvent<string>) => {
const val = e.target.value as LookingTo;
setLookingTo(val);
if (val === 'arrange-now') {
setPlanningFor('someone-else');
} else {
setPlanningFor('');
}
};
const handlePlanningFor = (e: SelectChangeEvent<string>) => {
setPlanningFor(e.target.value as PlanningFor);
};
const handleFuneralType = (e: SelectChangeEvent<string>) => {
setFuneralType(e.target.value as FuneralType);
};
const handleSubmit = () => {
setSubmitted(true);
if (!lookingTo || !planningFor || !funeralType || location.trim().length < 3) return;
onSearch?.({
lookingTo,
planningFor,
funeralType,
location: location.trim(),
});
};
// ─── Helpers ─────────────────────────────────────────────────
const placeholder = (
<span style={{ color: 'var(--fa-color-neutral-400)' }}>Select&hellip;</span>
);
const findLabel = (opts: { value: string; label: string }[], val: string) =>
opts.find((o) => o.value === val)?.label ?? '';
// ─── Render ──────────────────────────────────────────────────
return (
<Box
ref={ref}
role="search"
aria-label="Find funeral providers"
sx={[
{
bgcolor: 'background.paper',
borderRadius: 'var(--fa-card-border-radius-default, 12px)',
boxShadow: 'var(--fa-shadow-md)',
px: { xs: 3, sm: 4 },
pb: { xs: 3, sm: 4 },
pt: { xs: 4, sm: 5 },
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* ── Header ──────────────────────────────────────────── */}
<Typography
variant="h3"
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: 0 }}
>
{subheading}
</Typography>
<Divider sx={{ my: 3.5 }} />
{/* ── Steps ───────────────────────────────────────────── */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Step 1: I'm looking to */}
<Box sx={{ display: 'flex', gap: 2.5, alignItems: 'flex-end' }}>
<StepCircle step={1} completed={!!lookingTo} showConnector />
<Box sx={{ flex: 1 }}>
<Typography variant="body1" sx={{ fontWeight: 600, mb: 1, color: 'var(--fa-color-brand-700)' }}>
I&rsquo;m looking to&hellip;
</Typography>
<Select
value={lookingTo}
onChange={handleLookingTo}
displayEmpty
error={errs.lookingTo}
renderValue={(v) => (v ? findLabel(LOOKING_TO_OPTIONS, v) : placeholder)}
MenuProps={selectMenuProps}
sx={selectSx}
inputProps={{ 'aria-label': "I'm looking to", 'aria-required': true }}
>
{LOOKING_TO_OPTIONS.map((o) => (
<MenuItem key={o.value} value={o.value}>
{o.label}
</MenuItem>
))}
</Select>
</Box>
</Box>
{/* Step 2: I'm planning for */}
<Box sx={{ display: 'flex', gap: 2.5, alignItems: 'flex-end' }}>
<StepCircle step={2} completed={!!planningFor} showConnector />
<Box sx={{ flex: 1 }}>
<Typography variant="body1" sx={{ fontWeight: 600, mb: 1, color: lookingTo ? 'var(--fa-color-brand-700)' : 'text.disabled' }}>
I&rsquo;m planning for
</Typography>
<Select
value={planningFor}
onChange={handlePlanningFor}
displayEmpty
disabled={step2Disabled}
error={errs.planningFor}
renderValue={(v) => (v ? findLabel(PLANNING_FOR_OPTIONS, v) : placeholder)}
MenuProps={selectMenuProps}
sx={selectSx}
inputProps={{ 'aria-label': "I'm planning for", 'aria-required': true }}
>
{PLANNING_FOR_OPTIONS.map((o) => (
<MenuItem
key={o.value}
value={o.value}
disabled={isArrangeNow && o.value === 'myself'}
>
{o.label}
</MenuItem>
))}
</Select>
</Box>
</Box>
{/* Step 3: Type of funeral */}
<Box sx={{ display: 'flex', gap: 2.5, alignItems: 'flex-end' }}>
<StepCircle step={3} completed={!!funeralType} showConnector />
<Box sx={{ flex: 1 }}>
<Typography variant="body1" sx={{ fontWeight: 600, mb: 1, color: step3Disabled ? 'text.disabled' : 'var(--fa-color-brand-700)' }}>
Type of funeral
</Typography>
<Select
value={funeralType}
onChange={handleFuneralType}
displayEmpty
disabled={step3Disabled}
error={errs.funeralType}
renderValue={(v) => (v ? findLabel(FUNERAL_TYPE_OPTIONS, v) : placeholder)}
MenuProps={selectMenuProps}
sx={selectSx}
inputProps={{ 'aria-label': 'Type of funeral', 'aria-required': true }}
>
{FUNERAL_TYPE_OPTIONS.map((o) => (
<MenuItem key={o.value} value={o.value}>
{o.label}
</MenuItem>
))}
</Select>
</Box>
</Box>
{/* Step 4: Location */}
<Box sx={{ display: 'flex', gap: 2.5, alignItems: 'flex-end' }}>
<StepCircle step={4} completed={location.trim().length >= 3} />
<Box sx={{ flex: 1 }}>
<Typography variant="body1" sx={{ fontWeight: 600, mb: 1, color: step4Disabled ? 'text.disabled' : 'var(--fa-color-brand-700)' }}>
Looking for providers in
</Typography>
<Input
placeholder="Suburb or postcode"
value={location}
onChange={(e) => setLocation(e.target.value)}
fullWidth
disabled={step4Disabled}
error={errs.location}
inputProps={{ 'aria-label': 'Suburb or postcode', 'aria-required': true }}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSubmit();
}}
sx={{
'&.Mui-focused': { boxShadow: 'none' },
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: 'var(--fa-color-brand-400)',
borderWidth: '1px',
},
'& .MuiOutlinedInput-input': {
py: '14px',
px: 2,
fontSize: '0.875rem',
},
}}
/>
</Box>
</Box>
</Box>
{/* ── CTA ─────────────────────────────────────────────── */}
<Divider sx={{ my: 4.5 }} />
<Box>
<Button
variant="contained"
size="large"
fullWidth
loading={loading}
disabled={loading || location.trim().length < 3}
onClick={handleSubmit}
>
Find Providers
</Button>
<Typography
variant="captionSm"
color="text.secondary"
sx={{ textAlign: 'center', display: 'block', mt: 1.5 }}
>
Free to use &middot; No obligation
</Typography>
</Box>
{/* Error hint — always rendered to maintain fixed height */}
<Typography
variant="captionSm"
role={hasErrors ? 'alert' : undefined}
sx={{
mt: 1,
minHeight: '1.25rem',
textAlign: 'center',
color: hasErrors ? 'error.main' : 'transparent',
transition: 'color 200ms ease',
}}
>
{errs.location
? 'Please enter a suburb or postcode'
: errs.lookingTo
? 'Please tell us what you need help with'
: errs.funeralType
? 'Please select a funeral type'
: errs.planningFor
? 'Please select who you are planning for'
: '\u00A0'}
</Typography>
</Box>
);
},
);
FuneralFinderV2.displayName = 'FuneralFinderV2';
export default FuneralFinderV2;

View File

@@ -4,3 +4,9 @@ export {
type FuneralTypeOption,
type FuneralSearchParams,
} from './FuneralFinder';
export {
FuneralFinderV2,
type FuneralFinderV2Props,
type FuneralFinderV2SearchParams,
} from './FuneralFinderV2';