Refine FuneralFinder v1 — full stepped flow, always-visible CTA
- Extend one-question-at-a-time pattern through all steps (type, service, location) - CTA + location always visible at bottom; smart defaults for missing optional fields - Minimum search requirements: intent + location; type/service/themes default to "all" - Funeral types: Cremation, Burial, Water Burial (QLD only) + Explore All as TypeCard - Service preference step (conditional): With a service / No service / I'm flexible - Theme preferences (eco-friendly, budget-friendly, religious specialisation) as optional sub-option within type step - StepHeading sub-component: bodyLg centered, distinct from card labels - CompletedRows: generous py:1.5 spacing, caption-size "Change" with aria-label - Loading prop on CTA button, location validation (3+ chars) - Divider under subheading for visual structure - Main heading upgraded to h2 with display font - Audit: 14/20 (Good), Critique: 29/40 (Good) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,26 +4,31 @@ import { FuneralFinder } from './FuneralFinder';
|
||||
import { Navigation } from '../Navigation';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
|
||||
// ─── Shared data ────────────────────────────────────────────────────────────
|
||||
|
||||
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' },
|
||||
{ id: 'cremation', label: 'Cremation', hasServiceOption: true },
|
||||
{ id: 'burial', label: 'Burial', hasServiceOption: true },
|
||||
{ id: 'water-burial', label: 'Water Burial', note: 'Available in QLD only', hasServiceOption: false },
|
||||
];
|
||||
|
||||
const themeOptions = [
|
||||
{ id: 'eco-friendly', label: 'Eco-friendly' },
|
||||
{ id: 'budget-friendly', label: 'Budget-friendly' },
|
||||
{ id: 'religious', label: 'Religious specialisation' },
|
||||
];
|
||||
|
||||
const FALogoNav = () => (
|
||||
<Box component="img" src="/brandlogo/logo-full.svg" alt="Funeral Arranger" sx={{ height: 28 }} />
|
||||
);
|
||||
|
||||
// ─── Meta ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const meta: Meta<typeof FuneralFinder> = {
|
||||
title: 'Organisms/FuneralFinder',
|
||||
component: FuneralFinder,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
parameters: { layout: 'centered' },
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<Box sx={{ maxWidth: 520, width: '100%' }}>
|
||||
@@ -36,41 +41,67 @@ const meta: Meta<typeof FuneralFinder> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof FuneralFinder>;
|
||||
|
||||
// --- Default -----------------------------------------------------------------
|
||||
// ─── Default ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Initial state — step 1 active, all others locked */
|
||||
/** Initial state — full feature set with types, themes, and explore-all. */
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
funeralTypes,
|
||||
themeOptions,
|
||||
onSearch: (params) => alert(JSON.stringify(params, null, 2)),
|
||||
},
|
||||
};
|
||||
|
||||
// --- Fewer Funeral Types -----------------------------------------------------
|
||||
// ─── Without Themes ─────────────────────────────────────────────────────────
|
||||
|
||||
/** With only 3 funeral types — shows compact chip row */
|
||||
export const FewerTypes: Story = {
|
||||
/** No theme options — skips the preferences section on the final step. */
|
||||
export const WithoutThemes: Story = {
|
||||
args: {
|
||||
funeralTypes: funeralTypes.slice(0, 3),
|
||||
funeralTypes,
|
||||
onSearch: (params) => alert(JSON.stringify(params, null, 2)),
|
||||
},
|
||||
};
|
||||
|
||||
// --- Custom Heading ----------------------------------------------------------
|
||||
// ─── Without Explore All ────────────────────────────────────────────────────
|
||||
|
||||
/** With custom heading and subheading */
|
||||
/** Explore-all option hidden — users must pick a specific type. */
|
||||
export const WithoutExploreAll: Story = {
|
||||
args: {
|
||||
funeralTypes,
|
||||
themeOptions,
|
||||
showExploreAll: false,
|
||||
onSearch: (params) => alert(JSON.stringify(params, null, 2)),
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Loading State ──────────────────────────────────────────────────────────
|
||||
|
||||
/** CTA in loading state — shows spinner, button disabled. */
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
funeralTypes,
|
||||
themeOptions,
|
||||
loading: true,
|
||||
onSearch: (params) => alert(JSON.stringify(params, null, 2)),
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Custom Heading ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Custom heading and subheading for alternate page contexts. */
|
||||
export const CustomHeading: Story = {
|
||||
args: {
|
||||
funeralTypes,
|
||||
themeOptions,
|
||||
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) -----------------------------------------------
|
||||
// ─── In Hero Context (Desktop) ──────────────────────────────────────────────
|
||||
|
||||
/** As it appears in the homepage hero — desktop layout */
|
||||
/** As it appears in the homepage hero — desktop layout. */
|
||||
export const InHeroDesktop: Story = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
@@ -90,8 +121,6 @@ export const InHeroDesktop: Story = {
|
||||
{ label: 'Log in', href: '/login' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Hero section */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
@@ -100,7 +129,6 @@ export const InHeroDesktop: Story = {
|
||||
bgcolor: 'var(--fa-color-brand-100)',
|
||||
}}
|
||||
>
|
||||
{/* Left: heading + search widget */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -115,11 +143,7 @@ export const InHeroDesktop: Story = {
|
||||
<Typography
|
||||
variant="displaySm"
|
||||
component="h1"
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
mb: 2,
|
||||
color: 'var(--fa-color-brand-950)',
|
||||
}}
|
||||
sx={{ textAlign: 'center', mb: 2, color: 'var(--fa-color-brand-950)' }}
|
||||
>
|
||||
Discover, Explore, and Plan Funerals in Minutes
|
||||
</Typography>
|
||||
@@ -131,20 +155,19 @@ export const InHeroDesktop: Story = {
|
||||
Whether you're thinking ahead or arranging for a loved one, find
|
||||
trusted local providers with transparent pricing.
|
||||
</Typography>
|
||||
|
||||
<FuneralFinder
|
||||
funeralTypes={funeralTypes}
|
||||
themeOptions={themeOptions}
|
||||
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)',
|
||||
backgroundImage:
|
||||
'url(https://images.unsplash.com/photo-1516733968668-dbdce39c0571?w=800&h=600&fit=crop)',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
@@ -154,9 +177,9 @@ export const InHeroDesktop: Story = {
|
||||
),
|
||||
};
|
||||
|
||||
// --- In Hero Context (Mobile) ------------------------------------------------
|
||||
// ─── In Hero Context (Mobile) ───────────────────────────────────────────────
|
||||
|
||||
/** Mobile viewport — stacked layout with image above search */
|
||||
/** Mobile viewport — stacked layout with image above search. */
|
||||
export const InHeroMobile: Story = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
@@ -175,29 +198,14 @@ export const InHeroMobile: Story = {
|
||||
{ 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)' }}
|
||||
>
|
||||
<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,
|
||||
@@ -207,11 +215,10 @@ export const InHeroMobile: Story = {
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Search widget — overlaps image slightly */}
|
||||
<Box sx={{ px: 2, mt: -3, pb: 4, bgcolor: 'var(--fa-color-brand-100)' }}>
|
||||
<FuneralFinder
|
||||
funeralTypes={funeralTypes}
|
||||
themeOptions={themeOptions}
|
||||
onSearch={(params) => alert(JSON.stringify(params, null, 2))}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
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 ───────────────────────────────────────────────────────────────────
|
||||
@@ -15,6 +18,20 @@ export interface FuneralTypeOption {
|
||||
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 */
|
||||
@@ -23,8 +40,12 @@ export interface FuneralSearchParams {
|
||||
intent: 'arrange' | 'preplan';
|
||||
/** Only present when intent is "preplan" */
|
||||
planningFor?: 'myself' | 'someone-else';
|
||||
/** Selected funeral type ID */
|
||||
funeralTypeId: string;
|
||||
/** 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;
|
||||
}
|
||||
@@ -33,12 +54,18 @@ export interface FuneralSearchParams {
|
||||
export interface FuneralFinderProps {
|
||||
/** Available funeral types — dynamic list from API */
|
||||
funeralTypes: FuneralTypeOption[];
|
||||
/** Called when the user clicks "Find funeral directors" */
|
||||
/** 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>;
|
||||
}
|
||||
@@ -47,9 +74,21 @@ export interface FuneralFinderProps {
|
||||
|
||||
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,
|
||||
@@ -69,8 +108,7 @@ function ChoiceCard({
|
||||
aria-checked={selected}
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
px: 2.5,
|
||||
py: 2,
|
||||
border: '2px solid',
|
||||
@@ -85,6 +123,7 @@ function ChoiceCard({
|
||||
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,
|
||||
@@ -107,16 +146,7 @@ function ChoiceCard({
|
||||
</Typography>
|
||||
</Box>
|
||||
{description && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="span"
|
||||
sx={{
|
||||
display: 'block',
|
||||
mt: 0.5,
|
||||
color: 'text.secondary',
|
||||
ml: selected ? 3.5 : 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" component="span" sx={{ display: 'block', mt: 0.5, color: 'text.secondary' }}>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
@@ -124,13 +154,17 @@ function ChoiceCard({
|
||||
);
|
||||
}
|
||||
|
||||
/** Funeral type option — generous pill button */
|
||||
function TypePill({
|
||||
/** Funeral type card — compact selectable card */
|
||||
function TypeCard({
|
||||
label,
|
||||
description,
|
||||
note,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
note?: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
@@ -141,30 +175,54 @@ function TypePill({
|
||||
aria-checked={selected}
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
px: 2.5,
|
||||
py: 1.25,
|
||||
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-full)',
|
||||
borderRadius: 'var(--fa-border-radius-md)',
|
||||
bgcolor: selected ? 'var(--fa-color-surface-warm)' : 'var(--fa-color-surface-default)',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: selected ? 600 : 500,
|
||||
color: selected ? 'var(--fa-color-brand-700)' : 'text.primary',
|
||||
whiteSpace: 'nowrap',
|
||||
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,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -186,7 +244,7 @@ function CompletedRow({
|
||||
alignItems: 'baseline',
|
||||
flexWrap: 'wrap',
|
||||
gap: 0.75,
|
||||
py: 1,
|
||||
py: 1.5,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'var(--fa-color-neutral-100)',
|
||||
}}
|
||||
@@ -201,7 +259,9 @@ function CompletedRow({
|
||||
component="button"
|
||||
variant="caption"
|
||||
onClick={onChangeClick}
|
||||
sx={{ color: 'text.secondary', ml: 'auto' }}
|
||||
underline="hover"
|
||||
aria-label={`Change ${question.toLowerCase()}`}
|
||||
sx={{ color: 'text.secondary', ml: 'auto', minHeight: 44, display: 'inline-flex', alignItems: 'center' }}
|
||||
>
|
||||
Change
|
||||
</Link>
|
||||
@@ -209,63 +269,94 @@ function CompletedRow({
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 directors.
|
||||
* Each question appears naturally after the previous is answered. Completed
|
||||
* answers collapse to a compact row with a "Change" link to revert.
|
||||
* 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?" → dynamic pill buttons
|
||||
* 4. "Where are you located?" → suburb/postcode input
|
||||
* 5. CTA: "Find funeral directors"
|
||||
*
|
||||
* Composes Typography + Button + Input + Link + custom ChoiceCard/TypePill.
|
||||
* 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 [funeralTypeId, setFuneralTypeId] = React.useState<string | null>(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 funeralTypeLabel = funeralTypes.find((ft) => ft.id === funeralTypeId)?.label;
|
||||
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?
|
||||
// Which step is currently active? (0 = all complete)
|
||||
const activeStep = (() => {
|
||||
if (editingStep) return editingStep;
|
||||
if (editingStep !== null) return editingStep;
|
||||
if (!intent) return 1;
|
||||
if (needsPlanningFor && !planningFor) return 2;
|
||||
if (!funeralTypeId) return 3;
|
||||
return 4;
|
||||
if (!typeSelection) return 3;
|
||||
if (showServiceStep && !serviceAnswered) return 4;
|
||||
return 0;
|
||||
})();
|
||||
|
||||
const canSubmit =
|
||||
intent !== null &&
|
||||
(!needsPlanningFor || planningFor !== null) &&
|
||||
funeralTypeId !== null &&
|
||||
location.trim().length > 0;
|
||||
// ─── Labels ─────────────────────────────────────────────────────
|
||||
const intentLabel = intent === 'arrange' ? 'Arrange a funeral now' : 'Pre-plan a funeral';
|
||||
const planningForLabel = planningFor === 'myself' ? 'Myself' : 'Someone else';
|
||||
|
||||
// ─── Handlers ────────────────────────────────────────────────────
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -274,26 +365,57 @@ export const FuneralFinder = React.forwardRef<HTMLDivElement, FuneralFinderProps
|
||||
setEditingStep(null);
|
||||
};
|
||||
|
||||
const selectFuneralType = (id: string) => {
|
||||
setFuneralTypeId(id);
|
||||
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 = () => {
|
||||
if (!canSubmit || !intent || !funeralTypeId) return;
|
||||
// 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,
|
||||
funeralTypeId: isExploreAll ? null : (typeSelection ?? null),
|
||||
servicePreference: (showServiceStep && serviceAnswered) ? servicePref : 'either',
|
||||
themes: selectedThemes,
|
||||
location: location.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
// ─── Render ──────────────────────────────────────────────────────
|
||||
// ─── Render ─────────────────────────────────────────────────────
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
@@ -309,142 +431,197 @@ export const FuneralFinder = React.forwardRef<HTMLDivElement, FuneralFinderProps
|
||||
>
|
||||
{/* Header */}
|
||||
<Typography
|
||||
variant="h4"
|
||||
variant="h2"
|
||||
component="h2"
|
||||
sx={{ fontFamily: 'var(--fa-font-family-display)', textAlign: 'center', mb: 1 }}
|
||||
sx={{ fontFamily: 'var(--fa-font-family-display)', textAlign: 'center', mb: 1.5 }}
|
||||
>
|
||||
{heading}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: 'center', mb: 3.5, maxWidth: 400, mx: 'auto' }}
|
||||
sx={{ textAlign: 'center', maxWidth: 400, mx: 'auto' }}
|
||||
>
|
||||
{subheading}
|
||||
</Typography>
|
||||
<Divider sx={{ mt: 3, mb: 0 }} />
|
||||
|
||||
{/* Completed answers */}
|
||||
{intent && activeStep > 1 && editingStep !== 1 && (
|
||||
<CompletedRow
|
||||
question="I'm here to"
|
||||
answer={intent === 'arrange' ? 'Arrange a funeral now' : 'Pre-plan a funeral'}
|
||||
onChangeClick={() => revertTo(1)}
|
||||
/>
|
||||
)}
|
||||
{needsPlanningFor && planningFor && activeStep > 2 && editingStep !== 2 && (
|
||||
<CompletedRow
|
||||
question="Planning for"
|
||||
answer={planningFor === 'myself' ? 'Myself' : 'Someone else'}
|
||||
onChangeClick={() => revertTo(2)}
|
||||
/>
|
||||
)}
|
||||
{funeralTypeId && funeralTypeLabel && activeStep > 3 && editingStep !== 3 && (
|
||||
<CompletedRow
|
||||
question="Type of funeral"
|
||||
answer={funeralTypeLabel}
|
||||
onChangeClick={() => revertTo(3)}
|
||||
/>
|
||||
)}
|
||||
{/* ── 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>
|
||||
|
||||
{/* Active question */}
|
||||
<Box sx={{ mt: intent && activeStep > 1 && editingStep !== 1 ? 3 : 0 }}>
|
||||
{/* Step 1: Intent */}
|
||||
{activeStep === 1 && (
|
||||
<Box>
|
||||
<Typography variant="body1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
How can we help you today?
|
||||
{/* ── 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>
|
||||
<Box role="radiogroup" aria-label="How can we help" sx={{ display: 'flex', gap: 1.5, flexDirection: { xs: 'column', sm: 'row' } }}>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Step 2: Planning for (conditional) */}
|
||||
{activeStep === 2 && needsPlanningFor && (
|
||||
<Box>
|
||||
<Typography variant="body1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Who are you planning for?
|
||||
</Typography>
|
||||
<Box role="radiogroup" aria-label="Who are you planning for" sx={{ display: 'flex', gap: 1.5, flexDirection: { xs: 'column', sm: 'row' } }}>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Step 3: Funeral type */}
|
||||
{activeStep === 3 && (
|
||||
<Box>
|
||||
<Typography variant="body1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
What type of funeral are you considering?
|
||||
</Typography>
|
||||
<Box role="radiogroup" aria-label="Type of funeral" sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{funeralTypes.map((ft) => (
|
||||
<TypePill
|
||||
key={ft.id}
|
||||
label={ft.label}
|
||||
selected={funeralTypeId === ft.id}
|
||||
onClick={() => selectFuneralType(ft.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Step 4: Location */}
|
||||
{activeStep === 4 && (
|
||||
<Box>
|
||||
<Typography variant="body1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Where are you located?
|
||||
</Typography>
|
||||
<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();
|
||||
}}
|
||||
)}
|
||||
<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>
|
||||
</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();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* CTA — only visible once we're on the location step or beyond */}
|
||||
{activeStep >= 4 && (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
fullWidth
|
||||
disabled={!canSubmit}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Find funeral directors
|
||||
Find funeral providers
|
||||
</Button>
|
||||
<Typography
|
||||
variant="captionSm"
|
||||
@@ -454,7 +631,7 @@ export const FuneralFinder = React.forwardRef<HTMLDivElement, FuneralFinderProps
|
||||
Free to use · No obligation
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user