Feedback iteration: DialogShell, page consistency, popup standardisation

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

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

View File

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

View File

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