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

@@ -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',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'flex-start',
gap: 0.5,
py: option.description ? 2 : 1.5,
px: 3,

View File

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

View File

@@ -1,13 +1,11 @@
import React from 'react';
import Box from '@mui/material/Box';
import Popover from '@mui/material/Popover';
import TuneIcon from '@mui/icons-material/Tune';
import type { SxProps, Theme } from '@mui/material/styles';
import { DialogShell } from '../../atoms/DialogShell';
import { Button } from '../../atoms/Button';
import { Badge } from '../../atoms/Badge';
import { Typography } from '../../atoms/Typography';
import { Link } from '../../atoms/Link';
import { Divider } from '../../atoms/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -17,12 +15,10 @@ export interface FilterPanelProps {
label?: string;
/** Number of active filters (shown as count on the trigger) */
activeCount?: number;
/** Filter controls — rendered inside the Popover body */
/** Filter controls — rendered inside the dialog body */
children: React.ReactNode;
/** Callback when "Clear all" is clicked */
onClear?: () => void;
/** Popover min-width */
minWidth?: number;
/** MUI sx prop for the trigger button */
sx?: SxProps<Theme>;
}
@@ -32,29 +28,18 @@ export interface FilterPanelProps {
/**
* 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
* 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.
*/
export const FilterPanel = React.forwardRef<HTMLDivElement, FilterPanelProps>(
({ label = 'Filters', activeCount = 0, children, onClear, minWidth = 280, sx }, ref) => {
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
const open = Boolean(anchorEl);
const uniqueId = React.useId();
const popoverId = `filter-panel-${uniqueId}`;
const headingId = `filter-panel-heading-${uniqueId}`;
({ label = 'Filters', activeCount = 0, children, onClear, sx }, ref) => {
const [open, setOpen] = React.useState(false);
const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleOpen = () => setOpen(true);
const handleClose = () => setOpen(false);
return (
<>
@@ -66,7 +51,6 @@ export const FilterPanel = React.forwardRef<HTMLDivElement, FilterPanelProps>(
size="small"
startIcon={<TuneIcon />}
onClick={handleOpen}
aria-controls={open ? popoverId : undefined}
aria-expanded={open}
aria-haspopup="dialog"
>
@@ -87,94 +71,35 @@ export const FilterPanel = React.forwardRef<HTMLDivElement, FilterPanelProps>(
</Button>
</Box>
{/* Popover panel */}
<Popover
id={popoverId}
{/* Filter dialog */}
<DialogShell
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
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">
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
{label}
</Typography>
{onClear && activeCount > 0 && (
<Link
component="button"
onClick={() => {
onClear();
}}
underline="hover"
sx={{ fontSize: (theme: Theme) => theme.typography.caption.fontSize }}
>
Clear all
</Link>
)}
</Box>
<Divider />
{/* Filter controls */}
<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
</Button>
</Box>
</Popover>
{onClear && activeCount > 0 && (
<Link
component="button"
onClick={() => onClear()}
underline="hover"
sx={{ fontSize: (theme: Theme) => theme.typography.caption.fontSize }}
>
Clear all
</Link>
)}
</Box>
}
footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="contained" size="small" onClick={handleClose}>
Done
</Button>
</Box>
}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>{children}</Box>
</DialogShell>
</>
);
},

View File

@@ -1,20 +1,16 @@
import React from 'react';
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 MenuItem from '@mui/material/MenuItem';
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 CloseIcon from '@mui/icons-material/Close';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import GoogleIcon from '@mui/icons-material/Google';
import MicrosoftIcon from '@mui/icons-material/Window';
import type { SxProps, Theme } from '@mui/material/styles';
import type { PackageSection } from '../PackageDetail';
import { DialogShell } from '../../atoms/DialogShell';
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
import { Collapse } from '../../atoms/Collapse';
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 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 parent controls which step is shown and manages auth form state.
*
@@ -182,377 +180,328 @@ export const ArrangementDialog = React.forwardRef<HTMLDivElement, ArrangementDia
ref,
) => {
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) => {
onAuthChange({ ...authValues, [field]: value });
};
// ─── Footer CTAs per step ─────────────────────────────────────────
const previewFooter = (
<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>
);
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 (
<Dialog
<DialogShell
ref={ref}
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
scroll="body"
aria-labelledby="arrangement-dialog-title"
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}
PaperProps={{
sx: { borderRadius: 2 },
}}
>
{/* ─── Header ─── */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 3,
pt: 2.5,
pb: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{step === 'auth' && (
<IconButton
size="small"
onClick={() => onStepChange('preview')}
aria-label="Back to preview"
>
<ArrowBackIcon fontSize="small" />
</IconButton>
)}
<Typography id="arrangement-dialog-title" ref={titleRef} tabIndex={-1} variant="h5">
{step === 'preview' ? 'Your selected package' : 'Save your plan'}
</Typography>
</Box>
<IconButton size="small" onClick={onClose} aria-label="Close">
<CloseIcon fontSize="small" />
</IconButton>
</Box>
{/* ═══════════ Step 1: Preview ═══════════ */}
{step === 'preview' && (
<Box>
{/* Provider */}
<Box sx={{ mb: 3 }}>
<ProviderCardCompact
name={provider.name}
location={provider.location}
imageUrl={provider.imageUrl}
rating={provider.rating}
reviewCount={provider.reviewCount}
/>
</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'}
</Box>
<DialogContent sx={{ px: 3, pb: 3 }}>
{/* ═══════════ Step 1: Preview ═══════════ */}
{step === 'preview' && (
<Box>
{/* Provider */}
<Box sx={{ mb: 3 }}>
<ProviderCardCompact
name={provider.name}
location={provider.location}
imageUrl={provider.imageUrl}
rating={provider.rating}
reviewCount={provider.reviewCount}
/>
</Box>
{/* Package summary */}
<Box
sx={{
bgcolor: 'var(--fa-color-surface-warm)',
borderRadius: 2,
p: 2.5,
mb: 3,
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
mb: 2,
}}
>
<Typography variant="h6">{selectedPackage.name}</Typography>
<Typography variant="h6" color="primary">
${(selectedPackage.total ?? selectedPackage.price).toLocaleString('en-AU')}
</Typography>
</Box>
{selectedPackage.sections.map((section) => (
<Box key={section.heading} sx={{ mb: 1.5 }}>
<Typography variant="labelSm" color="text.secondary" sx={{ mb: 0.5 }}>
{section.heading}
</Typography>
{section.items.map((item) => (
<Typography key={item.name} variant="body2" sx={{ pl: 1 }}>
{item.name}
</Typography>
))}
</Box>
))}
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
You&apos;ll be able to customise everything in the next steps.
</Typography>
{/* What's next */}
<Box sx={{ mb: 3 }}>
<Typography variant="h6" sx={{ mb: 1.5 }}>
What happens next
</Typography>
<List disablePadding>
{nextSteps.map((s) => (
<ListItem
key={s.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,
}}
>
{s.number}
</Box>
</ListItemIcon>
<ListItemText
primary={s.label}
primaryTypographyProps={{ variant: 'body2', color: 'text.primary' }}
/>
</ListItem>
))}
</List>
</Box>
<Divider sx={{ mb: 3 }} />
{/* CTAs */}
{/* Package summary */}
<Box
sx={{
bgcolor: 'var(--fa-color-surface-warm)',
borderRadius: 2,
p: 2.5,
mb: 3,
}}
>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: 2,
justifyContent: 'flex-end',
justifyContent: 'space-between',
alignItems: 'baseline',
mb: 2,
}}
>
{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>
)}
{/* ═══════════ Step 2: Auth ═══════════ */}
{step === 'auth' && (
<Box
component="form"
noValidate
aria-busy={loading}
onSubmit={(e: React.FormEvent) => {
e.preventDefault();
if (!loading) onContinue();
}}
>
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
{isPrePlanning
? 'Save your plan to return and update it anytime.'
: 'We need a few details so a funeral arranger can help you with the next steps.'}
</Typography>
{/* SSO buttons */}
<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 variant="h6">{selectedPackage.name}</Typography>
<Typography variant="h6" color="primary">
${(selectedPackage.total ?? selectedPackage.price).toLocaleString('en-AU')}
</Typography>
</Divider>
</Box>
{/* Email */}
<TextField
label="Your email address"
type="email"
value={authValues.email}
onChange={(e) => handleAuthField('email', e.target.value)}
error={!!authErrors?.email}
helperText={authErrors?.email}
placeholder="you@example.com"
autoComplete="email"
inputMode="email"
fullWidth
required
disabled={authValues.subStep !== 'email'}
sx={{ mb: 3 }}
/>
{/* Details (after email) */}
<Collapse in={authValues.subStep === 'details' || authValues.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
{selectedPackage.sections.map((section) => (
<Box key={section.heading} sx={{ mb: 1.5 }}>
<Typography variant="labelSm" color="text.secondary" sx={{ mb: 0.5 }}>
{section.heading}
</Typography>
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, gap: 2 }}>
<TextField
label="First name"
value={authValues.firstName}
onChange={(e) => handleAuthField('firstName', e.target.value)}
error={!!authErrors?.firstName}
helperText={authErrors?.firstName}
autoComplete="given-name"
fullWidth
required
disabled={authValues.subStep === 'verify'}
/>
<TextField
label="Last name"
value={authValues.lastName}
onChange={(e) => handleAuthField('lastName', e.target.value)}
error={!!authErrors?.lastName}
helperText={authErrors?.lastName}
autoComplete="family-name"
fullWidth
required
disabled={authValues.subStep === 'verify'}
/>
</Box>
<TextField
label={isEmailOnly ? 'Phone (optional)' : 'Best number to reach you'}
type="tel"
value={authValues.phone}
onChange={(e) => handleAuthField('phone', e.target.value)}
error={!!authErrors?.phone}
helperText={authErrors?.phone}
placeholder="e.g. 0412 345 678"
autoComplete="tel"
inputMode="tel"
fullWidth
required={!isEmailOnly}
disabled={authValues.subStep === 'verify'}
/>
<TextField
select
label="Contact preference"
value={authValues.contactPreference}
onChange={(e) => handleAuthField('contactPreference', e.target.value)}
fullWidth
disabled={authValues.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>
{section.items.map((item) => (
<Typography key={item.name} variant="body2" sx={{ pl: 1 }}>
{item.name}
</Typography>
))}
</Box>
</Collapse>
))}
</Box>
{/* Verification */}
<Collapse in={authValues.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>{authValues.email}</strong>. Please
enter it below.
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
You&apos;ll be able to customise everything in the next steps.
</Typography>
{/* What's next */}
<Box>
<Typography variant="h6" sx={{ mb: 1.5 }}>
What happens next
</Typography>
<List disablePadding>
{nextSteps.map((s) => (
<ListItem key={s.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,
}}
>
{s.number}
</Box>
</ListItemIcon>
<ListItemText
primary={s.label}
primaryTypographyProps={{ variant: 'body2', color: 'text.primary' }}
/>
</ListItem>
))}
</List>
</Box>
</Box>
)}
{/* ═══════════ Step 2: Auth ═══════════ */}
{step === 'auth' && (
<Box
component="form"
noValidate
aria-busy={loading}
onSubmit={(e: React.FormEvent) => {
e.preventDefault();
if (!loading) onContinue();
}}
>
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
{isPrePlanning
? 'Save your plan to return and update it anytime.'
: 'We need a few details so a funeral arranger can help you with the next steps.'}
</Typography>
{/* SSO buttons */}
<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>
{/* Email */}
<TextField
label="Your email address"
type="email"
value={authValues.email}
onChange={(e) => handleAuthField('email', e.target.value)}
error={!!authErrors?.email}
helperText={authErrors?.email}
placeholder="you@example.com"
autoComplete="email"
inputMode="email"
fullWidth
required
disabled={authValues.subStep !== 'email'}
sx={{ mb: 3 }}
/>
{/* Details (after email) */}
<Collapse in={authValues.subStep === 'details' || authValues.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="Verification code"
value={authValues.verificationCode}
onChange={(e) => handleAuthField('verificationCode', e.target.value)}
error={!!authErrors?.verificationCode}
helperText={
authErrors?.verificationCode || 'Check your email for the 6-digit code'
}
placeholder="Enter 6-digit code"
autoComplete="one-time-code"
inputMode="numeric"
label="First name"
value={authValues.firstName}
onChange={(e) => handleAuthField('firstName', e.target.value)}
error={!!authErrors?.firstName}
helperText={authErrors?.firstName}
autoComplete="given-name"
fullWidth
required
disabled={authValues.subStep === 'verify'}
/>
<TextField
label="Last name"
value={authValues.lastName}
onChange={(e) => handleAuthField('lastName', e.target.value)}
error={!!authErrors?.lastName}
helperText={authErrors?.lastName}
autoComplete="family-name"
fullWidth
required
disabled={authValues.subStep === 'verify'}
/>
</Box>
</Collapse>
{/* Terms */}
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 3 }}>
By continuing, you agree to the{' '}
<Link href="#" sx={{ fontSize: 'inherit' }}>
terms and conditions
</Link>
.
</Typography>
<TextField
label={isEmailOnly ? 'Phone (optional)' : 'Best number to reach you'}
type="tel"
value={authValues.phone}
onChange={(e) => handleAuthField('phone', e.target.value)}
error={!!authErrors?.phone}
helperText={authErrors?.phone}
placeholder="e.g. 0412 345 678"
autoComplete="tel"
inputMode="tel"
fullWidth
required={!isEmailOnly}
disabled={authValues.subStep === 'verify'}
/>
<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>
<TextField
select
label="Contact preference"
value={authValues.contactPreference}
onChange={(e) => handleAuthField('contactPreference', e.target.value)}
fullWidth
disabled={authValues.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>
</Box>
)}
</DialogContent>
</Dialog>
</Collapse>
{/* Verification */}
<Collapse in={authValues.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>{authValues.email}</strong>. Please
enter it below.
</Typography>
<TextField
label="Verification code"
value={authValues.verificationCode}
onChange={(e) => handleAuthField('verificationCode', e.target.value)}
error={!!authErrors?.verificationCode}
helperText={
authErrors?.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' }}>
By continuing, you agree to the{' '}
<Link href="#" sx={{ fontSize: 'inherit' }}>
terms and conditions
</Link>
.
</Typography>
</Box>
)}
</DialogShell>
);
},
);

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,
}}
>
<FilterPanel activeCount={activeFilterCount} onClear={handleFilterClear} minWidth={300}>
<FilterPanel activeCount={activeFilterCount} onClear={handleFilterClear}>
<TextField
select
label="Category"

