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>
This commit is contained in:
493
src/components/organisms/FuneralFinder/FuneralFinderV4.tsx
Normal file
493
src/components/organisms/FuneralFinder/FuneralFinderV4.tsx
Normal file
@@ -0,0 +1,493 @@
|
||||
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…</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’m looking to…
|
||||
</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’m planning for…
|
||||
</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 · No obligation
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
FuneralFinderV4.displayName = 'FuneralFinderV4';
|
||||
export default FuneralFinderV4;
|
||||
Reference in New Issue
Block a user