Feedback iteration: DialogShell, page consistency, popup standardisation

- Add DialogShell atom — shared dialog container (header, scrollable body, footer)
- Refactor FilterPanel to use DialogShell (Popover → centered Dialog)
- Refactor ArrangementDialog to use DialogShell
- Remove PreviewStep + AuthGateStep pages (consolidated into ArrangementDialog, D-E)
- IntroStep: static subheading, top-left aligned toggle button content
- ProvidersStep: h4 heading "Find a funeral director", location search with pin icon,
  filter moved below search right-aligned, map fill fix, hover scrollbar
- VenueStep: same consistency fixes (h4 heading, filter layout, location icon, map fix)
- PackagesStep: grouped packages ("Matching your preferences" / "Other packages from
  [Provider]"), removed budget filter + Most Popular badge, clickable provider card,
  onArrange replaces onContinue, h4 heading
- WizardLayout: list-map left panel gets thin scrollbar visible on hover

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-30 12:20:26 +11:00
parent 5c3e0c4e56
commit 1faa320f4b
22 changed files with 904 additions and 1721 deletions

View File

@@ -32,6 +32,7 @@ duplicates) and MUST update it after completing one.
| Slider | planned | single, range × desktop, mobile | | Price range filter. Wraps MUI Slider. Deferred until search/filtering molecules. | | Slider | planned | single, range × desktop, mobile | | Price range filter. Wraps MUI Slider. Deferred until search/filtering molecules. |
| Link | done | underline: hover/always/none × any MUI colour | color.text.brand (copper brand.600, 4.8:1), color.interactive.active | Navigation text link. Wraps MUI Link. Copper default, underline on hover, focus ring. | | Link | done | underline: hover/always/none × any MUI colour | color.text.brand (copper brand.600, 4.8:1), color.interactive.active | Navigation text link. Wraps MUI Link. Copper default, underline on hover, focus ring. |
| Collapse | done | in/out × unmountOnExit | (none — uses MUI defaults) | Progressive disclosure wrapper. Thin MUI Collapse wrapper with unmountOnExit default. Slide-down animation for wizard field reveal. | | Collapse | done | in/out × unmountOnExit | (none — uses MUI defaults) | Progressive disclosure wrapper. Thin MUI Collapse wrapper with unmountOnExit default. Slide-down animation for wizard field reveal. |
| DialogShell | done | open/closed × with/without back button × with/without footer | (theme defaults — borderRadius, palette) | Standard dialog container. Header (title + optional back + close), divider, scrollable body, optional footer. Used by FilterPanel and ArrangementDialog. |
| ToggleButtonGroup | done | exclusive single-select × small, medium, large × error × fullWidth + descriptions | color.neutral.100-200, color.brand.50/100, color.interactive.focus, color.feedback.error | Button-select for binary/small-set choices. Fieldset/legend a11y, external label, helper/error text. Brand styling on selected. | | ToggleButtonGroup | done | exclusive single-select × small, medium, large × error × fullWidth + descriptions | color.neutral.100-200, color.brand.50/100, color.interactive.focus, color.feedback.error | Button-select for binary/small-set choices. Fieldset/legend a11y, external label, helper/error text. Brand styling on selected. |
## Molecules ## Molecules
@@ -76,8 +77,8 @@ duplicates) and MUST update it after completing one.
| IntroStep | done | WizardLayout (centered-form) + ToggleButtonGroup × 2 + Collapse + Typography + Button + Divider | Wizard step 1 — entry point. forWhom (Myself/Someone else) + hasPassedAway (Yes/No) with progressive disclosure. Auto-sets hasPassedAway="no" for "Myself". `<form>` wrapper, aria-live subheading, grief-sensitive copy. Pure presentation. Audit: 18/20 → 20/20 after fixes. | | IntroStep | done | WizardLayout (centered-form) + ToggleButtonGroup × 2 + Collapse + Typography + Button + Divider | Wizard step 1 — entry point. forWhom (Myself/Someone else) + hasPassedAway (Yes/No) with progressive disclosure. Auto-sets hasPassedAway="no" for "Myself". `<form>` wrapper, aria-live subheading, grief-sensitive copy. Pure presentation. Audit: 18/20 → 20/20 after fixes. |
| ProvidersStep | done | WizardLayout (list-map) + ProviderCard + SearchBar + Chip + Typography + Button | Wizard step 2 — provider selection. List-map split: provider cards w/ radiogroup + search + filter chips (left), map slot (right). aria-live results count, back link. ProviderCard extended with HTML/ARIA passthrough. Audit: 18/20. | | ProvidersStep | done | WizardLayout (list-map) + ProviderCard + SearchBar + Chip + Typography + Button | Wizard step 2 — provider selection. List-map split: provider cards w/ radiogroup + search + filter chips (left), map slot (right). aria-live results count, back link. ProviderCard extended with HTML/ARIA passthrough. Audit: 18/20. |
| PackagesStep | done | WizardLayout (list-detail) + ProviderCardCompact + ServiceOption + PackageDetail + Badge + TextField + Typography + Button | Wizard step 3 — package selection. List-detail split: compact provider + budget filter + package list w/ radiogroup (left), PackageDetail breakdown (right). "Most Popular" badge. Mobile Continue button. | | PackagesStep | done | WizardLayout (list-detail) + ProviderCardCompact + ServiceOption + PackageDetail + Badge + TextField + Typography + Button | Wizard step 3 — package selection. List-detail split: compact provider + budget filter + package list w/ radiogroup (left), PackageDetail breakdown (right). "Most Popular" badge. Mobile Continue button. |
| PreviewStep | done | WizardLayout (list-detail) + ProviderCardCompact + PackageDetail + Typography + Button + List + Divider | Wizard step 4 — package review. Informational (no form fields). "What happens next" numbered checklist. Pre-planning shows "Explore other options" tertiary CTA. | | ~~PreviewStep~~ | removed | — | Replaced by ArrangementDialog organism (D-E). Package preview + "what's next" checklist now in the dialog's preview step. |
| AuthGateStep | done | WizardLayout (centered-form) + TextField + MenuItem + Collapse + Button + Divider | Wizard step 5 — auth gate. 3 progressive sub-steps: SSO/email → details → verify. Phone optional when email-only. Benefit framing. Audit: 18/20. | | ~~AuthGateStep~~ | removed | — | Replaced by ArrangementDialog organism (D-E). SSO/email auth flow now in the dialog's auth step. |
| DateTimeStep | done | WizardLayout (centered-form) + TextField + RadioGroup + Autocomplete + Collapse + Divider + Button | Wizard step 6 — details & scheduling. Deceased name + date/time radios + religion Autocomplete. Two fieldset sections. Grief-sensitive labels. Save-and-exit CTA. | | DateTimeStep | done | WizardLayout (centered-form) + TextField + RadioGroup + Autocomplete + Collapse + Divider + Button | Wizard step 6 — details & scheduling. Deceased name + date/time radios + religion Autocomplete. Two fieldset sections. Grief-sensitive labels. Save-and-exit CTA. |
| VenueStep | done | WizardLayout (centered-form) + VenueCard + AddOnOption + Collapse + Chip + TextField + Divider + Button | Wizard step 7 — venue selection. Consolidated from 3 baseline steps. Card grid with search/filters, inline detail, service toggles (photo, streaming, recording). | | VenueStep | done | WizardLayout (centered-form) + VenueCard + AddOnOption + Collapse + Chip + TextField + Divider + Button | Wizard step 7 — venue selection. Consolidated from 3 baseline steps. Card grid with search/filters, inline detail, service toggles (photo, streaming, recording). |
| CrematoriumStep | done | WizardLayout (centered-form) + Card + RadioGroup + Collapse + TextField + Divider + Button | Wizard step 8 — crematorium. Single confirmation card or multi-card grid. Witness question personalised with deceased name. Special instructions textarea. | | CrematoriumStep | done | WizardLayout (centered-form) + Card + RadioGroup + Collapse + TextField + Divider + Button | Wizard step 8 — crematorium. Single confirmation card or multi-card grid. Witness question personalised with deceased name. Special instructions textarea. |

View File

@@ -0,0 +1,137 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { DialogShell } from './DialogShell';
import { Button } from '../Button';
import { Typography } from '../Typography';
import Box from '@mui/material/Box';
const meta: Meta<typeof DialogShell> = {
title: 'Atoms/DialogShell',
component: DialogShell,
tags: ['autodocs'],
parameters: { layout: 'centered' },
};
export default meta;
type Story = StoryObj<typeof DialogShell>;
/** Default dialog with title, body, and footer */
export const Default: Story = {
render: () => {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="contained" onClick={() => setOpen(true)}>
Open dialog
</Button>
<DialogShell
open={open}
onClose={() => setOpen(false)}
title="Dialog title"
footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="contained" size="small" onClick={() => setOpen(false)}>
Done
</Button>
</Box>
}
>
<Typography variant="body1">
This is the dialog body content. It scrolls when the content exceeds the max height.
</Typography>
</DialogShell>
</>
);
},
};
/** Dialog with a back button */
export const WithBackButton: Story = {
render: () => {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="contained" onClick={() => setOpen(true)}>
Open dialog
</Button>
<DialogShell
open={open}
onClose={() => setOpen(false)}
title="Step 2 of 3"
onBack={() => alert('Back')}
backLabel="Back to step 1"
footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
<Button variant="outlined" color="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button variant="contained" onClick={() => setOpen(false)}>
Continue
</Button>
</Box>
}
>
<Typography variant="body1">
Content for the second step of a multi-step dialog.
</Typography>
</DialogShell>
</>
);
},
};
/** Long content that triggers scrollable body */
export const LongContent: Story = {
render: () => {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="contained" onClick={() => setOpen(true)}>
Open dialog
</Button>
<DialogShell
open={open}
onClose={() => setOpen(false)}
title="Scrollable content"
footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="contained" size="small" onClick={() => setOpen(false)}>
Done
</Button>
</Box>
}
>
{Array.from({ length: 12 }, (_, i) => (
<Typography key={i} variant="body1" sx={{ mb: 2 }}>
Paragraph {i + 1}: This is sample content to demonstrate the scrollable body area.
When the content exceeds the dialog&apos;s max height, the body scrolls while the
header and footer remain fixed.
</Typography>
))}
</DialogShell>
</>
);
},
};
/** Dialog without a footer */
export const NoFooter: Story = {
render: () => {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="contained" onClick={() => setOpen(true)}>
Open dialog
</Button>
<DialogShell open={open} onClose={() => setOpen(false)} title="Information">
<Typography variant="body1" sx={{ mb: 2 }}>
This dialog has no footer just a close button in the header.
</Typography>
<Typography variant="body2" color="text.secondary">
Useful for informational popups or content that doesn&apos;t need actions.
</Typography>
</DialogShell>
</>
);
},
};

