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:
@@ -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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
||||
## 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| ~~PreviewStep~~ | removed | — | Replaced by ArrangementDialog organism (D-E). Package preview + "what's next" checklist now in the dialog's preview step. |
|
||||
| ~~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. |
|
||||
| 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. |
|
||||
|
||||
137
src/components/atoms/DialogShell/DialogShell.stories.tsx
Normal file
137
src/components/atoms/DialogShell/DialogShell.stories.tsx
Normal 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'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't need actions.
|
||||
</Typography>
|
||||
</DialogShell>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
174
src/components/atoms/DialogShell/DialogShell.tsx
Normal file
174
src/components/atoms/DialogShell/DialogShell.tsx
Normal 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;
|
||||
2
src/components/atoms/DialogShell/index.ts
Normal file
2
src/components/atoms/DialogShell/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { DialogShell } from './DialogShell';
|
||||
export type { DialogShellProps } from './DialogShell';
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,53 +71,17 @@ 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();
|
||||
}}
|
||||
onClick={() => onClear()}
|
||||
underline="hover"
|
||||
sx={{ fontSize: (theme: Theme) => theme.typography.caption.fontSize }}
|
||||
>
|
||||
@@ -141,40 +89,17 @@ export const FilterPanel = React.forwardRef<HTMLDivElement, FilterPanelProps>(
|
||||
</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"
|
||||
>
|
||||
}
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="contained" size="small" onClick={handleClose}>
|
||||
Done
|
||||
</Button>
|
||||
</Box>
|
||||
</Popover>
|
||||
}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>{children}</Box>
|
||||
</DialogShell>
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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,73 +180,64 @@ 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 });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
ref={ref}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
scroll="body"
|
||||
aria-labelledby="arrangement-dialog-title"
|
||||
sx={sx}
|
||||
PaperProps={{
|
||||
sx: { borderRadius: 2 },
|
||||
}}
|
||||
>
|
||||
{/* ─── Header ─── */}
|
||||
// ─── Footer CTAs per step ─────────────────────────────────────────
|
||||
|
||||
const previewFooter = (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 3,
|
||||
pt: 2.5,
|
||||
pb: 1,
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
gap: 2,
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{isPrePlanning && onExplore && (
|
||||
<Button variant="outlined" color="secondary" size="large" onClick={onExplore}>
|
||||
Explore other options
|
||||
</Button>
|
||||
)}
|
||||
<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>
|
||||
|
||||
{/* Screen reader step announcement */}
|
||||
<Box
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
sx={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden' }}
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={() => onStepChange('auth')}
|
||||
loading={loading}
|
||||
>
|
||||
{step === 'preview' ? 'Viewing package preview' : 'Create your account'}
|
||||
Continue with this package
|
||||
</Button>
|
||||
</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 === 'preview' && (
|
||||
<Box>
|
||||
@@ -305,17 +294,13 @@ export const ArrangementDialog = React.forwardRef<HTMLDivElement, ArrangementDia
|
||||
</Typography>
|
||||
|
||||
{/* What's next */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<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' }}
|
||||
>
|
||||
<ListItem key={s.number} disablePadding sx={{ mb: 1, alignItems: 'flex-start' }}>
|
||||
<ListItemIcon sx={{ minWidth: 36, mt: 0.25 }}>
|
||||
<Box
|
||||
sx={{
|
||||
@@ -342,32 +327,6 @@ export const ArrangementDialog = React.forwardRef<HTMLDivElement, ArrangementDia
|
||||
))}
|
||||
</List>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -533,26 +492,16 @@ export const ArrangementDialog = React.forwardRef<HTMLDivElement, ArrangementDia
|
||||
</Collapse>
|
||||
|
||||
{/* 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{' '}
|
||||
<Link href="#" sx={{ fontSize: 'inherit' }}>
|
||||
terms and conditions
|
||||
</Link>
|
||||
.
|
||||
</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>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DialogShell>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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'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;
|
||||
@@ -1,8 +0,0 @@
|
||||
export { AuthGateStep, default } from './AuthGateStep';
|
||||
export type {
|
||||
AuthGateStepProps,
|
||||
AuthGateStepValues,
|
||||
AuthGateStepErrors,
|
||||
AuthSubStep,
|
||||
ContactPreference,
|
||||
} from './AuthGateStep';
|
||||
@@ -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"
|
||||
|
||||
@@ -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'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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
key={pkg.id}
|
||||
name={pkg.name}
|
||||
description={pkg.description}
|
||||
price={pkg.price}
|
||||
selected={selectedPackageId === pkg.id}
|
||||
onClick={() => onSelectPackage(pkg.id)}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{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 "All packages" 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}
|
||||
{/* ─── Other packages (passive discovery) ─── */}
|
||||
{hasOtherPackages && (
|
||||
<>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
};
|
||||
@@ -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's what's included. You'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;
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default } from './PreviewStep';
|
||||
export * from './PreviewStep';
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
{/* Location search */}
|
||||
<TextField
|
||||
placeholder="Search a town or suburb..."
|
||||
aria-label="Search providers by town or suburb"
|
||||
value={searchQuery}
|
||||
onChange={onSearchChange}
|
||||
onSearch={onSearch}
|
||||
placeholder="Search providers..."
|
||||
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 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Results count */}
|
||||
<Typography
|
||||
|
||||
@@ -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' }}>
|
||||
{/* ─── 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">
|
||||
<SearchIcon sx={{ color: 'text.secondary' }} />
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user