Files
Parsons/src/components/organisms/FuneralFinder/FuneralFinder.tsx
Richie 047d913960 format: Apply Prettier to existing codebase
Formatting-only changes across all component and story files.
No logic or behaviour changes — only whitespace, line breaks, and trailing commas.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:42:16 +11:00

712 lines
24 KiB
TypeScript

import React from 'react';
import Box from '@mui/material/Box';
import Collapse from '@mui/material/Collapse';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Input } from '../../atoms/Input';
import { Chip } from '../../atoms/Chip';
import { Divider } from '../../atoms/Divider';
import { Link } from '../../atoms/Link';
// ─── Types ───────────────────────────────────────────────────────────────────
/** A funeral type option (dynamic, from API) */
export interface FuneralTypeOption {
/** Unique identifier */
id: string;
/** Display label */
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 */
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, or null if "Explore all" / not specified */
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 */
location: string;
}
/** Props for the FA FuneralFinder organism */
export interface FuneralFinderProps {
/** Available funeral types — dynamic list from API */
funeralTypes: FuneralTypeOption[];
/** Optional thematic filter options (e.g., eco-friendly, budget-friendly) */
themeOptions?: ThemeOption[];
/** Called when the user clicks "Find funeral providers" */
onSearch?: (params: FuneralSearchParams) => void;
/** Whether a search is in progress — shows loading state on the CTA */
loading?: boolean;
/** Optional heading override */
heading?: string;
/** Optional subheading override */
subheading?: string;
/** Show "Explore all options" choice. Default true. */
showExploreAll?: boolean;
/** MUI sx prop for the root card */
sx?: SxProps<Theme>;
}
// ─── Internal types ──────────────────────────────────────────────────────────
type Intent = 'arrange' | 'preplan' | null;
type PlanningFor = 'myself' | 'someone-else' | null;
type ServicePref = 'with-service' | 'without-service' | 'either';
// ─── 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) */
function ChoiceCard({
label,
description,
selected,
onClick,
}: {
label: string;
description?: string;
selected: boolean;
onClick: () => void;
}) {
return (
<Box
component="button"
role="radio"
aria-checked={selected}
onClick={onClick}
sx={{
width: '100%',
px: 2.5,
py: 2,
border: '2px solid',
borderColor: selected ? 'var(--fa-color-brand-500)' : 'var(--fa-color-neutral-200)',
borderRadius: 'var(--fa-border-radius-md)',
bgcolor: selected ? 'var(--fa-color-surface-warm)' : 'var(--fa-color-surface-default)',
cursor: 'pointer',
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)',
},
'&:active': { filter: 'brightness(0.95)' },
'&: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' }}
>
{description}
</Typography>
)}
</Box>
);
}
/** Funeral type card — compact selectable card */
function TypeCard({
label,
description,
note,
selected,
onClick,
}: {
label: string;
description?: string;
note?: string;
selected: boolean;
onClick: () => void;
}) {
return (
<Box
component="button"
role="radio"
aria-checked={selected}
onClick={onClick}
sx={{
width: '100%',
minHeight: 44,
px: 2,
py: 2,
border: '2px solid',
borderColor: selected ? 'var(--fa-color-brand-500)' : 'var(--fa-color-neutral-200)',
borderRadius: 'var(--fa-border-radius-md)',
bgcolor: selected ? 'var(--fa-color-surface-warm)' : 'var(--fa-color-surface-default)',
cursor: 'pointer',
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)',
},
'&:active': { filter: 'brightness(0.95)' },
'&: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="body2"
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.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>
);
}
/** 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.5,
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}
underline="hover"
aria-label={`Change ${question.toLowerCase()}`}
sx={{
color: 'text.secondary',
ml: 'auto',
minHeight: 44,
display: 'inline-flex',
alignItems: 'center',
}}
>
Change
</Link>
</Box>
);
}
// ─── 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 ───────────────────────────────────────────────────────────────
/**
* Hero search widget for the FA homepage.
*
* Guides users through a conversational stepped flow to find funeral providers.
* Every question is its own step that collapses to a compact summary row once
* 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:
* 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?" → type cards + "Explore all" + optional theme chips
* 4. "Would you like a service?" (conditional) → chips (auto-advance)
* 5. Location + CTA (always visible)
*/
export const FuneralFinder = React.forwardRef<HTMLDivElement, FuneralFinderProps>(
(
{
funeralTypes,
themeOptions = [],
onSearch,
loading = false,
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.",
showExploreAll = true,
sx,
},
ref,
) => {
const [intent, setIntent] = React.useState<Intent>(null);
const [planningFor, setPlanningFor] = React.useState<PlanningFor>(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 [locationError, setLocationError] = React.useState('');
const [showIntentPrompt, setShowIntentPrompt] = React.useState(false);
const [editingStep, setEditingStep] = React.useState<number | null>(null);
const needsPlanningFor = intent === 'preplan';
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? (0 = all complete)
const activeStep = (() => {
if (editingStep !== null) return editingStep;
if (!intent) return 1;
if (needsPlanningFor && !planningFor) return 2;
if (!typeSelection) return 3;
if (showServiceStep && !serviceAnswered) return 4;
return 0;
})();
// ─── Labels ─────────────────────────────────────────────────────
const intentLabel = intent === 'arrange' ? 'Arrange a funeral now' : 'Pre-plan a funeral';
const planningForLabel = planningFor === 'myself' ? 'Myself' : 'Someone else';
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) => {
setIntent(value);
if (value === 'arrange') setPlanningFor(null);
setShowIntentPrompt(false);
setEditingStep(null);
};
const selectPlanningFor = (value: PlanningFor) => {
setPlanningFor(value);
setEditingStep(null);
};
const selectType = (id: string) => {
setTypeSelection(id);
if (id === 'all' || !funeralTypes.find((ft) => ft.id === id)?.hasServiceOption) {
setServicePref('either');
setServiceAnswered(false);
}
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) => {
setEditingStep(step);
};
const handleSubmit = () => {
// 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?.({
intent,
planningFor: needsPlanningFor ? (planningFor ?? undefined) : undefined,
funeralTypeId: isExploreAll ? null : (typeSelection ?? null),
servicePreference: showServiceStep && serviceAnswered ? servicePref : 'either',
themes: selectedThemes,
location: location.trim(),
});
};
// ─── 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="h2"
component="h2"
sx={{ fontFamily: 'var(--fa-font-family-display)', textAlign: 'center', mb: 1.5 }}
>
{heading}
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ textAlign: 'center', maxWidth: 400, mx: 'auto' }}
>
{subheading}
</Typography>
<Divider sx={{ mt: 3, mb: 0 }} />
{/* ── Completed rows ─────────────────────────────────────── */}
<Collapse in={intent !== null && activeStep !== 1} timeout={250}>
<CompletedRow
question="I'm here to"
answer={intentLabel}
onChangeClick={() => revertTo(1)}
/>
</Collapse>
<Collapse in={needsPlanningFor && planningFor !== null && activeStep !== 2} timeout={250}>
<CompletedRow
question="Planning for"
answer={planningForLabel}
onChangeClick={() => revertTo(2)}
/>
</Collapse>
<Collapse in={typeSelected && activeStep !== 3} timeout={250}>
<CompletedRow
question="Looking for"
answer={typeSummary}
onChangeClick={() => revertTo(3)}
/>
</Collapse>
<Collapse in={showServiceStep && serviceAnswered && activeStep !== 4} timeout={250}>
<CompletedRow
question="Service"
answer={serviceLabel}
onChangeClick={() => revertTo(4)}
/>
</Collapse>
{/* ── Step 1: Intent ─────────────────────────────────────── */}
<Collapse in={activeStep === 1} timeout={250}>
<Box sx={{ mt: 3 }}>
{showIntentPrompt && (
<Typography
variant="caption"
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>
)}
<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
label="Arrange a funeral now"
description="Someone has passed and I need to make arrangements"
selected={intent === 'arrange'}
onClick={() => selectIntent('arrange')}
/>
<ChoiceCard
label="Pre-plan a funeral"
description="I'd like to plan ahead for the future"
selected={intent === 'preplan'}
onClick={() => selectIntent('preplan')}
/>
</Box>
</Box>
</Collapse>
{/* ── Step 2: Planning for (conditional) ─────────────────── */}
<Collapse in={activeStep === 2 && needsPlanningFor} timeout={250}>
<Box sx={{ mt: 3 }}>
<StepHeading>Who are you planning for?</StepHeading>
<Box
role="radiogroup"
aria-label="Who are you planning for"
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}
>
<ChoiceCard
label="Myself"
description="I want to plan my own funeral in advance"
selected={planningFor === 'myself'}
onClick={() => selectPlanningFor('myself')}
/>
<ChoiceCard
label="Someone else"
description="I'm helping a family member or friend plan ahead"
selected={planningFor === 'someone-else'}
onClick={() => selectPlanningFor('someone-else')}
/>
</Box>
</Box>
</Collapse>
{/* ── Step 3: Type + Preferences ─────────────────────────── */}
<Collapse in={activeStep === 3} timeout={250}>
<Box sx={{ mt: 3 }}>
<StepHeading>What type of funeral are you considering?</StepHeading>
<Box
role="radiogroup"
aria-label="Type of funeral"
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}
>
{funeralTypes.map((ft) => (
<TypeCard
key={ft.id}
label={ft.label}
description={ft.description}
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>
</Collapse>
{/* ── Always visible: Location + CTA ─────────────────────── */}
<Box sx={{ mt: 3 }}>
<Typography variant="body1" sx={{ fontWeight: 600, mb: 1.5 }}>
Where are you looking?
</Typography>
<Input
placeholder="Suburb or postcode"
value={location}
onChange={(e) => {
setLocation(e.target.value);
if (locationError) setLocationError('');
}}
size="small"
fullWidth
error={!!locationError}
helperText={locationError || 'Enter a suburb name or 4-digit postcode'}
inputProps={{ 'aria-label': 'Where are you looking — suburb or postcode' }}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSubmit();
}}
/>
<Box sx={{ mt: 3 }}>
<Button
variant="contained"
size="large"
fullWidth
loading={loading}
disabled={loading}
onClick={handleSubmit}
>
Find funeral providers
</Button>
<Typography
variant="captionSm"
color="text.secondary"
sx={{ textAlign: 'center', display: 'block', mt: 1.5 }}
>
Free to use · No obligation
</Typography>
</Box>
</Box>
</Box>
);
},
);
FuneralFinder.displayName = 'FuneralFinder';
export default FuneralFinder;