Files
Parsons/src/components/pages/SummaryStep/SummaryStep.tsx
Richie f33a1e1d42 SummaryStep: fix share button position + save button visibility
- Share this plan: right-aligned below divider, clear spacing before content
- Save and continue later: outlined button, full width, under confirm CTA

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:03:33 +11:00

739 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import TextField from '@mui/material/TextField';
import ForwardToInboxOutlinedIcon from '@mui/icons-material/ForwardToInboxOutlined';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
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 { WizardLayout } from '../../templates/WizardLayout';
import { DialogShell } from '../../atoms/DialogShell';
import { Card } from '../../atoms/Card';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { IconButton } from '../../atoms/IconButton';
import { Divider } from '../../atoms/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
/** A line item within a summary section */
export interface SummaryLineItem {
label: string;
value?: string;
price?: number;
/** Custom price label like "POA" */
priceLabel?: string;
}
/** A section of the plan summary */
export interface SummarySection {
id: string;
/** Section heading (e.g. "Coffin", "Service Venue") */
title: string;
/** Step ID for edit navigation */
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;
}
/** Props for the SummaryStep page component */
export interface SummaryStepProps {
/** Arrangement details shown at the top */
arrangementDetails?: ArrangementDetails;
/** Summary sections */
sections: SummarySection[];
/** Total cost */
totalPrice: number;
/** Total allowances applied */
totalAllowances?: number;
/** Callback when Confirm is clicked */
onConfirm: () => void;
/** Callback for back navigation */
onBack?: () => void;
/** Callback for save-and-exit */
onSaveAndExit?: () => void;
/** Callback when edit is clicked on a section */
onEdit?: (stepId: string) => void;
/** Callback when plan is shared via email */
onShareByEmail?: (emails: string[]) => void;
/** Whether the share is in progress */
shareLoading?: boolean;
/** Whether the Confirm button is in a loading state */
loading?: boolean;
/** Whether this is a pre-planning flow */
isPrePlanning?: boolean;
/** Navigation bar */
navigation?: React.ReactNode;
/** Progress stepper */
progressStepper?: React.ReactNode;
/** Hide the help bar */
hideHelpBar?: boolean;
/** MUI sx prop */
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 ───────────────────────────────────────────────────────────────
/**
* Step 13 — Summary / Review for the FA arrangement wizard.
*
* Visual cart-style summary of the funeral plan. Each selection is shown
* as a compact card with image, details, and pricing. Allowances are
* displayed inline per section. Email sharing via dialog.
*
* Pure presentation component — props in, callbacks out.
*
* Spec: documentation/steps/steps/13_summary.yaml
*/
export const SummaryStep: React.FC<SummaryStepProps> = ({
arrangementDetails,
sections,
totalPrice,
totalAllowances,
onConfirm,
onBack,
onSaveAndExit,
onEdit,
onShareByEmail,
shareLoading = false,
loading = false,
isPrePlanning = false,
navigation,
progressStepper,
hideHelpBar,
sx,
}) => {
const [shareOpen, setShareOpen] = React.useState(false);
return (
<WizardLayout
variant="centered-form"
navigation={navigation}
progressStepper={progressStepper}
showBackLink={!!onBack}
backLabel="Back"
onBack={onBack}
hideHelpBar={hideHelpBar}
sx={sx}
>
{/* Header */}
<Typography variant="display3" component="h1" tabIndex={-1} sx={{ mb: 1 }}>
Review your plan
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
{isPrePlanning
? "Here's an overview of your plan. You can make changes at any time."
: 'Please review your selections below. You can edit any section before confirming.'}
</Typography>
<Divider sx={{ mb: 2 }} />
{onShareByEmail && (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 4 }}>
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<ForwardToInboxOutlinedIcon />}
onClick={() => setShareOpen(true)}
>
Share this plan
</Button>
</Box>
)}
{/* ─── 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>
{/* Payment reassurance */}
{!isPrePlanning && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 2, textAlign: 'center' }}>
You won&apos;t be charged until you complete the next step.
</Typography>
)}
<Divider sx={{ my: 3 }} />
{/* CTAs */}
<Button variant="contained" size="large" fullWidth loading={loading} onClick={onConfirm}>
{isPrePlanning ? 'Save your plan' : 'Confirm and continue to payment'}
</Button>
{onSaveAndExit && (
<Button
variant="outlined"
color="secondary"
size="large"
fullWidth
onClick={onSaveAndExit}
sx={{ mt: 1.5 }}
>
Save and continue later
</Button>
)}
{/* Share dialog */}
{onShareByEmail && (
<ShareDialog
open={shareOpen}
onClose={() => setShareOpen(false)}
onSend={onShareByEmail}
loading={shareLoading}
/>
)}
</WizardLayout>
);
};
SummaryStep.displayName = 'SummaryStep';
export default SummaryStep;