Files
Parsons/src/components/organisms/FuneralFinder/FuneralFinderV4.tsx
Richie eb6cf6a185 Add FuneralFinder V4, HomePage V3/V4, restyle Footer to light grey
- FuneralFinder V4: 3 numbered steps (48px circles), ungated location,
  no heading, inline copper errors, "Search" CTA. Archived.
- FuneralFinderV3: heading weight 600, "Find your local providers",
  "Search Local Providers" CTA, optional subheading
- HomePage V1/V2: split into separate archived stories
- HomePage V3: hero-3.png, updated copy, venue photos, map placeholder,
  scrolling partner logo bar, warm gradient CTA, increased spacing
- HomePage V4: same as V3 with FuneralFinderV4 via new finderSlot prop
- Footer: surface.subtle bg (matches header), dark-on-light text
- All versions archived in Storybook

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:02:52 +11:00

494 lines
18 KiB
TypeScript

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 FuneralFinderV4SearchParams {
/** 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 v4 organism */
export interface FuneralFinderV4Props {
/** Called when the user submits with valid data */
onSearch?: (params: FuneralFinderV4SearchParams) => void;
/** Shows loading state on the CTA */
loading?: boolean;
/** 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)' },
];
// ─── Step indicator ─────────────────────────────────────────────────────────
const INDICATOR_SIZE = 48;
const ICON_SIZE = 20;
type StepState = 'inactive' | 'active' | 'completed';
function StepIndicator({ step, state }: { step: number; state: StepState }) {
return (
<Box
sx={{
width: INDICATOR_SIZE,
height: INDICATOR_SIZE,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
transition: 'all 250ms cubic-bezier(0.4, 0, 0.2, 1)',
...(state === 'completed' && {
bgcolor: 'var(--fa-color-brand-500)',
color: 'common.white',
boxShadow: '0 0 0 3px var(--fa-color-brand-100, #F5EDE4)',
}),
...(state === 'active' && {
bgcolor: 'var(--fa-color-brand-500)',
color: 'common.white',
boxShadow: '0 0 0 3px var(--fa-color-brand-100, #F5EDE4)',
}),
...(state === 'inactive' && {
bgcolor: 'transparent',
border: '2px solid var(--fa-color-neutral-300, #C4C4C4)',
color: 'var(--fa-color-neutral-400, #9E9E9E)',
}),
}}
>
{state === 'completed' ? (
<CheckIcon
sx={{
fontSize: ICON_SIZE,
animation: 'fadeScaleIn 250ms cubic-bezier(0.34, 1.56, 0.64, 1)',
'@keyframes fadeScaleIn': {
'0%': { opacity: 0, transform: 'scale(0.5)' },
'100%': { opacity: 1, transform: 'scale(1)' },
},
}}
/>
) : (
<Typography
variant="captionSm"
component="span"
sx={{
fontWeight: 700,
fontSize: '1.125rem',
lineHeight: 1,
color: 'inherit',
}}
>
{step}
</Typography>
)}
</Box>
);
}
/** Inline error message shown below a field */
function FieldError({ children }: { children: React.ReactNode }) {
return (
<Typography
variant="captionSm"
role="alert"
sx={{
mt: 0.5,
color: 'var(--fa-color-brand-600, #B0610F)',
}}
>
{children}
</Typography>
);
}
// ─── Shared select 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-brand-600, #B0610F)',
},
'.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 ──────────────────────────────────────────────────────────────
/**
* FuneralFinder V4 — compact search widget.
*
* Based on V2's field set with a streamlined layout:
* - No heading/subheading — designed to sit inside a hero or card
* - 3 numbered steps (intent, planning-for, funeral type) with refined indicators
* - Location field is always enabled (not a numbered step)
* - "Search" CTA
*
* 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).
* - Steps 2 and 3 unlock sequentially; location is always available.
*/
export const FuneralFinderV4 = React.forwardRef<HTMLDivElement, FuneralFinderV4Props>(
({ onSearch, loading = false, 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;
// Step states for indicators
const step1State: StepState = lookingTo ? 'completed' : 'active';
const step2State: StepState = planningFor ? 'completed' : lookingTo ? 'active' : 'inactive';
const step3State: StepState = funeralType ? 'completed' : planningFor ? 'active' : 'inactive';
// Errors only show after first submit attempt
const errs = submitted
? {
lookingTo: !lookingTo,
planningFor: !planningFor,
funeralType: !funeralType,
location: location.trim().length < 3,
}
: { lookingTo: false, planningFor: false, funeralType: false, location: false };
// ─── 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)',
textAlign: 'left',
px: { xs: 3, sm: 5 },
py: { xs: 3, sm: 4 },
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* ── Steps ───────────────────────────────────────────── */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3.5 }}>
{/* Step 1: I'm looking to */}
<Box>
<Typography
variant="body1"
sx={{ fontWeight: 600, mb: 0.75, color: 'var(--fa-color-brand-700)' }}
>
I&rsquo;m looking to&hellip;
</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<StepIndicator step={1} state={step1State} />
<Select
value={lookingTo}
onChange={handleLookingTo}
displayEmpty
error={errs.lookingTo}
renderValue={(v) => (v ? findLabel(LOOKING_TO_OPTIONS, v) : placeholder)}
MenuProps={selectMenuProps}
sx={{ ...selectSx, flex: 1 } as SxProps<Theme>}
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>
{errs.lookingTo && <FieldError>Please tell us what you need help with</FieldError>}
</Box>
{/* Step 2: I'm planning for */}
<Box>
<Typography
variant="body1"
sx={{
fontWeight: 600,
mb: 0.75,
color: lookingTo ? 'var(--fa-color-brand-700)' : 'text.disabled',
}}
>
I&rsquo;m planning for&hellip;
</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<StepIndicator step={2} state={step2State} />
<Select
value={planningFor}
onChange={handlePlanningFor}
displayEmpty
disabled={step2Disabled}
error={errs.planningFor}
renderValue={(v) => (v ? findLabel(PLANNING_FOR_OPTIONS, v) : placeholder)}
MenuProps={selectMenuProps}
sx={{ ...selectSx, flex: 1 } as SxProps<Theme>}
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>
{errs.planningFor && <FieldError>Please select who you are planning for</FieldError>}
</Box>
{/* Step 3: Type of funeral */}
<Box>
<Typography
variant="body1"
sx={{
fontWeight: 600,
mb: 0.75,
color: step3Disabled ? 'text.disabled' : 'var(--fa-color-brand-700)',
}}
>
Type of funeral
</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<StepIndicator step={3} state={step3State} />
<Select
value={funeralType}
onChange={handleFuneralType}
displayEmpty
disabled={step3Disabled}
error={errs.funeralType}
renderValue={(v) => (v ? findLabel(FUNERAL_TYPE_OPTIONS, v) : placeholder)}
MenuProps={selectMenuProps}
sx={{ ...selectSx, flex: 1 } as SxProps<Theme>}
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>
{errs.funeralType && <FieldError>Please select a funeral type</FieldError>}
</Box>
</Box>
{/* ── Location (not a numbered step) ─────────────────── */}
<Box sx={{ mt: 3.5 }}>
<Typography
variant="body1"
sx={{
fontWeight: 600,
mb: 0.75,
color: funeralType ? 'var(--fa-color-brand-700)' : 'text.disabled',
}}
>
Looking for providers in
</Typography>
<Input
placeholder="Suburb or postcode"
value={location}
onChange={(e) => setLocation(e.target.value)}
fullWidth
disabled={!funeralType}
error={errs.location}
inputProps={{ 'aria-label': 'Suburb or postcode', 'aria-required': true }}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSubmit();
}}
sx={{
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: '1px',
},
'&.Mui-disabled': {
opacity: 0.6,
'& .MuiOutlinedInput-notchedOutline': { borderStyle: 'dashed' },
},
'&.Mui-error .MuiOutlinedInput-notchedOutline': {
borderColor: 'var(--fa-color-brand-600, #B0610F)',
},
'& .MuiOutlinedInput-input': {
py: '14px',
px: 2,
fontSize: '0.875rem',
},
}}
/>
{errs.location && <FieldError>Please enter a suburb or postcode</FieldError>}
</Box>
{/* ── CTA ─────────────────────────────────────────────── */}
<Divider sx={{ my: 3 }} />
<Box>
<Button
variant="contained"
size="large"
fullWidth
loading={loading}
disabled={loading}
onClick={handleSubmit}
>
Search
</Button>
<Typography
variant="captionSm"
color="text.secondary"
sx={{ textAlign: 'center', display: 'block', mt: 1.5 }}
>
Free to use &middot; No obligation
</Typography>
</Box>
</Box>
);
},
);
FuneralFinderV4.displayName = 'FuneralFinderV4';
export default FuneralFinderV4;