SummaryStep: visual cart layout with arrangement details and share dialog

- Visual cart-style cards with image thumbnails for provider, venue,
  crematorium, coffin (replaces accordion text lists)
- Arrangement details section at top: arranger name, deceased name,
  service tradition, preferred dates/times
- Location pin icons on all location-based cards
- Allowance display: fully covered shows "Included in your package",
  partially covered shows price/allowance/remaining breakdown
- Share dialog: "Share this plan" button opens DialogShell with multi-email
  input, add/remove recipients, send confirmation state
- Included services as checkmark list, extras as priced list (consistent
  tick logic — only in sections with no priced items)
- Full-width CTA, deposit deferred to payment step
- Edit buttons with pencil icon in secondary colour

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 12:58:25 +11:00
parent 6cb3184130
commit 59f4eb46db
3 changed files with 711 additions and 179 deletions

View File

@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { SummaryStep } from './SummaryStep'; import { SummaryStep } from './SummaryStep';
import type { SummarySection } from './SummaryStep'; import type { SummarySection, ArrangementDetails } from './SummaryStep';
import { Navigation } from '../../organisms/Navigation'; import { Navigation } from '../../organisms/Navigation';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
@@ -33,52 +33,78 @@ const nav = (
/> />
); );
const sampleDetails: ArrangementDetails = {
arrangerName: 'Sarah Mitchell',
deceasedName: 'Robert Mitchell',
serviceTradition: 'Non-denominational',
preferredDates: ['Tuesday 15 April', 'Wednesday 16 April'],
preferredTime: 'Morning',
editStepId: 'date_time',
};
const sampleSections: SummarySection[] = [ const sampleSections: SummarySection[] = [
{ {
id: 'provider', id: 'provider',
title: 'Funeral Provider', title: 'Funeral Provider',
editStepId: 'providers', editStepId: 'providers',
items: [ imageUrl: 'https://placehold.co/320x200/F5F5F0/8B8B7E?text=H.+Parsons',
{ label: 'Provider', value: 'H. Parsons Funeral Directors' }, name: 'H. Parsons Funeral Directors',
{ label: 'Package', value: 'Essential Service Package', price: 4950 }, subtitle: 'Essential Service Package',
], location: 'Mackay, QLD',
price: 4950,
}, },
{ {
id: 'venue', id: 'venue',
title: 'Service Venue', title: 'Service Venue',
editStepId: 'venue', editStepId: 'venue',
imageUrl: 'https://placehold.co/320x200/F5F5F0/8B8B7E?text=West+Chapel',
name: 'West Chapel',
location: 'Strathfield, NSW',
price: 900,
items: [ items: [
{ label: 'Venue', value: 'West Chapel, Strathfield', price: 900 }, { label: 'Photo presentation', price: 150 },
{ label: 'Photo presentation', value: 'Included', price: 150 }, { label: 'Livestream', price: 200 },
{ label: 'Livestream', value: 'Included', price: 200 },
], ],
}, },
{ {
id: 'crematorium', id: 'crematorium',
title: 'Crematorium', title: 'Crematorium',
editStepId: 'crematorium', editStepId: 'crematorium',
items: [ imageUrl: 'https://placehold.co/320x200/F5F5F0/8B8B7E?text=Warrill+Park',
{ label: 'Crematorium', value: 'Warrill Park Crematorium', price: 850 }, name: 'Warrill Park Crematorium',
{ label: 'Following hearse', value: 'Yes' }, location: 'Ipswich, QLD',
], price: 850,
}, },
{ {
id: 'coffin', id: 'coffin',
title: 'Coffin', title: 'Coffin',
editStepId: 'coffins', editStepId: 'coffins',
imageUrl: 'https://images.unsplash.com/photo-1618220179428-22790b461013?w=320&h=200&fit=crop',
name: 'Richmond Rosewood Coffin',
colourName: 'Natural Oak',
colourHex: '#D4A76A',
price: 1750,
allowanceAmount: 500,
},
{
id: 'included',
title: 'Included Services',
editStepId: 'included_services',
items: [ items: [
{ label: 'Coffin', value: 'Cedar Classic', price: 2800 }, { label: 'Dressing and preparation' },
{ label: 'Handles', value: 'Brass Bar Handle', price: 0 }, { label: 'Viewing', value: 'Same venue' },
{ label: 'Lining', value: 'White Satin', price: 0 }, { label: 'Funeral announcement' },
], ],
}, },
{ {
id: 'services', id: 'extras',
title: 'Additional Services', title: 'Optional Extras',
editStepId: 'additional_services', editStepId: 'extras',
items: [ items: [
{ label: 'Funeral announcement', value: 'Included' }, { label: 'Catering', priceLabel: 'Price on application' },
{ label: 'Bearing', value: 'Family and friends' }, { label: 'Live musician', value: 'Vocalist', price: 450 },
{ label: 'Coffin bearing', value: 'Family and friends' },
{ label: 'Newspaper notice', price: 250 },
], ],
}, },
]; ];
@@ -99,35 +125,61 @@ type Story = StoryObj<typeof SummaryStep>;
// ─── At-need (default) ────────────────────────────────────────────────────── // ─── At-need (default) ──────────────────────────────────────────────────────
/** Full summary for at-need flow */ /** Full summary for at-need flow with allowances */
export const Default: Story = { export const Default: Story = {
render: () => ( render: () => (
<SummaryStep <SummaryStep
arrangementDetails={sampleDetails}
sections={sampleSections} sections={sampleSections}
totalPrice={9850} totalPrice={9050}
depositAmount={2000} totalAllowances={500}
onConfirm={() => alert('Confirmed — proceed to payment')} onConfirm={() => alert('Confirmed — proceed to payment')}
onBack={() => alert('Back')} onBack={() => alert('Back')}
onSaveAndExit={() => alert('Save')} onSaveAndExit={() => alert('Save')}
onEdit={(stepId) => alert(`Edit: ${stepId}`)} onEdit={(stepId) => alert(`Edit: ${stepId}`)}
onShare={() => alert('Share plan')} onShareByEmail={(emails) => alert(`Share to: ${emails.join(', ')}`)}
navigation={nav} navigation={nav}
/> />
), ),
}; };
// ─── Coffin fully covered ───────────────────────────────────────────────────
/** Coffin fully covered by allowance */
export const FullyCovered: Story = {
render: () => {
const sections = sampleSections.map((s) =>
s.id === 'coffin' ? { ...s, price: 900, allowanceAmount: 1000 } : s,
);
return (
<SummaryStep
arrangementDetails={sampleDetails}
sections={sections}
totalPrice={7500}
onConfirm={() => alert('Confirmed')}
onBack={() => alert('Back')}
onEdit={(stepId) => alert(`Edit: ${stepId}`)}
onShareByEmail={(emails) => alert(`Share to: ${emails.join(', ')}`)}
navigation={nav}
/>
);
},
};
// ─── Pre-planning ─────────────────────────────────────────────────────────── // ─── Pre-planning ───────────────────────────────────────────────────────────
/** Pre-planning variant — "Save your plan" CTA, no payment */ /** Pre-planning variant — "Save your plan" CTA */
export const PrePlanning: Story = { export const PrePlanning: Story = {
render: () => ( render: () => (
<SummaryStep <SummaryStep
arrangementDetails={sampleDetails}
sections={sampleSections} sections={sampleSections}
totalPrice={9850} totalPrice={9050}
totalAllowances={500}
onConfirm={() => alert('Plan saved')} onConfirm={() => alert('Plan saved')}
onBack={() => alert('Back')} onBack={() => alert('Back')}
onEdit={(stepId) => alert(`Edit: ${stepId}`)} onEdit={(stepId) => alert(`Edit: ${stepId}`)}
onShare={() => alert('Share plan')} onShareByEmail={(emails) => alert(`Share to: ${emails.join(', ')}`)}
isPrePlanning isPrePlanning
navigation={nav} navigation={nav}
/> />
@@ -140,8 +192,9 @@ export const PrePlanning: Story = {
export const Loading: Story = { export const Loading: Story = {
render: () => ( render: () => (
<SummaryStep <SummaryStep
arrangementDetails={sampleDetails}
sections={sampleSections} sections={sampleSections}
totalPrice={9850} totalPrice={9050}
onConfirm={() => {}} onConfirm={() => {}}
loading loading
navigation={nav} navigation={nav}

View File

@@ -1,14 +1,18 @@
import React from 'react'; import React from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper'; import Paper from '@mui/material/Paper';
import Accordion from '@mui/material/Accordion'; import TextField from '@mui/material/TextField';
import AccordionSummary from '@mui/material/AccordionSummary'; import ForwardToInboxOutlinedIcon from '@mui/icons-material/ForwardToInboxOutlined';
import AccordionDetails from '@mui/material/AccordionDetails';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import ShareOutlinedIcon from '@mui/icons-material/ShareOutlined'; import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import CheckIcon from '@mui/icons-material/Check';
import AddIcon from '@mui/icons-material/Add';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
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 { DialogShell } from '../../atoms/DialogShell';
import { Card } from '../../atoms/Card';
import { Typography } from '../../atoms/Typography'; import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button'; import { Button } from '../../atoms/Button';
import { IconButton } from '../../atoms/IconButton'; import { IconButton } from '../../atoms/IconButton';
@@ -16,30 +20,67 @@ import { Divider } from '../../atoms/Divider';
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
/** A single line item in the summary */ /** A line item within a summary section */
export interface SummaryLineItem { export interface SummaryLineItem {
label: string; label: string;
value: string; value?: string;
price?: number; price?: number;
/** Custom price label like "POA" */
priceLabel?: string;
} }
/** A section in the summary (e.g. Provider, Venue, Coffin) */ /** A section of the plan summary */
export interface SummarySection { export interface SummarySection {
id: string; id: string;
/** Section heading (e.g. "Coffin", "Service Venue") */
title: string; title: string;
items: SummaryLineItem[]; /** Step ID for edit navigation */
/** Step ID to navigate back to for editing */ editStepId?: string;
/** Image URL for the visual card thumbnail */
imageUrl?: string;
/** Primary selection name (e.g. provider name, coffin name) */
name?: string;
/** Supporting text (e.g. package name) */
subtitle?: string;
/** Location with pin icon (e.g. "Strathfield, NSW") */
location?: string;
/** Colour swatch for coffin */
colourName?: string;
colourHex?: string;
/** Price of the primary selection */
price?: number;
/** Package allowance for this section's primary price */
allowanceAmount?: number;
/** Additional line items (services, extras, details) */
items?: SummaryLineItem[];
}
/** Arrangement details shown at the top of the summary */
export interface ArrangementDetails {
/** Name of the person arranging */
arrangerName?: string;
/** Name of the deceased or person the arrangement is for */
deceasedName?: string;
/** Service tradition / religious considerations */
serviceTradition?: string;
/** Preferred date(s) */
preferredDates?: string[];
/** Preferred time of day */
preferredTime?: string;
/** Step ID for editing these details */
editStepId?: string; editStepId?: string;
} }
/** Props for the SummaryStep page component */ /** Props for the SummaryStep page component */
export interface SummaryStepProps { export interface SummaryStepProps {
/** Arrangement details shown at the top */
arrangementDetails?: ArrangementDetails;
/** Summary sections */ /** Summary sections */
sections: SummarySection[]; sections: SummarySection[];
/** Total cost */ /** Total cost */
totalPrice: number; totalPrice: number;
/** Deposit amount (if applicable) */ /** Total allowances applied */
depositAmount?: number; totalAllowances?: number;
/** Callback when Confirm is clicked */ /** Callback when Confirm is clicked */
onConfirm: () => void; onConfirm: () => void;
/** Callback for back navigation */ /** Callback for back navigation */
@@ -48,8 +89,10 @@ export interface SummaryStepProps {
onSaveAndExit?: () => void; onSaveAndExit?: () => void;
/** Callback when edit is clicked on a section */ /** Callback when edit is clicked on a section */
onEdit?: (stepId: string) => void; onEdit?: (stepId: string) => void;
/** Callback for sharing the plan */ /** Callback when plan is shared via email */
onShare?: () => void; onShareByEmail?: (emails: string[]) => void;
/** Whether the share is in progress */
shareLoading?: boolean;
/** Whether the Confirm button is in a loading state */ /** Whether the Confirm button is in a loading state */
loading?: boolean; loading?: boolean;
/** Whether this is a pre-planning flow */ /** Whether this is a pre-planning flow */
@@ -64,30 +107,182 @@ export interface SummaryStepProps {
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
} }
// ─── Internal: Share Dialog ─────────────────────────────────────────────────
const ShareDialog: React.FC<{
open: boolean;
onClose: () => void;
onSend: (emails: string[]) => void;
loading?: boolean;
}> = ({ open, onClose, onSend, loading }) => {
const [emails, setEmails] = React.useState(['']);
const [sent, setSent] = React.useState(false);
const handleAdd = () => setEmails((prev) => [...prev, '']);
const handleRemove = (index: number) => setEmails((prev) => prev.filter((_, i) => i !== index));
const handleChange = (index: number, value: string) =>
setEmails((prev) => prev.map((e, i) => (i === index ? value : e)));
const validEmails = emails.filter((e) => e.includes('@') && e.includes('.'));
const handleSend = () => {
if (validEmails.length > 0) {
onSend(validEmails);
setSent(true);
}
};
const handleClose = () => {
onClose();
setTimeout(() => {
setEmails(['']);
setSent(false);
}, 300);
};
return (
<DialogShell
open={open}
onClose={handleClose}
title="Send your plan"
maxWidth="sm"
fullWidth
footer={
!sent ? (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button variant="text" color="secondary" onClick={handleClose}>
Cancel
</Button>
<Button
variant="contained"
onClick={handleSend}
loading={loading}
disabled={validEmails.length === 0}
>
Send
</Button>
</Box>
) : (
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="contained" onClick={handleClose}>
Done
</Button>
</Box>
)
}
>
{!sent ? (
<>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Enter the email address of anyone you&apos;d like to share this plan with.
</Typography>
{emails.map((email, index) => (
<Box key={index} sx={{ display: 'flex', gap: 1, mb: 1.5, alignItems: 'center' }}>
<TextField
fullWidth
size="small"
type="email"
placeholder="Email address"
value={email}
onChange={(e) => handleChange(index, e.target.value)}
inputRef={(el) => {
if (el && index === emails.length - 1 && emails.length > 1) el.focus();
}}
/>
{emails.length > 1 && (
<IconButton
aria-label="Remove email"
size="small"
onClick={() => handleRemove(index)}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
)}
</Box>
))}
<Button
variant="text"
size="small"
startIcon={<AddIcon />}
onClick={handleAdd}
sx={{ mt: 0.5 }}
>
Add another recipient
</Button>
</>
) : (
<Box sx={{ textAlign: 'center', py: 3 }}>
<CheckCircleOutlineIcon sx={{ fontSize: 48, color: 'success.main', mb: 2 }} />
<Typography variant="h5" sx={{ mb: 1 }}>
Plan sent
</Typography>
<Typography variant="body2" color="text.secondary">
Your plan has been sent to{' '}
{validEmails.length === 1 ? validEmails[0] : `${validEmails.length} recipients`}.
</Typography>
</Box>
)}
</DialogShell>
);
};
// ─── Internal: Section Header ───────────────────────────────────────────────
const SectionHeader: React.FC<{
title: string;
editStepId?: string;
onEdit?: (stepId: string) => void;
}> = ({ title, editStepId, onEdit }) => (
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1.5,
}}
>
<Typography variant="h6" component="h2">
{title}
</Typography>
{editStepId && onEdit && (
<Button
variant="text"
color="secondary"
size="small"
startIcon={<EditOutlinedIcon sx={{ fontSize: '16px !important' }} />}
onClick={() => onEdit(editStepId)}
sx={{ fontWeight: 500 }}
>
Edit
</Button>
)}
</Box>
);
// ─── Component ─────────────────────────────────────────────────────────────── // ─── Component ───────────────────────────────────────────────────────────────
/** /**
* Step 13 — Summary / Review for the FA arrangement wizard. * Step 13 — Summary / Review for the FA arrangement wizard.
* *
* Complete summary of the funeral plan with all selections and pricing. * Visual cart-style summary of the funeral plan. Each selection is shown
* Accordion sections with edit links back to each step. Total bar at bottom. * as a compact card with image, details, and pricing. Allowances are
* * displayed inline per section. Email sharing via dialog.
* For pre-planning: CTA is "Save your plan" instead of "Confirm".
* For at-need: CTA is "Confirm and continue to payment".
* *
* Pure presentation component — props in, callbacks out. * Pure presentation component — props in, callbacks out.
* *
* Spec: documentation/steps/steps/13_summary.yaml * Spec: documentation/steps/steps/13_summary.yaml
*/ */
export const SummaryStep: React.FC<SummaryStepProps> = ({ export const SummaryStep: React.FC<SummaryStepProps> = ({
arrangementDetails,
sections, sections,
totalPrice, totalPrice,
depositAmount, totalAllowances,
onConfirm, onConfirm,
onBack, onBack,
onSaveAndExit, onSaveAndExit,
onEdit, onEdit,
onShare, onShareByEmail,
shareLoading = false,
loading = false, loading = false,
isPrePlanning = false, isPrePlanning = false,
navigation, navigation,
@@ -95,6 +290,8 @@ export const SummaryStep: React.FC<SummaryStepProps> = ({
hideHelpBar, hideHelpBar,
sx, sx,
}) => { }) => {
const [shareOpen, setShareOpen] = React.useState(false);
return ( return (
<WizardLayout <WizardLayout
variant="centered-form" variant="centered-form"
@@ -106,124 +303,402 @@ export const SummaryStep: React.FC<SummaryStepProps> = ({
hideHelpBar={hideHelpBar} hideHelpBar={hideHelpBar}
sx={sx} sx={sx}
> >
{/* Header with share */} {/* Header */}
<Typography variant="display3" component="h1" tabIndex={-1} sx={{ mb: 1 }}>
Review your plan
</Typography>
<Box <Box
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}
>
<Typography variant="display3" component="h1" tabIndex={-1}>
Review your plan
</Typography>
{onShare && (
<IconButton aria-label="Share this plan via email" onClick={onShare} size="medium">
<ShareOutlinedIcon />
</IconButton>
)}
</Box>
<Typography variant="body1" color="text.secondary" sx={{ mb: 1 }}>
Check everything looks right before confirming.
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 5 }}>
You can edit any section by tapping the edit icon.
</Typography>
{/* ─── Summary sections ─── */}
{sections.map((section) => (
<Accordion
key={section.id}
defaultExpanded
disableGutters
elevation={0}
sx={{
border: 1,
borderColor: 'divider',
borderRadius: '8px !important',
mb: 2,
'&:before': { display: 'none' },
overflow: 'hidden',
}}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 3, py: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1, gap: 1 }}>
<Typography variant="h5" sx={{ flex: 1 }}>
{section.title}
</Typography>
{section.editStepId && onEdit && (
<IconButton
aria-label={`Edit ${section.title}`}
size="small"
onClick={(e) => {
e.stopPropagation();
onEdit(section.editStepId!);
}}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
)}
</Box>
</AccordionSummary>
<AccordionDetails sx={{ px: 3, pb: 3 }}>
<Box component="dl" sx={{ m: 0 }}>
{section.items.map((item, i) => (
<Box
key={i}
sx={{
display: 'flex',
justifyContent: 'space-between',
py: 1,
borderBottom: i < section.items.length - 1 ? 1 : 0,
borderColor: 'divider',
}}
>
<Box>
<Typography component="dt" variant="body2" color="text.secondary">
{item.label}
</Typography>
<Typography component="dd" variant="body1" sx={{ m: 0 }}>
{item.value}
</Typography>
</Box>
{item.price != null && (
<Typography
variant="body1"
color="primary"
sx={{ ml: 2, whiteSpace: 'nowrap' }}
>
${item.price.toLocaleString('en-AU')}
</Typography>
)}
</Box>
))}
</Box>
</AccordionDetails>
</Accordion>
))}
{/* ─── Total bar ─── */}
<Paper
elevation={2}
sx={{ sx={{
p: 3,
mt: 3,
mb: 4,
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
borderRadius: 2, mb: 3,
}} }}
> >
<Box> <Typography variant="body1" color="text.secondary">
<Typography variant="h5">Total cost</Typography> {isPrePlanning
{depositAmount != null && !isPrePlanning && ( ? "Here's an overview of your plan. You can make changes at any time."
<Typography variant="body2" color="text.secondary"> : 'Please review your selections below. You can edit any section before confirming.'}
Deposit: ${depositAmount.toLocaleString('en-AU')}
</Typography>
)}
</Box>
<Typography variant="display3" color="primary" aria-live="polite" aria-atomic="true">
${totalPrice.toLocaleString('en-AU')}
</Typography> </Typography>
{onShareByEmail && (
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<ForwardToInboxOutlinedIcon />}
onClick={() => setShareOpen(true)}
sx={{ ml: 2, whiteSpace: 'nowrap', flexShrink: 0 }}
>
Share this plan
</Button>
)}
</Box>
<Divider sx={{ mb: 4 }} />
{/* ─── Arrangement details ─── */}
{arrangementDetails && (
<>
<SectionHeader
title="Arrangement Details"
editStepId={arrangementDetails.editStepId}
onEdit={onEdit}
/>
<Card variant="outlined" padding="compact" sx={{ mb: 4 }}>
<Box
component="dl"
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' },
gap: 1.5,
m: 0,
}}
>
{arrangementDetails.arrangerName && (
<Box>
<Typography component="dt" variant="caption" color="text.secondary">
Arranged by
</Typography>
<Typography component="dd" variant="body2" sx={{ m: 0 }}>
{arrangementDetails.arrangerName}
</Typography>
</Box>
)}
{arrangementDetails.deceasedName && (
<Box>
<Typography component="dt" variant="caption" color="text.secondary">
In memory of
</Typography>
<Typography component="dd" variant="body2" sx={{ m: 0 }}>
{arrangementDetails.deceasedName}
</Typography>
</Box>
)}
{arrangementDetails.serviceTradition && (
<Box>
<Typography component="dt" variant="caption" color="text.secondary">
Service tradition
</Typography>
<Typography component="dd" variant="body2" sx={{ m: 0 }}>
{arrangementDetails.serviceTradition}
</Typography>
</Box>
)}
{arrangementDetails.preferredTime && (
<Box>
<Typography component="dt" variant="caption" color="text.secondary">
Preferred time
</Typography>
<Typography component="dd" variant="body2" sx={{ m: 0 }}>
{arrangementDetails.preferredTime}
</Typography>
</Box>
)}
{arrangementDetails.preferredDates &&
arrangementDetails.preferredDates.length > 0 && (
<Box sx={{ gridColumn: { sm: '1 / -1' } }}>
<Typography component="dt" variant="caption" color="text.secondary">
Preferred date{arrangementDetails.preferredDates.length > 1 ? 's' : ''}
</Typography>
<Typography component="dd" variant="body2" sx={{ m: 0 }}>
{arrangementDetails.preferredDates.join(' · ')}
</Typography>
</Box>
)}
</Box>
</Card>
</>
)}
{/* ─── Summary sections ─── */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{sections.map((section) => {
// Determine if this section has any priced items (affects tick display)
const hasPricedItems =
section.items?.some((item) => item.price != null || item.priceLabel) ?? false;
// Allowance logic for primary price
const hasAllowance = section.allowanceAmount != null && section.price != null;
const isFullyCovered = hasAllowance && section.allowanceAmount! >= section.price!;
const remainingCost =
hasAllowance && !isFullyCovered ? section.price! - section.allowanceAmount! : 0;
return (
<Box key={section.id}>
<SectionHeader
title={section.title}
editStepId={section.editStepId}
onEdit={onEdit}
/>
{/* Visual card for sections with image/name */}
{section.name && (
<Card variant="outlined" padding="none" sx={{ overflow: 'hidden' }}>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
}}
>
{/* Thumbnail */}
{section.imageUrl && (
<Box
sx={{
width: { xs: '100%', sm: 160 },
height: { xs: 160, sm: 'auto' },
minHeight: { sm: 120 },
flexShrink: 0,
backgroundImage: `url(${section.imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
bgcolor: 'var(--fa-color-surface-subtle)',
}}
/>
)}
{/* Details */}
<Box
sx={{
p: 2,
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
}}
>
<Typography variant="label" sx={{ display: 'block', mb: 0.25 }}>
{section.name}
</Typography>
{section.subtitle && (
<Typography variant="body2" color="text.secondary">
{section.subtitle}
</Typography>
)}
{/* Location with pin */}
{section.location && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
<LocationOnOutlinedIcon
sx={{ fontSize: 16, color: 'text.secondary' }}
aria-hidden
/>
<Typography variant="body2" color="text.secondary">
{section.location}
</Typography>
</Box>
)}
{/* Colour swatch */}
{section.colourName && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.75 }}>
{section.colourHex && (
<Box
sx={{
width: 16,
height: 16,
borderRadius: '50%',
bgcolor: section.colourHex,
border: '1px solid',
borderColor: 'divider',
flexShrink: 0,
}}
/>
)}
<Typography variant="caption" color="text.secondary">
{section.colourName}
</Typography>
</Box>
)}
{/* Spacer pushes price to bottom */}
<Box sx={{ flex: 1 }} />
{/* Price — hugs bottom of card detail area */}
{section.price != null && !hasAllowance && (
<Box
sx={{
display: 'flex',
justifyContent: 'flex-end',
mt: 1.5,
}}
>
<Typography variant="label" color="primary">
${section.price.toLocaleString('en-AU')}
</Typography>
</Box>
)}
{/* Fully covered — no price breakdown */}
{isFullyCovered && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1.5 }}>
Included in your package
</Typography>
)}
</Box>
</Box>
{/* Allowance breakdown — full-width divider section */}
{hasAllowance && !isFullyCovered && (
<Box sx={{ px: 2, pb: 2 }}>
<Divider sx={{ mb: 1.5 }} />
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2" color="text.secondary">
Price
</Typography>
<Typography variant="body2" color="text.secondary">
${section.price!.toLocaleString('en-AU')}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2" color="text.secondary">
Allowance applied
</Typography>
<Typography variant="body2" color="text.secondary">
${section.allowanceAmount!.toLocaleString('en-AU')}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 0.5 }}>
<Typography variant="label">Remaining</Typography>
<Typography variant="label" color="primary">
${remainingCost.toLocaleString('en-AU')}
</Typography>
</Box>
</Box>
)}
{/* Sub-items — full-width divider section */}
{section.items && section.items.length > 0 && (
<Box sx={{ px: 2, pb: 2 }}>
<Divider sx={{ mb: 1.5 }} />
{section.items.map((item, i) => (
<Box
key={i}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
py: 0.5,
}}
>
<Typography variant="body2" color="text.secondary">
{item.label}
{item.value && `${item.value}`}
</Typography>
{item.priceLabel ? (
<Typography
variant="body2"
color="primary"
sx={{ ml: 2, whiteSpace: 'nowrap', fontStyle: 'italic' }}
>
{item.priceLabel}
</Typography>
) : (
item.price != null && (
<Typography
variant="body2"
color="primary"
sx={{ ml: 2, whiteSpace: 'nowrap' }}
>
${item.price.toLocaleString('en-AU')}
</Typography>
)
)}
</Box>
))}
</Box>
)}
</Card>
)}
{/* List-style section (no image/name — included services, extras) */}
{!section.name && section.items && section.items.length > 0 && (
<Card variant="outlined" padding="compact">
{section.items.map((item, i) => (
<Box
key={i}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
py: 0.75,
borderBottom: i < section.items!.length - 1 ? 1 : 0,
borderColor: 'divider',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{/* Ticks only in sections with no priced items (included services) */}
{!hasPricedItems && (
<CheckIcon sx={{ fontSize: 18, color: 'success.main' }} />
)}
<Typography variant="body2">
{item.label}
{item.value && (
<Typography component="span" variant="body2" color="text.secondary">
{' '}
{item.value}
</Typography>
)}
</Typography>
</Box>
{item.priceLabel ? (
<Typography
variant="body2"
color="primary"
sx={{ ml: 2, whiteSpace: 'nowrap', fontStyle: 'italic' }}
>
{item.priceLabel}
</Typography>
) : (
item.price != null && (
<Typography
variant="body2"
color="primary"
sx={{ ml: 2, whiteSpace: 'nowrap' }}
>
${item.price.toLocaleString('en-AU')}
</Typography>
)
)}
</Box>
))}
</Card>
)}
</Box>
);
})}
</Box>
{/* ─── Total bar ─── */}
<Paper
elevation={0}
sx={{
p: 3,
mt: 4,
mb: 4,
borderRadius: 2,
bgcolor: 'var(--fa-color-surface-warm)',
border: '1px solid',
borderColor: 'var(--fa-color-brand-200)',
}}
>
{totalAllowances != null && totalAllowances > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" color="text.secondary">
Package allowances applied
</Typography>
<Typography variant="body2" color="text.secondary">
${totalAllowances.toLocaleString('en-AU')}
</Typography>
</Box>
)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<Typography variant="h5">Total</Typography>
<Typography variant="h4" color="primary" aria-live="polite" aria-atomic="true">
${totalPrice.toLocaleString('en-AU')}
</Typography>
</Box>
</Paper> </Paper>
{/* Payment reassurance */} {/* Payment reassurance */}
@@ -236,26 +711,25 @@ export const SummaryStep: React.FC<SummaryStepProps> = ({
<Divider sx={{ my: 3 }} /> <Divider sx={{ my: 3 }} />
{/* CTAs */} {/* CTAs */}
<Box <Button variant="contained" size="large" fullWidth loading={loading} onClick={onConfirm}>
sx={{ {isPrePlanning ? 'Save your plan' : 'Confirm and continue to payment'}
display: 'flex', </Button>
justifyContent: 'space-between',
alignItems: 'center', {onSaveAndExit && (
flexDirection: { xs: 'column-reverse', sm: 'row' }, <Button variant="text" color="secondary" fullWidth onClick={onSaveAndExit} sx={{ mt: 1 }}>
gap: 2, Save and continue later
}}
>
{onSaveAndExit ? (
<Button variant="text" color="secondary" onClick={onSaveAndExit} type="button">
Save and continue later
</Button>
) : (
<Box />
)}
<Button variant="contained" size="large" loading={loading} onClick={onConfirm}>
{isPrePlanning ? 'Save your plan' : 'Confirm'}
</Button> </Button>
</Box> )}
{/* Share dialog */}
{onShareByEmail && (
<ShareDialog
open={shareOpen}
onClose={() => setShareOpen(false)}
onSend={onShareByEmail}
loading={shareLoading}
/>
)}
</WizardLayout> </WizardLayout>
); );
}; };

View File

@@ -1,2 +1,7 @@
export { SummaryStep, default } from './SummaryStep'; export { SummaryStep, default } from './SummaryStep';
export type { SummaryStepProps, SummarySection, SummaryLineItem } from './SummaryStep'; export type {
SummaryStepProps,
SummarySection,
SummaryLineItem,
ArrangementDetails,
} from './SummaryStep';