From a65524584206fea3928e4a2e57a238a49a2faa97 Mon Sep 17 00:00:00 2001 From: Richie Date: Thu, 26 Mar 2026 11:39:57 +1100 Subject: [PATCH] =?UTF-8?q?Refine=20FuneralFinder=20v1=20=E2=80=94=20full?= =?UTF-8?q?=20stepped=20flow,=20always-visible=20CTA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../FuneralFinder/FuneralFinder.stories.tsx | 111 ++-- .../organisms/FuneralFinder/FuneralFinder.tsx | 509 ++++++++++++------ 2 files changed, 402 insertions(+), 218 deletions(-) diff --git a/src/components/organisms/FuneralFinder/FuneralFinder.stories.tsx b/src/components/organisms/FuneralFinder/FuneralFinder.stories.tsx index bb40a39..7d9d4fa 100644 --- a/src/components/organisms/FuneralFinder/FuneralFinder.stories.tsx +++ b/src/components/organisms/FuneralFinder/FuneralFinder.stories.tsx @@ -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 = () => ( ); +// ─── Meta ─────────────────────────────────────────────────────────────────── + const meta: Meta = { title: 'Organisms/FuneralFinder', component: FuneralFinder, tags: ['autodocs'], - parameters: { - layout: 'centered', - }, + parameters: { layout: 'centered' }, decorators: [ (Story) => ( @@ -36,41 +41,67 @@ const meta: Meta = { export default meta; type Story = StoryObj; -// --- 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 */} - {/* Left: heading + search widget */} Discover, Explore, and Plan Funerals in Minutes @@ -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. - alert(JSON.stringify(params, null, 2))} /> - - {/* Right: hero image placeholder */} ( @@ -175,29 +198,14 @@ export const InHeroMobile: Story = { { label: 'Log in', href: '/login' }, ]} /> - - {/* Hero heading */} - - + + Discover, Explore, and Plan Funerals in Minutes Find trusted local providers with transparent pricing, at your own pace. - - {/* Hero image */} - - {/* Search widget — overlaps image slightly */} alert(JSON.stringify(params, null, 2))} /> diff --git a/src/components/organisms/FuneralFinder/FuneralFinder.tsx b/src/components/organisms/FuneralFinder/FuneralFinder.tsx index 05f3e00..80e2fb6 100644 --- a/src/components/organisms/FuneralFinder/FuneralFinder.tsx +++ b/src/components/organisms/FuneralFinder/FuneralFinder.tsx @@ -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; } @@ -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 ( + + + {children} + + + ); +} + /** 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({ {description && ( - + {description} )} @@ -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} + + {selected && ( + + {description && ( + + {description} + + )} + {note && ( + + {note} + + )} ); } @@ -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 @@ -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( ( { 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(null); const [planningFor, setPlanningFor] = React.useState(null); - const [funeralTypeId, setFuneralTypeId] = React.useState(null); + const [typeSelection, setTypeSelection] = React.useState(null); + const [servicePref, setServicePref] = React.useState('either'); + const [serviceAnswered, setServiceAnswered] = React.useState(false); + const [selectedThemes, setSelectedThemes] = React.useState([]); const [location, setLocation] = React.useState(''); + const [locationError, setLocationError] = React.useState(''); + const [showIntentPrompt, setShowIntentPrompt] = React.useState(false); const [editingStep, setEditingStep] = React.useState(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 { - 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 ( {/* Header */} {heading} {subheading} + - {/* Completed answers */} - {intent && activeStep > 1 && editingStep !== 1 && ( - revertTo(1)} - /> - )} - {needsPlanningFor && planningFor && activeStep > 2 && editingStep !== 2 && ( - revertTo(2)} - /> - )} - {funeralTypeId && funeralTypeLabel && activeStep > 3 && editingStep !== 3 && ( - revertTo(3)} - /> - )} + {/* ── Completed rows ─────────────────────────────────────── */} + + revertTo(1)} /> + + + revertTo(2)} /> + + + revertTo(3)} /> + + + revertTo(4)} /> + - {/* Active question */} - 1 && editingStep !== 1 ? 3 : 0 }}> - {/* Step 1: Intent */} - {activeStep === 1 && ( - - - How can we help you today? + {/* ── Step 1: Intent ─────────────────────────────────────── */} + + + {showIntentPrompt && ( + + Please let us know how we can help - - selectIntent('arrange')} - /> - selectIntent('preplan')} - /> - - - )} - - {/* Step 2: Planning for (conditional) */} - {activeStep === 2 && needsPlanningFor && ( - - - Who are you planning for? - - - selectPlanningFor('myself')} - /> - selectPlanningFor('someone-else')} - /> - - - )} - - {/* Step 3: Funeral type */} - {activeStep === 3 && ( - - - What type of funeral are you considering? - - - {funeralTypes.map((ft) => ( - selectFuneralType(ft.id)} - /> - ))} - - - )} - - {/* Step 4: Location */} - {activeStep === 4 && ( - - - Where are you located? - - setLocation(e.target.value)} - size="small" - fullWidth - inputProps={{ 'aria-label': 'Location — suburb or postcode' }} - onKeyDown={(e) => { - if (e.key === 'Enter' && canSubmit) handleSubmit(); - }} + )} + How can we help you today? + + selectIntent('arrange')} + /> + selectIntent('preplan')} /> - )} - + + + + {/* ── Step 2: Planning for (conditional) ─────────────────── */} + + + Who are you planning for? + + selectPlanningFor('myself')} + /> + selectPlanningFor('someone-else')} + /> + + + + + {/* ── Step 3: Type + Preferences ─────────────────────────── */} + + + What type of funeral are you considering? + + {funeralTypes.map((ft) => ( + selectType(ft.id)} + /> + ))} + {showExploreAll && ( + selectType('all')} + /> + )} + + + {/* Theme preferences — optional, inside type step */} + {themeOptions.length > 0 && ( + + + + Any preferences? + + + (optional) + + + + {themeOptions.map((theme) => { + const isSelected = selectedThemes.includes(theme.id); + return ( + toggleTheme(theme.id)} + clickable + aria-pressed={isSelected} + sx={{ height: 44 }} + /> + ); + })} + + + )} + + + + {/* ── Step 4: Service (conditional, auto-advance) ────────── */} + + + Would you like a service? + + {SERVICE_OPTIONS.map((opt) => ( + selectService(opt.value)} + clickable + aria-pressed={serviceAnswered && servicePref === opt.value} + sx={{ justifyContent: 'flex-start', height: 44, borderRadius: 'var(--fa-border-radius-md)' }} + /> + ))} + + + + + {/* ── Always visible: Location + CTA ─────────────────────── */} + + + Where are you looking? + + { + 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 && ( - )} + ); },