Add CemeteryStep page (wizard step 9)

- Progressive disclosure: "Have a plot?" → "Choose cemetery?" → card grid
- Dependent field resets (changing parent answer clears child selections)
- Cemetery card grid with radiogroup pattern + roving tabindex
- Pre-planning variant: softer subheading about provider arranging later
- "Provider can arrange this" shortcut skips grid entirely
- Grief-sensitive copy: "we need to find one" not "I don't have a plot"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 14:57:19 +11:00
parent 1f9f7e2611
commit 41901ed81d
3 changed files with 546 additions and 0 deletions

View File

@@ -0,0 +1,243 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { CemeteryStep } from './CemeteryStep';
import type { CemeteryStepValues, CemeteryStepErrors, Cemetery } from './CemeteryStep';
import { Navigation } from '../../organisms/Navigation';
import Box from '@mui/material/Box';
// ─── Helpers ─────────────────────────────────────────────────────────────────
const FALogo = () => (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box
component="img"
src="/brandlogo/logo-full.svg"
alt="Funeral Arranger"
sx={{ height: 28, display: { xs: 'none', md: 'block' } }}
/>
<Box
component="img"
src="/brandlogo/logo-short.svg"
alt="Funeral Arranger"
sx={{ height: 28, display: { xs: 'block', md: 'none' } }}
/>
</Box>
);
const nav = (
<Navigation
logo={<FALogo />}
items={[
{ label: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' },
]}
/>
);
const sampleCemeteries: Cemetery[] = [
{
id: 'rookwood',
name: 'Rookwood Cemetery',
location: 'Lidcombe, NSW',
price: 4500,
},
{
id: 'northern-suburbs',
name: 'Northern Suburbs Memorial Gardens',
location: 'North Ryde, NSW',
price: 5200,
},
{
id: 'macquarie-park',
name: 'Macquarie Park Cemetery',
location: 'Macquarie Park, NSW',
price: 4800,
},
];
const defaultValues: CemeteryStepValues = {
burialOwn: null,
burialCustom: null,
selectedCemeteryId: null,
};
// ─── Meta ────────────────────────────────────────────────────────────────────
const meta: Meta<typeof CemeteryStep> = {
title: 'Pages/CemeteryStep',
component: CemeteryStep,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
};
export default meta;
type Story = StoryObj<typeof CemeteryStep>;
// ─── Interactive (default) ──────────────────────────────────────────────────
/** Fully interactive — progressive disclosure flow */
export const Default: Story = {
render: () => {
const [values, setValues] = useState<CemeteryStepValues>({ ...defaultValues });
const [errors, setErrors] = useState<CemeteryStepErrors>({});
const handleContinue = () => {
const newErrors: CemeteryStepErrors = {};
if (!values.burialOwn) newErrors.burialOwn = 'Please let us know about the burial plot.';
if (values.burialOwn === 'no' && !values.burialCustom)
newErrors.burialCustom = "Please let us know if you'd like to choose a specific cemetery.";
if (values.burialOwn === 'no' && values.burialCustom === 'yes' && !values.selectedCemeteryId)
newErrors.selectedCemeteryId = 'Please choose a cemetery.';
setErrors(newErrors);
if (Object.keys(newErrors).length === 0) alert('Continue');
};
return (
<CemeteryStep
values={values}
onChange={(v) => {
setValues(v);
setErrors({});
}}
onContinue={handleContinue}
onBack={() => alert('Back')}
onSaveAndExit={() => alert('Save')}
errors={errors}
cemeteries={sampleCemeteries}
navigation={nav}
/>
);
},
};
// ─── Has existing plot ──────────────────────────────────────────────────────
/** User already owns a burial plot — short confirmation */
export const HasExistingPlot: Story = {
render: () => {
const [values, setValues] = useState<CemeteryStepValues>({
...defaultValues,
burialOwn: 'yes',
});
return (
<CemeteryStep
values={values}
onChange={setValues}
onContinue={() => alert('Continue')}
onBack={() => alert('Back')}
cemeteries={sampleCemeteries}
navigation={nav}
/>
);
},
};
// ─── Provider arranges ──────────────────────────────────────────────────────
/** User wants provider to arrange — no cemetery grid */
export const ProviderArranges: Story = {
render: () => {
const [values, setValues] = useState<CemeteryStepValues>({
...defaultValues,
burialOwn: 'no',
burialCustom: 'no',
});
return (
<CemeteryStep
values={values}
onChange={setValues}
onContinue={() => alert('Continue')}
onBack={() => alert('Back')}
cemeteries={sampleCemeteries}
navigation={nav}
/>
);
},
};
// ─── Cemetery grid visible ──────────────────────────────────────────────────
/** User wants to choose — cemetery grid revealed */
export const CemeteryGridVisible: Story = {
render: () => {
const [values, setValues] = useState<CemeteryStepValues>({
...defaultValues,
burialOwn: 'no',
burialCustom: 'yes',
});
return (
<CemeteryStep
values={values}
onChange={setValues}
onContinue={() => alert('Continue')}
onBack={() => alert('Back')}
cemeteries={sampleCemeteries}
navigation={nav}
/>
);
},
};
// ─── Cemetery selected ──────────────────────────────────────────────────────
/** Cemetery selected */
export const CemeterySelected: Story = {
render: () => {
const [values, setValues] = useState<CemeteryStepValues>({
burialOwn: 'no',
burialCustom: 'yes',
selectedCemeteryId: 'rookwood',
});
return (
<CemeteryStep
values={values}
onChange={setValues}
onContinue={() => alert('Continue')}
onBack={() => alert('Back')}
cemeteries={sampleCemeteries}
navigation={nav}
/>
);
},
};
// ─── Pre-planning ───────────────────────────────────────────────────────────
/** Pre-planning variant */
export const PrePlanning: Story = {
render: () => {
const [values, setValues] = useState<CemeteryStepValues>({ ...defaultValues });
return (
<CemeteryStep
values={values}
onChange={setValues}
onContinue={() => alert('Continue')}
onBack={() => alert('Back')}
cemeteries={sampleCemeteries}
isPrePlanning
navigation={nav}
/>
);
},
};
// ─── Validation errors ──────────────────────────────────────────────────────
/** All errors showing */
export const WithErrors: Story = {
render: () => {
const [values, setValues] = useState<CemeteryStepValues>({ ...defaultValues });
return (
<CemeteryStep
values={values}
onChange={setValues}
onContinue={() => {}}
errors={{ burialOwn: 'Please let us know about the burial plot.' }}
cemeteries={sampleCemeteries}
navigation={nav}
/>
);
},
};

View File

@@ -0,0 +1,296 @@
import React from 'react';
import Box from '@mui/material/Box';
import FormControl from '@mui/material/FormControl';
import FormLabel from '@mui/material/FormLabel';
import FormControlLabel from '@mui/material/FormControlLabel';
import RadioGroup from '@mui/material/RadioGroup';
import Radio from '@mui/material/Radio';
import type { SxProps, Theme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout';
import { Card } from '../../atoms/Card';
import { Collapse } from '../../atoms/Collapse';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Divider } from '../../atoms/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
/** A cemetery available for selection */
export interface Cemetery {
id: string;
name: string;
location: string;
price?: number;
}
/** Form values for the cemetery step */
export interface CemeteryStepValues {
/** Does the family already own a burial plot? */
burialOwn: 'yes' | 'no' | null;
/** Would they like to choose a specific cemetery? (when burialOwn=no) */
burialCustom: 'yes' | 'no' | null;
/** Selected cemetery ID */
selectedCemeteryId: string | null;
}
/** Field-level error messages */
export interface CemeteryStepErrors {
burialOwn?: string;
burialCustom?: string;
selectedCemeteryId?: string;
}
/** Props for the CemeteryStep page component */
export interface CemeteryStepProps {
/** Current form values */
values: CemeteryStepValues;
/** Callback when any field value changes */
onChange: (values: CemeteryStepValues) => void;
/** Callback when the Continue button is clicked */
onContinue: () => void;
/** Callback for back navigation */
onBack?: () => void;
/** Callback for save-and-exit */
onSaveAndExit?: () => void;
/** Field-level validation errors */
errors?: CemeteryStepErrors;
/** Whether the Continue button is in a loading state */
loading?: boolean;
/** Available cemeteries */
cemeteries: Cemetery[];
/** Whether this is a pre-planning flow */
isPrePlanning?: boolean;
/** Navigation bar */
navigation?: React.ReactNode;
/** Progress stepper */
progressStepper?: React.ReactNode;
/** Running total */
runningTotal?: React.ReactNode;
/** Hide the help bar */
hideHelpBar?: boolean;
/** MUI sx prop */
sx?: SxProps<Theme>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Step 9 — Cemetery for the FA arrangement wizard.
*
* Cemetery selection and burial plot preferences. Only shown for
* burial-type funerals (Service & Burial, Graveside, Burial Only).
*
* Progressive disclosure flow:
* 1. "Do you have a burial plot?" → Yes/No
* 2. If No: "Would you like to choose a specific cemetery?" → Yes/No
* 3. If Yes to #2: Cemetery card grid
*
* If the user already owns a plot, the cemetery grid can be shown
* for confirmation (passed via showGridForExistingPlot).
*
* Pure presentation component — props in, callbacks out.
*
* Spec: documentation/steps/steps/09_cemetery.yaml
*/
export const CemeteryStep: React.FC<CemeteryStepProps> = ({
values,
onChange,
onContinue,
onBack,
onSaveAndExit,
errors,
loading = false,
cemeteries,
isPrePlanning = false,
navigation,
progressStepper,
runningTotal,
hideHelpBar,
sx,
}) => {
const showCustomQuestion = values.burialOwn === 'no';
const showCemeteryGrid = values.burialOwn === 'no' && values.burialCustom === 'yes';
const handleBurialOwnChange = (value: string) => {
onChange({
...values,
burialOwn: value as CemeteryStepValues['burialOwn'],
// Reset dependent fields when parent changes
burialCustom: null,
selectedCemeteryId: null,
});
};
const handleBurialCustomChange = (value: string) => {
onChange({
...values,
burialCustom: value as CemeteryStepValues['burialCustom'],
selectedCemeteryId: null,
});
};
const handleCemeterySelect = (id: string) => {
onChange({ ...values, selectedCemeteryId: id });
};
return (
<WizardLayout
variant="centered-form"
navigation={navigation}
progressStepper={progressStepper}
runningTotal={runningTotal}
showBackLink={!!onBack}
backLabel="Back"
onBack={onBack}
hideHelpBar={hideHelpBar}
sx={sx}
>
{/* Page heading */}
<Typography variant="display3" component="h1" sx={{ mb: 1 }} tabIndex={-1}>
Cemetery
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
{isPrePlanning
? "If you haven't decided on a cemetery yet, the funeral provider can help with this later."
: 'Choose where the burial will take place.'}
</Typography>
<Box
component="form"
noValidate
onSubmit={(e: React.FormEvent) => {
e.preventDefault();
onContinue();
}}
>
{/* ─── Burial plot question ─── */}
<FormControl component="fieldset" sx={{ mb: 3, display: 'block' }}>
<FormLabel component="legend" sx={{ mb: 1 }}>
Do you already have a burial plot?
</FormLabel>
<RadioGroup
value={values.burialOwn ?? ''}
onChange={(e) => handleBurialOwnChange(e.target.value)}
>
<FormControlLabel value="yes" control={<Radio />} label="Yes, we have a plot" />
<FormControlLabel value="no" control={<Radio />} label="No, we need to find one" />
</RadioGroup>
{errors?.burialOwn && (
<Typography variant="body2" color="error" sx={{ mt: 0.5 }} role="alert">
{errors.burialOwn}
</Typography>
)}
</FormControl>
{/* ─── Custom cemetery question (progressive disclosure) ─── */}
<Collapse in={showCustomQuestion}>
<FormControl component="fieldset" sx={{ mb: 3, display: 'block' }}>
<FormLabel component="legend" sx={{ mb: 1 }}>
Would you like to choose a specific cemetery?
</FormLabel>
<RadioGroup
value={values.burialCustom ?? ''}
onChange={(e) => handleBurialCustomChange(e.target.value)}
>
<FormControlLabel value="yes" control={<Radio />} label="Yes, I'd like to choose" />
<FormControlLabel
value="no"
control={<Radio />}
label="No, the funeral provider can arrange this"
/>
</RadioGroup>
{errors?.burialCustom && (
<Typography variant="body2" color="error" sx={{ mt: 0.5 }} role="alert">
{errors.burialCustom}
</Typography>
)}
</FormControl>
</Collapse>
{/* ─── Cemetery card grid (progressive disclosure) ─── */}
<Collapse in={showCemeteryGrid}>
<Box sx={{ mb: 3 }}>
<Typography variant="h5" sx={{ mb: 2 }}>
Available cemeteries
</Typography>
<Box
role="radiogroup"
aria-label="Available cemeteries"
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)' },
gap: 2,
}}
>
{cemeteries.map((cemetery, index) => (
<Card
key={cemetery.id}
interactive
selected={cemetery.id === values.selectedCemeteryId}
onClick={() => handleCemeterySelect(cemetery.id)}
role="radio"
aria-checked={cemetery.id === values.selectedCemeteryId}
tabIndex={
values.selectedCemeteryId === null
? index === 0
? 0
: -1
: cemetery.id === values.selectedCemeteryId
? 0
: -1
}
sx={{ p: 3 }}
>
<Typography variant="h5">{cemetery.name}</Typography>
<Typography variant="body2" color="text.secondary">
{cemetery.location}
</Typography>
{cemetery.price != null && (
<Typography variant="h6" color="primary" sx={{ mt: 1 }}>
${cemetery.price.toLocaleString('en-AU')}
</Typography>
)}
</Card>
))}
</Box>
{errors?.selectedCemeteryId && (
<Typography variant="body2" color="error" sx={{ mt: 1 }} role="alert">
{errors.selectedCemeteryId}
</Typography>
)}
</Box>
</Collapse>
<Divider sx={{ my: 3 }} />
{/* CTAs */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexDirection: { xs: 'column-reverse', sm: 'row' },
gap: 2,
}}
>
{onSaveAndExit ? (
<Button variant="text" color="secondary" onClick={onSaveAndExit} type="button">
Save and continue later
</Button>
) : (
<Box />
)}
<Button type="submit" variant="contained" size="large" loading={loading}>
Continue
</Button>
</Box>
</Box>
</WizardLayout>
);
};
CemeteryStep.displayName = 'CemeteryStep';
export default CemeteryStep;

View File

@@ -0,0 +1,7 @@
export { CemeteryStep, default } from './CemeteryStep';
export type {
CemeteryStepProps,
CemeteryStepValues,
CemeteryStepErrors,
Cemetery,
} from './CemeteryStep';