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. |
|
| Slider | planned | single, range × desktop, mobile | | Price range filter. Wraps MUI Slider. Deferred until search/filtering molecules. |
|
||||||
| Link | done | underline: hover/always/none × any MUI colour | color.text.brand (copper brand.600, 4.8:1), color.interactive.active | Navigation text link. Wraps MUI Link. Copper default, underline on hover, focus ring. |
|
| Link | done | underline: hover/always/none × any MUI colour | color.text.brand (copper brand.600, 4.8:1), color.interactive.active | Navigation text link. Wraps MUI Link. Copper default, underline on hover, focus ring. |
|
||||||
| Collapse | done | in/out × unmountOnExit | (none — uses MUI defaults) | Progressive disclosure wrapper. Thin MUI Collapse wrapper with unmountOnExit default. Slide-down animation for wizard field reveal. |
|
| Collapse | done | in/out × unmountOnExit | (none — uses MUI defaults) | Progressive disclosure wrapper. Thin MUI Collapse wrapper with unmountOnExit default. Slide-down animation for wizard field reveal. |
|
||||||
|
| DialogShell | done | open/closed × with/without back button × with/without footer | (theme defaults — borderRadius, palette) | Standard dialog container. Header (title + optional back + close), divider, scrollable body, optional footer. Used by FilterPanel and ArrangementDialog. |
|
||||||
| ToggleButtonGroup | done | exclusive single-select × small, medium, large × error × fullWidth + descriptions | color.neutral.100-200, color.brand.50/100, color.interactive.focus, color.feedback.error | Button-select for binary/small-set choices. Fieldset/legend a11y, external label, helper/error text. Brand styling on selected. |
|
| ToggleButtonGroup | done | exclusive single-select × small, medium, large × error × fullWidth + descriptions | color.neutral.100-200, color.brand.50/100, color.interactive.focus, color.feedback.error | Button-select for binary/small-set choices. Fieldset/legend a11y, external label, helper/error text. Brand styling on selected. |
|
||||||
|
|
||||||
## Molecules
|
## Molecules
|
||||||
@@ -76,8 +77,8 @@ duplicates) and MUST update it after completing one.
|
|||||||
| IntroStep | done | WizardLayout (centered-form) + ToggleButtonGroup × 2 + Collapse + Typography + Button + Divider | Wizard step 1 — entry point. forWhom (Myself/Someone else) + hasPassedAway (Yes/No) with progressive disclosure. Auto-sets hasPassedAway="no" for "Myself". `<form>` wrapper, aria-live subheading, grief-sensitive copy. Pure presentation. Audit: 18/20 → 20/20 after fixes. |
|
| IntroStep | done | WizardLayout (centered-form) + ToggleButtonGroup × 2 + Collapse + Typography + Button + Divider | Wizard step 1 — entry point. forWhom (Myself/Someone else) + hasPassedAway (Yes/No) with progressive disclosure. Auto-sets hasPassedAway="no" for "Myself". `<form>` wrapper, aria-live subheading, grief-sensitive copy. Pure presentation. Audit: 18/20 → 20/20 after fixes. |
|
||||||
| ProvidersStep | done | WizardLayout (list-map) + ProviderCard + SearchBar + Chip + Typography + Button | Wizard step 2 — provider selection. List-map split: provider cards w/ radiogroup + search + filter chips (left), map slot (right). aria-live results count, back link. ProviderCard extended with HTML/ARIA passthrough. Audit: 18/20. |
|
| ProvidersStep | done | WizardLayout (list-map) + ProviderCard + SearchBar + Chip + Typography + Button | Wizard step 2 — provider selection. List-map split: provider cards w/ radiogroup + search + filter chips (left), map slot (right). aria-live results count, back link. ProviderCard extended with HTML/ARIA passthrough. Audit: 18/20. |
|
||||||
| PackagesStep | done | WizardLayout (list-detail) + ProviderCardCompact + ServiceOption + PackageDetail + Badge + TextField + Typography + Button | Wizard step 3 — package selection. List-detail split: compact provider + budget filter + package list w/ radiogroup (left), PackageDetail breakdown (right). "Most Popular" badge. Mobile Continue button. |
|
| PackagesStep | done | WizardLayout (list-detail) + ProviderCardCompact + ServiceOption + PackageDetail + Badge + TextField + Typography + Button | Wizard step 3 — package selection. List-detail split: compact provider + budget filter + package list w/ radiogroup (left), PackageDetail breakdown (right). "Most Popular" badge. Mobile Continue button. |
|
||||||
| PreviewStep | done | WizardLayout (list-detail) + ProviderCardCompact + PackageDetail + Typography + Button + List + Divider | Wizard step 4 — package review. Informational (no form fields). "What happens next" numbered checklist. Pre-planning shows "Explore other options" tertiary CTA. |
|
| ~~PreviewStep~~ | removed | — | Replaced by ArrangementDialog organism (D-E). Package preview + "what's next" checklist now in the dialog's preview step. |
|
||||||
| AuthGateStep | done | WizardLayout (centered-form) + TextField + MenuItem + Collapse + Button + Divider | Wizard step 5 — auth gate. 3 progressive sub-steps: SSO/email → details → verify. Phone optional when email-only. Benefit framing. Audit: 18/20. |
|
| ~~AuthGateStep~~ | removed | — | Replaced by ArrangementDialog organism (D-E). SSO/email auth flow now in the dialog's auth step. |
|
||||||
| DateTimeStep | done | WizardLayout (centered-form) + TextField + RadioGroup + Autocomplete + Collapse + Divider + Button | Wizard step 6 — details & scheduling. Deceased name + date/time radios + religion Autocomplete. Two fieldset sections. Grief-sensitive labels. Save-and-exit CTA. |
|
| DateTimeStep | done | WizardLayout (centered-form) + TextField + RadioGroup + Autocomplete + Collapse + Divider + Button | Wizard step 6 — details & scheduling. Deceased name + date/time radios + religion Autocomplete. Two fieldset sections. Grief-sensitive labels. Save-and-exit CTA. |
|
||||||
| VenueStep | done | WizardLayout (centered-form) + VenueCard + AddOnOption + Collapse + Chip + TextField + Divider + Button | Wizard step 7 — venue selection. Consolidated from 3 baseline steps. Card grid with search/filters, inline detail, service toggles (photo, streaming, recording). |
|
| VenueStep | done | WizardLayout (centered-form) + VenueCard + AddOnOption + Collapse + Chip + TextField + Divider + Button | Wizard step 7 — venue selection. Consolidated from 3 baseline steps. Card grid with search/filters, inline detail, service toggles (photo, streaming, recording). |
|
||||||
| CrematoriumStep | done | WizardLayout (centered-form) + Card + RadioGroup + Collapse + TextField + Divider + Button | Wizard step 8 — crematorium. Single confirmation card or multi-card grid. Witness question personalised with deceased name. Special instructions textarea. |
|
| CrematoriumStep | done | WizardLayout (centered-form) + Card + RadioGroup + Collapse + TextField + Divider + Button | Wizard step 8 — crematorium. Single confirmation card or multi-card grid. Witness question personalised with deceased name. Special instructions textarea. |
|
||||||
|
|||||||
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',
|
textTransform: 'none',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
gap: 0.5,
|
gap: 0.5,
|
||||||
py: option.description ? 2 : 1.5,
|
py: option.description ? 2 : 1.5,
|
||||||
px: 3,
|
px: 3,
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ const meta: Meta<typeof FilterPanel> = {
|
|||||||
argTypes: {
|
argTypes: {
|
||||||
label: { control: 'text' },
|
label: { control: 'text' },
|
||||||
activeCount: { control: 'number' },
|
activeCount: { control: 'number' },
|
||||||
minWidth: { control: 'number' },
|
|
||||||
},
|
},
|
||||||
decorators: [
|
decorators: [
|
||||||
(Story) => (
|
(Story) => (
|
||||||
@@ -64,7 +63,6 @@ export const SelectFilters: Story = {
|
|||||||
args: {
|
args: {
|
||||||
activeCount: 1,
|
activeCount: 1,
|
||||||
onClear: () => {},
|
onClear: () => {},
|
||||||
minWidth: 300,
|
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<TextField select label="Category" value="solid_timber" fullWidth>
|
<TextField select label="Category" value="solid_timber" fullWidth>
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Popover from '@mui/material/Popover';
|
|
||||||
import TuneIcon from '@mui/icons-material/Tune';
|
import TuneIcon from '@mui/icons-material/Tune';
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
|
import { DialogShell } from '../../atoms/DialogShell';
|
||||||
import { Button } from '../../atoms/Button';
|
import { Button } from '../../atoms/Button';
|
||||||
import { Badge } from '../../atoms/Badge';
|
import { Badge } from '../../atoms/Badge';
|
||||||
import { Typography } from '../../atoms/Typography';
|
|
||||||
import { Link } from '../../atoms/Link';
|
import { Link } from '../../atoms/Link';
|
||||||
import { Divider } from '../../atoms/Divider';
|
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -17,12 +15,10 @@ export interface FilterPanelProps {
|
|||||||
label?: string;
|
label?: string;
|
||||||
/** Number of active filters (shown as count on the trigger) */
|
/** Number of active filters (shown as count on the trigger) */
|
||||||
activeCount?: number;
|
activeCount?: number;
|
||||||
/** Filter controls — rendered inside the Popover body */
|
/** Filter controls — rendered inside the dialog body */
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
/** Callback when "Clear all" is clicked */
|
/** Callback when "Clear all" is clicked */
|
||||||
onClear?: () => void;
|
onClear?: () => void;
|
||||||
/** Popover min-width */
|
|
||||||
minWidth?: number;
|
|
||||||
/** MUI sx prop for the trigger button */
|
/** MUI sx prop for the trigger button */
|
||||||
sx?: SxProps<Theme>;
|
sx?: SxProps<Theme>;
|
||||||
}
|
}
|
||||||
@@ -32,29 +28,18 @@ export interface FilterPanelProps {
|
|||||||
/**
|
/**
|
||||||
* Reusable filter panel for the FA arrangement wizard.
|
* Reusable filter panel for the FA arrangement wizard.
|
||||||
*
|
*
|
||||||
* Renders a trigger button ("Filters") that opens a Popover containing
|
* Renders a trigger button ("Filters") that opens a DialogShell containing
|
||||||
* arbitrary filter controls (chips, selects, sliders, etc.) passed as
|
* arbitrary filter controls (chips, selects, sliders, etc.) passed as
|
||||||
* children. Active filter count shown as a badge on the trigger.
|
* children. Active filter count shown as a badge on the trigger.
|
||||||
*
|
*
|
||||||
* D-C: Popover for desktop MVP. Mobile Drawer variant planned for later.
|
|
||||||
*
|
|
||||||
* Used in ProvidersStep, VenueStep, and CoffinsStep.
|
* Used in ProvidersStep, VenueStep, and CoffinsStep.
|
||||||
*/
|
*/
|
||||||
export const FilterPanel = React.forwardRef<HTMLDivElement, FilterPanelProps>(
|
export const FilterPanel = React.forwardRef<HTMLDivElement, FilterPanelProps>(
|
||||||
({ label = 'Filters', activeCount = 0, children, onClear, minWidth = 280, sx }, ref) => {
|
({ label = 'Filters', activeCount = 0, children, onClear, sx }, ref) => {
|
||||||
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
|
const [open, setOpen] = React.useState(false);
|
||||||
const open = Boolean(anchorEl);
|
|
||||||
const uniqueId = React.useId();
|
|
||||||
const popoverId = `filter-panel-${uniqueId}`;
|
|
||||||
const headingId = `filter-panel-heading-${uniqueId}`;
|
|
||||||
|
|
||||||
const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
|
const handleOpen = () => setOpen(true);
|
||||||
setAnchorEl(event.currentTarget);
|
const handleClose = () => setOpen(false);
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setAnchorEl(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -66,7 +51,6 @@ export const FilterPanel = React.forwardRef<HTMLDivElement, FilterPanelProps>(
|
|||||||
size="small"
|
size="small"
|
||||||
startIcon={<TuneIcon />}
|
startIcon={<TuneIcon />}
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
aria-controls={open ? popoverId : undefined}
|
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
>
|
>
|
||||||
@@ -87,94 +71,35 @@ export const FilterPanel = React.forwardRef<HTMLDivElement, FilterPanelProps>(
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Popover panel */}
|
{/* Filter dialog */}
|
||||||
<Popover
|
<DialogShell
|
||||||
id={popoverId}
|
|
||||||
open={open}
|
open={open}
|
||||||
anchorEl={anchorEl}
|
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
title={
|
||||||
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
slotProps={{
|
|
||||||
paper: {
|
|
||||||
sx: {
|
|
||||||
minWidth,
|
|
||||||
maxHeight: '70vh',
|
|
||||||
mt: 1,
|
|
||||||
borderRadius: 2,
|
|
||||||
boxShadow: 3,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
PaperProps={{
|
|
||||||
role: 'dialog' as const,
|
|
||||||
'aria-labelledby': headingId,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
px: 2.5,
|
|
||||||
pt: 2,
|
|
||||||
pb: 1.5,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography id={headingId} variant="h6">
|
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
{onClear && activeCount > 0 && (
|
||||||
{onClear && activeCount > 0 && (
|
<Link
|
||||||
<Link
|
component="button"
|
||||||
component="button"
|
onClick={() => onClear()}
|
||||||
onClick={() => {
|
underline="hover"
|
||||||
onClear();
|
sx={{ fontSize: (theme: Theme) => theme.typography.caption.fontSize }}
|
||||||
}}
|
>
|
||||||
underline="hover"
|
Clear all
|
||||||
sx={{ fontSize: (theme: Theme) => theme.typography.caption.fontSize }}
|
</Link>
|
||||||
>
|
)}
|
||||||
Clear all
|
</Box>
|
||||||
</Link>
|
}
|
||||||
)}
|
footer={
|
||||||
</Box>
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button variant="contained" size="small" onClick={handleClose}>
|
||||||
<Divider />
|
Done
|
||||||
|
</Button>
|
||||||
{/* Filter controls */}
|
</Box>
|
||||||
<Box
|
}
|
||||||
sx={{
|
>
|
||||||
px: 2.5,
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>{children}</Box>
|
||||||
py: 2,
|
</DialogShell>
|
||||||
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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Dialog from '@mui/material/Dialog';
|
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
|
||||||
import IconButton from '@mui/material/IconButton';
|
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
import MenuItem from '@mui/material/MenuItem';
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
import List from '@mui/material/List';
|
import List from '@mui/material/List';
|
||||||
import ListItem from '@mui/material/ListItem';
|
import ListItem from '@mui/material/ListItem';
|
||||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
import ListItemText from '@mui/material/ListItemText';
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
|
||||||
import GoogleIcon from '@mui/icons-material/Google';
|
import GoogleIcon from '@mui/icons-material/Google';
|
||||||
import MicrosoftIcon from '@mui/icons-material/Window';
|
import MicrosoftIcon from '@mui/icons-material/Window';
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
import type { PackageSection } from '../PackageDetail';
|
import type { PackageSection } from '../PackageDetail';
|
||||||
|
import { DialogShell } from '../../atoms/DialogShell';
|
||||||
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
|
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
|
||||||
import { Collapse } from '../../atoms/Collapse';
|
import { Collapse } from '../../atoms/Collapse';
|
||||||
import { Typography } from '../../atoms/Typography';
|
import { Typography } from '../../atoms/Typography';
|
||||||
@@ -153,6 +149,8 @@ function getAuthCTALabel(subStep: AuthSubStep): string {
|
|||||||
* - **Step 1 (preview):** Package summary, provider info, "What's next" checklist
|
* - **Step 1 (preview):** Package summary, provider info, "What's next" checklist
|
||||||
* - **Step 2 (auth):** SSO buttons, email entry, details, verification
|
* - **Step 2 (auth):** SSO buttons, email entry, details, verification
|
||||||
*
|
*
|
||||||
|
* Uses DialogShell for consistent dialog chrome across the site.
|
||||||
|
*
|
||||||
* The dialog is opened after a user selects a package (from PackagesStep).
|
* The dialog is opened after a user selects a package (from PackagesStep).
|
||||||
* The parent controls which step is shown and manages auth form state.
|
* The parent controls which step is shown and manages auth form state.
|
||||||
*
|
*
|
||||||
@@ -182,377 +180,328 @@ export const ArrangementDialog = React.forwardRef<HTMLDivElement, ArrangementDia
|
|||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const isEmailOnly = authValues.contactPreference === 'email_only';
|
const isEmailOnly = authValues.contactPreference === 'email_only';
|
||||||
const titleRef = React.useRef<HTMLHeadingElement>(null);
|
|
||||||
|
|
||||||
// Focus the dialog title when step changes
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (open && titleRef.current) {
|
|
||||||
titleRef.current.focus();
|
|
||||||
}
|
|
||||||
}, [step, open]);
|
|
||||||
|
|
||||||
const handleAuthField = (field: keyof AuthValues, value: string) => {
|
const handleAuthField = (field: keyof AuthValues, value: string) => {
|
||||||
onAuthChange({ ...authValues, [field]: value });
|
onAuthChange({ ...authValues, [field]: value });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── 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 (
|
return (
|
||||||
<Dialog
|
<DialogShell
|
||||||
ref={ref}
|
ref={ref}
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
maxWidth="sm"
|
title={step === 'preview' ? 'Your selected package' : 'Save your plan'}
|
||||||
fullWidth
|
onBack={step === 'auth' ? () => onStepChange('preview') : undefined}
|
||||||
scroll="body"
|
backLabel="Back to preview"
|
||||||
aria-labelledby="arrangement-dialog-title"
|
footer={step === 'preview' ? previewFooter : authFooter}
|
||||||
sx={sx}
|
sx={sx}
|
||||||
PaperProps={{
|
|
||||||
sx: { borderRadius: 2 },
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* ─── Header ─── */}
|
{/* ═══════════ Step 1: Preview ═══════════ */}
|
||||||
<Box
|
{step === 'preview' && (
|
||||||
sx={{
|
<Box>
|
||||||
display: 'flex',
|
{/* Provider */}
|
||||||
alignItems: 'center',
|
<Box sx={{ mb: 3 }}>
|
||||||
justifyContent: 'space-between',
|
<ProviderCardCompact
|
||||||
px: 3,
|
name={provider.name}
|
||||||
pt: 2.5,
|
location={provider.location}
|
||||||
pb: 1,
|
imageUrl={provider.imageUrl}
|
||||||
}}
|
rating={provider.rating}
|
||||||
>
|
reviewCount={provider.reviewCount}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
/>
|
||||||
{step === 'auth' && (
|
</Box>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Screen reader step announcement */}
|
{/* Package summary */}
|
||||||
<Box
|
<Box
|
||||||
aria-live="polite"
|
sx={{
|
||||||
aria-atomic="true"
|
bgcolor: 'var(--fa-color-surface-warm)',
|
||||||
sx={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden' }}
|
borderRadius: 2,
|
||||||
>
|
p: 2.5,
|
||||||
{step === 'preview' ? 'Viewing package preview' : 'Create your account'}
|
mb: 3,
|
||||||
</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'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 */}
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: { xs: 'column', sm: 'row' },
|
justifyContent: 'space-between',
|
||||||
gap: 2,
|
alignItems: 'baseline',
|
||||||
justifyContent: 'flex-end',
|
mb: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isPrePlanning && onExplore && (
|
<Typography variant="h6">{selectedPackage.name}</Typography>
|
||||||
<Button variant="outlined" color="secondary" size="large" onClick={onExplore}>
|
<Typography variant="h6" color="primary">
|
||||||
Explore other options
|
${(selectedPackage.total ?? selectedPackage.price).toLocaleString('en-AU')}
|
||||||
</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>
|
</Typography>
|
||||||
</Divider>
|
</Box>
|
||||||
|
|
||||||
{/* Email */}
|
{selectedPackage.sections.map((section) => (
|
||||||
<TextField
|
<Box key={section.heading} sx={{ mb: 1.5 }}>
|
||||||
label="Your email address"
|
<Typography variant="labelSm" color="text.secondary" sx={{ mb: 0.5 }}>
|
||||||
type="email"
|
{section.heading}
|
||||||
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>
|
</Typography>
|
||||||
|
{section.items.map((item) => (
|
||||||
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, gap: 2 }}>
|
<Typography key={item.name} variant="body2" sx={{ pl: 1 }}>
|
||||||
<TextField
|
{item.name}
|
||||||
label="First name"
|
</Typography>
|
||||||
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>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Collapse>
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Verification */}
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
<Collapse in={authValues.subStep === 'verify'}>
|
You'll be able to customise everything in the next steps.
|
||||||
<Box sx={{ mb: 3 }} role="group" aria-label="Email verification">
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
|
||||||
We've sent a 6-digit code to <strong>{authValues.email}</strong>. Please
|
|
||||||
enter it below.
|
|
||||||
</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
|
<TextField
|
||||||
label="Verification code"
|
label="First name"
|
||||||
value={authValues.verificationCode}
|
value={authValues.firstName}
|
||||||
onChange={(e) => handleAuthField('verificationCode', e.target.value)}
|
onChange={(e) => handleAuthField('firstName', e.target.value)}
|
||||||
error={!!authErrors?.verificationCode}
|
error={!!authErrors?.firstName}
|
||||||
helperText={
|
helperText={authErrors?.firstName}
|
||||||
authErrors?.verificationCode || 'Check your email for the 6-digit code'
|
autoComplete="given-name"
|
||||||
}
|
|
||||||
placeholder="Enter 6-digit code"
|
|
||||||
autoComplete="one-time-code"
|
|
||||||
inputMode="numeric"
|
|
||||||
fullWidth
|
fullWidth
|
||||||
required
|
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>
|
</Box>
|
||||||
</Collapse>
|
|
||||||
|
|
||||||
{/* Terms */}
|
<TextField
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 3 }}>
|
label={isEmailOnly ? 'Phone (optional)' : 'Best number to reach you'}
|
||||||
By continuing, you agree to the{' '}
|
type="tel"
|
||||||
<Link href="#" sx={{ fontSize: 'inherit' }}>
|
value={authValues.phone}
|
||||||
terms and conditions
|
onChange={(e) => handleAuthField('phone', e.target.value)}
|
||||||
</Link>
|
error={!!authErrors?.phone}
|
||||||
.
|
helperText={authErrors?.phone}
|
||||||
</Typography>
|
placeholder="e.g. 0412 345 678"
|
||||||
|
autoComplete="tel"
|
||||||
|
inputMode="tel"
|
||||||
|
fullWidth
|
||||||
|
required={!isEmailOnly}
|
||||||
|
disabled={authValues.subStep === 'verify'}
|
||||||
|
/>
|
||||||
|
|
||||||
<Divider sx={{ my: 3 }} />
|
<TextField
|
||||||
|
select
|
||||||
{/* CTA */}
|
label="Contact preference"
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
value={authValues.contactPreference}
|
||||||
<Button type="submit" variant="contained" size="large" loading={loading}>
|
onChange={(e) => handleAuthField('contactPreference', e.target.value)}
|
||||||
{getAuthCTALabel(authValues.subStep)}
|
fullWidth
|
||||||
</Button>
|
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>
|
||||||
</Box>
|
</Collapse>
|
||||||
)}
|
|
||||||
</DialogContent>
|
{/* Verification */}
|
||||||
</Dialog>
|
<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'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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
mb: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FilterPanel activeCount={activeFilterCount} onClear={handleFilterClear} minWidth={300}>
|
<FilterPanel activeCount={activeFilterCount} onClear={handleFilterClear}>
|
||||||
<TextField
|
<TextField
|
||||||
select
|
select
|
||||||
label="Category"
|
label="Category"
|
||||||
|
|||||||
@@ -46,17 +46,10 @@ export interface IntroStepProps {
|
|||||||
sx?: SxProps<Theme>;
|
sx?: SxProps<Theme>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Copy helpers ────────────────────────────────────────────────────────────
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function getSubheading(values: IntroStepValues): string {
|
const SUBHEADING =
|
||||||
if (values.forWhom === 'someone' && values.hasPassedAway === 'yes') {
|
"We'll guide you through arranging a funeral, step by step. You can save your progress and come back anytime.";
|
||||||
return "We'll guide you through each step. You can save your progress and come back anytime.";
|
|
||||||
}
|
|
||||||
if (values.forWhom === 'myself' || values.hasPassedAway === 'no') {
|
|
||||||
return "Explore your options and plan at your own pace. Nothing is locked in until you're ready.";
|
|
||||||
}
|
|
||||||
return "We'll guide you through arranging a funeral, step by step. You can save your progress and come back anytime.";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Component ───────────────────────────────────────────────────────────────
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -110,8 +103,8 @@ export const IntroStep: React.FC<IntroStepProps> = ({
|
|||||||
Let's get started
|
Let's get started
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 5 }} aria-live="polite">
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 5 }}>
|
||||||
{getSubheading(values)}
|
{SUBHEADING}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -43,14 +43,13 @@ const mockProvider: PackagesStepProvider = {
|
|||||||
reviewCount: 7,
|
reviewCount: 7,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockPackages: PackageData[] = [
|
const matchedPackages: PackageData[] = [
|
||||||
{
|
{
|
||||||
id: 'everyday',
|
id: 'everyday',
|
||||||
name: 'Everyday Funeral Package',
|
name: 'Everyday Funeral Package',
|
||||||
price: 2700,
|
price: 2700,
|
||||||
description:
|
description:
|
||||||
'This package includes a funeral service at a chapel or a church with a funeral procession. It includes many of the most commonly selected funeral options.',
|
'This package includes a funeral service at a chapel or a church with a funeral procession. It includes many of the most commonly selected funeral options.',
|
||||||
popular: true,
|
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
heading: 'Essentials',
|
heading: 'Essentials',
|
||||||
@@ -86,6 +85,27 @@ const mockPackages: PackageData[] = [
|
|||||||
terms:
|
terms:
|
||||||
'This package includes a funeral service at a chapel or a church with a funeral procession. Pricing may vary based on additional selections.',
|
'This package includes a funeral service at a chapel or a church with a funeral procession. Pricing may vary based on additional selections.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'essential',
|
||||||
|
name: 'Essential Funeral Package',
|
||||||
|
price: 1800,
|
||||||
|
description:
|
||||||
|
'A simple, dignified option covering the essential requirements for a cremation service.',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: 'Essentials',
|
||||||
|
items: [
|
||||||
|
{ name: 'Death registration certificate', price: 150 },
|
||||||
|
{ name: 'Professional Mortuary Care', price: 800 },
|
||||||
|
{ name: 'Professional Service Fee', price: 850 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1800,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const otherPackages: PackageData[] = [
|
||||||
{
|
{
|
||||||
id: 'deluxe',
|
id: 'deluxe',
|
||||||
name: 'Deluxe Funeral Package',
|
name: 'Deluxe Funeral Package',
|
||||||
@@ -106,24 +126,6 @@ const mockPackages: PackageData[] = [
|
|||||||
],
|
],
|
||||||
total: 4900,
|
total: 4900,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'essential',
|
|
||||||
name: 'Essential Funeral Package',
|
|
||||||
price: 1800,
|
|
||||||
description:
|
|
||||||
'A simple, dignified option covering the essential requirements for a cremation service.',
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
heading: 'Essentials',
|
|
||||||
items: [
|
|
||||||
{ name: 'Death registration certificate', price: 150 },
|
|
||||||
{ name: 'Professional Mortuary Care', price: 800 },
|
|
||||||
{ name: 'Professional Service Fee', price: 850 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
total: 1800,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'catholic',
|
id: 'catholic',
|
||||||
name: 'Catholic Service',
|
name: 'Catholic Service',
|
||||||
@@ -161,44 +163,21 @@ type Story = StoryObj<typeof PackagesStep>;
|
|||||||
|
|
||||||
// ─── Interactive (default) ──────────────────────────────────────────────────
|
// ─── Interactive (default) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Fully interactive — browse, filter, select a package, see detail */
|
/** Matched + other packages — select a package, see detail, click Make Arrangement */
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
render: () => {
|
render: () => {
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [budget, setBudget] = useState('all');
|
|
||||||
const [error, setError] = useState<string | undefined>();
|
|
||||||
|
|
||||||
const filtered =
|
|
||||||
budget === 'all'
|
|
||||||
? mockPackages
|
|
||||||
: mockPackages.filter((p) => {
|
|
||||||
const [min, max] = budget.split('-').map(Number);
|
|
||||||
return p.price >= min && p.price <= (max || Infinity);
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleContinue = () => {
|
|
||||||
if (!selectedId) {
|
|
||||||
setError('Please choose a package to continue.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError(undefined);
|
|
||||||
alert(`Continue with package: ${selectedId}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PackagesStep
|
<PackagesStep
|
||||||
provider={mockProvider}
|
provider={mockProvider}
|
||||||
packages={filtered}
|
packages={matchedPackages}
|
||||||
|
otherPackages={otherPackages}
|
||||||
selectedPackageId={selectedId}
|
selectedPackageId={selectedId}
|
||||||
onSelectPackage={(id) => {
|
onSelectPackage={setSelectedId}
|
||||||
setSelectedId(id);
|
onArrange={() => alert('Open ArrangementDialog')}
|
||||||
setError(undefined);
|
onProviderClick={() => alert('Open provider profile')}
|
||||||
}}
|
|
||||||
budgetFilter={budget}
|
|
||||||
onBudgetFilterChange={setBudget}
|
|
||||||
onContinue={handleContinue}
|
|
||||||
onBack={() => alert('Back')}
|
onBack={() => alert('Back')}
|
||||||
error={error}
|
|
||||||
navigation={nav}
|
navigation={nav}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -211,17 +190,38 @@ export const Default: Story = {
|
|||||||
export const WithSelection: Story = {
|
export const WithSelection: Story = {
|
||||||
render: () => {
|
render: () => {
|
||||||
const [selectedId, setSelectedId] = useState<string | null>('everyday');
|
const [selectedId, setSelectedId] = useState<string | null>('everyday');
|
||||||
const [budget, setBudget] = useState('all');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PackagesStep
|
<PackagesStep
|
||||||
provider={mockProvider}
|
provider={mockProvider}
|
||||||
packages={mockPackages}
|
packages={matchedPackages}
|
||||||
|
otherPackages={otherPackages}
|
||||||
selectedPackageId={selectedId}
|
selectedPackageId={selectedId}
|
||||||
onSelectPackage={setSelectedId}
|
onSelectPackage={setSelectedId}
|
||||||
budgetFilter={budget}
|
onArrange={() => alert('Open ArrangementDialog')}
|
||||||
onBudgetFilterChange={setBudget}
|
onProviderClick={() => alert('Open provider profile')}
|
||||||
onContinue={() => alert('Continue')}
|
onBack={() => alert('Back')}
|
||||||
|
navigation={nav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── No other packages (all match) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/** All packages match filters — no "Other packages" section */
|
||||||
|
export const AllMatching: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PackagesStep
|
||||||
|
provider={mockProvider}
|
||||||
|
packages={[...matchedPackages, ...otherPackages]}
|
||||||
|
selectedPackageId={selectedId}
|
||||||
|
onSelectPackage={setSelectedId}
|
||||||
|
onArrange={() => alert('Open ArrangementDialog')}
|
||||||
|
onProviderClick={() => alert('Open provider profile')}
|
||||||
onBack={() => alert('Back')}
|
onBack={() => alert('Back')}
|
||||||
navigation={nav}
|
navigation={nav}
|
||||||
/>
|
/>
|
||||||
@@ -231,21 +231,20 @@ export const WithSelection: Story = {
|
|||||||
|
|
||||||
// ─── Pre-planning ───────────────────────────────────────────────────────────
|
// ─── Pre-planning ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Pre-planning flow — softer helper text */
|
/** Pre-planning flow — softer copy */
|
||||||
export const PrePlanning: Story = {
|
export const PrePlanning: Story = {
|
||||||
render: () => {
|
render: () => {
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [budget, setBudget] = useState('all');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PackagesStep
|
<PackagesStep
|
||||||
provider={mockProvider}
|
provider={mockProvider}
|
||||||
packages={mockPackages}
|
packages={matchedPackages}
|
||||||
|
otherPackages={otherPackages}
|
||||||
selectedPackageId={selectedId}
|
selectedPackageId={selectedId}
|
||||||
onSelectPackage={setSelectedId}
|
onSelectPackage={setSelectedId}
|
||||||
budgetFilter={budget}
|
onArrange={() => alert('Open ArrangementDialog')}
|
||||||
onBudgetFilterChange={setBudget}
|
onProviderClick={() => alert('Open provider profile')}
|
||||||
onContinue={() => alert('Continue')}
|
|
||||||
onBack={() => alert('Back')}
|
onBack={() => alert('Back')}
|
||||||
navigation={nav}
|
navigation={nav}
|
||||||
isPrePlanning
|
isPrePlanning
|
||||||
@@ -254,46 +253,20 @@ export const PrePlanning: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Filtered empty ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Budget filter yielding no results */
|
|
||||||
export const FilteredEmpty: Story = {
|
|
||||||
render: () => {
|
|
||||||
const [budget, setBudget] = useState('7000-10000');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PackagesStep
|
|
||||||
provider={mockProvider}
|
|
||||||
packages={[]}
|
|
||||||
selectedPackageId={null}
|
|
||||||
onSelectPackage={() => {}}
|
|
||||||
budgetFilter={budget}
|
|
||||||
onBudgetFilterChange={setBudget}
|
|
||||||
onContinue={() => {}}
|
|
||||||
onBack={() => alert('Back')}
|
|
||||||
navigation={nav}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Validation error ───────────────────────────────────────────────────────
|
// ─── Validation error ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Error shown when no package selected */
|
/** Error shown when no package selected */
|
||||||
export const WithError: Story = {
|
export const WithError: Story = {
|
||||||
render: () => {
|
render: () => {
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [budget, setBudget] = useState('all');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PackagesStep
|
<PackagesStep
|
||||||
provider={mockProvider}
|
provider={mockProvider}
|
||||||
packages={mockPackages}
|
packages={matchedPackages}
|
||||||
selectedPackageId={selectedId}
|
selectedPackageId={selectedId}
|
||||||
onSelectPackage={setSelectedId}
|
onSelectPackage={setSelectedId}
|
||||||
budgetFilter={budget}
|
onArrange={() => {}}
|
||||||
onBudgetFilterChange={setBudget}
|
|
||||||
onContinue={() => {}}
|
|
||||||
onBack={() => alert('Back')}
|
onBack={() => alert('Back')}
|
||||||
error="Please choose a package to continue."
|
error="Please choose a package to continue."
|
||||||
navigation={nav}
|
navigation={nav}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import TextField from '@mui/material/TextField';
|
|
||||||
import MenuItem from '@mui/material/MenuItem';
|
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
import { WizardLayout } from '../../templates/WizardLayout';
|
import { WizardLayout } from '../../templates/WizardLayout';
|
||||||
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
|
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
|
||||||
@@ -9,8 +7,7 @@ import { ServiceOption } from '../../molecules/ServiceOption';
|
|||||||
import { PackageDetail } from '../../organisms/PackageDetail';
|
import { PackageDetail } from '../../organisms/PackageDetail';
|
||||||
import type { PackageSection } from '../../organisms/PackageDetail';
|
import type { PackageSection } from '../../organisms/PackageDetail';
|
||||||
import { Typography } from '../../atoms/Typography';
|
import { Typography } from '../../atoms/Typography';
|
||||||
import { Badge } from '../../atoms/Badge';
|
import { Divider } from '../../atoms/Divider';
|
||||||
import { Button } from '../../atoms/Button';
|
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -38,8 +35,6 @@ export interface PackageData {
|
|||||||
price: number;
|
price: number;
|
||||||
/** Short description */
|
/** Short description */
|
||||||
description?: string;
|
description?: string;
|
||||||
/** Whether this is a "Most Popular" package */
|
|
||||||
popular?: boolean;
|
|
||||||
/** Line item sections for the detail panel */
|
/** Line item sections for the detail panel */
|
||||||
sections: PackageSection[];
|
sections: PackageSection[];
|
||||||
/** Total price (may differ from base price with extras) */
|
/** Total price (may differ from base price with extras) */
|
||||||
@@ -50,37 +45,27 @@ export interface PackageData {
|
|||||||
terms?: string;
|
terms?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Budget filter option */
|
|
||||||
export interface BudgetOption {
|
|
||||||
/** Option value */
|
|
||||||
value: string;
|
|
||||||
/** Display label */
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Props for the PackagesStep page component */
|
/** Props for the PackagesStep page component */
|
||||||
export interface PackagesStepProps {
|
export interface PackagesStepProps {
|
||||||
/** Provider summary shown at top of the list panel */
|
/** Provider summary shown at top of the list panel */
|
||||||
provider: PackagesStepProvider;
|
provider: PackagesStepProvider;
|
||||||
/** Available packages */
|
/** Packages matching the user's filters from the previous step */
|
||||||
packages: PackageData[];
|
packages: PackageData[];
|
||||||
|
/** Other packages from this provider that didn't match filters (shown in secondary group) */
|
||||||
|
otherPackages?: PackageData[];
|
||||||
/** Currently selected package ID */
|
/** Currently selected package ID */
|
||||||
selectedPackageId: string | null;
|
selectedPackageId: string | null;
|
||||||
/** Callback when a package is selected */
|
/** Callback when a package is selected */
|
||||||
onSelectPackage: (id: string) => void;
|
onSelectPackage: (id: string) => void;
|
||||||
/** Current budget filter value */
|
/** Callback when "Make Arrangement" is clicked (opens ArrangementDialog) */
|
||||||
budgetFilter: string;
|
onArrange: () => void;
|
||||||
/** Callback when budget filter changes */
|
/** Callback when the provider card is clicked (opens provider profile popup) */
|
||||||
onBudgetFilterChange: (value: string) => void;
|
onProviderClick?: () => void;
|
||||||
/** Budget filter options */
|
|
||||||
budgetOptions?: BudgetOption[];
|
|
||||||
/** Callback for the Continue button */
|
|
||||||
onContinue: () => void;
|
|
||||||
/** Callback for the Back button */
|
/** Callback for the Back button */
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
/** Validation error */
|
/** Validation error */
|
||||||
error?: string;
|
error?: string;
|
||||||
/** Whether Continue is loading */
|
/** Whether the arrange action is loading */
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
/** Navigation bar */
|
/** Navigation bar */
|
||||||
navigation?: React.ReactNode;
|
navigation?: React.ReactNode;
|
||||||
@@ -90,26 +75,23 @@ export interface PackagesStepProps {
|
|||||||
sx?: SxProps<Theme>;
|
sx?: SxProps<Theme>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const DEFAULT_BUDGET_OPTIONS: BudgetOption[] = [
|
|
||||||
{ value: 'all', label: 'All packages' },
|
|
||||||
{ value: '2000-4000', label: '$2,000 \u2013 $4,000' },
|
|
||||||
{ value: '4000-7000', label: '$4,000 \u2013 $7,000' },
|
|
||||||
{ value: '7000-10000', label: '$7,000 \u2013 $10,000+' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ─── Component ───────────────────────────────────────────────────────────────
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Step 3 — Package selection page for the FA arrangement wizard.
|
* Step 3 — Package selection page for the FA arrangement wizard.
|
||||||
*
|
*
|
||||||
* List + Detail split layout. Left panel shows the selected provider
|
* List + Detail split layout. Left panel shows the selected provider
|
||||||
* (compact), a budget filter, and selectable package cards. Right panel
|
* (compact) and selectable package cards. Right panel shows the full
|
||||||
* shows the full detail breakdown of the selected package.
|
* detail breakdown of the selected package with "Make Arrangement" CTA.
|
||||||
*
|
*
|
||||||
* Packages are displayed as ServiceOption cards in a radiogroup pattern.
|
* Packages are split into two groups:
|
||||||
* "Most Popular" badge on qualifying packages reduces decision paralysis.
|
* - **Matching your preferences**: packages that matched the user's filters
|
||||||
|
* from the providers step
|
||||||
|
* - **Other packages from [Provider]**: remaining packages outside those
|
||||||
|
* filters, shown below a divider for passive discovery
|
||||||
|
*
|
||||||
|
* Selecting a package reveals its detail. Clicking "Make Arrangement"
|
||||||
|
* on the detail panel triggers the ArrangementDialog (D-E).
|
||||||
*
|
*
|
||||||
* Pure presentation component — props in, callbacks out.
|
* Pure presentation component — props in, callbacks out.
|
||||||
*
|
*
|
||||||
@@ -118,12 +100,11 @@ const DEFAULT_BUDGET_OPTIONS: BudgetOption[] = [
|
|||||||
export const PackagesStep: React.FC<PackagesStepProps> = ({
|
export const PackagesStep: React.FC<PackagesStepProps> = ({
|
||||||
provider,
|
provider,
|
||||||
packages,
|
packages,
|
||||||
|
otherPackages = [],
|
||||||
selectedPackageId,
|
selectedPackageId,
|
||||||
onSelectPackage,
|
onSelectPackage,
|
||||||
budgetFilter,
|
onArrange,
|
||||||
onBudgetFilterChange,
|
onProviderClick,
|
||||||
budgetOptions = DEFAULT_BUDGET_OPTIONS,
|
|
||||||
onContinue,
|
|
||||||
onBack,
|
onBack,
|
||||||
error,
|
error,
|
||||||
loading = false,
|
loading = false,
|
||||||
@@ -131,13 +112,13 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
|
|||||||
isPrePlanning = false,
|
isPrePlanning = false,
|
||||||
sx,
|
sx,
|
||||||
}) => {
|
}) => {
|
||||||
const selectedPackage = packages.find((p) => p.id === selectedPackageId);
|
const allPackages = [...packages, ...otherPackages];
|
||||||
|
const selectedPackage = allPackages.find((p) => p.id === selectedPackageId);
|
||||||
|
const hasOtherPackages = otherPackages.length > 0;
|
||||||
|
|
||||||
const subheading =
|
const subheading = isPrePlanning
|
||||||
'Each package includes a set of services. You can customise your selections in the next steps.';
|
|
||||||
const helperText = isPrePlanning
|
|
||||||
? 'Compare packages to find what suits your wishes. Nothing is committed until you confirm.'
|
? 'Compare packages to find what suits your wishes. Nothing is committed until you confirm.'
|
||||||
: 'Prices shown include the base services listed. Additional options may change the total.';
|
: 'Each package includes a set of services. You can customise your selections in the next steps.';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WizardLayout
|
<WizardLayout
|
||||||
@@ -156,7 +137,7 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
|
|||||||
total={selectedPackage.total}
|
total={selectedPackage.total}
|
||||||
extras={selectedPackage.extras}
|
extras={selectedPackage.extras}
|
||||||
terms={selectedPackage.terms}
|
terms={selectedPackage.terms}
|
||||||
onArrange={onContinue}
|
onArrange={onArrange}
|
||||||
arrangeDisabled={loading}
|
arrangeDisabled={loading}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -179,7 +160,7 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Provider compact card */}
|
{/* Provider compact card — clickable to open provider profile */}
|
||||||
<Box sx={{ mb: 3 }}>
|
<Box sx={{ mb: 3 }}>
|
||||||
<ProviderCardCompact
|
<ProviderCardCompact
|
||||||
name={provider.name}
|
name={provider.name}
|
||||||
@@ -187,37 +168,17 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
|
|||||||
imageUrl={provider.imageUrl}
|
imageUrl={provider.imageUrl}
|
||||||
rating={provider.rating}
|
rating={provider.rating}
|
||||||
reviewCount={provider.reviewCount}
|
reviewCount={provider.reviewCount}
|
||||||
|
onClick={onProviderClick}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Heading */}
|
{/* Heading */}
|
||||||
<Typography variant="display3" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
|
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
|
||||||
Choose a funeral package
|
Choose a funeral package
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
{subheading}
|
{subheading}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 3, display: 'block' }}>
|
|
||||||
{helperText}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{/* Budget filter */}
|
|
||||||
<Box sx={{ mb: 3, pt: 0.5 }}>
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
size="small"
|
|
||||||
value={budgetFilter}
|
|
||||||
onChange={(e) => onBudgetFilterChange(e.target.value)}
|
|
||||||
label="Budget range"
|
|
||||||
sx={{ width: { xs: '100%', sm: 240 } }}
|
|
||||||
>
|
|
||||||
{budgetOptions.map((opt) => (
|
|
||||||
<MenuItem key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Error message */}
|
{/* Error message */}
|
||||||
{error && (
|
{error && (
|
||||||
@@ -230,64 +191,99 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Package list — radiogroup pattern */}
|
{/* ─── Matching packages ─── */}
|
||||||
|
{hasOtherPackages && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1.5,
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 3,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: 'primary.main',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
|
||||||
|
Matching your preferences
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
aria-label="Funeral packages"
|
aria-label="Funeral packages"
|
||||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
|
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
|
||||||
>
|
>
|
||||||
{packages.map((pkg) => (
|
{packages.map((pkg) => (
|
||||||
<Box key={pkg.id} sx={{ position: 'relative' }}>
|
<ServiceOption
|
||||||
{pkg.popular && (
|
key={pkg.id}
|
||||||
<Badge
|
name={pkg.name}
|
||||||
variant="filled"
|
description={pkg.description}
|
||||||
color="brand"
|
price={pkg.price}
|
||||||
size="small"
|
selected={selectedPackageId === pkg.id}
|
||||||
aria-label="Most popular choice"
|
onClick={() => onSelectPackage(pkg.id)}
|
||||||
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>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{packages.length === 0 && (
|
{packages.length === 0 && (
|
||||||
<Box sx={{ py: 6, textAlign: 'center' }}>
|
<Box sx={{ py: 4, textAlign: 'center' }}>
|
||||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 1 }}>
|
|
||||||
No packages match the selected budget range.
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
Try selecting "All packages" to see the full range.
|
No packages match your current preferences.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Mobile: Continue button (desktop uses PackageDetail's CTA) */}
|
{/* ─── Other packages (passive discovery) ─── */}
|
||||||
<Box sx={{ display: { xs: 'flex', md: 'none' }, justifyContent: 'flex-end', pb: 2 }}>
|
{hasOtherPackages && (
|
||||||
<Button
|
<>
|
||||||
variant="contained"
|
<Divider sx={{ mb: 2 }} />
|
||||||
size="large"
|
<Box
|
||||||
onClick={onContinue}
|
sx={{
|
||||||
disabled={!selectedPackageId}
|
display: 'flex',
|
||||||
loading={loading}
|
alignItems: 'center',
|
||||||
>
|
gap: 1.5,
|
||||||
Continue
|
mb: 2,
|
||||||
</Button>
|
}}
|
||||||
</Box>
|
>
|
||||||
|
<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>
|
</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 [filters, setFilters] = useState(defaultFilters);
|
||||||
|
|
||||||
const filtered = mockProviders.filter((p) =>
|
const filtered = mockProviders.filter((p) =>
|
||||||
p.name.toLowerCase().includes(query.toLowerCase()),
|
p.location.toLowerCase().includes(query.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
import { WizardLayout } from '../../templates/WizardLayout';
|
import { WizardLayout } from '../../templates/WizardLayout';
|
||||||
import { ProviderCard } from '../../molecules/ProviderCard';
|
import { ProviderCard } from '../../molecules/ProviderCard';
|
||||||
import { SearchBar } from '../../molecules/SearchBar';
|
|
||||||
import { FilterPanel } from '../../molecules/FilterPanel';
|
import { FilterPanel } from '../../molecules/FilterPanel';
|
||||||
import { Chip } from '../../atoms/Chip';
|
import { Chip } from '../../atoms/Chip';
|
||||||
import { Typography } from '../../atoms/Typography';
|
import { Typography } from '../../atoms/Typography';
|
||||||
@@ -122,7 +124,7 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: 'var(--fa-color-surface-cool)',
|
bgcolor: 'var(--fa-color-surface-cool)',
|
||||||
height: '100%',
|
flex: 1,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@@ -150,25 +152,40 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
|||||||
px: { xs: 2, md: 3 },
|
px: { xs: 2, md: 3 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="display3" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
|
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
|
||||||
Choose a funeral provider
|
Find a funeral director
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
{subheading}
|
{subheading}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* Search bar + filter button */}
|
{/* Location search */}
|
||||||
<Box sx={{ display: 'flex', gap: 1, mb: 2, alignItems: 'flex-start' }}>
|
<TextField
|
||||||
<Box sx={{ flex: 1 }}>
|
placeholder="Search a town or suburb..."
|
||||||
<SearchBar
|
aria-label="Search providers by town or suburb"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={onSearchChange}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
onSearch={onSearch}
|
onKeyDown={(e) => {
|
||||||
placeholder="Search providers..."
|
if (e.key === 'Enter' && onSearch) {
|
||||||
size="small"
|
e.preventDefault();
|
||||||
/>
|
onSearch(searchQuery);
|
||||||
</Box>
|
}
|
||||||
{filters && filters.length > 0 && (
|
}}
|
||||||
|
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
|
<FilterPanel
|
||||||
activeCount={filters.filter((f) => f.active).length}
|
activeCount={filters.filter((f) => f.active).length}
|
||||||
onClear={onFilterClear}
|
onClear={onFilterClear}
|
||||||
@@ -186,8 +203,8 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
|||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</FilterPanel>
|
</FilterPanel>
|
||||||
)}
|
</Box>
|
||||||
</Box>
|
)}
|
||||||
|
|
||||||
{/* Results count */}
|
{/* Results count */}
|
||||||
<Typography
|
<Typography
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import React from 'react';
|
|||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
import InputAdornment from '@mui/material/InputAdornment';
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
|
||||||
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
|
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
|
||||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
@@ -182,7 +181,7 @@ export const VenueStep: React.FC<VenueStepProps> = ({
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: 'var(--fa-color-surface-cool)',
|
bgcolor: 'var(--fa-color-surface-cool)',
|
||||||
height: '100%',
|
flex: 1,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@@ -219,7 +218,7 @@ export const VenueStep: React.FC<VenueStepProps> = ({
|
|||||||
px: { xs: 2, md: 3 },
|
px: { xs: 2, md: 3 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="display3" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
|
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
|
||||||
Where would you like the service?
|
Where would you like the service?
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
@@ -229,22 +228,26 @@ export const VenueStep: React.FC<VenueStepProps> = ({
|
|||||||
: 'Choose a venue for the funeral service. You can filter by location, features, and religion.'}
|
: 'Choose a venue for the funeral service. You can filter by location, features, and religion.'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* ─── Search + Filter button ─── */}
|
{/* ─── Location search ─── */}
|
||||||
<Box sx={{ display: 'flex', gap: 1, mb: 2, alignItems: 'flex-start' }}>
|
<TextField
|
||||||
<TextField
|
placeholder="Search a town or suburb..."
|
||||||
placeholder="Search a town or suburb..."
|
aria-label="Search venues by town or suburb"
|
||||||
aria-label="Search venues by town or suburb"
|
value={values.search}
|
||||||
value={values.search}
|
onChange={(e) => onChange({ ...values, search: e.target.value })}
|
||||||
onChange={(e) => onChange({ ...values, search: e.target.value })}
|
fullWidth
|
||||||
fullWidth
|
size="small"
|
||||||
InputProps={{
|
InputProps={{
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
<InputAdornment position="start">
|
<InputAdornment position="start">
|
||||||
<SearchIcon sx={{ color: 'text.secondary' }} />
|
<LocationOnOutlinedIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
sx={{ mb: 1.5 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ─── Filters — right-aligned below search ─── */}
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
|
||||||
<FilterPanel activeCount={values.activeFilters.length} onClear={onFilterClear}>
|
<FilterPanel activeCount={values.activeFilters.length} onClear={onFilterClear}>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
{filterOptions.map((filter) => (
|
{filterOptions.map((filter) => (
|
||||||
@@ -260,9 +263,14 @@ export const VenueStep: React.FC<VenueStepProps> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* ─── Results count ─── */}
|
{/* ─── Results count ─── */}
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 0 }} aria-live="polite">
|
<Typography
|
||||||
Found {venues.length} venue{venues.length !== 1 ? 's' : ''}
|
variant="caption"
|
||||||
{locationName ? ` near ${locationName}` : ''}
|
color="text.secondary"
|
||||||
|
sx={{ mb: 0, display: 'block' }}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{venues.length} venue{venues.length !== 1 ? 's' : ''}
|
||||||
|
{locationName ? ` near ${locationName}` : ''} found
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ const ListMapLayout: React.FC<{
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Left panel — scrollable list with scrollbar visible on hover */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: { xs: '100%', md: 420 },
|
width: { xs: '100%', md: 420 },
|
||||||
@@ -177,16 +178,31 @@ const ListMapLayout: React.FC<{
|
|||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
px: { xs: 2, md: 3 },
|
px: { xs: 2, md: 3 },
|
||||||
py: 3,
|
py: 3,
|
||||||
|
// Thin scrollbar, hidden until hover
|
||||||
|
scrollbarWidth: 'thin',
|
||||||
|
scrollbarColor: 'transparent transparent',
|
||||||
|
'&:hover': {
|
||||||
|
scrollbarColor: 'rgba(0,0,0,0.25) transparent',
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar': { width: 6 },
|
||||||
|
'&::-webkit-scrollbar-thumb': {
|
||||||
|
background: 'transparent',
|
||||||
|
borderRadius: 3,
|
||||||
|
},
|
||||||
|
'&:hover::-webkit-scrollbar-thumb': {
|
||||||
|
background: 'rgba(0,0,0,0.25)',
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{backLink}
|
{backLink}
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
|
{/* Right panel — map or placeholder, fills available space */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: { xs: 'none', md: 'flex' },
|
display: { xs: 'none', md: 'flex' },
|
||||||
flex: 1,
|
flex: 1,
|
||||||
position: 'relative',
|
minHeight: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{secondaryPanel}
|
{secondaryPanel}
|
||||||
|
|||||||
Reference in New Issue
Block a user