From 59f4eb46db3a27294148c5bc813c730cbaa04f5b Mon Sep 17 00:00:00 2001 From: Richie Date: Tue, 31 Mar 2026 12:58:25 +1100 Subject: [PATCH] SummaryStep: visual cart layout with arrangement details and share dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../pages/SummaryStep/SummaryStep.stories.tsx | 109 ++- .../pages/SummaryStep/SummaryStep.tsx | 774 ++++++++++++++---- src/components/pages/SummaryStep/index.ts | 7 +- 3 files changed, 711 insertions(+), 179 deletions(-) diff --git a/src/components/pages/SummaryStep/SummaryStep.stories.tsx b/src/components/pages/SummaryStep/SummaryStep.stories.tsx index c4b2fb1..ed8dc53 100644 --- a/src/components/pages/SummaryStep/SummaryStep.stories.tsx +++ b/src/components/pages/SummaryStep/SummaryStep.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react'; import { SummaryStep } from './SummaryStep'; -import type { SummarySection } from './SummaryStep'; +import type { SummarySection, ArrangementDetails } from './SummaryStep'; import { Navigation } from '../../organisms/Navigation'; 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[] = [ { id: 'provider', title: 'Funeral Provider', editStepId: 'providers', - items: [ - { label: 'Provider', value: 'H. Parsons Funeral Directors' }, - { label: 'Package', value: 'Essential Service Package', price: 4950 }, - ], + imageUrl: 'https://placehold.co/320x200/F5F5F0/8B8B7E?text=H.+Parsons', + name: 'H. Parsons Funeral Directors', + subtitle: 'Essential Service Package', + location: 'Mackay, QLD', + price: 4950, }, { id: 'venue', title: 'Service Venue', editStepId: 'venue', + imageUrl: 'https://placehold.co/320x200/F5F5F0/8B8B7E?text=West+Chapel', + name: 'West Chapel', + location: 'Strathfield, NSW', + price: 900, items: [ - { label: 'Venue', value: 'West Chapel, Strathfield', price: 900 }, - { label: 'Photo presentation', value: 'Included', price: 150 }, - { label: 'Livestream', value: 'Included', price: 200 }, + { label: 'Photo presentation', price: 150 }, + { label: 'Livestream', price: 200 }, ], }, { id: 'crematorium', title: 'Crematorium', editStepId: 'crematorium', - items: [ - { label: 'Crematorium', value: 'Warrill Park Crematorium', price: 850 }, - { label: 'Following hearse', value: 'Yes' }, - ], + imageUrl: 'https://placehold.co/320x200/F5F5F0/8B8B7E?text=Warrill+Park', + name: 'Warrill Park Crematorium', + location: 'Ipswich, QLD', + price: 850, }, { id: 'coffin', title: 'Coffin', 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: [ - { label: 'Coffin', value: 'Cedar Classic', price: 2800 }, - { label: 'Handles', value: 'Brass Bar Handle', price: 0 }, - { label: 'Lining', value: 'White Satin', price: 0 }, + { label: 'Dressing and preparation' }, + { label: 'Viewing', value: 'Same venue' }, + { label: 'Funeral announcement' }, ], }, { - id: 'services', - title: 'Additional Services', - editStepId: 'additional_services', + id: 'extras', + title: 'Optional Extras', + editStepId: 'extras', items: [ - { label: 'Funeral announcement', value: 'Included' }, - { label: 'Bearing', value: 'Family and friends' }, + { label: 'Catering', priceLabel: 'Price on application' }, + { 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; // ─── At-need (default) ────────────────────────────────────────────────────── -/** Full summary for at-need flow */ +/** Full summary for at-need flow with allowances */ export const Default: Story = { render: () => ( alert('Confirmed — proceed to payment')} onBack={() => alert('Back')} onSaveAndExit={() => alert('Save')} onEdit={(stepId) => alert(`Edit: ${stepId}`)} - onShare={() => alert('Share plan')} + onShareByEmail={(emails) => alert(`Share to: ${emails.join(', ')}`)} 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 ( + alert('Confirmed')} + onBack={() => alert('Back')} + onEdit={(stepId) => alert(`Edit: ${stepId}`)} + onShareByEmail={(emails) => alert(`Share to: ${emails.join(', ')}`)} + navigation={nav} + /> + ); + }, +}; + // ─── Pre-planning ─────────────────────────────────────────────────────────── -/** Pre-planning variant — "Save your plan" CTA, no payment */ +/** Pre-planning variant — "Save your plan" CTA */ export const PrePlanning: Story = { render: () => ( alert('Plan saved')} onBack={() => alert('Back')} onEdit={(stepId) => alert(`Edit: ${stepId}`)} - onShare={() => alert('Share plan')} + onShareByEmail={(emails) => alert(`Share to: ${emails.join(', ')}`)} isPrePlanning navigation={nav} /> @@ -140,8 +192,9 @@ export const PrePlanning: Story = { export const Loading: Story = { render: () => ( {}} loading navigation={nav} diff --git a/src/components/pages/SummaryStep/SummaryStep.tsx b/src/components/pages/SummaryStep/SummaryStep.tsx index 4872043..9043696 100644 --- a/src/components/pages/SummaryStep/SummaryStep.tsx +++ b/src/components/pages/SummaryStep/SummaryStep.tsx @@ -1,14 +1,18 @@ import React from 'react'; import Box from '@mui/material/Box'; import Paper from '@mui/material/Paper'; -import Accordion from '@mui/material/Accordion'; -import AccordionSummary from '@mui/material/AccordionSummary'; -import AccordionDetails from '@mui/material/AccordionDetails'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import TextField from '@mui/material/TextField'; +import ForwardToInboxOutlinedIcon from '@mui/icons-material/ForwardToInboxOutlined'; 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 { 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'; @@ -16,30 +20,67 @@ import { Divider } from '../../atoms/Divider'; // ─── Types ─────────────────────────────────────────────────────────────────── -/** A single line item in the summary */ +/** A line item within a summary section */ export interface SummaryLineItem { label: string; - value: string; + value?: string; 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 { id: string; + /** Section heading (e.g. "Coffin", "Service Venue") */ title: string; - items: SummaryLineItem[]; - /** Step ID to navigate back to for editing */ + /** 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; - /** Deposit amount (if applicable) */ - depositAmount?: number; + /** Total allowances applied */ + totalAllowances?: number; /** Callback when Confirm is clicked */ onConfirm: () => void; /** Callback for back navigation */ @@ -48,8 +89,10 @@ export interface SummaryStepProps { onSaveAndExit?: () => void; /** Callback when edit is clicked on a section */ onEdit?: (stepId: string) => void; - /** Callback for sharing the plan */ - onShare?: () => 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 */ @@ -64,30 +107,182 @@ export interface SummaryStepProps { sx?: SxProps; } +// ─── 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 ( + + + + + ) : ( + + + + ) + } + > + {!sent ? ( + <> + + Enter the email address of anyone you'd like to share this plan with. + + {emails.map((email, index) => ( + + handleChange(index, e.target.value)} + inputRef={(el) => { + if (el && index === emails.length - 1 && emails.length > 1) el.focus(); + }} + /> + {emails.length > 1 && ( + handleRemove(index)} + > + + + )} + + ))} + + + ) : ( + + + + Plan sent + + + Your plan has been sent to{' '} + {validEmails.length === 1 ? validEmails[0] : `${validEmails.length} recipients`}. + + + )} + + ); +}; + +// ─── Internal: Section Header ─────────────────────────────────────────────── + +const SectionHeader: React.FC<{ + title: string; + editStepId?: string; + onEdit?: (stepId: string) => void; +}> = ({ title, editStepId, onEdit }) => ( + + + {title} + + {editStepId && onEdit && ( + + )} + +); + // ─── Component ─────────────────────────────────────────────────────────────── /** * Step 13 — Summary / Review for the FA arrangement wizard. * - * Complete summary of the funeral plan with all selections and pricing. - * Accordion sections with edit links back to each step. Total bar at bottom. - * - * For pre-planning: CTA is "Save your plan" instead of "Confirm". - * For at-need: CTA is "Confirm and continue to payment". + * 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 = ({ + arrangementDetails, sections, totalPrice, - depositAmount, + totalAllowances, onConfirm, onBack, onSaveAndExit, onEdit, - onShare, + onShareByEmail, + shareLoading = false, loading = false, isPrePlanning = false, navigation, @@ -95,6 +290,8 @@ export const SummaryStep: React.FC = ({ hideHelpBar, sx, }) => { + const [shareOpen, setShareOpen] = React.useState(false); + return ( = ({ hideHelpBar={hideHelpBar} sx={sx} > - {/* Header with share */} + {/* Header */} + + Review your plan + + - - Review your plan - - {onShare && ( - - - - )} - - - - Check everything looks right before confirming. - - - - You can edit any section by tapping the edit icon. - - - {/* ─── Summary sections ─── */} - {sections.map((section) => ( - - } sx={{ px: 3, py: 1 }}> - - - {section.title} - - {section.editStepId && onEdit && ( - { - e.stopPropagation(); - onEdit(section.editStepId!); - }} - > - - - )} - - - - - {section.items.map((item, i) => ( - - - - {item.label} - - - {item.value} - - - {item.price != null && ( - - ${item.price.toLocaleString('en-AU')} - - )} - - ))} - - - - ))} - - {/* ─── Total bar ─── */} - - - Total cost - {depositAmount != null && !isPrePlanning && ( - - Deposit: ${depositAmount.toLocaleString('en-AU')} - - )} - - - ${totalPrice.toLocaleString('en-AU')} + + {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.'} + {onShareByEmail && ( + + )} + + + + + {/* ─── Arrangement details ─── */} + {arrangementDetails && ( + <> + + + + {arrangementDetails.arrangerName && ( + + + Arranged by + + + {arrangementDetails.arrangerName} + + + )} + {arrangementDetails.deceasedName && ( + + + In memory of + + + {arrangementDetails.deceasedName} + + + )} + {arrangementDetails.serviceTradition && ( + + + Service tradition + + + {arrangementDetails.serviceTradition} + + + )} + {arrangementDetails.preferredTime && ( + + + Preferred time + + + {arrangementDetails.preferredTime} + + + )} + {arrangementDetails.preferredDates && + arrangementDetails.preferredDates.length > 0 && ( + + + Preferred date{arrangementDetails.preferredDates.length > 1 ? 's' : ''} + + + {arrangementDetails.preferredDates.join(' · ')} + + + )} + + + + )} + + {/* ─── Summary sections ─── */} + + {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 ( + + + + {/* Visual card for sections with image/name */} + {section.name && ( + + + {/* Thumbnail */} + {section.imageUrl && ( + + )} + + {/* Details */} + + + {section.name} + + + {section.subtitle && ( + + {section.subtitle} + + )} + + {/* Location with pin */} + {section.location && ( + + + + {section.location} + + + )} + + {/* Colour swatch */} + {section.colourName && ( + + {section.colourHex && ( + + )} + + {section.colourName} + + + )} + + {/* Spacer pushes price to bottom */} + + + {/* Price — hugs bottom of card detail area */} + {section.price != null && !hasAllowance && ( + + + ${section.price.toLocaleString('en-AU')} + + + )} + + {/* Fully covered — no price breakdown */} + {isFullyCovered && ( + + Included in your package + + )} + + + + {/* Allowance breakdown — full-width divider section */} + {hasAllowance && !isFullyCovered && ( + + + + + Price + + + ${section.price!.toLocaleString('en-AU')} + + + + + Allowance applied + + + −${section.allowanceAmount!.toLocaleString('en-AU')} + + + + Remaining + + ${remainingCost.toLocaleString('en-AU')} + + + + )} + + {/* Sub-items — full-width divider section */} + {section.items && section.items.length > 0 && ( + + + {section.items.map((item, i) => ( + + + {item.label} + {item.value && ` — ${item.value}`} + + {item.priceLabel ? ( + + {item.priceLabel} + + ) : ( + item.price != null && ( + + ${item.price.toLocaleString('en-AU')} + + ) + )} + + ))} + + )} + + )} + + {/* List-style section (no image/name — included services, extras) */} + {!section.name && section.items && section.items.length > 0 && ( + + {section.items.map((item, i) => ( + + + {/* Ticks only in sections with no priced items (included services) */} + {!hasPricedItems && ( + + )} + + {item.label} + {item.value && ( + + {' '} + — {item.value} + + )} + + + {item.priceLabel ? ( + + {item.priceLabel} + + ) : ( + item.price != null && ( + + ${item.price.toLocaleString('en-AU')} + + ) + )} + + ))} + + )} + + ); + })} + + + {/* ─── Total bar ─── */} + + {totalAllowances != null && totalAllowances > 0 && ( + + + Package allowances applied + + + −${totalAllowances.toLocaleString('en-AU')} + + + )} + + Total + + ${totalPrice.toLocaleString('en-AU')} + + {/* Payment reassurance */} @@ -236,26 +711,25 @@ export const SummaryStep: React.FC = ({ {/* CTAs */} - - {onSaveAndExit ? ( - - ) : ( - - )} - + + {onSaveAndExit && ( + - + )} + + {/* Share dialog */} + {onShareByEmail && ( + setShareOpen(false)} + onSend={onShareByEmail} + loading={shareLoading} + /> + )} ); }; diff --git a/src/components/pages/SummaryStep/index.ts b/src/components/pages/SummaryStep/index.ts index 020b817..0d81ae2 100644 --- a/src/components/pages/SummaryStep/index.ts +++ b/src/components/pages/SummaryStep/index.ts @@ -1,2 +1,7 @@ export { SummaryStep, default } from './SummaryStep'; -export type { SummaryStepProps, SummarySection, SummaryLineItem } from './SummaryStep'; +export type { + SummaryStepProps, + SummarySection, + SummaryLineItem, + ArrangementDetails, +} from './SummaryStep';