Add FuneralFinder organism — hero search with stepped flow
Procedural stepped search widget for the homepage: 1. "I'm here to" — Arrange now / Pre-plan (radiogroup) 2. "I'm planning for" — Myself / Someone else (conditional, pre-plan only) 3. "Type of funeral" — dynamic Chip list from prop 4. Location — suburb or postcode input 5. CTA: "Find funeral directors" (disabled until complete) Features: - Progressive disclosure: steps unlock sequentially - Brand checkmarks + edit icon on completed steps - Click completed value to revert (only resets dependents) - Step numbering adjusts when conditional step hidden - Trust signal: "Free to use · No obligation" Audit: 17/20 (Good). P1/P2 fixes applied: - Location input aria-label for screen readers - Option groups wrapped in role="radiogroup" - Completed values have edit icon affordance - Heading uses display font CSS variable - CheckCircleIcon aria-hidden 5 stories: Default, FewerTypes, CustomHeading, InHeroDesktop, InHeroMobile Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -54,6 +54,7 @@ duplicates) and MUST update it after completing one.
|
|||||||
| ServiceSelector | done | ServiceOption × n + Typography + Button | Single-select service panel for arrangement flow. Heading + subheading + ServiceOption list (radiogroup) + optional continue Button. Manages selection state via selectedId/onSelect. maxDescriptionLines pass-through. |
|
| ServiceSelector | done | ServiceOption × n + Typography + Button | Single-select service panel for arrangement flow. Heading + subheading + ServiceOption list (radiogroup) + optional continue Button. Manages selection state via selectedId/onSelect. maxDescriptionLines pass-through. |
|
||||||
| PricingTable | planned | PriceCard × n + Typography | Comparative pricing display |
|
| PricingTable | planned | PriceCard × n + Typography | Comparative pricing display |
|
||||||
| PackageDetail | done | LineItem × n + Typography + Button + Divider | Right-side package detail panel. Warm header band (surface.warm) with "Package" overline, name, price (brand colour), Make Arrangement + Compare (with loading) buttons. Sections (before total) + total + extras (after total, with subtext). T&C grey footer. Audit: 19/20. Maps to Figma Package Select (5405:181955). |
|
| PackageDetail | done | LineItem × n + Typography + Button + Divider | Right-side package detail panel. Warm header band (surface.warm) with "Package" overline, name, price (brand colour), Make Arrangement + Compare (with loading) buttons. Sections (before total) + total + extras (after total, with subtext). T&C grey footer. Audit: 19/20. Maps to Figma Package Select (5405:181955). |
|
||||||
|
| FuneralFinder | review | Typography + Button + Chip + Input + custom OptionCard | Hero search widget. Procedural stepped flow: Intent → Planning For (conditional) → Funeral Type (chips, dynamic) → Location → CTA. Brand checkmarks on completion, edit icon to revert, radiogroup semantics. Audit: 17/20 → fixes applied. |
|
||||||
| ArrangementForm | planned | StepIndicator + ServiceSelector + AddOnOption + Button + Typography | Multi-step arrangement wizard. Deferred — build remaining atoms/molecules first. |
|
| ArrangementForm | planned | StepIndicator + ServiceSelector + AddOnOption + Button + Typography | Multi-step arrangement wizard. Deferred — build remaining atoms/molecules first. |
|
||||||
| Navigation | done | AppBar + Link + IconButton + Button + Divider + Drawer | Responsive site header. Desktop: logo left, links right, optional CTA. Mobile: hamburger + drawer with nav items, CTA, help footer. Sticky, grey surface bg (surface.subtle). Real FA logo from brandassets/. Maps to Figma Main Nav (14:108) + Mobile Header (2391:41508). |
|
| Navigation | done | AppBar + Link + IconButton + Button + Divider + Drawer | Responsive site header. Desktop: logo left, links right, optional CTA. Mobile: hamburger + drawer with nav items, CTA, help footer. Sticky, grey surface bg (surface.subtle). Real FA logo from brandassets/. Maps to Figma Main Nav (14:108) + Mobile Header (2391:41508). |
|
||||||
| Footer | done | Link × n + Typography + Divider + Container + Grid | Dark espresso (brand.950) site footer. Logo + tagline + contact (phone/email) + link group columns + legal bar. Semantic HTML (footer, nav, ul). Critique: 38/40 (Excellent). |
|
| Footer | done | Link × n + Typography + Divider + Container + Grid | Dark espresso (brand.950) site footer. Logo + tagline + contact (phone/email) + link group columns + legal bar. Semantic HTML (footer, nav, ul). Critique: 38/40 (Excellent). |
|
||||||
|
|||||||
220
src/components/organisms/FuneralFinder/FuneralFinder.stories.tsx
Normal file
220
src/components/organisms/FuneralFinder/FuneralFinder.stories.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { FuneralFinder } from './FuneralFinder';
|
||||||
|
import { Navigation } from '../Navigation';
|
||||||
|
import { Typography } from '../../atoms/Typography';
|
||||||
|
|
||||||
|
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' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FALogoNav = () => (
|
||||||
|
<Box component="img" src="/brandlogo/logo-full.svg" alt="Funeral Arranger" sx={{ height: 28 }} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const meta: Meta<typeof FuneralFinder> = {
|
||||||
|
title: 'Organisms/FuneralFinder',
|
||||||
|
component: FuneralFinder,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<Box sx={{ maxWidth: 520, width: '100%' }}>
|
||||||
|
<Story />
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof FuneralFinder>;
|
||||||
|
|
||||||
|
// --- Default -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Initial state — step 1 active, all others locked */
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
funeralTypes,
|
||||||
|
onSearch: (params) => alert(JSON.stringify(params, null, 2)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Fewer Funeral Types -----------------------------------------------------
|
||||||
|
|
||||||
|
/** With only 3 funeral types — shows compact chip row */
|
||||||
|
export const FewerTypes: Story = {
|
||||||
|
args: {
|
||||||
|
funeralTypes: funeralTypes.slice(0, 3),
|
||||||
|
onSearch: (params) => alert(JSON.stringify(params, null, 2)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Custom Heading ----------------------------------------------------------
|
||||||
|
|
||||||
|
/** With custom heading and subheading */
|
||||||
|
export const CustomHeading: Story = {
|
||||||
|
args: {
|
||||||
|
funeralTypes,
|
||||||
|
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) -----------------------------------------------
|
||||||
|
|
||||||
|
/** As it appears in the homepage hero — desktop layout */
|
||||||
|
export const InHeroDesktop: Story = {
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<Box sx={{ maxWidth: 'none', width: '100%' }}>
|
||||||
|
<Story />
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
render: () => (
|
||||||
|
<Box>
|
||||||
|
<Navigation
|
||||||
|
logo={<FALogoNav />}
|
||||||
|
items={[
|
||||||
|
{ label: 'Provider Portal', href: '/provider-portal' },
|
||||||
|
{ label: 'FAQ', href: '/faq' },
|
||||||
|
{ label: 'Contact Us', href: '/contact' },
|
||||||
|
{ label: 'Log in', href: '/login' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Hero section */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' },
|
||||||
|
minHeight: { md: 600 },
|
||||||
|
bgcolor: 'var(--fa-color-brand-100)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left: heading + search widget */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
px: { xs: 2, md: 6 },
|
||||||
|
py: { xs: 4, md: 6 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ maxWidth: 520, width: '100%' }}>
|
||||||
|
<Typography
|
||||||
|
variant="displaySm"
|
||||||
|
component="h1"
|
||||||
|
sx={{
|
||||||
|
textAlign: 'center',
|
||||||
|
mb: 2,
|
||||||
|
color: 'var(--fa-color-brand-950)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Discover, Explore, and Plan Funerals in Minutes
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ textAlign: 'center', mb: 4, maxWidth: 440, mx: 'auto' }}
|
||||||
|
>
|
||||||
|
Whether you're thinking ahead or arranging for a loved one, find
|
||||||
|
trusted local providers with transparent pricing.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<FuneralFinder
|
||||||
|
funeralTypes={funeralTypes}
|
||||||
|
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)',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- In Hero Context (Mobile) ------------------------------------------------
|
||||||
|
|
||||||
|
/** Mobile viewport — stacked layout with image above search */
|
||||||
|
export const InHeroMobile: Story = {
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<Box sx={{ maxWidth: 420, width: '100%', mx: 'auto' }}>
|
||||||
|
<Story />
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
render: () => (
|
||||||
|
<Box>
|
||||||
|
<Navigation
|
||||||
|
logo={<FALogoNav />}
|
||||||
|
items={[
|
||||||
|
{ label: 'FAQ', href: '/faq' },
|
||||||
|
{ label: 'Contact Us', href: '/contact' },
|
||||||
|
{ 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)' }}
|
||||||
|
>
|
||||||
|
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,
|
||||||
|
bgcolor: 'var(--fa-color-brand-200)',
|
||||||
|
backgroundImage: 'url(https://images.unsplash.com/photo-1516733968668-dbdce39c0571?w=800&h=400&fit=crop)',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Search widget — overlaps image slightly */}
|
||||||
|
<Box sx={{ px: 2, mt: -3, pb: 4, bgcolor: 'var(--fa-color-brand-100)' }}>
|
||||||
|
<FuneralFinder
|
||||||
|
funeralTypes={funeralTypes}
|
||||||
|
onSearch={(params) => alert(JSON.stringify(params, null, 2))}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
};
|
||||||
478
src/components/organisms/FuneralFinder/FuneralFinder.tsx
Normal file
478
src/components/organisms/FuneralFinder/FuneralFinder.tsx
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
|
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||||
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
|
import { Typography } from '../../atoms/Typography';
|
||||||
|
import { Button } from '../../atoms/Button';
|
||||||
|
import { Chip } from '../../atoms/Chip';
|
||||||
|
import { Input } from '../../atoms/Input';
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** A funeral type option (dynamic, from API) */
|
||||||
|
export interface FuneralTypeOption {
|
||||||
|
/** 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 */
|
||||||
|
funeralTypeId: 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[];
|
||||||
|
/** Called when the user clicks "Find funeral directors" */
|
||||||
|
onSearch?: (params: FuneralSearchParams) => void;
|
||||||
|
/** Optional heading override */
|
||||||
|
heading?: string;
|
||||||
|
/** Optional subheading override */
|
||||||
|
subheading?: string;
|
||||||
|
/** MUI sx prop for the root card */
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Step state types ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Intent = 'arrange' | 'preplan' | null;
|
||||||
|
type PlanningFor = 'myself' | 'someone-else' | null;
|
||||||
|
|
||||||
|
type StepStatus = 'active' | 'completed' | 'locked';
|
||||||
|
|
||||||
|
// ─── Sub-components ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Step number badge or completed checkmark */
|
||||||
|
function StepBadge({ step, status }: { step: number; status: StepStatus }) {
|
||||||
|
if (status === 'completed') {
|
||||||
|
return (
|
||||||
|
<CheckCircleIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
sx={{
|
||||||
|
fontSize: 24,
|
||||||
|
color: 'var(--fa-color-brand-500)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: status === 'active' ? 'var(--fa-color-brand-500)' : 'var(--fa-color-neutral-300)',
|
||||||
|
color: status === 'active' ? 'var(--fa-color-white)' : 'var(--fa-color-neutral-500)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A selectable option card within a step — uses radio semantics */
|
||||||
|
function OptionCard({
|
||||||
|
label,
|
||||||
|
selected,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
selected: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selected}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
px: 2,
|
||||||
|
py: 1.5,
|
||||||
|
border: '2px solid',
|
||||||
|
borderColor: selected ? 'var(--fa-color-brand-500)' : 'divider',
|
||||||
|
borderRadius: 'var(--fa-border-radius-md)',
|
||||||
|
bgcolor: selected ? 'var(--fa-color-surface-warm)' : 'transparent',
|
||||||
|
cursor: disabled ? 'default' : 'pointer',
|
||||||
|
opacity: disabled ? 0.4 : 1,
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: selected ? 'var(--fa-color-brand-700)' : 'text.primary',
|
||||||
|
textAlign: 'left',
|
||||||
|
transition: 'all 150ms ease-in-out',
|
||||||
|
'&:hover:not(:disabled)': {
|
||||||
|
borderColor: 'var(--fa-color-brand-400)',
|
||||||
|
bgcolor: 'var(--fa-color-brand-50)',
|
||||||
|
},
|
||||||
|
'&:focus-visible': {
|
||||||
|
outline: '2px solid var(--fa-color-brand-500)',
|
||||||
|
outlineOffset: 2,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hero search widget for the FA homepage.
|
||||||
|
*
|
||||||
|
* Guides users through a procedural stepped flow to find funeral directors:
|
||||||
|
* 1. "I'm here to" — Arrange a funeral now / Pre-plan a funeral
|
||||||
|
* 2. "I'm planning for" (conditional, only for pre-plan) — Myself / Someone else
|
||||||
|
* 3. "Type of funeral" — dynamic list of funeral types (Cremation, Burial, etc.)
|
||||||
|
* 4. Location — suburb or postcode input
|
||||||
|
* 5. CTA: "Find funeral directors"
|
||||||
|
*
|
||||||
|
* Each step reveals progressively. Completed steps collapse to show the
|
||||||
|
* selected value with a brand checkmark. Click a completed step to re-edit.
|
||||||
|
* Reverting a step only resets dependent steps (e.g., changing intent from
|
||||||
|
* "Pre-plan" to "Arrange now" removes the "I'm planning for" step).
|
||||||
|
*
|
||||||
|
* Composes Typography + Button + Chip + Input.
|
||||||
|
*/
|
||||||
|
export const FuneralFinder = React.forwardRef<HTMLDivElement, FuneralFinderProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
funeralTypes,
|
||||||
|
onSearch,
|
||||||
|
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.",
|
||||||
|
sx,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
// ─── State ───────────────────────────────────────────────────────
|
||||||
|
const [intent, setIntent] = React.useState<Intent>(null);
|
||||||
|
const [planningFor, setPlanningFor] = React.useState<PlanningFor>(null);
|
||||||
|
const [funeralTypeId, setFuneralTypeId] = React.useState<string | null>(null);
|
||||||
|
const [location, setLocation] = React.useState('');
|
||||||
|
|
||||||
|
// Track which step the user is currently editing (null = auto-advance)
|
||||||
|
const [editingStep, setEditingStep] = React.useState<number | null>(null);
|
||||||
|
|
||||||
|
// ─── Derived state ──────────────────────────────────────────────
|
||||||
|
const needsPlanningFor = intent === 'preplan';
|
||||||
|
const funeralTypeLabel = funeralTypes.find((ft) => ft.id === funeralTypeId)?.label;
|
||||||
|
|
||||||
|
// Determine step statuses
|
||||||
|
const getStepStatus = (step: number): StepStatus => {
|
||||||
|
if (editingStep === step) return 'active';
|
||||||
|
|
||||||
|
switch (step) {
|
||||||
|
case 1:
|
||||||
|
return intent ? 'completed' : 'active';
|
||||||
|
case 2: // planning for (conditional)
|
||||||
|
if (!needsPlanningFor) return 'locked';
|
||||||
|
if (planningFor) return 'completed';
|
||||||
|
return intent ? 'active' : 'locked';
|
||||||
|
case 3: // funeral type
|
||||||
|
if (funeralTypeId) return 'completed';
|
||||||
|
if (!intent) return 'locked';
|
||||||
|
if (needsPlanningFor && !planningFor) return 'locked';
|
||||||
|
return 'active';
|
||||||
|
case 4: // location
|
||||||
|
if (!funeralTypeId) return 'locked';
|
||||||
|
return 'active';
|
||||||
|
default:
|
||||||
|
return 'locked';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step numbering adjusts when planning-for step is hidden
|
||||||
|
const getVisibleStepNumber = (step: number): number => {
|
||||||
|
if (!needsPlanningFor && step > 2) return step - 1;
|
||||||
|
return step;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Can submit?
|
||||||
|
const canSubmit =
|
||||||
|
intent !== null &&
|
||||||
|
(!needsPlanningFor || planningFor !== null) &&
|
||||||
|
funeralTypeId !== null &&
|
||||||
|
location.trim().length > 0;
|
||||||
|
|
||||||
|
// ─── Handlers ───────────────────────────────────────────────────
|
||||||
|
const handleIntentSelect = (value: Intent) => {
|
||||||
|
setIntent(value);
|
||||||
|
// If switching from preplan to arrange, clear planningFor
|
||||||
|
if (value === 'arrange') {
|
||||||
|
setPlanningFor(null);
|
||||||
|
}
|
||||||
|
setEditingStep(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlanningForSelect = (value: PlanningFor) => {
|
||||||
|
setPlanningFor(value);
|
||||||
|
setEditingStep(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFuneralTypeSelect = (id: string) => {
|
||||||
|
setFuneralTypeId(id);
|
||||||
|
setEditingStep(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevert = (step: number) => {
|
||||||
|
setEditingStep(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!canSubmit || !intent || !funeralTypeId) return;
|
||||||
|
onSearch?.({
|
||||||
|
intent,
|
||||||
|
planningFor: needsPlanningFor ? (planningFor ?? undefined) : undefined,
|
||||||
|
funeralTypeId,
|
||||||
|
location: location.trim(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Render helpers ─────────────────────────────────────────────
|
||||||
|
const renderStep = (
|
||||||
|
stepNum: number,
|
||||||
|
label: string,
|
||||||
|
status: StepStatus,
|
||||||
|
completedValue: string | undefined,
|
||||||
|
content: React.ReactNode,
|
||||||
|
) => {
|
||||||
|
const visibleNum = getVisibleStepNumber(stepNum);
|
||||||
|
const isCompleted = status === 'completed' && editingStep !== stepNum;
|
||||||
|
const isActive = status === 'active' || editingStep === stepNum;
|
||||||
|
const isLocked = status === 'locked';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={stepNum}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 1.5,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
opacity: isLocked ? 0.5 : 1,
|
||||||
|
transition: 'opacity 150ms',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ pt: 0.25 }}>
|
||||||
|
<StepBadge step={visibleNum} status={isCompleted ? 'completed' : isActive ? 'active' : 'locked'} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography
|
||||||
|
variant="overlineSm"
|
||||||
|
sx={{
|
||||||
|
display: 'block',
|
||||||
|
color: isLocked ? 'text.disabled' : 'text.secondary',
|
||||||
|
mb: isCompleted ? 0 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{isCompleted && completedValue && (
|
||||||
|
<Box
|
||||||
|
component="button"
|
||||||
|
onClick={() => handleRevert(stepNum)}
|
||||||
|
sx={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
p: 0,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: '0.9375rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'text.primary',
|
||||||
|
textAlign: 'left',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.75,
|
||||||
|
'&:hover': { color: 'primary.main' },
|
||||||
|
'&:focus-visible': {
|
||||||
|
outline: '2px solid var(--fa-color-brand-500)',
|
||||||
|
outlineOffset: 2,
|
||||||
|
borderRadius: '2px',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
aria-label={`${label}: ${completedValue}. Click to change.`}
|
||||||
|
>
|
||||||
|
{completedValue}
|
||||||
|
<EditOutlinedIcon sx={{ fontSize: 14, opacity: 0.5 }} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isActive && content}
|
||||||
|
|
||||||
|
{isLocked && (
|
||||||
|
<Typography variant="caption" color="text.disabled">
|
||||||
|
Complete the step above
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 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="h4"
|
||||||
|
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: 3, maxWidth: 400, mx: 'auto' }}
|
||||||
|
>
|
||||||
|
{subheading}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Steps */}
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||||
|
{/* Step 1: Intent */}
|
||||||
|
{renderStep(
|
||||||
|
1,
|
||||||
|
"I'M HERE TO",
|
||||||
|
getStepStatus(1),
|
||||||
|
intent === 'arrange' ? 'Arrange a funeral now' : intent === 'preplan' ? 'Pre-plan a funeral' : undefined,
|
||||||
|
<Box role="radiogroup" aria-label="I'm here to" sx={{ display: 'flex', gap: 1.5, flexDirection: { xs: 'column', sm: 'row' } }}>
|
||||||
|
<OptionCard
|
||||||
|
label="Arrange a funeral now"
|
||||||
|
selected={intent === 'arrange'}
|
||||||
|
onClick={() => handleIntentSelect('arrange')}
|
||||||
|
/>
|
||||||
|
<OptionCard
|
||||||
|
label="Pre-plan a funeral"
|
||||||
|
selected={intent === 'preplan'}
|
||||||
|
onClick={() => handleIntentSelect('preplan')}
|
||||||
|
/>
|
||||||
|
</Box>,
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Planning for (conditional) */}
|
||||||
|
{needsPlanningFor &&
|
||||||
|
renderStep(
|
||||||
|
2,
|
||||||
|
"I'M PLANNING FOR",
|
||||||
|
getStepStatus(2),
|
||||||
|
planningFor === 'myself' ? 'Myself' : planningFor === 'someone-else' ? 'Someone else' : undefined,
|
||||||
|
<Box role="radiogroup" aria-label="I'm planning for" sx={{ display: 'flex', gap: 1.5, flexDirection: { xs: 'column', sm: 'row' } }}>
|
||||||
|
<OptionCard
|
||||||
|
label="Myself"
|
||||||
|
selected={planningFor === 'myself'}
|
||||||
|
onClick={() => handlePlanningForSelect('myself')}
|
||||||
|
/>
|
||||||
|
<OptionCard
|
||||||
|
label="Someone else"
|
||||||
|
selected={planningFor === 'someone-else'}
|
||||||
|
onClick={() => handlePlanningForSelect('someone-else')}
|
||||||
|
/>
|
||||||
|
</Box>,
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Funeral type */}
|
||||||
|
{renderStep(
|
||||||
|
3,
|
||||||
|
'TYPE OF FUNERAL',
|
||||||
|
getStepStatus(3),
|
||||||
|
funeralTypeLabel,
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
|
{funeralTypes.map((ft) => (
|
||||||
|
<Chip
|
||||||
|
key={ft.id}
|
||||||
|
label={ft.label}
|
||||||
|
variant={funeralTypeId === ft.id ? 'filled' : 'outlined'}
|
||||||
|
selected={funeralTypeId === ft.id}
|
||||||
|
onClick={() => handleFuneralTypeSelect(ft.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>,
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 4: Location */}
|
||||||
|
{renderStep(
|
||||||
|
4,
|
||||||
|
'LOCATION',
|
||||||
|
getStepStatus(4),
|
||||||
|
undefined, // Location doesn't collapse — always editable when unlocked
|
||||||
|
<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();
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<Box sx={{ mt: 3 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
fullWidth
|
||||||
|
disabled={!canSubmit}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
Find funeral directors
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Trust signal */}
|
||||||
|
<Typography
|
||||||
|
variant="captionSm"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ textAlign: 'center', display: 'block', mt: 1.5 }}
|
||||||
|
>
|
||||||
|
Free to use · No obligation
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
FuneralFinder.displayName = 'FuneralFinder';
|
||||||
|
export default FuneralFinder;
|
||||||
6
src/components/organisms/FuneralFinder/index.ts
Normal file
6
src/components/organisms/FuneralFinder/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
FuneralFinder,
|
||||||
|
type FuneralFinderProps,
|
||||||
|
type FuneralTypeOption,
|
||||||
|
type FuneralSearchParams,
|
||||||
|
} from './FuneralFinder';
|
||||||
Reference in New Issue
Block a user