View File

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

View File

@@ -43,14 +43,13 @@ const mockProvider: PackagesStepProvider = {
reviewCount: 7,
};
const mockPackages: PackageData[] = [
const matchedPackages: PackageData[] = [
{
id: 'everyday',
name: 'Everyday Funeral Package',
price: 2700,
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.',
popular: true,
sections: [
{
heading: 'Essentials',
@@ -86,6 +85,27 @@ const mockPackages: PackageData[] = [
terms:
'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',
name: 'Deluxe Funeral Package',
@@ -106,24 +126,6 @@ const mockPackages: PackageData[] = [
],
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',
name: 'Catholic Service',
@@ -161,44 +163,21 @@ type Story = StoryObj<typeof PackagesStep>;
// ─── 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 = {
render: () => {
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 (
<PackagesStep
provider={mockProvider}
packages={filtered}
packages={matchedPackages}
otherPackages={otherPackages}
selectedPackageId={selectedId}
onSelectPackage={(id) => {
setSelectedId(id);
setError(undefined);
}}
budgetFilter={budget}
onBudgetFilterChange={setBudget}
onContinue={handleContinue}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
error={error}
navigation={nav}
/>
);
@@ -211,17 +190,38 @@ export const Default: Story = {
export const WithSelection: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>('everyday');
const [budget, setBudget] = useState('all');
return (
<PackagesStep
provider={mockProvider}
packages={mockPackages}
packages={matchedPackages}
otherPackages={otherPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
budgetFilter={budget}
onBudgetFilterChange={setBudget}
onContinue={() => alert('Continue')}
onArrange={() => alert('Open ArrangementDialog')}
onProviderClick={() => alert('Open provider profile')}
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')}
navigation={nav}
/>
@@ -231,21 +231,20 @@ export const WithSelection: Story = {
// ─── Pre-planning ───────────────────────────────────────────────────────────
/** Pre-planning flow — softer helper text */
/** Pre-planning flow — softer copy */
export const PrePlanning: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [budget, setBudget] = useState('all');
return (
<PackagesStep
provider={mockProvider}
packages={mockPackages}
packages={matchedPackages}
otherPackages={otherPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
budgetFilter={budget}
onBudgetFilterChange={setBudget}
onContinue={() => alert('Continue')}
onArrange={() => alert('Open ArrangementDialog')}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
navigation={nav}
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 ───────────────────────────────────────────────────────
/** Error shown when no package selected */
export const WithError: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [budget, setBudget] = useState('all');
return (
<PackagesStep
provider={mockProvider}
packages={mockPackages}
packages={matchedPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
budgetFilter={budget}
onBudgetFilterChange={setBudget}
onContinue={() => {}}
onArrange={() => {}}
onBack={() => alert('Back')}
error="Please choose a package to continue."
navigation={nav}

View File

@@ -1,7 +1,5 @@
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 { WizardLayout } from '../../templates/WizardLayout';
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
@@ -9,8 +7,7 @@ import { ServiceOption } from '../../molecules/ServiceOption';
import { PackageDetail } from '../../organisms/PackageDetail';
import type { PackageSection } from '../../organisms/PackageDetail';
import { Typography } from '../../atoms/Typography';
import { Badge } from '../../atoms/Badge';
import { Button } from '../../atoms/Button';
import { Divider } from '../../atoms/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -38,8 +35,6 @@ export interface PackageData {
price: number;
/** Short description */
description?: string;
/** Whether this is a "Most Popular" package */
popular?: boolean;
/** Line item sections for the detail panel */
sections: PackageSection[];
/** Total price (may differ from base price with extras) */
@@ -50,37 +45,27 @@ export interface PackageData {
terms?: string;
}
/** Budget filter option */
export interface BudgetOption {
/** Option value */
value: string;
/** Display label */
label: string;
}
/** Props for the PackagesStep page component */
export interface PackagesStepProps {
/** Provider summary shown at top of the list panel */
provider: PackagesStepProvider;
/** Available packages */
/** Packages matching the user's filters from the previous step */
packages: PackageData[];
/** Other packages from this provider that didn't match filters (shown in secondary group) */
otherPackages?: PackageData[];
/** Currently selected package ID */
selectedPackageId: string | null;
/** Callback when a package is selected */
onSelectPackage: (id: string) => void;
/** Current budget filter value */
budgetFilter: string;
/** Callback when budget filter changes */
onBudgetFilterChange: (value: string) => void;
/** Budget filter options */
budgetOptions?: BudgetOption[];
/** Callback for the Continue button */
onContinue: () => void;
/** Callback when "Make Arrangement" is clicked (opens ArrangementDialog) */
onArrange: () => void;
/** Callback when the provider card is clicked (opens provider profile popup) */
onProviderClick?: () => void;
/** Callback for the Back button */
onBack: () => void;
/** Validation error */
error?: string;
/** Whether Continue is loading */
/** Whether the arrange action is loading */
loading?: boolean;
/** Navigation bar */
navigation?: React.ReactNode;
@@ -90,26 +75,23 @@ export interface PackagesStepProps {
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 ───────────────────────────────────────────────────────────────
/**
* Step 3 — Package selection page for the FA arrangement wizard.
*
* List + Detail split layout. Left panel shows the selected provider
* (compact), a budget filter, and selectable package cards. Right panel
* shows the full detail breakdown of the selected package.
* (compact) and selectable package cards. Right panel shows the full
* detail breakdown of the selected package with "Make Arrangement" CTA.
*
* Packages are displayed as ServiceOption cards in a radiogroup pattern.
* "Most Popular" badge on qualifying packages reduces decision paralysis.
* Packages are split into two groups:
* - **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.
*
@@ -118,12 +100,11 @@ const DEFAULT_BUDGET_OPTIONS: BudgetOption[] = [
export const PackagesStep: React.FC<PackagesStepProps> = ({
provider,
packages,
otherPackages = [],
selectedPackageId,
onSelectPackage,
budgetFilter,
onBudgetFilterChange,
budgetOptions = DEFAULT_BUDGET_OPTIONS,
onContinue,
onArrange,
onProviderClick,
onBack,
error,
loading = false,
@@ -131,13 +112,13 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
isPrePlanning = false,
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 =
'Each package includes a set of services. You can customise your selections in the next steps.';
const helperText = isPrePlanning
const subheading = isPrePlanning
? '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 (
<WizardLayout
@@ -156,7 +137,7 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
total={selectedPackage.total}
extras={selectedPackage.extras}
terms={selectedPackage.terms}
onArrange={onContinue}
onArrange={onArrange}
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 }}>
<ProviderCardCompact
name={provider.name}
@@ -187,37 +168,17 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
imageUrl={provider.imageUrl}
rating={provider.rating}
reviewCount={provider.reviewCount}
onClick={onProviderClick}
/>
</Box>
{/* 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
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{subheading}
</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 && (
@@ -230,64 +191,99 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
</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
role="radiogroup"
aria-label="Funeral packages"
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
>
{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
name={pkg.name}
description={pkg.description}
price={pkg.price}
selected={selectedPackageId === pkg.id}
onClick={() => onSelectPackage(pkg.id)}
/>
</Box>
<ServiceOption
key={pkg.id}
name={pkg.name}
description={pkg.description}
price={pkg.price}
selected={selectedPackageId === pkg.id}
onClick={() => onSelectPackage(pkg.id)}
/>
))}
{packages.length === 0 && (
<Box sx={{ py: 6, textAlign: 'center' }}>
<Typography variant="body1" color="text.secondary" sx={{ mb: 1 }}>
No packages match the selected budget range.
</Typography>
<Box sx={{ py: 4, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
Try selecting &quot;All packages&quot; to see the full range.
No packages match your current preferences.
</Typography>
</Box>
)}
</Box>
{/* Mobile: Continue button (desktop uses PackageDetail's CTA) */}
<Box sx={{ display: { xs: 'flex', md: 'none' }, justifyContent: 'flex-end', pb: 2 }}>
<Button
variant="contained"
size="large"
onClick={onContinue}
disabled={!selectedPackageId}
loading={loading}
>
Continue
</Button>
</Box>
{/* ─── Other packages (passive discovery) ─── */}
{hasOtherPackages && (
<>
<Divider sx={{ mb: 2 }} />
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
mb: 2,
}}
>
<Box
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
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>
);
};

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 filtered = mockProviders.filter((p) =>
p.name.toLowerCase().includes(query.toLowerCase()),
p.location.toLowerCase().includes(query.toLowerCase()),
);
return (

View File

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

View File

@@ -2,7 +2,6 @@ import React from 'react';
import Box from '@mui/material/Box';
import TextField from '@mui/material/TextField';
import InputAdornment from '@mui/material/InputAdornment';
import SearchIcon from '@mui/icons-material/Search';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import type { SxProps, Theme } from '@mui/material/styles';
@@ -182,7 +181,7 @@ export const VenueStep: React.FC<VenueStepProps> = ({
<Box
sx={{
bgcolor: 'var(--fa-color-surface-cool)',
height: '100%',
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -219,7 +218,7 @@ export const VenueStep: React.FC<VenueStepProps> = ({
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?
</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.'}
</Typography>
{/* ─── Search + Filter button ─── */}
<Box sx={{ display: 'flex', gap: 1, mb: 2, alignItems: 'flex-start' }}>
<TextField
placeholder="Search a town or suburb..."
aria-label="Search venues by town or suburb"
value={values.search}
onChange={(e) => onChange({ ...values, search: e.target.value })}
fullWidth
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon sx={{ color: 'text.secondary' }} />
</InputAdornment>
),
}}
/>
{/* ─── Location search ─── */}
<TextField
placeholder="Search a town or suburb..."
aria-label="Search venues by town or suburb"
value={values.search}
onChange={(e) => onChange({ ...values, search: e.target.value })}
fullWidth
size="small"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<LocationOnOutlinedIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
</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}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{filterOptions.map((filter) => (
@@ -260,9 +263,14 @@ export const VenueStep: React.FC<VenueStepProps> = ({
</Box>
{/* ─── Results count ─── */}
<Typography variant="body2" color="text.secondary" sx={{ mb: 0 }} aria-live="polite">
Found {venues.length} venue{venues.length !== 1 ? 's' : ''}
{locationName ? ` near ${locationName}` : ''}
<Typography
variant="caption"
color="text.secondary"
sx={{ mb: 0, display: 'block' }}
aria-live="polite"
>
{venues.length} venue{venues.length !== 1 ? 's' : ''}
{locationName ? ` near ${locationName}` : ''} found
</Typography>
</Box>

View File

@@ -170,6 +170,7 @@ const ListMapLayout: React.FC<{
overflow: 'hidden',
}}
>
{/* Left panel — scrollable list with scrollbar visible on hover */}
<Box
sx={{
width: { xs: '100%', md: 420 },
@@ -177,16 +178,31 @@ const ListMapLayout: React.FC<{
overflowY: 'auto',
px: { xs: 2, md: 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}
{children}
</Box>
{/* Right panel — map or placeholder, fills available space */}
<Box
sx={{
display: { xs: 'none', md: 'flex' },
flex: 1,
position: 'relative',
minHeight: 0,
}}
>
{secondaryPanel}