View File

@@ -0,0 +1,174 @@
import React from 'react';
import Box from '@mui/material/Box';
import Dialog from '@mui/material/Dialog';
import type { DialogProps } from '@mui/material/Dialog';
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../Typography';
import { Divider } from '../Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the DialogShell atom */
export interface DialogShellProps {
/** Whether the dialog is open */
open: boolean;
/** Callback when the dialog is closed (close button or backdrop) */
onClose: () => void;
/** Dialog title */
title: React.ReactNode;
/** Show a back arrow before the title */
onBack?: () => void;
/** Back button aria-label */
backLabel?: string;
/** Main content — rendered in the scrollable body */
children: React.ReactNode;
/** Footer actions — rendered below the body divider */
footer?: React.ReactNode;
/** MUI Dialog maxWidth */
maxWidth?: DialogProps['maxWidth'];
/** Whether the dialog should be full-width up to maxWidth */
fullWidth?: boolean;
/** MUI sx prop for the Dialog Paper */
paperSx?: SxProps<Theme>;
/** MUI sx prop for the root Dialog */
sx?: SxProps<Theme>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Standard dialog container for the FA design system.
*
* Provides consistent chrome for all popup dialogs across the site:
* header (title + optional back + close), scrollable body, optional footer.
*
* Used by FilterPanel, ArrangementDialog, and any future popup pattern.
*
* Usage:
* ```tsx
* <DialogShell open={open} onClose={handleClose} title="Filters" footer={<Button>Done</Button>}>
* {filterControls}
* </DialogShell>
* ```
*/
export const DialogShell = React.forwardRef<HTMLDivElement, DialogShellProps>(
(
{
open,
onClose,
title,
onBack,
backLabel = 'Back',
children,
footer,
maxWidth = 'sm',
fullWidth = true,
paperSx,
sx,
},
ref,
) => {
const titleId = React.useId();
const titleRef = React.useRef<HTMLHeadingElement>(null);
// Focus title on open or when title changes (e.g. step transitions)
React.useEffect(() => {
if (open && titleRef.current) {
titleRef.current.focus();
}
}, [open, title]);
return (
<Dialog
ref={ref}
open={open}
onClose={onClose}
maxWidth={maxWidth}
fullWidth={fullWidth}
aria-labelledby={titleId}
sx={sx}
PaperProps={{
sx: [
{
borderRadius: 2,
maxHeight: '80vh',
display: 'flex',
flexDirection: 'column',
},
...(Array.isArray(paperSx) ? paperSx : paperSx ? [paperSx] : []),
],
}}
>
{/* Header */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 3,
pt: 2.5,
pb: 2,
flexShrink: 0,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
{onBack && (
<IconButton
onClick={onBack}
aria-label={backLabel}
sx={{ minWidth: 44, minHeight: 44 }}
>
<ArrowBackIcon fontSize="small" />
</IconButton>
)}
<Typography
id={titleId}
ref={titleRef}
variant="h6"
component="h2"
tabIndex={-1}
sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{title}
</Typography>
</Box>
<IconButton
onClick={onClose}
aria-label="Close"
sx={{ color: 'text.secondary', flexShrink: 0, ml: 1, minWidth: 44, minHeight: 44 }}
>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
<Divider />
{/* Scrollable body */}
<Box
sx={{
px: 3,
py: 2.5,
overflowY: 'auto',
flex: 1,
}}
>
{children}
</Box>
{/* Footer (optional) */}
{footer && (
<>
<Divider />
<Box sx={{ px: 3, py: 2, flexShrink: 0 }}>{footer}</Box>
</>
)}
</Dialog>
);
},
);
DialogShell.displayName = 'DialogShell';
export default DialogShell;

View File

@@ -0,0 +1,2 @@
export { DialogShell } from './DialogShell';
export type { DialogShellProps } from './DialogShell';

View File

@@ -159,6 +159,7 @@ export const ToggleButtonGroup = React.forwardRef<HTMLFieldSetElement, ToggleBut
textTransform: 'none', textTransform: 'none',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'flex-start', alignItems: 'flex-start',
justifyContent: 'flex-start',
gap: 0.5, gap: 0.5,
py: option.description ? 2 : 1.5, py: option.description ? 2 : 1.5,
px: 3, px: 3,

View File

@@ -16,7 +16,6 @@ const meta: Meta<typeof FilterPanel> = {
argTypes: { argTypes: {
label: { control: 'text' }, label: { control: 'text' },
activeCount: { control: 'number' }, activeCount: { control: 'number' },
minWidth: { control: 'number' },
}, },
decorators: [ decorators: [
(Story) => ( (Story) => (
@@ -64,7 +63,6 @@ export const SelectFilters: Story = {
args: { args: {
activeCount: 1, activeCount: 1,
onClear: () => {}, onClear: () => {},
minWidth: 300,
children: ( children: (
<> <>
<TextField select label="Category" value="solid_timber" fullWidth> <TextField select label="Category" value="solid_timber" fullWidth>

View File

@@ -1,13 +1,11 @@
import React from 'react'; import React from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Popover from '@mui/material/Popover';
import TuneIcon from '@mui/icons-material/Tune'; import TuneIcon from '@mui/icons-material/Tune';
import type { SxProps, Theme } from '@mui/material/styles'; import type { SxProps, Theme } from '@mui/material/styles';
import { DialogShell } from '../../atoms/DialogShell';
import { Button } from '../../atoms/Button'; import { Button } from '../../atoms/Button';
import { Badge } from '../../atoms/Badge'; import { Badge } from '../../atoms/Badge';
import { Typography } from '../../atoms/Typography';
import { Link } from '../../atoms/Link'; import { Link } from '../../atoms/Link';
import { Divider } from '../../atoms/Divider';
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
@@ -17,12 +15,10 @@ export interface FilterPanelProps {
label?: string; label?: string;
/** Number of active filters (shown as count on the trigger) */ /** Number of active filters (shown as count on the trigger) */
activeCount?: number; activeCount?: number;
/** Filter controls — rendered inside the Popover body */ /** Filter controls — rendered inside the dialog body */
children: React.ReactNode; children: React.ReactNode;
/** Callback when "Clear all" is clicked */ /** Callback when "Clear all" is clicked */
onClear?: () => void; onClear?: () => void;
/** Popover min-width */
minWidth?: number;
/** MUI sx prop for the trigger button */ /** MUI sx prop for the trigger button */
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
} }
@@ -32,29 +28,18 @@ export interface FilterPanelProps {
/** /**
* Reusable filter panel for the FA arrangement wizard. * Reusable filter panel for the FA arrangement wizard.
* *
* Renders a trigger button ("Filters") that opens a Popover containing * Renders a trigger button ("Filters") that opens a DialogShell containing
* arbitrary filter controls (chips, selects, sliders, etc.) passed as * arbitrary filter controls (chips, selects, sliders, etc.) passed as
* children. Active filter count shown as a badge on the trigger. * children. Active filter count shown as a badge on the trigger.
* *
* D-C: Popover for desktop MVP. Mobile Drawer variant planned for later.
*
* Used in ProvidersStep, VenueStep, and CoffinsStep. * Used in ProvidersStep, VenueStep, and CoffinsStep.
*/ */
export const FilterPanel = React.forwardRef<HTMLDivElement, FilterPanelProps>( export const FilterPanel = React.forwardRef<HTMLDivElement, FilterPanelProps>(
({ label = 'Filters', activeCount = 0, children, onClear, minWidth = 280, sx }, ref) => { ({ label = 'Filters', activeCount = 0, children, onClear, sx }, ref) => {
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null); const [open, setOpen] = React.useState(false);
const open = Boolean(anchorEl);
const uniqueId = React.useId();
const popoverId = `filter-panel-${uniqueId}`;
const headingId = `filter-panel-heading-${uniqueId}`;
const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => { const handleOpen = () => setOpen(true);
setAnchorEl(event.currentTarget); const handleClose = () => setOpen(false);
};
const handleClose = () => {
setAnchorEl(null);
};
return ( return (
<> <>
@@ -66,7 +51,6 @@ export const FilterPanel = React.forwardRef<HTMLDivElement, FilterPanelProps>(
size="small" size="small"
startIcon={<TuneIcon />} startIcon={<TuneIcon />}
onClick={handleOpen} onClick={handleOpen}
aria-controls={open ? popoverId : undefined}
aria-expanded={open} aria-expanded={open}
aria-haspopup="dialog" aria-haspopup="dialog"
> >
@@ -87,53 +71,17 @@ export const FilterPanel = React.forwardRef<HTMLDivElement, FilterPanelProps>(
</Button> </Button>
</Box> </Box>
{/* Popover panel */} {/* Filter dialog */}
<Popover <DialogShell
id={popoverId}
open={open} open={open}
anchorEl={anchorEl}
onClose={handleClose} onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} title={
transformOrigin={{ vertical: 'top', horizontal: 'left' }} <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
slotProps={{
paper: {
sx: {
minWidth,
maxHeight: '70vh',
mt: 1,
borderRadius: 2,
boxShadow: 3,
display: 'flex',
flexDirection: 'column',
},
},
}}
PaperProps={{
role: 'dialog' as const,
'aria-labelledby': headingId,
}}
>
{/* Header */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2.5,
pt: 2,
pb: 1.5,
flexShrink: 0,
}}
>
<Typography id={headingId} variant="h6">
{label} {label}
</Typography>
{onClear && activeCount > 0 && ( {onClear && activeCount > 0 && (
<Link <Link
component="button" component="button"
onClick={() => { onClick={() => onClear()}
onClear();
}}
underline="hover" underline="hover"
sx={{ fontSize: (theme: Theme) => theme.typography.caption.fontSize }} sx={{ fontSize: (theme: Theme) => theme.typography.caption.fontSize }}
> >
@@ -141,40 +89,17 @@ export const FilterPanel = React.forwardRef<HTMLDivElement, FilterPanelProps>(
</Link> </Link>
)} )}
</Box> </Box>
}
<Divider /> footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
{/* Filter controls */} <Button variant="contained" size="small" onClick={handleClose}>
<Box
sx={{
px: 2.5,
py: 2,
display: 'flex',
flexDirection: 'column',
gap: 2.5,
overflowY: 'auto',
flex: 1,
}}
>
{children}
</Box>
<Divider />
{/* Footer — done button */}
<Box
sx={{ px: 2.5, py: 1.5, display: 'flex', justifyContent: 'flex-end', flexShrink: 0 }}
>
<Button
variant="contained"
size="small"
onClick={handleClose}
aria-label="Close filters"
>
Done Done
</Button> </Button>
</Box> </Box>
</Popover> }
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>{children}</Box>
</DialogShell>
</> </>
); );
}, },

View File

@@ -1,20 +1,16 @@
import React from 'react'; import React from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import IconButton from '@mui/material/IconButton';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import MenuItem from '@mui/material/MenuItem'; import MenuItem from '@mui/material/MenuItem';
import List from '@mui/material/List'; import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem'; import ListItem from '@mui/material/ListItem';
import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText'; import ListItemText from '@mui/material/ListItemText';
import CloseIcon from '@mui/icons-material/Close';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import GoogleIcon from '@mui/icons-material/Google'; import GoogleIcon from '@mui/icons-material/Google';
import MicrosoftIcon from '@mui/icons-material/Window'; import MicrosoftIcon from '@mui/icons-material/Window';
import type { SxProps, Theme } from '@mui/material/styles'; import type { SxProps, Theme } from '@mui/material/styles';
import type { PackageSection } from '../PackageDetail'; import type { PackageSection } from '../PackageDetail';
import { DialogShell } from '../../atoms/DialogShell';
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact'; import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
import { Collapse } from '../../atoms/Collapse'; import { Collapse } from '../../atoms/Collapse';
import { Typography } from '../../atoms/Typography'; import { Typography } from '../../atoms/Typography';
@@ -153,6 +149,8 @@ function getAuthCTALabel(subStep: AuthSubStep): string {
* - **Step 1 (preview):** Package summary, provider info, "What's next" checklist * - **Step 1 (preview):** Package summary, provider info, "What's next" checklist
* - **Step 2 (auth):** SSO buttons, email entry, details, verification * - **Step 2 (auth):** SSO buttons, email entry, details, verification
* *
* Uses DialogShell for consistent dialog chrome across the site.
*
* The dialog is opened after a user selects a package (from PackagesStep). * The dialog is opened after a user selects a package (from PackagesStep).
* The parent controls which step is shown and manages auth form state. * The parent controls which step is shown and manages auth form state.
* *
@@ -182,73 +180,64 @@ export const ArrangementDialog = React.forwardRef<HTMLDivElement, ArrangementDia
ref, ref,
) => { ) => {
const isEmailOnly = authValues.contactPreference === 'email_only'; const isEmailOnly = authValues.contactPreference === 'email_only';
const titleRef = React.useRef<HTMLHeadingElement>(null);
// Focus the dialog title when step changes
React.useEffect(() => {
if (open && titleRef.current) {
titleRef.current.focus();
}
}, [step, open]);
const handleAuthField = (field: keyof AuthValues, value: string) => { const handleAuthField = (field: keyof AuthValues, value: string) => {
onAuthChange({ ...authValues, [field]: value }); onAuthChange({ ...authValues, [field]: value });
}; };
return ( // ─── Footer CTAs per step ─────────────────────────────────────────
<Dialog
ref={ref} const previewFooter = (
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
scroll="body"
aria-labelledby="arrangement-dialog-title"
sx={sx}
PaperProps={{
sx: { borderRadius: 2 },
}}
>
{/* ─── Header ─── */}
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
alignItems: 'center', flexDirection: { xs: 'column', sm: 'row' },
justifyContent: 'space-between', gap: 2,
px: 3, justifyContent: 'flex-end',
pt: 2.5,
pb: 1,
}} }}
> >
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> {isPrePlanning && onExplore && (
{step === 'auth' && ( <Button variant="outlined" color="secondary" size="large" onClick={onExplore}>
<IconButton Explore other options
size="small" </Button>
onClick={() => onStepChange('preview')}
aria-label="Back to preview"
>
<ArrowBackIcon fontSize="small" />
</IconButton>
)} )}
<Typography id="arrangement-dialog-title" ref={titleRef} tabIndex={-1} variant="h5"> <Button
{step === 'preview' ? 'Your selected package' : 'Save your plan'} variant="contained"
</Typography> size="large"
</Box> onClick={() => onStepChange('auth')}
<IconButton size="small" onClick={onClose} aria-label="Close"> loading={loading}
<CloseIcon fontSize="small" />
</IconButton>
</Box>
{/* Screen reader step announcement */}
<Box
aria-live="polite"
aria-atomic="true"
sx={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden' }}
> >
{step === 'preview' ? 'Viewing package preview' : 'Create your account'} Continue with this package
</Button>
</Box> </Box>
);
<DialogContent sx={{ px: 3, pb: 3 }}> const authFooter = (
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
size="large"
loading={loading}
onClick={() => {
if (!loading) onContinue();
}}
>
{getAuthCTALabel(authValues.subStep)}
</Button>
</Box>
);
return (
<DialogShell
ref={ref}
open={open}
onClose={onClose}
title={step === 'preview' ? 'Your selected package' : 'Save your plan'}
onBack={step === 'auth' ? () => onStepChange('preview') : undefined}
backLabel="Back to preview"
footer={step === 'preview' ? previewFooter : authFooter}
sx={sx}
>
{/* ═══════════ Step 1: Preview ═══════════ */} {/* ═══════════ Step 1: Preview ═══════════ */}
{step === 'preview' && ( {step === 'preview' && (
<Box> <Box>
@@ -305,17 +294,13 @@ export const ArrangementDialog = React.forwardRef<HTMLDivElement, ArrangementDia
</Typography> </Typography>
{/* What's next */} {/* What's next */}
<Box sx={{ mb: 3 }}> <Box>
<Typography variant="h6" sx={{ mb: 1.5 }}> <Typography variant="h6" sx={{ mb: 1.5 }}>
What happens next What happens next
</Typography> </Typography>
<List disablePadding> <List disablePadding>
{nextSteps.map((s) => ( {nextSteps.map((s) => (
<ListItem <ListItem key={s.number} disablePadding sx={{ mb: 1, alignItems: 'flex-start' }}>
key={s.number}
disablePadding
sx={{ mb: 1, alignItems: 'flex-start' }}
>
<ListItemIcon sx={{ minWidth: 36, mt: 0.25 }}> <ListItemIcon sx={{ minWidth: 36, mt: 0.25 }}>
<Box <Box
sx={{ sx={{
@@ -342,32 +327,6 @@ export const ArrangementDialog = React.forwardRef<HTMLDivElement, ArrangementDia
))} ))}
</List> </List>
</Box> </Box>
<Divider sx={{ mb: 3 }} />
{/* CTAs */}
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: 2,
justifyContent: 'flex-end',
}}
>
{isPrePlanning && onExplore && (
<Button variant="outlined" color="secondary" size="large" onClick={onExplore}>
Explore other options
</Button>
)}
<Button
variant="contained"
size="large"
onClick={() => onStepChange('auth')}
loading={loading}
>
Continue with this package
</Button>
</Box>
</Box> </Box>
)} )}
@@ -533,26 +492,16 @@ export const ArrangementDialog = React.forwardRef<HTMLDivElement, ArrangementDia
</Collapse> </Collapse>
{/* Terms */} {/* Terms */}
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 3 }}> <Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
By continuing, you agree to the{' '} By continuing, you agree to the{' '}
<Link href="#" sx={{ fontSize: 'inherit' }}> <Link href="#" sx={{ fontSize: 'inherit' }}>
terms and conditions terms and conditions
</Link> </Link>
. .
</Typography> </Typography>
<Divider sx={{ my: 3 }} />
{/* CTA */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button type="submit" variant="contained" size="large" loading={loading}>
{getAuthCTALabel(authValues.subStep)}
</Button>
</Box>
</Box> </Box>
)} )}
</DialogContent> </DialogShell>
</Dialog>
); );
}, },
); );

View File

@@ -1,269 +0,0 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { AuthGateStep } from './AuthGateStep';
import type { AuthGateStepValues, AuthGateStepErrors } from './AuthGateStep';
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 defaultValues: AuthGateStepValues = {
subStep: 'email',
email: '',
firstName: '',
lastName: '',
phone: '',
contactPreference: 'call_anytime',
verificationCode: '',
};
// ─── Meta ────────────────────────────────────────────────────────────────────
const meta: Meta<typeof AuthGateStep> = {
title: 'Pages/AuthGateStep',
component: AuthGateStep,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
};
export default meta;
type Story = StoryObj<typeof AuthGateStep>;
// ─── Interactive (default) ──────────────────────────────────────────────────
/** Fully interactive — walk through all three sub-steps */
export const Default: Story = {
render: () => {
const [values, setValues] = useState<AuthGateStepValues>({ ...defaultValues });
const [errors, setErrors] = useState<AuthGateStepErrors>({});
const handleContinue = () => {
const newErrors: AuthGateStepErrors = {};
if (values.subStep === 'email') {
if (!values.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) {
newErrors.email =
"That email address doesn't look quite right. Please check it and try again.";
}
if (Object.keys(newErrors).length === 0) {
setValues((v) => ({ ...v, subStep: 'details' }));
setErrors({});
return;
}
}
if (values.subStep === 'details') {
if (!values.firstName) newErrors.firstName = 'We need your first name to save the plan.';
if (!values.lastName) newErrors.lastName = 'We need your last name to save the plan.';
if (values.contactPreference !== 'email_only' && !values.phone) {
newErrors.phone = 'Please enter a valid Australian phone number, like 0412 345 678.';
}
if (Object.keys(newErrors).length === 0) {
setValues((v) => ({ ...v, subStep: 'verify' }));
setErrors({});
return;
}
}
if (values.subStep === 'verify') {
if (!values.verificationCode || values.verificationCode.length !== 6) {
newErrors.verificationCode =
"That code doesn't match. Please check the email we sent and try again.";
}
if (Object.keys(newErrors).length === 0) {
alert(`Authenticated: ${values.firstName} ${values.lastName} (${values.email})`);
return;
}
}
setErrors(newErrors);
};
return (
<AuthGateStep
values={values}
onChange={(v) => {
setValues(v);
setErrors({});
}}
onContinue={handleContinue}
onBack={() => alert('Back to preview')}
onGoogleSSO={() => alert('Google SSO')}
onMicrosoftSSO={() => alert('Microsoft SSO')}
errors={errors}
navigation={nav}
/>
);
},
};
// ─── Sub-step 2: Details ────────────────────────────────────────────────────
/** Details sub-step — email entered, name/phone fields revealed */
export const DetailsSubStep: Story = {
render: () => {
const [values, setValues] = useState<AuthGateStepValues>({
...defaultValues,
subStep: 'details',
email: 'jane@example.com',
});
return (
<AuthGateStep
values={values}
onChange={setValues}
onContinue={() => setValues((v) => ({ ...v, subStep: 'verify' }))}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── Sub-step 3: Verification ───────────────────────────────────────────────
/** Verification sub-step — code entry */
export const VerifySubStep: Story = {
render: () => {
const [values, setValues] = useState<AuthGateStepValues>({
...defaultValues,
subStep: 'verify',
email: 'jane@example.com',
firstName: 'Jane',
lastName: 'Smith',
phone: '0412 345 678',
});
return (
<AuthGateStep
values={values}
onChange={setValues}
onContinue={() => alert('Verified!')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── At-need variant ────────────────────────────────────────────────────────
/** At-need subheading copy variant */
export const AtNeed: Story = {
render: () => {
const [values, setValues] = useState<AuthGateStepValues>({ ...defaultValues });
return (
<AuthGateStep
values={values}
onChange={setValues}
onContinue={() => setValues((v) => ({ ...v, subStep: 'details' }))}
onBack={() => alert('Back')}
isAtNeed
navigation={nav}
/>
);
},
};
// ─── Email-only preference ──────────────────────────────────────────────────
/** Phone becomes optional when contact preference is email-only */
export const EmailOnlyPreference: Story = {
render: () => {
const [values, setValues] = useState<AuthGateStepValues>({
...defaultValues,
subStep: 'details',
email: 'jane@example.com',
contactPreference: 'email_only',
});
return (
<AuthGateStep
values={values}
onChange={setValues}
onContinue={() => setValues((v) => ({ ...v, subStep: 'verify' }))}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── Validation errors ──────────────────────────────────────────────────────
/** Details sub-step with all validation errors showing */
export const WithErrors: Story = {
render: () => {
const [values, setValues] = useState<AuthGateStepValues>({
...defaultValues,
subStep: 'details',
email: 'jane@example.com',
});
return (
<AuthGateStep
values={values}
onChange={setValues}
onContinue={() => {}}
errors={{
firstName: 'We need your first name to save the plan.',
lastName: 'We need your last name to save the plan.',
phone: 'Please enter a valid Australian phone number, like 0412 345 678.',
}}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── Loading state ──────────────────────────────────────────────────────────
/** Continue button in loading state */
export const Loading: Story = {
render: () => {
const [values, setValues] = useState<AuthGateStepValues>({
...defaultValues,
subStep: 'verify',
email: 'jane@example.com',
firstName: 'Jane',
lastName: 'Smith',
phone: '0412 345 678',
verificationCode: '123456',
});
return (
<AuthGateStep
values={values}
onChange={setValues}
onContinue={() => {}}
loading
navigation={nav}
/>
);
},
};

View File

@@ -1,337 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import TextField from '@mui/material/TextField';
import MenuItem from '@mui/material/MenuItem';
import type { SxProps, Theme } from '@mui/material/styles';
import GoogleIcon from '@mui/icons-material/Google';
import MicrosoftIcon from '@mui/icons-material/Window';
import { WizardLayout } from '../../templates/WizardLayout';
import { Collapse } from '../../atoms/Collapse';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Divider } from '../../atoms/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Which sub-step of the auth flow the user is on */
export type AuthSubStep = 'email' | 'details' | 'verify';
/** Contact preference options */
export type ContactPreference = 'call_anytime' | 'email_preferred' | 'email_only';
/** Form values for the auth gate step */
export interface AuthGateStepValues {
/** Current sub-step */
subStep: AuthSubStep;
/** Email address */
email: string;
/** First name */
firstName: string;
/** Last name */
lastName: string;
/** Phone number */
phone: string;
/** Contact preference */
contactPreference: ContactPreference;
/** Email verification code */
verificationCode: string;
}
/** Field-level error messages */
export interface AuthGateStepErrors {
email?: string;
firstName?: string;
lastName?: string;
phone?: string;
verificationCode?: string;
}
/** Props for the AuthGateStep page component */
export interface AuthGateStepProps {
/** Current form values */
values: AuthGateStepValues;
/** Callback when any field value changes */
onChange: (values: AuthGateStepValues) => void;
/** Callback when the Continue button is clicked */
onContinue: () => void;
/** Callback for back navigation */
onBack?: () => void;
/** Callback for Google SSO */
onGoogleSSO?: () => void;
/** Callback for Microsoft SSO */
onMicrosoftSSO?: () => void;
/** Field-level validation errors */
errors?: AuthGateStepErrors;
/** Whether the Continue button is in a loading state */
loading?: boolean;
/** Whether the user is arranging at-need (vs pre-planning) */
isAtNeed?: boolean;
/** Navigation bar — passed through to WizardLayout */
navigation?: React.ReactNode;
/** Hide the help bar */
hideHelpBar?: boolean;
/** MUI sx prop for the root */
sx?: SxProps<Theme>;
}
// ─── Copy helpers ────────────────────────────────────────────────────────────
function getSubheading(isAtNeed: boolean): string {
if (isAtNeed) {
return 'We need a few details so a funeral arranger can help you with the next steps.';
}
return 'Save your plan to return and update it anytime.';
}
function getCTALabel(subStep: AuthSubStep): string {
switch (subStep) {
case 'email':
return 'Continue with email';
case 'details':
return 'Continue';
case 'verify':
return 'Verify and continue';
}
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Step 5 — Auth Gate for the FA arrangement wizard.
*
* Registration/login step positioned after preview (step 4). Users have
* already seen packages with pricing before being asked to register.
* Framed as a benefit ("Save your plan") not a gate.
*
* Three sub-steps with progressive disclosure:
* 1. SSO buttons + email entry
* 2. Name, phone, contact preference (after email)
* 3. Verification code (after details)
*
* Phone becomes optional when contactPreference is "email_only".
*
* Pure presentation component — props in, callbacks out.
*
* Spec: documentation/steps/steps/05_auth_gate.yaml
*/
export const AuthGateStep: React.FC<AuthGateStepProps> = ({
values,
onChange,
onContinue,
onBack,
onGoogleSSO,
onMicrosoftSSO,
errors,
loading = false,
isAtNeed = false,
navigation,
hideHelpBar,
sx,
}) => {
const isEmailOnly = values.contactPreference === 'email_only';
const handleFieldChange = (field: keyof AuthGateStepValues, value: string) => {
onChange({ ...values, [field]: value });
};
return (
<WizardLayout
variant="centered-form"
navigation={navigation}
showBackLink={!!onBack}
backLabel="Back"
onBack={onBack}
hideHelpBar={hideHelpBar}
sx={sx}
>
{/* Page heading */}
<Typography variant="display3" component="h1" sx={{ mb: 1 }} tabIndex={-1}>
Save your plan
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 5 }} aria-live="polite">
{getSubheading(isAtNeed)}
</Typography>
<Box
component="form"
noValidate
aria-busy={loading}
onSubmit={(e: React.FormEvent) => {
e.preventDefault();
if (!loading) onContinue();
}}
>
{/* ─── Sub-step 1: SSO + Email ─── */}
<Box
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, mb: 3 }}
role="group"
aria-label="Sign in options"
>
<Button
variant="outlined"
color="secondary"
size="large"
fullWidth
startIcon={<GoogleIcon />}
onClick={onGoogleSSO}
type="button"
>
Continue with Google
</Button>
<Button
variant="outlined"
color="secondary"
size="large"
fullWidth
startIcon={<MicrosoftIcon />}
onClick={onMicrosoftSSO}
type="button"
>
Continue with Microsoft
</Button>
</Box>
<Divider sx={{ my: 3 }}>
<Typography variant="body2" color="text.secondary">
or
</Typography>
</Divider>
<TextField
label="Your email address"
type="email"
value={values.email}
onChange={(e) => handleFieldChange('email', e.target.value)}
error={!!errors?.email}
helperText={errors?.email}
placeholder="you@example.com"
autoComplete="email"
inputMode="email"
fullWidth
required
disabled={values.subStep !== 'email'}
sx={{ mb: 3 }}
/>
{/* ─── Sub-step 2: Details (after email) ─── */}
<Collapse in={values.subStep === 'details' || values.subStep === 'verify'}>
<Box
sx={{ display: 'flex', flexDirection: 'column', gap: 2.5, mb: 3 }}
role="group"
aria-label="Your details"
>
<Typography variant="labelLg" component="h2" sx={{ mb: 0.5 }}>
A few details to save your plan
</Typography>
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, gap: 2 }}>
<TextField
label="First name"
value={values.firstName}
onChange={(e) => handleFieldChange('firstName', e.target.value)}
error={!!errors?.firstName}
helperText={errors?.firstName}
autoComplete="given-name"
fullWidth
required
disabled={values.subStep === 'verify'}
/>
<TextField
label="Last name"
value={values.lastName}
onChange={(e) => handleFieldChange('lastName', e.target.value)}
error={!!errors?.lastName}
helperText={errors?.lastName}
autoComplete="family-name"
fullWidth
required
disabled={values.subStep === 'verify'}
/>
</Box>
<TextField
label={isEmailOnly ? 'Phone (optional)' : 'Best number to reach you'}
type="tel"
value={values.phone}
onChange={(e) => handleFieldChange('phone', e.target.value)}
error={!!errors?.phone}
helperText={errors?.phone}
placeholder="e.g. 0412 345 678"
autoComplete="tel"
inputMode="tel"
fullWidth
required={!isEmailOnly}
disabled={values.subStep === 'verify'}
/>
<TextField
select
label="Contact preference"
value={values.contactPreference}
onChange={(e) => handleFieldChange('contactPreference', e.target.value)}
fullWidth
disabled={values.subStep === 'verify'}
>
<MenuItem value="call_anytime">Call me anytime</MenuItem>
<MenuItem value="email_preferred">Email is preferred</MenuItem>
<MenuItem value="email_only">Only contact by email</MenuItem>
</TextField>
</Box>
</Collapse>
{/* ─── Sub-step 3: Verification code ─── */}
<Collapse in={values.subStep === 'verify'}>
<Box sx={{ mb: 3 }} role="group" aria-label="Email verification">
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
We&apos;ve sent a 6-digit code to <strong>{values.email}</strong>. Please enter it
below.
</Typography>
<TextField
label="Verification code"
value={values.verificationCode}
onChange={(e) => handleFieldChange('verificationCode', e.target.value)}
error={!!errors?.verificationCode}
helperText={errors?.verificationCode || 'Check your email for the 6-digit code'}
placeholder="Enter 6-digit code"
autoComplete="one-time-code"
inputMode="numeric"
fullWidth
required
/>
</Box>
</Collapse>
{/* Terms */}
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 3 }}>
By continuing, you agree to the{' '}
<Box
component="a"
href="#"
sx={{
color: 'var(--fa-color-text-brand)',
textDecoration: 'underline',
fontSize: 'inherit',
}}
>
terms and conditions
</Box>
.
</Typography>
<Divider sx={{ my: 3 }} />
{/* CTA */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button type="submit" variant="contained" size="large" loading={loading}>
{getCTALabel(values.subStep)}
</Button>
</Box>
</Box>
</WizardLayout>
);
};
AuthGateStep.displayName = 'AuthGateStep';
export default AuthGateStep;

View File

@@ -1,8 +0,0 @@
export { AuthGateStep, default } from './AuthGateStep';
export type {
AuthGateStepProps,
AuthGateStepValues,
AuthGateStepErrors,
AuthSubStep,
ContactPreference,
} from './AuthGateStep';

View File

@@ -200,7 +200,7 @@ export const CoffinsStep: React.FC<CoffinsStepProps> = ({
mb: 3, mb: 3,
}} }}
> >
<FilterPanel activeCount={activeFilterCount} onClear={handleFilterClear} minWidth={300}> <FilterPanel activeCount={activeFilterCount} onClear={handleFilterClear}>
<TextField <TextField
select select
label="Category" label="Category"

View File

@@ -46,17 +46,10 @@ export interface IntroStepProps {
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
} }
// ─── Copy helpers ──────────────────────────────────────────────────────────── // ─── Constants ──────────────────────────────────────────────────────────────
function getSubheading(values: IntroStepValues): string { const SUBHEADING =
if (values.forWhom === 'someone' && values.hasPassedAway === 'yes') { "We'll guide you through arranging a funeral, step by step. You can save your progress and come back anytime.";
return "We'll guide you through each step. You can save your progress and come back anytime.";
}
if (values.forWhom === 'myself' || values.hasPassedAway === 'no') {
return "Explore your options and plan at your own pace. Nothing is locked in until you're ready.";
}
return "We'll guide you through arranging a funeral, step by step. You can save your progress and come back anytime.";
}
// ─── Component ─────────────────────────────────────────────────────────────── // ─── Component ───────────────────────────────────────────────────────────────
@@ -110,8 +103,8 @@ export const IntroStep: React.FC<IntroStepProps> = ({
Let&apos;s get started Let&apos;s get started
</Typography> </Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 5 }} aria-live="polite"> <Typography variant="body1" color="text.secondary" sx={{ mb: 5 }}>
{getSubheading(values)} {SUBHEADING}
</Typography> </Typography>
<Box <Box

View File

@@ -43,14 +43,13 @@ const mockProvider: PackagesStepProvider = {
reviewCount: 7, reviewCount: 7,
}; };
const mockPackages: PackageData[] = [ const matchedPackages: PackageData[] = [
{ {
id: 'everyday', id: 'everyday',
name: 'Everyday Funeral Package', name: 'Everyday Funeral Package',
price: 2700, price: 2700,
description: description:
'This package includes a funeral service at a chapel or a church with a funeral procession. It includes many of the most commonly selected funeral options.', 'This package includes a funeral service at a chapel or a church with a funeral procession. It includes many of the most commonly selected funeral options.',
popular: true,
sections: [ sections: [
{ {
heading: 'Essentials', heading: 'Essentials',
@@ -86,6 +85,27 @@ const mockPackages: PackageData[] = [
terms: terms:
'This package includes a funeral service at a chapel or a church with a funeral procession. Pricing may vary based on additional selections.', 'This package includes a funeral service at a chapel or a church with a funeral procession. Pricing may vary based on additional selections.',
}, },
{
id: 'essential',
name: 'Essential Funeral Package',
price: 1800,
description:
'A simple, dignified option covering the essential requirements for a cremation service.',
sections: [
{
heading: 'Essentials',
items: [
{ name: 'Death registration certificate', price: 150 },
{ name: 'Professional Mortuary Care', price: 800 },
{ name: 'Professional Service Fee', price: 850 },
],
},
],
total: 1800,
},
];
const otherPackages: PackageData[] = [
{ {
id: 'deluxe', id: 'deluxe',
name: 'Deluxe Funeral Package', name: 'Deluxe Funeral Package',
@@ -106,24 +126,6 @@ const mockPackages: PackageData[] = [
], ],
total: 4900, total: 4900,
}, },
{
id: 'essential',
name: 'Essential Funeral Package',
price: 1800,
description:
'A simple, dignified option covering the essential requirements for a cremation service.',
sections: [
{
heading: 'Essentials',
items: [
{ name: 'Death registration certificate', price: 150 },
{ name: 'Professional Mortuary Care', price: 800 },
{ name: 'Professional Service Fee', price: 850 },
],
},
],
total: 1800,
},
{ {
id: 'catholic', id: 'catholic',
name: 'Catholic Service', name: 'Catholic Service',
@@ -161,44 +163,21 @@ type Story = StoryObj<typeof PackagesStep>;
// ─── Interactive (default) ────────────────────────────────────────────────── // ─── Interactive (default) ──────────────────────────────────────────────────
/** Fully interactive — browse, filter, select a package, see detail */ /** Matched + other packages — select a package, see detail, click Make Arrangement */
export const Default: Story = { export const Default: Story = {
render: () => { render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [budget, setBudget] = useState('all');
const [error, setError] = useState<string | undefined>();
const filtered =
budget === 'all'
? mockPackages
: mockPackages.filter((p) => {
const [min, max] = budget.split('-').map(Number);
return p.price >= min && p.price <= (max || Infinity);
});
const handleContinue = () => {
if (!selectedId) {
setError('Please choose a package to continue.');
return;
}
setError(undefined);
alert(`Continue with package: ${selectedId}`);
};
return ( return (
<PackagesStep <PackagesStep
provider={mockProvider} provider={mockProvider}
packages={filtered} packages={matchedPackages}
otherPackages={otherPackages}
selectedPackageId={selectedId} selectedPackageId={selectedId}
onSelectPackage={(id) => { onSelectPackage={setSelectedId}
setSelectedId(id); onArrange={() => alert('Open ArrangementDialog')}
setError(undefined); onProviderClick={() => alert('Open provider profile')}
}}
budgetFilter={budget}
onBudgetFilterChange={setBudget}
onContinue={handleContinue}
onBack={() => alert('Back')} onBack={() => alert('Back')}
error={error}
navigation={nav} navigation={nav}
/> />
); );
@@ -211,17 +190,38 @@ export const Default: Story = {
export const WithSelection: Story = { export const WithSelection: Story = {
render: () => { render: () => {
const [selectedId, setSelectedId] = useState<string | null>('everyday'); const [selectedId, setSelectedId] = useState<string | null>('everyday');
const [budget, setBudget] = useState('all');
return ( return (
<PackagesStep <PackagesStep
provider={mockProvider} provider={mockProvider}
packages={mockPackages} packages={matchedPackages}
otherPackages={otherPackages}
selectedPackageId={selectedId} selectedPackageId={selectedId}
onSelectPackage={setSelectedId} onSelectPackage={setSelectedId}
budgetFilter={budget} onArrange={() => alert('Open ArrangementDialog')}
onBudgetFilterChange={setBudget} onProviderClick={() => alert('Open provider profile')}
onContinue={() => alert('Continue')} onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── No other packages (all match) ─────────────────────────────────────────
/** All packages match filters — no "Other packages" section */
export const AllMatching: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<PackagesStep
provider={mockProvider}
packages={[...matchedPackages, ...otherPackages]}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')} onBack={() => alert('Back')}
navigation={nav} navigation={nav}
/> />
@@ -231,21 +231,20 @@ export const WithSelection: Story = {
// ─── Pre-planning ─────────────────────────────────────────────────────────── // ─── Pre-planning ───────────────────────────────────────────────────────────
/** Pre-planning flow — softer helper text */ /** Pre-planning flow — softer copy */
export const PrePlanning: Story = { export const PrePlanning: Story = {
render: () => { render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [budget, setBudget] = useState('all');
return ( return (
<PackagesStep <PackagesStep
provider={mockProvider} provider={mockProvider}
packages={mockPackages} packages={matchedPackages}
otherPackages={otherPackages}
selectedPackageId={selectedId} selectedPackageId={selectedId}
onSelectPackage={setSelectedId} onSelectPackage={setSelectedId}
budgetFilter={budget} onArrange={() => alert('Open ArrangementDialog')}
onBudgetFilterChange={setBudget} onProviderClick={() => alert('Open provider profile')}
onContinue={() => alert('Continue')}
onBack={() => alert('Back')} onBack={() => alert('Back')}
navigation={nav} navigation={nav}
isPrePlanning isPrePlanning
@@ -254,46 +253,20 @@ export const PrePlanning: Story = {
}, },
}; };
// ─── Filtered empty ─────────────────────────────────────────────────────────
/** Budget filter yielding no results */
export const FilteredEmpty: Story = {
render: () => {
const [budget, setBudget] = useState('7000-10000');
return (
<PackagesStep
provider={mockProvider}
packages={[]}
selectedPackageId={null}
onSelectPackage={() => {}}
budgetFilter={budget}
onBudgetFilterChange={setBudget}
onContinue={() => {}}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── Validation error ─────────────────────────────────────────────────────── // ─── Validation error ───────────────────────────────────────────────────────
/** Error shown when no package selected */ /** Error shown when no package selected */
export const WithError: Story = { export const WithError: Story = {
render: () => { render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [budget, setBudget] = useState('all');
return ( return (
<PackagesStep <PackagesStep
provider={mockProvider} provider={mockProvider}
packages={mockPackages} packages={matchedPackages}
selectedPackageId={selectedId} selectedPackageId={selectedId}
onSelectPackage={setSelectedId} onSelectPackage={setSelectedId}
budgetFilter={budget} onArrange={() => {}}
onBudgetFilterChange={setBudget}
onContinue={() => {}}
onBack={() => alert('Back')} onBack={() => alert('Back')}
error="Please choose a package to continue." error="Please choose a package to continue."
navigation={nav} navigation={nav}

View File

@@ -1,7 +1,5 @@
import React from 'react'; import React from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import TextField from '@mui/material/TextField';
import MenuItem from '@mui/material/MenuItem';
import type { SxProps, Theme } from '@mui/material/styles'; import type { SxProps, Theme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout'; import { WizardLayout } from '../../templates/WizardLayout';
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact'; import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
@@ -9,8 +7,7 @@ import { ServiceOption } from '../../molecules/ServiceOption';
import { PackageDetail } from '../../organisms/PackageDetail'; import { PackageDetail } from '../../organisms/PackageDetail';
import type { PackageSection } from '../../organisms/PackageDetail'; import type { PackageSection } from '../../organisms/PackageDetail';
import { Typography } from '../../atoms/Typography'; import { Typography } from '../../atoms/Typography';
import { Badge } from '../../atoms/Badge'; import { Divider } from '../../atoms/Divider';
import { Button } from '../../atoms/Button';
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
@@ -38,8 +35,6 @@ export interface PackageData {
price: number; price: number;
/** Short description */ /** Short description */
description?: string; description?: string;
/** Whether this is a "Most Popular" package */
popular?: boolean;
/** Line item sections for the detail panel */ /** Line item sections for the detail panel */
sections: PackageSection[]; sections: PackageSection[];
/** Total price (may differ from base price with extras) */ /** Total price (may differ from base price with extras) */
@@ -50,37 +45,27 @@ export interface PackageData {
terms?: string; terms?: string;
} }
/** Budget filter option */
export interface BudgetOption {
/** Option value */
value: string;
/** Display label */
label: string;
}
/** Props for the PackagesStep page component */ /** Props for the PackagesStep page component */
export interface PackagesStepProps { export interface PackagesStepProps {
/** Provider summary shown at top of the list panel */ /** Provider summary shown at top of the list panel */
provider: PackagesStepProvider; provider: PackagesStepProvider;
/** Available packages */ /** Packages matching the user's filters from the previous step */
packages: PackageData[]; packages: PackageData[];
/** Other packages from this provider that didn't match filters (shown in secondary group) */
otherPackages?: PackageData[];
/** Currently selected package ID */ /** Currently selected package ID */
selectedPackageId: string | null; selectedPackageId: string | null;
/** Callback when a package is selected */ /** Callback when a package is selected */
onSelectPackage: (id: string) => void; onSelectPackage: (id: string) => void;
/** Current budget filter value */ /** Callback when "Make Arrangement" is clicked (opens ArrangementDialog) */
budgetFilter: string; onArrange: () => void;
/** Callback when budget filter changes */ /** Callback when the provider card is clicked (opens provider profile popup) */
onBudgetFilterChange: (value: string) => void; onProviderClick?: () => void;
/** Budget filter options */
budgetOptions?: BudgetOption[];
/** Callback for the Continue button */
onContinue: () => void;
/** Callback for the Back button */ /** Callback for the Back button */
onBack: () => void; onBack: () => void;
/** Validation error */ /** Validation error */
error?: string; error?: string;
/** Whether Continue is loading */ /** Whether the arrange action is loading */
loading?: boolean; loading?: boolean;
/** Navigation bar */ /** Navigation bar */
navigation?: React.ReactNode; navigation?: React.ReactNode;
@@ -90,26 +75,23 @@ export interface PackagesStepProps {
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
} }
// ─── Constants ───────────────────────────────────────────────────────────────
const DEFAULT_BUDGET_OPTIONS: BudgetOption[] = [
{ value: 'all', label: 'All packages' },
{ value: '2000-4000', label: '$2,000 \u2013 $4,000' },
{ value: '4000-7000', label: '$4,000 \u2013 $7,000' },
{ value: '7000-10000', label: '$7,000 \u2013 $10,000+' },
];
// ─── Component ─────────────────────────────────────────────────────────────── // ─── Component ───────────────────────────────────────────────────────────────
/** /**
* Step 3 — Package selection page for the FA arrangement wizard. * Step 3 — Package selection page for the FA arrangement wizard.
* *
* List + Detail split layout. Left panel shows the selected provider * List + Detail split layout. Left panel shows the selected provider
* (compact), a budget filter, and selectable package cards. Right panel * (compact) and selectable package cards. Right panel shows the full
* shows the full detail breakdown of the selected package. * detail breakdown of the selected package with "Make Arrangement" CTA.
* *
* Packages are displayed as ServiceOption cards in a radiogroup pattern. * Packages are split into two groups:
* "Most Popular" badge on qualifying packages reduces decision paralysis. * - **Matching your preferences**: packages that matched the user's filters
* from the providers step
* - **Other packages from [Provider]**: remaining packages outside those
* filters, shown below a divider for passive discovery
*
* Selecting a package reveals its detail. Clicking "Make Arrangement"
* on the detail panel triggers the ArrangementDialog (D-E).
* *
* Pure presentation component — props in, callbacks out. * Pure presentation component — props in, callbacks out.
* *
@@ -118,12 +100,11 @@ const DEFAULT_BUDGET_OPTIONS: BudgetOption[] = [
export const PackagesStep: React.FC<PackagesStepProps> = ({ export const PackagesStep: React.FC<PackagesStepProps> = ({
provider, provider,
packages, packages,
otherPackages = [],
selectedPackageId, selectedPackageId,
onSelectPackage, onSelectPackage,
budgetFilter, onArrange,
onBudgetFilterChange, onProviderClick,
budgetOptions = DEFAULT_BUDGET_OPTIONS,
onContinue,
onBack, onBack,
error, error,
loading = false, loading = false,
@@ -131,13 +112,13 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
isPrePlanning = false, isPrePlanning = false,
sx, sx,
}) => { }) => {
const selectedPackage = packages.find((p) => p.id === selectedPackageId); const allPackages = [...packages, ...otherPackages];
const selectedPackage = allPackages.find((p) => p.id === selectedPackageId);
const hasOtherPackages = otherPackages.length > 0;
const subheading = const subheading = isPrePlanning
'Each package includes a set of services. You can customise your selections in the next steps.';
const helperText = isPrePlanning
? 'Compare packages to find what suits your wishes. Nothing is committed until you confirm.' ? 'Compare packages to find what suits your wishes. Nothing is committed until you confirm.'
: 'Prices shown include the base services listed. Additional options may change the total.'; : 'Each package includes a set of services. You can customise your selections in the next steps.';
return ( return (
<WizardLayout <WizardLayout
@@ -156,7 +137,7 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
total={selectedPackage.total} total={selectedPackage.total}
extras={selectedPackage.extras} extras={selectedPackage.extras}
terms={selectedPackage.terms} terms={selectedPackage.terms}
onArrange={onContinue} onArrange={onArrange}
arrangeDisabled={loading} arrangeDisabled={loading}
/> />
) : ( ) : (
@@ -179,7 +160,7 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
) )
} }
> >
{/* Provider compact card */} {/* Provider compact card — clickable to open provider profile */}
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 3 }}>
<ProviderCardCompact <ProviderCardCompact
name={provider.name} name={provider.name}
@@ -187,37 +168,17 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
imageUrl={provider.imageUrl} imageUrl={provider.imageUrl}
rating={provider.rating} rating={provider.rating}
reviewCount={provider.reviewCount} reviewCount={provider.reviewCount}
onClick={onProviderClick}
/> />
</Box> </Box>
{/* Heading */} {/* Heading */}
<Typography variant="display3" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}> <Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
Choose a funeral package Choose a funeral package
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{subheading} {subheading}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary" sx={{ mb: 3, display: 'block' }}>
{helperText}
</Typography>
{/* Budget filter */}
<Box sx={{ mb: 3, pt: 0.5 }}>
<TextField
select
size="small"
value={budgetFilter}
onChange={(e) => onBudgetFilterChange(e.target.value)}
label="Budget range"
sx={{ width: { xs: '100%', sm: 240 } }}
>
{budgetOptions.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</TextField>
</Box>
{/* Error message */} {/* Error message */}
{error && ( {error && (
@@ -230,64 +191,99 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
</Typography> </Typography>
)} )}
{/* Package list — radiogroup pattern */} {/* ─── Matching packages ─── */}
{hasOtherPackages && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
mb: 2,
}}
>
<Box
sx={{
width: 3,
height: 20,
borderRadius: 1,
bgcolor: 'primary.main',
flexShrink: 0,
}}
/>
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
Matching your preferences
</Typography>
</Box>
)}
<Box <Box
role="radiogroup" role="radiogroup"
aria-label="Funeral packages" aria-label="Funeral packages"
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }} sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
> >
{packages.map((pkg) => ( {packages.map((pkg) => (
<Box key={pkg.id} sx={{ position: 'relative' }}>
{pkg.popular && (
<Badge
variant="filled"
color="brand"
size="small"
aria-label="Most popular choice"
sx={{
position: 'absolute',
top: -8,
right: 12,
zIndex: 1,
}}
>
Most Popular
</Badge>
)}
<ServiceOption <ServiceOption
key={pkg.id}
name={pkg.name} name={pkg.name}
description={pkg.description} description={pkg.description}
price={pkg.price} price={pkg.price}
selected={selectedPackageId === pkg.id} selected={selectedPackageId === pkg.id}
onClick={() => onSelectPackage(pkg.id)} onClick={() => onSelectPackage(pkg.id)}
/> />
</Box>
))} ))}
{packages.length === 0 && ( {packages.length === 0 && (
<Box sx={{ py: 6, textAlign: 'center' }}> <Box sx={{ py: 4, textAlign: 'center' }}>
<Typography variant="body1" color="text.secondary" sx={{ mb: 1 }}>
No packages match the selected budget range.
</Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Try selecting &quot;All packages&quot; to see the full range. No packages match your current preferences.
</Typography> </Typography>
</Box> </Box>
)} )}
</Box> </Box>
{/* Mobile: Continue button (desktop uses PackageDetail's CTA) */} {/* ─── Other packages (passive discovery) ─── */}
<Box sx={{ display: { xs: 'flex', md: 'none' }, justifyContent: 'flex-end', pb: 2 }}> {hasOtherPackages && (
<Button <>
variant="contained" <Divider sx={{ mb: 2 }} />
size="large" <Box
onClick={onContinue} sx={{
disabled={!selectedPackageId} display: 'flex',
loading={loading} alignItems: 'center',
gap: 1.5,
mb: 2,
}}
> >
Continue <Box
</Button> sx={{
width: 3,
height: 20,
borderRadius: 1,
bgcolor: 'text.secondary',
flexShrink: 0,
}}
/>
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.secondary' }}>
Other packages from {provider.name}
</Typography>
</Box> </Box>
<Box
role="radiogroup"
aria-label={`Other packages from ${provider.name}`}
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3, opacity: 0.85 }}
>
{otherPackages.map((pkg) => (
<ServiceOption
key={pkg.id}
name={pkg.name}
description={pkg.description}
price={pkg.price}
selected={selectedPackageId === pkg.id}
onClick={() => onSelectPackage(pkg.id)}
/>
))}
</Box>
</>
)}
</WizardLayout> </WizardLayout>
); );
}; };

View File

@@ -1,163 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react';
import { PreviewStep } from './PreviewStep';
import type { PreviewStepPackage, PreviewStepProvider } from './PreviewStep';
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' },
{ label: 'Log in', href: '/login' },
]}
/>
);
const mockProvider: PreviewStepProvider = {
name: 'H.Parsons Funeral Directors',
location: 'Wentworth, NSW',
imageUrl: 'https://placehold.co/120x80/E8E0D6/8B6F47?text=H.Parsons',
rating: 4.6,
reviewCount: 7,
};
const mockPackage: PreviewStepPackage = {
name: 'Everyday Funeral Package',
price: 2700,
sections: [
{
heading: 'Essentials',
items: [
{ name: 'Accommodation', price: 500 },
{ name: 'Death registration certificate', price: 150 },
{ name: 'Doctor fee for Cremation', price: 150 },
{ name: 'NSW Government Levy - Cremation', price: 83 },
{ name: 'Professional Mortuary Care', price: 1200 },
{ name: 'Professional Service Fee', price: 1120 },
],
},
{
heading: 'Complimentary Items',
items: [
{ name: 'Dressing Fee', price: 0 },
{ name: 'Viewing Fee', price: 0 },
],
},
],
total: 2700,
extras: {
heading: 'Extras',
items: [
{ name: 'Allowance for Flowers', price: 150, isAllowance: true },
{ name: 'Allowance for Master of Ceremonies', price: 500, isAllowance: true },
{ name: 'After Business Hours Service Surcharge', price: 150 },
{ name: 'Coffin Bearing by Funeral Directors', price: 1500 },
],
},
terms:
'This package includes a funeral service at a chapel or a church with a funeral procession. Pricing may vary based on additional selections.',
};
// ─── Meta ────────────────────────────────────────────────────────────────────
const meta: Meta<typeof PreviewStep> = {
title: 'Pages/PreviewStep',
component: PreviewStep,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
};
export default meta;
type Story = StoryObj<typeof PreviewStep>;
// ─── Default (at-need) ─────────────────────────────────────────────────────
/** At-need flow — Continue CTA only, no explore option */
export const Default: Story = {
args: {
provider: mockProvider,
selectedPackage: mockPackage,
navigation: nav,
onContinue: () => alert('Continue with this package'),
onBack: () => alert('Back'),
},
};
// ─── Pre-planning ───────────────────────────────────────────────────────────
/** Pre-planning flow — shows "Explore other options" tertiary CTA */
export const PrePlanning: Story = {
args: {
provider: mockProvider,
selectedPackage: mockPackage,
navigation: nav,
isPrePlanning: true,
onContinue: () => alert('Continue'),
onBack: () => alert('Back'),
onExplore: () => alert('Explore other options'),
},
};
// ─── Loading ────────────────────────────────────────────────────────────────
/** Continue button in loading state */
export const Loading: Story = {
args: {
provider: mockProvider,
selectedPackage: mockPackage,
navigation: nav,
loading: true,
onContinue: () => {},
onBack: () => alert('Back'),
},
};
// ─── Minimal package ────────────────────────────────────────────────────────
/** Basic package with fewer inclusions */
export const MinimalPackage: Story = {
args: {
provider: mockProvider,
selectedPackage: {
name: 'Essential Cremation',
price: 1800,
sections: [
{
heading: 'Essentials',
items: [
{ name: 'Death registration certificate', price: 150 },
{ name: 'Professional Mortuary Care', price: 800 },
{ name: 'Professional Service Fee', price: 850 },
],
},
],
total: 1800,
},
navigation: nav,
onContinue: () => alert('Continue'),
onBack: () => alert('Back'),
},
};

View File

@@ -1,228 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import type { SxProps, Theme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout';
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
import { PackageDetail } from '../../organisms/PackageDetail';
import type { PackageSection } from '../../organisms/PackageDetail';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Divider } from '../../atoms/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Provider summary for the compact card */
export interface PreviewStepProvider {
/** Provider name */
name: string;
/** Location */
location: string;
/** Image URL */
imageUrl?: string;
/** Rating */
rating?: number;
/** Review count */
reviewCount?: number;
}
/** Selected package data for the preview */
export interface PreviewStepPackage {
/** Package display name */
name: string;
/** Package price */
price: number;
/** Line item sections */
sections: PackageSection[];
/** Total */
total?: number;
/** Extras section */
extras?: PackageSection;
/** Terms */
terms?: string;
}
/** A step in the "What's next?" checklist */
export interface NextStepItem {
/** Step number (1-based) */
number: number;
/** Step description */
label: string;
}
/** Props for the PreviewStep page component */
export interface PreviewStepProps {
/** Provider summary */
provider: PreviewStepProvider;
/** Selected package details */
selectedPackage: PreviewStepPackage;
/** What's next checklist items */
nextSteps?: NextStepItem[];
/** Callback for the primary CTA */
onContinue: () => void;
/** Callback for the back button */
onBack: () => void;
/** Callback for "Explore other options" (pre-planning only) */
onExplore?: () => void;
/** Whether Continue is loading */
loading?: boolean;
/** Whether this is a pre-planning flow */
isPrePlanning?: boolean;
/** Navigation bar */
navigation?: React.ReactNode;
/** MUI sx prop */
sx?: SxProps<Theme>;
}
// ─── Default checklist ──────────────────────────────────────────────────────
const DEFAULT_NEXT_STEPS: NextStepItem[] = [
{ number: 1, label: 'Create your account to save your selections' },
{ number: 2, label: 'Choose a date and time for the service' },
{ number: 3, label: 'Select a venue' },
{ number: 4, label: 'Choose a coffin' },
{ number: 5, label: 'Review and confirm your arrangement' },
];
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Step 4 — Package preview page for the FA arrangement wizard.
*
* Informational review step — no form fields. Shows the selected
* package breakdown and a "What's next?" orientation checklist to
* reduce anxiety about the remaining steps.
*
* List + Detail split: provider info + checklist + CTAs (left),
* PackageDetail breakdown (right).
*
* Pre-planning users see an additional "Explore other options" CTA.
*
* Pure presentation component — props in, callbacks out.
*
* Spec: documentation/steps/steps/04_preview.yaml
*/
export const PreviewStep: React.FC<PreviewStepProps> = ({
provider,
selectedPackage,
nextSteps = DEFAULT_NEXT_STEPS,
onContinue,
onBack,
onExplore,
loading = false,
isPrePlanning = false,
navigation,
sx,
}) => {
return (
<WizardLayout
variant="list-detail"
navigation={navigation}
showBackLink
backLabel="Back"
onBack={onBack}
sx={sx}
secondaryPanel={
<PackageDetail
name={selectedPackage.name}
price={selectedPackage.price}
sections={selectedPackage.sections}
total={selectedPackage.total}
extras={selectedPackage.extras}
terms={selectedPackage.terms}
/>
}
>
{/* Provider compact card */}
<Box sx={{ mb: 3 }}>
<ProviderCardCompact
name={provider.name}
location={provider.location}
imageUrl={provider.imageUrl}
rating={provider.rating}
reviewCount={provider.reviewCount}
/>
</Box>
{/* Page heading */}
<Typography variant="display3" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
Your selected package
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}>
Here&apos;s what&apos;s included. You&apos;ll be able to customise everything in the next
steps.
</Typography>
{/* What's next? checklist */}
<Box sx={{ mb: 4 }}>
<Typography variant="h6" sx={{ mb: 1.5 }}>
What happens next
</Typography>
<List disablePadding>
{nextSteps.map((step) => (
<ListItem key={step.number} disablePadding sx={{ mb: 1, alignItems: 'flex-start' }}>
<ListItemIcon
sx={{
minWidth: 36,
mt: 0.25,
}}
>
<Box
sx={{
width: 24,
height: 24,
borderRadius: '50%',
bgcolor: 'var(--fa-color-brand-100)',
color: 'var(--fa-color-brand-700)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.75rem',
fontWeight: 600,
}}
>
{step.number}
</Box>
</ListItemIcon>
<ListItemText
primary={step.label}
primaryTypographyProps={{
variant: 'body2',
color: 'text.primary',
}}
/>
</ListItem>
))}
</List>
</Box>
<Divider sx={{ mb: 3 }} />
{/* Action buttons */}
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: 2,
justifyContent: 'flex-end',
pb: 2,
}}
>
{isPrePlanning && onExplore && (
<Button variant="outlined" color="secondary" size="large" onClick={onExplore}>
Explore other options
</Button>
)}
<Button variant="contained" size="large" onClick={onContinue} loading={loading}>
Continue with this package
</Button>
</Box>
</WizardLayout>
);
};
PreviewStep.displayName = 'PreviewStep';
export default PreviewStep;

View File

@@ -1,2 +0,0 @@
export { default } from './PreviewStep';
export * from './PreviewStep';

View File

@@ -125,7 +125,7 @@ export const Default: Story = {
const [filters, setFilters] = useState(defaultFilters); const [filters, setFilters] = useState(defaultFilters);
const filtered = mockProviders.filter((p) => const filtered = mockProviders.filter((p) =>
p.name.toLowerCase().includes(query.toLowerCase()), p.location.toLowerCase().includes(query.toLowerCase()),
); );
return ( return (

View File

@@ -1,9 +1,11 @@
import React from 'react'; import React from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import TextField from '@mui/material/TextField';
import InputAdornment from '@mui/material/InputAdornment';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import type { SxProps, Theme } from '@mui/material/styles'; import type { SxProps, Theme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout'; import { WizardLayout } from '../../templates/WizardLayout';
import { ProviderCard } from '../../molecules/ProviderCard'; import { ProviderCard } from '../../molecules/ProviderCard';
import { SearchBar } from '../../molecules/SearchBar';
import { FilterPanel } from '../../molecules/FilterPanel'; import { FilterPanel } from '../../molecules/FilterPanel';
import { Chip } from '../../atoms/Chip'; import { Chip } from '../../atoms/Chip';
import { Typography } from '../../atoms/Typography'; import { Typography } from '../../atoms/Typography';
@@ -122,7 +124,7 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
<Box <Box
sx={{ sx={{
bgcolor: 'var(--fa-color-surface-cool)', bgcolor: 'var(--fa-color-surface-cool)',
height: '100%', flex: 1,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -150,25 +152,40 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
px: { xs: 2, md: 3 }, px: { xs: 2, md: 3 },
}} }}
> >
<Typography variant="display3" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}> <Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
Choose a funeral provider Find a funeral director
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{subheading} {subheading}
</Typography> </Typography>
{/* Search bar + filter button */} {/* Location search */}
<Box sx={{ display: 'flex', gap: 1, mb: 2, alignItems: 'flex-start' }}> <TextField
<Box sx={{ flex: 1 }}> placeholder="Search a town or suburb..."
<SearchBar aria-label="Search providers by town or suburb"
value={searchQuery} value={searchQuery}
onChange={onSearchChange} onChange={(e) => onSearchChange(e.target.value)}
onSearch={onSearch} onKeyDown={(e) => {
placeholder="Search providers..." if (e.key === 'Enter' && onSearch) {
e.preventDefault();
onSearch(searchQuery);
}
}}
fullWidth
size="small" size="small"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<LocationOnOutlinedIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
</InputAdornment>
),
}}
sx={{ mb: 1.5 }}
/> />
</Box>
{/* Filters — right-aligned below search */}
{filters && filters.length > 0 && ( {filters && filters.length > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
<FilterPanel <FilterPanel
activeCount={filters.filter((f) => f.active).length} activeCount={filters.filter((f) => f.active).length}
onClear={onFilterClear} onClear={onFilterClear}
@@ -186,8 +203,8 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
))} ))}
</Box> </Box>
</FilterPanel> </FilterPanel>
)}
</Box> </Box>
)}
{/* Results count */} {/* Results count */}
<Typography <Typography

View File

@@ -2,7 +2,6 @@ import React from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import InputAdornment from '@mui/material/InputAdornment'; import InputAdornment from '@mui/material/InputAdornment';
import SearchIcon from '@mui/icons-material/Search';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined'; import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import type { SxProps, Theme } from '@mui/material/styles'; import type { SxProps, Theme } from '@mui/material/styles';
@@ -182,7 +181,7 @@ export const VenueStep: React.FC<VenueStepProps> = ({
<Box <Box
sx={{ sx={{
bgcolor: 'var(--fa-color-surface-cool)', bgcolor: 'var(--fa-color-surface-cool)',
height: '100%', flex: 1,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -219,7 +218,7 @@ export const VenueStep: React.FC<VenueStepProps> = ({
px: { xs: 2, md: 3 }, px: { xs: 2, md: 3 },
}} }}
> >
<Typography variant="display3" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}> <Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
Where would you like the service? Where would you like the service?
</Typography> </Typography>
@@ -229,22 +228,26 @@ export const VenueStep: React.FC<VenueStepProps> = ({
: 'Choose a venue for the funeral service. You can filter by location, features, and religion.'} : 'Choose a venue for the funeral service. You can filter by location, features, and religion.'}
</Typography> </Typography>
{/* ─── Search + Filter button ─── */} {/* ─── Location search ─── */}
<Box sx={{ display: 'flex', gap: 1, mb: 2, alignItems: 'flex-start' }}>
<TextField <TextField
placeholder="Search a town or suburb..." placeholder="Search a town or suburb..."
aria-label="Search venues by town or suburb" aria-label="Search venues by town or suburb"
value={values.search} value={values.search}
onChange={(e) => onChange({ ...values, search: e.target.value })} onChange={(e) => onChange({ ...values, search: e.target.value })}
fullWidth fullWidth
size="small"
InputProps={{ InputProps={{
startAdornment: ( startAdornment: (
<InputAdornment position="start"> <InputAdornment position="start">
<SearchIcon sx={{ color: 'text.secondary' }} /> <LocationOnOutlinedIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
</InputAdornment> </InputAdornment>
), ),
}} }}
sx={{ mb: 1.5 }}
/> />
{/* ─── Filters — right-aligned below search ─── */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
<FilterPanel activeCount={values.activeFilters.length} onClear={onFilterClear}> <FilterPanel activeCount={values.activeFilters.length} onClear={onFilterClear}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{filterOptions.map((filter) => ( {filterOptions.map((filter) => (
@@ -260,9 +263,14 @@ export const VenueStep: React.FC<VenueStepProps> = ({
</Box> </Box>
{/* ─── Results count ─── */} {/* ─── Results count ─── */}
<Typography variant="body2" color="text.secondary" sx={{ mb: 0 }} aria-live="polite"> <Typography
Found {venues.length} venue{venues.length !== 1 ? 's' : ''} variant="caption"
{locationName ? ` near ${locationName}` : ''} color="text.secondary"
sx={{ mb: 0, display: 'block' }}
aria-live="polite"
>
{venues.length} venue{venues.length !== 1 ? 's' : ''}
{locationName ? ` near ${locationName}` : ''} found
</Typography> </Typography>
</Box> </Box>

View File

@@ -170,6 +170,7 @@ const ListMapLayout: React.FC<{
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
{/* Left panel — scrollable list with scrollbar visible on hover */}
<Box <Box
sx={{ sx={{
width: { xs: '100%', md: 420 }, width: { xs: '100%', md: 420 },
@@ -177,16 +178,31 @@ const ListMapLayout: React.FC<{
overflowY: 'auto', overflowY: 'auto',
px: { xs: 2, md: 3 }, px: { xs: 2, md: 3 },
py: 3, py: 3,
// Thin scrollbar, hidden until hover
scrollbarWidth: 'thin',
scrollbarColor: 'transparent transparent',
'&:hover': {
scrollbarColor: 'rgba(0,0,0,0.25) transparent',
},
'&::-webkit-scrollbar': { width: 6 },
'&::-webkit-scrollbar-thumb': {
background: 'transparent',
borderRadius: 3,
},
'&:hover::-webkit-scrollbar-thumb': {
background: 'rgba(0,0,0,0.25)',
},
}} }}
> >
{backLink} {backLink}
{children} {children}
</Box> </Box>
{/* Right panel — map or placeholder, fills available space */}
<Box <Box
sx={{ sx={{
display: { xs: 'none', md: 'flex' }, display: { xs: 'none', md: 'flex' },
flex: 1, flex: 1,
position: 'relative', minHeight: 0,
}} }}
> >
{secondaryPanel} {secondaryPanel}