From 1faa320f4bfbeb8c66d5faf60096f67d7667546d Mon Sep 17 00:00:00 2001 From: Richie Date: Mon, 30 Mar 2026 12:20:26 +1100 Subject: [PATCH] Feedback iteration: DialogShell, page consistency, popup standardisation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- docs/memory/component-registry.md | 5 +- .../atoms/DialogShell/DialogShell.stories.tsx | 137 ++++ .../atoms/DialogShell/DialogShell.tsx | 174 +++++ src/components/atoms/DialogShell/index.ts | 2 + .../ToggleButtonGroup/ToggleButtonGroup.tsx | 1 + .../FilterPanel/FilterPanel.stories.tsx | 2 - .../molecules/FilterPanel/FilterPanel.tsx | 141 +--- .../ArrangementDialog/ArrangementDialog.tsx | 639 ++++++++---------- .../AuthGateStep/AuthGateStep.stories.tsx | 269 -------- .../pages/AuthGateStep/AuthGateStep.tsx | 337 --------- src/components/pages/AuthGateStep/index.ts | 8 - .../pages/CoffinsStep/CoffinsStep.tsx | 2 +- src/components/pages/IntroStep/IntroStep.tsx | 17 +- .../PackagesStep/PackagesStep.stories.tsx | 149 ++-- .../pages/PackagesStep/PackagesStep.tsx | 224 +++--- .../pages/PreviewStep/PreviewStep.stories.tsx | 163 ----- .../pages/PreviewStep/PreviewStep.tsx | 228 ------- src/components/pages/PreviewStep/index.ts | 2 - .../ProvidersStep/ProvidersStep.stories.tsx | 2 +- .../pages/ProvidersStep/ProvidersStep.tsx | 53 +- src/components/pages/VenueStep/VenueStep.tsx | 52 +- .../templates/WizardLayout/WizardLayout.tsx | 18 +- 22 files changed, 904 insertions(+), 1721 deletions(-) create mode 100644 src/components/atoms/DialogShell/DialogShell.stories.tsx create mode 100644 src/components/atoms/DialogShell/DialogShell.tsx create mode 100644 src/components/atoms/DialogShell/index.ts delete mode 100644 src/components/pages/AuthGateStep/AuthGateStep.stories.tsx delete mode 100644 src/components/pages/AuthGateStep/AuthGateStep.tsx delete mode 100644 src/components/pages/AuthGateStep/index.ts delete mode 100644 src/components/pages/PreviewStep/PreviewStep.stories.tsx delete mode 100644 src/components/pages/PreviewStep/PreviewStep.tsx delete mode 100644 src/components/pages/PreviewStep/index.ts diff --git a/docs/memory/component-registry.md b/docs/memory/component-registry.md index 4de8cb9..506b87b 100644 --- a/docs/memory/component-registry.md +++ b/docs/memory/component-registry.md @@ -32,6 +32,7 @@ duplicates) and MUST update it after completing one. | Slider | planned | single, range × desktop, mobile | | Price range filter. Wraps MUI Slider. Deferred until search/filtering molecules. | | Link | done | underline: hover/always/none × any MUI colour | color.text.brand (copper brand.600, 4.8:1), color.interactive.active | Navigation text link. Wraps MUI Link. Copper default, underline on hover, focus ring. | | Collapse | done | in/out × unmountOnExit | (none — uses MUI defaults) | Progressive disclosure wrapper. Thin MUI Collapse wrapper with unmountOnExit default. Slide-down animation for wizard field reveal. | +| DialogShell | done | open/closed × with/without back button × with/without footer | (theme defaults — borderRadius, palette) | Standard dialog container. Header (title + optional back + close), divider, scrollable body, optional footer. Used by FilterPanel and ArrangementDialog. | | ToggleButtonGroup | done | exclusive single-select × small, medium, large × error × fullWidth + descriptions | color.neutral.100-200, color.brand.50/100, color.interactive.focus, color.feedback.error | Button-select for binary/small-set choices. Fieldset/legend a11y, external label, helper/error text. Brand styling on selected. | ## Molecules @@ -76,8 +77,8 @@ duplicates) and MUST update it after completing one. | IntroStep | done | WizardLayout (centered-form) + ToggleButtonGroup × 2 + Collapse + Typography + Button + Divider | Wizard step 1 — entry point. forWhom (Myself/Someone else) + hasPassedAway (Yes/No) with progressive disclosure. Auto-sets hasPassedAway="no" for "Myself". `
` wrapper, aria-live subheading, grief-sensitive copy. Pure presentation. Audit: 18/20 → 20/20 after fixes. | | ProvidersStep | done | WizardLayout (list-map) + ProviderCard + SearchBar + Chip + Typography + Button | Wizard step 2 — provider selection. List-map split: provider cards w/ radiogroup + search + filter chips (left), map slot (right). aria-live results count, back link. ProviderCard extended with HTML/ARIA passthrough. Audit: 18/20. | | PackagesStep | done | WizardLayout (list-detail) + ProviderCardCompact + ServiceOption + PackageDetail + Badge + TextField + Typography + Button | Wizard step 3 — package selection. List-detail split: compact provider + budget filter + package list w/ radiogroup (left), PackageDetail breakdown (right). "Most Popular" badge. Mobile Continue button. | -| PreviewStep | done | WizardLayout (list-detail) + ProviderCardCompact + PackageDetail + Typography + Button + List + Divider | Wizard step 4 — package review. Informational (no form fields). "What happens next" numbered checklist. Pre-planning shows "Explore other options" tertiary CTA. | -| AuthGateStep | done | WizardLayout (centered-form) + TextField + MenuItem + Collapse + Button + Divider | Wizard step 5 — auth gate. 3 progressive sub-steps: SSO/email → details → verify. Phone optional when email-only. Benefit framing. Audit: 18/20. | +| ~~PreviewStep~~ | removed | — | Replaced by ArrangementDialog organism (D-E). Package preview + "what's next" checklist now in the dialog's preview step. | +| ~~AuthGateStep~~ | removed | — | Replaced by ArrangementDialog organism (D-E). SSO/email auth flow now in the dialog's auth step. | | DateTimeStep | done | WizardLayout (centered-form) + TextField + RadioGroup + Autocomplete + Collapse + Divider + Button | Wizard step 6 — details & scheduling. Deceased name + date/time radios + religion Autocomplete. Two fieldset sections. Grief-sensitive labels. Save-and-exit CTA. | | VenueStep | done | WizardLayout (centered-form) + VenueCard + AddOnOption + Collapse + Chip + TextField + Divider + Button | Wizard step 7 — venue selection. Consolidated from 3 baseline steps. Card grid with search/filters, inline detail, service toggles (photo, streaming, recording). | | CrematoriumStep | done | WizardLayout (centered-form) + Card + RadioGroup + Collapse + TextField + Divider + Button | Wizard step 8 — crematorium. Single confirmation card or multi-card grid. Witness question personalised with deceased name. Special instructions textarea. | diff --git a/src/components/atoms/DialogShell/DialogShell.stories.tsx b/src/components/atoms/DialogShell/DialogShell.stories.tsx new file mode 100644 index 0000000..104ac75 --- /dev/null +++ b/src/components/atoms/DialogShell/DialogShell.stories.tsx @@ -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 = { + title: 'Atoms/DialogShell', + component: DialogShell, + tags: ['autodocs'], + parameters: { layout: 'centered' }, +}; + +export default meta; +type Story = StoryObj; + +/** Default dialog with title, body, and footer */ +export const Default: Story = { + render: () => { + const [open, setOpen] = useState(false); + return ( + <> + + setOpen(false)} + title="Dialog title" + footer={ + + + + } + > + + This is the dialog body content. It scrolls when the content exceeds the max height. + + + + ); + }, +}; + +/** Dialog with a back button */ +export const WithBackButton: Story = { + render: () => { + const [open, setOpen] = useState(false); + return ( + <> + + setOpen(false)} + title="Step 2 of 3" + onBack={() => alert('Back')} + backLabel="Back to step 1" + footer={ + + + + + } + > + + Content for the second step of a multi-step dialog. + + + + ); + }, +}; + +/** Long content that triggers scrollable body */ +export const LongContent: Story = { + render: () => { + const [open, setOpen] = useState(false); + return ( + <> + + setOpen(false)} + title="Scrollable content" + footer={ + + + + } + > + {Array.from({ length: 12 }, (_, i) => ( + + 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. + + ))} + + + ); + }, +}; + +/** Dialog without a footer */ +export const NoFooter: Story = { + render: () => { + const [open, setOpen] = useState(false); + return ( + <> + + setOpen(false)} title="Information"> + + This dialog has no footer — just a close button in the header. + + + Useful for informational popups or content that doesn't need actions. + + + + ); + }, +}; diff --git a/src/components/atoms/DialogShell/DialogShell.tsx b/src/components/atoms/DialogShell/DialogShell.tsx new file mode 100644 index 0000000..02ff898 --- /dev/null +++ b/src/components/atoms/DialogShell/DialogShell.tsx @@ -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; + /** MUI sx prop for the root Dialog */ + sx?: SxProps; +} + +// ─── 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 + * Done}> + * {filterControls} + * + * ``` + */ +export const DialogShell = React.forwardRef( + ( + { + open, + onClose, + title, + onBack, + backLabel = 'Back', + children, + footer, + maxWidth = 'sm', + fullWidth = true, + paperSx, + sx, + }, + ref, + ) => { + const titleId = React.useId(); + const titleRef = React.useRef(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 ( + + {/* Header */} + + + {onBack && ( + + + + )} + + {title} + + + + + + + + + + {/* Scrollable body */} + + {children} + + + {/* Footer (optional) */} + {footer && ( + <> + + {footer} + + )} + + ); + }, +); + +DialogShell.displayName = 'DialogShell'; +export default DialogShell; diff --git a/src/components/atoms/DialogShell/index.ts b/src/components/atoms/DialogShell/index.ts new file mode 100644 index 0000000..ec661b8 --- /dev/null +++ b/src/components/atoms/DialogShell/index.ts @@ -0,0 +1,2 @@ +export { DialogShell } from './DialogShell'; +export type { DialogShellProps } from './DialogShell'; diff --git a/src/components/atoms/ToggleButtonGroup/ToggleButtonGroup.tsx b/src/components/atoms/ToggleButtonGroup/ToggleButtonGroup.tsx index 88687f8..d0994d6 100644 --- a/src/components/atoms/ToggleButtonGroup/ToggleButtonGroup.tsx +++ b/src/components/atoms/ToggleButtonGroup/ToggleButtonGroup.tsx @@ -159,6 +159,7 @@ export const ToggleButtonGroup = React.forwardRef = { argTypes: { label: { control: 'text' }, activeCount: { control: 'number' }, - minWidth: { control: 'number' }, }, decorators: [ (Story) => ( @@ -64,7 +63,6 @@ export const SelectFilters: Story = { args: { activeCount: 1, onClear: () => {}, - minWidth: 300, children: ( <> diff --git a/src/components/molecules/FilterPanel/FilterPanel.tsx b/src/components/molecules/FilterPanel/FilterPanel.tsx index 704cf2a..b4d3493 100644 --- a/src/components/molecules/FilterPanel/FilterPanel.tsx +++ b/src/components/molecules/FilterPanel/FilterPanel.tsx @@ -1,13 +1,11 @@ import React from 'react'; import Box from '@mui/material/Box'; -import Popover from '@mui/material/Popover'; import TuneIcon from '@mui/icons-material/Tune'; import type { SxProps, Theme } from '@mui/material/styles'; +import { DialogShell } from '../../atoms/DialogShell'; import { Button } from '../../atoms/Button'; import { Badge } from '../../atoms/Badge'; -import { Typography } from '../../atoms/Typography'; import { Link } from '../../atoms/Link'; -import { Divider } from '../../atoms/Divider'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -17,12 +15,10 @@ export interface FilterPanelProps { label?: string; /** Number of active filters (shown as count on the trigger) */ activeCount?: number; - /** Filter controls — rendered inside the Popover body */ + /** Filter controls — rendered inside the dialog body */ children: React.ReactNode; /** Callback when "Clear all" is clicked */ onClear?: () => void; - /** Popover min-width */ - minWidth?: number; /** MUI sx prop for the trigger button */ sx?: SxProps; } @@ -32,29 +28,18 @@ export interface FilterPanelProps { /** * Reusable filter panel for the FA arrangement wizard. * - * Renders a trigger button ("Filters") that opens a Popover containing + * Renders a trigger button ("Filters") that opens a DialogShell containing * arbitrary filter controls (chips, selects, sliders, etc.) passed as * children. Active filter count shown as a badge on the trigger. * - * D-C: Popover for desktop MVP. Mobile Drawer variant planned for later. - * * Used in ProvidersStep, VenueStep, and CoffinsStep. */ export const FilterPanel = React.forwardRef( - ({ label = 'Filters', activeCount = 0, children, onClear, minWidth = 280, sx }, ref) => { - const [anchorEl, setAnchorEl] = React.useState(null); - const open = Boolean(anchorEl); - const uniqueId = React.useId(); - const popoverId = `filter-panel-${uniqueId}`; - const headingId = `filter-panel-heading-${uniqueId}`; + ({ label = 'Filters', activeCount = 0, children, onClear, sx }, ref) => { + const [open, setOpen] = React.useState(false); - const handleOpen = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; - - const handleClose = () => { - setAnchorEl(null); - }; + const handleOpen = () => setOpen(true); + const handleClose = () => setOpen(false); return ( <> @@ -66,7 +51,6 @@ export const FilterPanel = React.forwardRef( size="small" startIcon={} onClick={handleOpen} - aria-controls={open ? popoverId : undefined} aria-expanded={open} aria-haspopup="dialog" > @@ -87,94 +71,35 @@ export const FilterPanel = React.forwardRef( - {/* Popover panel */} - - {/* Header */} - - + title={ + {label} - - {onClear && activeCount > 0 && ( - { - onClear(); - }} - underline="hover" - sx={{ fontSize: (theme: Theme) => theme.typography.caption.fontSize }} - > - Clear all - - )} - - - - - {/* Filter controls */} - - {children} - - - - - {/* Footer — done button */} - - - - + {onClear && activeCount > 0 && ( + onClear()} + underline="hover" + sx={{ fontSize: (theme: Theme) => theme.typography.caption.fontSize }} + > + Clear all + + )} + + } + footer={ + + + + } + > + {children} + ); }, diff --git a/src/components/organisms/ArrangementDialog/ArrangementDialog.tsx b/src/components/organisms/ArrangementDialog/ArrangementDialog.tsx index f031c16..7ec74ee 100644 --- a/src/components/organisms/ArrangementDialog/ArrangementDialog.tsx +++ b/src/components/organisms/ArrangementDialog/ArrangementDialog.tsx @@ -1,20 +1,16 @@ import React from 'react'; import Box from '@mui/material/Box'; -import Dialog from '@mui/material/Dialog'; -import DialogContent from '@mui/material/DialogContent'; -import IconButton from '@mui/material/IconButton'; import TextField from '@mui/material/TextField'; import MenuItem from '@mui/material/MenuItem'; import List from '@mui/material/List'; import ListItem from '@mui/material/ListItem'; import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemText from '@mui/material/ListItemText'; -import CloseIcon from '@mui/icons-material/Close'; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import GoogleIcon from '@mui/icons-material/Google'; import MicrosoftIcon from '@mui/icons-material/Window'; import type { SxProps, Theme } from '@mui/material/styles'; import type { PackageSection } from '../PackageDetail'; +import { DialogShell } from '../../atoms/DialogShell'; import { ProviderCardCompact } from '../../molecules/ProviderCardCompact'; import { Collapse } from '../../atoms/Collapse'; import { Typography } from '../../atoms/Typography'; @@ -153,6 +149,8 @@ function getAuthCTALabel(subStep: AuthSubStep): string { * - **Step 1 (preview):** Package summary, provider info, "What's next" checklist * - **Step 2 (auth):** SSO buttons, email entry, details, verification * + * Uses DialogShell for consistent dialog chrome across the site. + * * The dialog is opened after a user selects a package (from PackagesStep). * The parent controls which step is shown and manages auth form state. * @@ -182,377 +180,328 @@ export const ArrangementDialog = React.forwardRef { const isEmailOnly = authValues.contactPreference === 'email_only'; - const titleRef = React.useRef(null); - - // Focus the dialog title when step changes - React.useEffect(() => { - if (open && titleRef.current) { - titleRef.current.focus(); - } - }, [step, open]); const handleAuthField = (field: keyof AuthValues, value: string) => { onAuthChange({ ...authValues, [field]: value }); }; + // ─── Footer CTAs per step ───────────────────────────────────────── + + const previewFooter = ( + + {isPrePlanning && onExplore && ( + + )} + + + ); + + const authFooter = ( + + + + ); + return ( - onStepChange('preview') : undefined} + backLabel="Back to preview" + footer={step === 'preview' ? previewFooter : authFooter} sx={sx} - PaperProps={{ - sx: { borderRadius: 2 }, - }} > - {/* ─── Header ─── */} - - - {step === 'auth' && ( - onStepChange('preview')} - aria-label="Back to preview" - > - - - )} - - {step === 'preview' ? 'Your selected package' : 'Save your plan'} - - - - - - + {/* ═══════════ Step 1: Preview ═══════════ */} + {step === 'preview' && ( + + {/* Provider */} + + + - {/* Screen reader step announcement */} - - {step === 'preview' ? 'Viewing package preview' : 'Create your account'} - - - - {/* ═══════════ Step 1: Preview ═══════════ */} - {step === 'preview' && ( - - {/* Provider */} - - - - - {/* Package summary */} - - - {selectedPackage.name} - - ${(selectedPackage.total ?? selectedPackage.price).toLocaleString('en-AU')} - - - - {selectedPackage.sections.map((section) => ( - - - {section.heading} - - {section.items.map((item) => ( - - {item.name} - - ))} - - ))} - - - - You'll be able to customise everything in the next steps. - - - {/* What's next */} - - - What happens next - - - {nextSteps.map((s) => ( - - - - {s.number} - - - - - ))} - - - - - - {/* CTAs */} + {/* Package summary */} + - {isPrePlanning && onExplore && ( - - )} - - - - )} - - {/* ═══════════ Step 2: Auth ═══════════ */} - {step === 'auth' && ( - { - e.preventDefault(); - if (!loading) onContinue(); - }} - > - - {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.'} - - - {/* SSO buttons */} - - - - - - - - or + {selectedPackage.name} + + ${(selectedPackage.total ?? selectedPackage.price).toLocaleString('en-AU')} - + - {/* Email */} - 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) */} - - - - A few details to save your plan + {selectedPackage.sections.map((section) => ( + + + {section.heading} - - - handleAuthField('firstName', e.target.value)} - error={!!authErrors?.firstName} - helperText={authErrors?.firstName} - autoComplete="given-name" - fullWidth - required - disabled={authValues.subStep === 'verify'} - /> - handleAuthField('lastName', e.target.value)} - error={!!authErrors?.lastName} - helperText={authErrors?.lastName} - autoComplete="family-name" - fullWidth - required - disabled={authValues.subStep === 'verify'} - /> - - - 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'} - /> - - handleAuthField('contactPreference', e.target.value)} - fullWidth - disabled={authValues.subStep === 'verify'} - > - Call me anytime - Email is preferred - Only contact by email - + {section.items.map((item) => ( + + {item.name} + + ))} - + ))} + - {/* Verification */} - - - - We've sent a 6-digit code to {authValues.email}. Please - enter it below. - + + You'll be able to customise everything in the next steps. + + {/* What's next */} + + + What happens next + + + {nextSteps.map((s) => ( + + + + {s.number} + + + + + ))} + + + + )} + + {/* ═══════════ Step 2: Auth ═══════════ */} + {step === 'auth' && ( + { + e.preventDefault(); + if (!loading) onContinue(); + }} + > + + {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.'} + + + {/* SSO buttons */} + + + + + + + + or + + + + {/* Email */} + 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) */} + + + + A few details to save your plan + + + handleAuthField('verificationCode', e.target.value)} - error={!!authErrors?.verificationCode} - helperText={ - authErrors?.verificationCode || 'Check your email for the 6-digit code' - } - placeholder="Enter 6-digit code" - autoComplete="one-time-code" - inputMode="numeric" + label="First name" + value={authValues.firstName} + onChange={(e) => handleAuthField('firstName', e.target.value)} + error={!!authErrors?.firstName} + helperText={authErrors?.firstName} + autoComplete="given-name" fullWidth required + disabled={authValues.subStep === 'verify'} + /> + handleAuthField('lastName', e.target.value)} + error={!!authErrors?.lastName} + helperText={authErrors?.lastName} + autoComplete="family-name" + fullWidth + required + disabled={authValues.subStep === 'verify'} /> - - {/* Terms */} - - By continuing, you agree to the{' '} - - terms and conditions - - . - + 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'} + /> - - - {/* CTA */} - - + handleAuthField('contactPreference', e.target.value)} + fullWidth + disabled={authValues.subStep === 'verify'} + > + Call me anytime + Email is preferred + Only contact by email + - - )} - - + + + {/* Verification */} + + + + We've sent a 6-digit code to {authValues.email}. Please + enter it below. + + + 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 + /> + + + + {/* Terms */} + + By continuing, you agree to the{' '} + + terms and conditions + + . + + + )} + ); }, ); diff --git a/src/components/pages/AuthGateStep/AuthGateStep.stories.tsx b/src/components/pages/AuthGateStep/AuthGateStep.stories.tsx deleted file mode 100644 index 952aa05..0000000 --- a/src/components/pages/AuthGateStep/AuthGateStep.stories.tsx +++ /dev/null @@ -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 = () => ( - - - - -); - -const nav = ( - } - 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 = { - title: 'Pages/AuthGateStep', - component: AuthGateStep, - tags: ['autodocs'], - parameters: { - layout: 'fullscreen', - }, -}; - -export default meta; -type Story = StoryObj; - -// ─── Interactive (default) ────────────────────────────────────────────────── - -/** Fully interactive — walk through all three sub-steps */ -export const Default: Story = { - render: () => { - const [values, setValues] = useState({ ...defaultValues }); - const [errors, setErrors] = useState({}); - - 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 ( - { - 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({ - ...defaultValues, - subStep: 'details', - email: 'jane@example.com', - }); - return ( - 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({ - ...defaultValues, - subStep: 'verify', - email: 'jane@example.com', - firstName: 'Jane', - lastName: 'Smith', - phone: '0412 345 678', - }); - return ( - alert('Verified!')} - onBack={() => alert('Back')} - navigation={nav} - /> - ); - }, -}; - -// ─── At-need variant ──────────────────────────────────────────────────────── - -/** At-need subheading copy variant */ -export const AtNeed: Story = { - render: () => { - const [values, setValues] = useState({ ...defaultValues }); - return ( - 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({ - ...defaultValues, - subStep: 'details', - email: 'jane@example.com', - contactPreference: 'email_only', - }); - return ( - 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({ - ...defaultValues, - subStep: 'details', - email: 'jane@example.com', - }); - return ( - {}} - 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({ - ...defaultValues, - subStep: 'verify', - email: 'jane@example.com', - firstName: 'Jane', - lastName: 'Smith', - phone: '0412 345 678', - verificationCode: '123456', - }); - return ( - {}} - loading - navigation={nav} - /> - ); - }, -}; diff --git a/src/components/pages/AuthGateStep/AuthGateStep.tsx b/src/components/pages/AuthGateStep/AuthGateStep.tsx deleted file mode 100644 index c22bb90..0000000 --- a/src/components/pages/AuthGateStep/AuthGateStep.tsx +++ /dev/null @@ -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; -} - -// ─── 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 = ({ - 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 ( - - {/* Page heading */} - - Save your plan - - - - {getSubheading(isAtNeed)} - - - { - e.preventDefault(); - if (!loading) onContinue(); - }} - > - {/* ─── Sub-step 1: SSO + Email ─── */} - - - - - - - - or - - - - 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) ─── */} - - - - A few details to save your plan - - - - handleFieldChange('firstName', e.target.value)} - error={!!errors?.firstName} - helperText={errors?.firstName} - autoComplete="given-name" - fullWidth - required - disabled={values.subStep === 'verify'} - /> - handleFieldChange('lastName', e.target.value)} - error={!!errors?.lastName} - helperText={errors?.lastName} - autoComplete="family-name" - fullWidth - required - disabled={values.subStep === 'verify'} - /> - - - 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'} - /> - - handleFieldChange('contactPreference', e.target.value)} - fullWidth - disabled={values.subStep === 'verify'} - > - Call me anytime - Email is preferred - Only contact by email - - - - - {/* ─── Sub-step 3: Verification code ─── */} - - - - We've sent a 6-digit code to {values.email}. Please enter it - below. - - - 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 - /> - - - - {/* Terms */} - - By continuing, you agree to the{' '} - - terms and conditions - - . - - - - - {/* CTA */} - - - - - - ); -}; - -AuthGateStep.displayName = 'AuthGateStep'; -export default AuthGateStep; diff --git a/src/components/pages/AuthGateStep/index.ts b/src/components/pages/AuthGateStep/index.ts deleted file mode 100644 index 66193b8..0000000 --- a/src/components/pages/AuthGateStep/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { AuthGateStep, default } from './AuthGateStep'; -export type { - AuthGateStepProps, - AuthGateStepValues, - AuthGateStepErrors, - AuthSubStep, - ContactPreference, -} from './AuthGateStep'; diff --git a/src/components/pages/CoffinsStep/CoffinsStep.tsx b/src/components/pages/CoffinsStep/CoffinsStep.tsx index e17b6ca..138ef55 100644 --- a/src/components/pages/CoffinsStep/CoffinsStep.tsx +++ b/src/components/pages/CoffinsStep/CoffinsStep.tsx @@ -200,7 +200,7 @@ export const CoffinsStep: React.FC = ({ mb: 3, }} > - + ; } -// ─── Copy helpers ──────────────────────────────────────────────────────────── +// ─── Constants ────────────────────────────────────────────────────────────── -function getSubheading(values: IntroStepValues): string { - if (values.forWhom === 'someone' && values.hasPassedAway === 'yes') { - return "We'll guide you through each step. You can save your progress and come back anytime."; - } - if (values.forWhom === 'myself' || values.hasPassedAway === 'no') { - return "Explore your options and plan at your own pace. Nothing is locked in until you're ready."; - } - return "We'll guide you through arranging a funeral, step by step. You can save your progress and come back anytime."; -} +const SUBHEADING = + "We'll guide you through arranging a funeral, step by step. You can save your progress and come back anytime."; // ─── Component ─────────────────────────────────────────────────────────────── @@ -110,8 +103,8 @@ export const IntroStep: React.FC = ({ Let's get started - - {getSubheading(values)} + + {SUBHEADING} ; // ─── Interactive (default) ────────────────────────────────────────────────── -/** Fully interactive — browse, filter, select a package, see detail */ +/** Matched + other packages — select a package, see detail, click Make Arrangement */ export const Default: Story = { render: () => { const [selectedId, setSelectedId] = useState(null); - const [budget, setBudget] = useState('all'); - const [error, setError] = useState(); - - 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 ( { - setSelectedId(id); - setError(undefined); - }} - budgetFilter={budget} - onBudgetFilterChange={setBudget} - onContinue={handleContinue} + onSelectPackage={setSelectedId} + onArrange={() => alert('Open ArrangementDialog')} + onProviderClick={() => alert('Open provider profile')} onBack={() => alert('Back')} - error={error} navigation={nav} /> ); @@ -211,17 +190,38 @@ export const Default: Story = { export const WithSelection: Story = { render: () => { const [selectedId, setSelectedId] = useState('everyday'); - const [budget, setBudget] = useState('all'); return ( alert('Continue')} + onArrange={() => alert('Open ArrangementDialog')} + onProviderClick={() => alert('Open provider profile')} + onBack={() => alert('Back')} + navigation={nav} + /> + ); + }, +}; + +// ─── No other packages (all match) ───────────────────────────────────────── + +/** All packages match filters — no "Other packages" section */ +export const AllMatching: Story = { + render: () => { + const [selectedId, setSelectedId] = useState(null); + + return ( + alert('Open ArrangementDialog')} + onProviderClick={() => alert('Open provider profile')} onBack={() => alert('Back')} navigation={nav} /> @@ -231,21 +231,20 @@ export const WithSelection: Story = { // ─── Pre-planning ─────────────────────────────────────────────────────────── -/** Pre-planning flow — softer helper text */ +/** Pre-planning flow — softer copy */ export const PrePlanning: Story = { render: () => { const [selectedId, setSelectedId] = useState(null); - const [budget, setBudget] = useState('all'); return ( alert('Continue')} + onArrange={() => alert('Open ArrangementDialog')} + onProviderClick={() => alert('Open provider profile')} onBack={() => alert('Back')} navigation={nav} isPrePlanning @@ -254,46 +253,20 @@ export const PrePlanning: Story = { }, }; -// ─── Filtered empty ───────────────────────────────────────────────────────── - -/** Budget filter yielding no results */ -export const FilteredEmpty: Story = { - render: () => { - const [budget, setBudget] = useState('7000-10000'); - - return ( - {}} - budgetFilter={budget} - onBudgetFilterChange={setBudget} - onContinue={() => {}} - onBack={() => alert('Back')} - navigation={nav} - /> - ); - }, -}; - // ─── Validation error ─────────────────────────────────────────────────────── /** Error shown when no package selected */ export const WithError: Story = { render: () => { const [selectedId, setSelectedId] = useState(null); - const [budget, setBudget] = useState('all'); return ( {}} + onArrange={() => {}} onBack={() => alert('Back')} error="Please choose a package to continue." navigation={nav} diff --git a/src/components/pages/PackagesStep/PackagesStep.tsx b/src/components/pages/PackagesStep/PackagesStep.tsx index 0effbd0..845b038 100644 --- a/src/components/pages/PackagesStep/PackagesStep.tsx +++ b/src/components/pages/PackagesStep/PackagesStep.tsx @@ -1,7 +1,5 @@ import React from 'react'; import Box from '@mui/material/Box'; -import TextField from '@mui/material/TextField'; -import MenuItem from '@mui/material/MenuItem'; import type { SxProps, Theme } from '@mui/material/styles'; import { WizardLayout } from '../../templates/WizardLayout'; import { ProviderCardCompact } from '../../molecules/ProviderCardCompact'; @@ -9,8 +7,7 @@ import { ServiceOption } from '../../molecules/ServiceOption'; import { PackageDetail } from '../../organisms/PackageDetail'; import type { PackageSection } from '../../organisms/PackageDetail'; import { Typography } from '../../atoms/Typography'; -import { Badge } from '../../atoms/Badge'; -import { Button } from '../../atoms/Button'; +import { Divider } from '../../atoms/Divider'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -38,8 +35,6 @@ export interface PackageData { price: number; /** Short description */ description?: string; - /** Whether this is a "Most Popular" package */ - popular?: boolean; /** Line item sections for the detail panel */ sections: PackageSection[]; /** Total price (may differ from base price with extras) */ @@ -50,37 +45,27 @@ export interface PackageData { terms?: string; } -/** Budget filter option */ -export interface BudgetOption { - /** Option value */ - value: string; - /** Display label */ - label: string; -} - /** Props for the PackagesStep page component */ export interface PackagesStepProps { /** Provider summary shown at top of the list panel */ provider: PackagesStepProvider; - /** Available packages */ + /** Packages matching the user's filters from the previous step */ packages: PackageData[]; + /** Other packages from this provider that didn't match filters (shown in secondary group) */ + otherPackages?: PackageData[]; /** Currently selected package ID */ selectedPackageId: string | null; /** Callback when a package is selected */ onSelectPackage: (id: string) => void; - /** Current budget filter value */ - budgetFilter: string; - /** Callback when budget filter changes */ - onBudgetFilterChange: (value: string) => void; - /** Budget filter options */ - budgetOptions?: BudgetOption[]; - /** Callback for the Continue button */ - onContinue: () => void; + /** Callback when "Make Arrangement" is clicked (opens ArrangementDialog) */ + onArrange: () => void; + /** Callback when the provider card is clicked (opens provider profile popup) */ + onProviderClick?: () => void; /** Callback for the Back button */ onBack: () => void; /** Validation error */ error?: string; - /** Whether Continue is loading */ + /** Whether the arrange action is loading */ loading?: boolean; /** Navigation bar */ navigation?: React.ReactNode; @@ -90,26 +75,23 @@ export interface PackagesStepProps { sx?: SxProps; } -// ─── Constants ─────────────────────────────────────────────────────────────── - -const DEFAULT_BUDGET_OPTIONS: BudgetOption[] = [ - { value: 'all', label: 'All packages' }, - { value: '2000-4000', label: '$2,000 \u2013 $4,000' }, - { value: '4000-7000', label: '$4,000 \u2013 $7,000' }, - { value: '7000-10000', label: '$7,000 \u2013 $10,000+' }, -]; - // ─── Component ─────────────────────────────────────────────────────────────── /** * Step 3 — Package selection page for the FA arrangement wizard. * * List + Detail split layout. Left panel shows the selected provider - * (compact), a budget filter, and selectable package cards. Right panel - * shows the full detail breakdown of the selected package. + * (compact) and selectable package cards. Right panel shows the full + * detail breakdown of the selected package with "Make Arrangement" CTA. * - * Packages are displayed as ServiceOption cards in a radiogroup pattern. - * "Most Popular" badge on qualifying packages reduces decision paralysis. + * Packages are split into two groups: + * - **Matching your preferences**: packages that matched the user's filters + * from the providers step + * - **Other packages from [Provider]**: remaining packages outside those + * filters, shown below a divider for passive discovery + * + * Selecting a package reveals its detail. Clicking "Make Arrangement" + * on the detail panel triggers the ArrangementDialog (D-E). * * Pure presentation component — props in, callbacks out. * @@ -118,12 +100,11 @@ const DEFAULT_BUDGET_OPTIONS: BudgetOption[] = [ export const PackagesStep: React.FC = ({ provider, packages, + otherPackages = [], selectedPackageId, onSelectPackage, - budgetFilter, - onBudgetFilterChange, - budgetOptions = DEFAULT_BUDGET_OPTIONS, - onContinue, + onArrange, + onProviderClick, onBack, error, loading = false, @@ -131,13 +112,13 @@ export const PackagesStep: React.FC = ({ isPrePlanning = false, sx, }) => { - const selectedPackage = packages.find((p) => p.id === selectedPackageId); + const allPackages = [...packages, ...otherPackages]; + const selectedPackage = allPackages.find((p) => p.id === selectedPackageId); + const hasOtherPackages = otherPackages.length > 0; - const subheading = - 'Each package includes a set of services. You can customise your selections in the next steps.'; - const helperText = isPrePlanning + const subheading = isPrePlanning ? 'Compare packages to find what suits your wishes. Nothing is committed until you confirm.' - : 'Prices shown include the base services listed. Additional options may change the total.'; + : 'Each package includes a set of services. You can customise your selections in the next steps.'; return ( = ({ total={selectedPackage.total} extras={selectedPackage.extras} terms={selectedPackage.terms} - onArrange={onContinue} + onArrange={onArrange} arrangeDisabled={loading} /> ) : ( @@ -179,7 +160,7 @@ export const PackagesStep: React.FC = ({ ) } > - {/* Provider compact card */} + {/* Provider compact card — clickable to open provider profile */} = ({ imageUrl={provider.imageUrl} rating={provider.rating} reviewCount={provider.reviewCount} + onClick={onProviderClick} /> {/* Heading */} - + Choose a funeral package - + {subheading} - - {helperText} - - - {/* Budget filter */} - - onBudgetFilterChange(e.target.value)} - label="Budget range" - sx={{ width: { xs: '100%', sm: 240 } }} - > - {budgetOptions.map((opt) => ( - - {opt.label} - - ))} - - {/* Error message */} {error && ( @@ -230,64 +191,99 @@ export const PackagesStep: React.FC = ({ )} - {/* Package list — radiogroup pattern */} + {/* ─── Matching packages ─── */} + {hasOtherPackages && ( + + + + Matching your preferences + + + )} + {packages.map((pkg) => ( - - {pkg.popular && ( - - Most Popular - - )} - onSelectPackage(pkg.id)} - /> - + onSelectPackage(pkg.id)} + /> ))} {packages.length === 0 && ( - - - No packages match the selected budget range. - + - Try selecting "All packages" to see the full range. + No packages match your current preferences. )} - {/* Mobile: Continue button (desktop uses PackageDetail's CTA) */} - - - + {/* ─── Other packages (passive discovery) ─── */} + {hasOtherPackages && ( + <> + + + + + Other packages from {provider.name} + + + + {otherPackages.map((pkg) => ( + onSelectPackage(pkg.id)} + /> + ))} + + + )} ); }; diff --git a/src/components/pages/PreviewStep/PreviewStep.stories.tsx b/src/components/pages/PreviewStep/PreviewStep.stories.tsx deleted file mode 100644 index 6c5f8fc..0000000 --- a/src/components/pages/PreviewStep/PreviewStep.stories.tsx +++ /dev/null @@ -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 = () => ( - - - - -); - -const nav = ( - } - 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 = { - title: 'Pages/PreviewStep', - component: PreviewStep, - tags: ['autodocs'], - parameters: { - layout: 'fullscreen', - }, -}; - -export default meta; -type Story = StoryObj; - -// ─── 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'), - }, -}; diff --git a/src/components/pages/PreviewStep/PreviewStep.tsx b/src/components/pages/PreviewStep/PreviewStep.tsx deleted file mode 100644 index 3f836f6..0000000 --- a/src/components/pages/PreviewStep/PreviewStep.tsx +++ /dev/null @@ -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; -} - -// ─── 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 = ({ - provider, - selectedPackage, - nextSteps = DEFAULT_NEXT_STEPS, - onContinue, - onBack, - onExplore, - loading = false, - isPrePlanning = false, - navigation, - sx, -}) => { - return ( - - } - > - {/* Provider compact card */} - - - - - {/* Page heading */} - - Your selected package - - - Here's what's included. You'll be able to customise everything in the next - steps. - - - {/* What's next? checklist */} - - - What happens next - - - {nextSteps.map((step) => ( - - - - {step.number} - - - - - ))} - - - - - - {/* Action buttons */} - - {isPrePlanning && onExplore && ( - - )} - - - - ); -}; - -PreviewStep.displayName = 'PreviewStep'; -export default PreviewStep; diff --git a/src/components/pages/PreviewStep/index.ts b/src/components/pages/PreviewStep/index.ts deleted file mode 100644 index 7b3a4cd..0000000 --- a/src/components/pages/PreviewStep/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from './PreviewStep'; -export * from './PreviewStep'; diff --git a/src/components/pages/ProvidersStep/ProvidersStep.stories.tsx b/src/components/pages/ProvidersStep/ProvidersStep.stories.tsx index d927378..31ea648 100644 --- a/src/components/pages/ProvidersStep/ProvidersStep.stories.tsx +++ b/src/components/pages/ProvidersStep/ProvidersStep.stories.tsx @@ -125,7 +125,7 @@ export const Default: Story = { const [filters, setFilters] = useState(defaultFilters); const filtered = mockProviders.filter((p) => - p.name.toLowerCase().includes(query.toLowerCase()), + p.location.toLowerCase().includes(query.toLowerCase()), ); return ( diff --git a/src/components/pages/ProvidersStep/ProvidersStep.tsx b/src/components/pages/ProvidersStep/ProvidersStep.tsx index 93c91aa..6fa79ef 100644 --- a/src/components/pages/ProvidersStep/ProvidersStep.tsx +++ b/src/components/pages/ProvidersStep/ProvidersStep.tsx @@ -1,9 +1,11 @@ import React from 'react'; import Box from '@mui/material/Box'; +import TextField from '@mui/material/TextField'; +import InputAdornment from '@mui/material/InputAdornment'; +import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined'; import type { SxProps, Theme } from '@mui/material/styles'; import { WizardLayout } from '../../templates/WizardLayout'; import { ProviderCard } from '../../molecules/ProviderCard'; -import { SearchBar } from '../../molecules/SearchBar'; import { FilterPanel } from '../../molecules/FilterPanel'; import { Chip } from '../../atoms/Chip'; import { Typography } from '../../atoms/Typography'; @@ -122,7 +124,7 @@ export const ProvidersStep: React.FC = ({ = ({ px: { xs: 2, md: 3 }, }} > - - Choose a funeral provider + + Find a funeral director {subheading} - {/* Search bar + filter button */} - - - - - {filters && filters.length > 0 && ( + {/* Location search */} + onSearchChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && onSearch) { + e.preventDefault(); + onSearch(searchQuery); + } + }} + fullWidth + size="small" + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ mb: 1.5 }} + /> + + {/* Filters — right-aligned below search */} + {filters && filters.length > 0 && ( + f.active).length} onClear={onFilterClear} @@ -186,8 +203,8 @@ export const ProvidersStep: React.FC = ({ ))} - )} - + + )} {/* Results count */} = ({ = ({ px: { xs: 2, md: 3 }, }} > - + Where would you like the service? @@ -229,22 +228,26 @@ export const VenueStep: React.FC = ({ : 'Choose a venue for the funeral service. You can filter by location, features, and religion.'} - {/* ─── Search + Filter button ─── */} - - onChange({ ...values, search: e.target.value })} - fullWidth - InputProps={{ - startAdornment: ( - - - - ), - }} - /> + {/* ─── Location search ─── */} + onChange({ ...values, search: e.target.value })} + fullWidth + size="small" + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ mb: 1.5 }} + /> + + {/* ─── Filters — right-aligned below search ─── */} + {filterOptions.map((filter) => ( @@ -260,9 +263,14 @@ export const VenueStep: React.FC = ({ {/* ─── Results count ─── */} - - Found {venues.length} venue{venues.length !== 1 ? 's' : ''} - {locationName ? ` near ${locationName}` : ''} + + {venues.length} venue{venues.length !== 1 ? 's' : ''} + {locationName ? ` near ${locationName}` : ''} found diff --git a/src/components/templates/WizardLayout/WizardLayout.tsx b/src/components/templates/WizardLayout/WizardLayout.tsx index 240b9c7..98be83e 100644 --- a/src/components/templates/WizardLayout/WizardLayout.tsx +++ b/src/components/templates/WizardLayout/WizardLayout.tsx @@ -170,6 +170,7 @@ const ListMapLayout: React.FC<{ overflow: 'hidden', }} > + {/* Left panel — scrollable list with scrollbar visible on hover */} + {/* Right panel — map or placeholder, fills available space */} {secondaryPanel}