New components for side-by-side funeral package comparison: - CompareBar molecule: floating bottom pill with fraction badge (1/3, 2/3, 3/3), contextual copy, Compare CTA. For ProvidersStep and PackagesStep. - ComparisonTable organism: CSS Grid comparison with info card, floating verified badges, separate section tables (Essentials/Optionals/Extras) with left accent borders, row hover, horizontal scroll on narrow desktops, font hierarchy. - ComparisonPage: WizardLayout wide-form with Share/Print actions. Desktop shows ComparisonTable, mobile shows mini-card tab rail + single package card view. Recommended package as separate prop (D038). Also fixes PackageDetail: adds priceLabel pass-through (D039), updates stories to Essentials/Optionals/Extras section naming (D035). Decisions: D035-D039 logged. Audits: CompareBar 18/20, ComparisonTable 17/20. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
498 lines
17 KiB
TypeScript
498 lines
17 KiB
TypeScript
import React, { useId, useState } from 'react';
|
|
import Box from '@mui/material/Box';
|
|
import useMediaQuery from '@mui/material/useMediaQuery';
|
|
import { useTheme } from '@mui/material/styles';
|
|
import Tooltip from '@mui/material/Tooltip';
|
|
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
|
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
|
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
|
|
import StarRoundedIcon from '@mui/icons-material/StarRounded';
|
|
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
|
import ShareOutlinedIcon from '@mui/icons-material/ShareOutlined';
|
|
import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
|
|
import type { SxProps, Theme } from '@mui/material/styles';
|
|
import { Typography } from '../../atoms/Typography';
|
|
import { Button } from '../../atoms/Button';
|
|
import { Badge } from '../../atoms/Badge';
|
|
import { Divider } from '../../atoms/Divider';
|
|
import { Card } from '../../atoms/Card';
|
|
import { WizardLayout } from '../../templates/WizardLayout';
|
|
import {
|
|
ComparisonTable,
|
|
type ComparisonPackage,
|
|
type ComparisonCellValue,
|
|
} from '../../organisms/ComparisonTable';
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
/** Props for the ComparisonPage */
|
|
export interface ComparisonPageProps {
|
|
/** User-selected packages to compare (max 3) */
|
|
packages: ComparisonPackage[];
|
|
/** System-recommended package — always shown as an additional column */
|
|
recommendedPackage?: ComparisonPackage;
|
|
/** Called when user clicks CTA on a package */
|
|
onArrange: (packageId: string) => void;
|
|
/** Called when user removes a package from comparison */
|
|
onRemove: (packageId: string) => void;
|
|
/** Called when user clicks Back */
|
|
onBack: () => void;
|
|
/** Called when user clicks Share */
|
|
onShare?: () => void;
|
|
/** Called when user clicks Print */
|
|
onPrint?: () => void;
|
|
/** Navigation bar slot */
|
|
navigation?: React.ReactNode;
|
|
/** MUI sx prop */
|
|
sx?: SxProps<Theme>;
|
|
}
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
function formatPrice(amount: number): string {
|
|
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
|
|
}
|
|
|
|
function MobileCellValue({ value }: { value: ComparisonCellValue }) {
|
|
switch (value.type) {
|
|
case 'price':
|
|
return (
|
|
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'right' }}>
|
|
{formatPrice(value.amount)}
|
|
</Typography>
|
|
);
|
|
case 'allowance':
|
|
return (
|
|
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'right' }}>
|
|
{formatPrice(value.amount)}*
|
|
</Typography>
|
|
);
|
|
case 'complimentary':
|
|
return (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, justifyContent: 'flex-end' }}>
|
|
<CheckCircleOutlineIcon
|
|
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
|
|
aria-hidden
|
|
/>
|
|
<Typography variant="body2" sx={{ color: 'var(--fa-color-feedback-success)' }}>
|
|
Complimentary
|
|
</Typography>
|
|
</Box>
|
|
);
|
|
case 'included':
|
|
return (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, justifyContent: 'flex-end' }}>
|
|
<CheckCircleOutlineIcon
|
|
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
|
|
aria-hidden
|
|
/>
|
|
<Typography variant="body2" sx={{ color: 'var(--fa-color-feedback-success)' }}>
|
|
Included
|
|
</Typography>
|
|
</Box>
|
|
);
|
|
case 'poa':
|
|
return (
|
|
<Typography
|
|
variant="body2"
|
|
color="text.secondary"
|
|
sx={{ fontStyle: 'italic', textAlign: 'right' }}
|
|
>
|
|
Price On Application
|
|
</Typography>
|
|
);
|
|
case 'unknown':
|
|
return (
|
|
<Badge color="default" variant="soft" size="small">
|
|
Unknown
|
|
</Badge>
|
|
);
|
|
case 'unavailable':
|
|
return (
|
|
<Typography
|
|
variant="body2"
|
|
sx={{ color: 'var(--fa-color-neutral-400)', textAlign: 'right' }}
|
|
>
|
|
—
|
|
</Typography>
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Mobile card view ───────────────────────────────────────────────────────
|
|
|
|
function MobilePackageCard({
|
|
pkg,
|
|
onArrange,
|
|
}: {
|
|
pkg: ComparisonPackage;
|
|
onArrange: (id: string) => void;
|
|
}) {
|
|
return (
|
|
<Card
|
|
variant="outlined"
|
|
selected={pkg.isRecommended}
|
|
padding="none"
|
|
sx={{ overflow: 'hidden' }}
|
|
>
|
|
{/* Recommended banner */}
|
|
{pkg.isRecommended && (
|
|
<Box sx={{ bgcolor: 'var(--fa-color-brand-600)', py: 0.75, textAlign: 'center' }}>
|
|
<Typography
|
|
variant="labelSm"
|
|
sx={{
|
|
color: 'var(--fa-color-white)',
|
|
fontWeight: 600,
|
|
letterSpacing: '0.05em',
|
|
textTransform: 'uppercase',
|
|
}}
|
|
>
|
|
Recommended
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Provider header */}
|
|
<Box
|
|
sx={{
|
|
bgcolor: pkg.isRecommended
|
|
? 'var(--fa-color-surface-warm)'
|
|
: 'var(--fa-color-surface-subtle)',
|
|
px: 2.5,
|
|
pt: 2.5,
|
|
pb: 2,
|
|
}}
|
|
>
|
|
{/* Verified badge */}
|
|
{pkg.provider.verified && (
|
|
<Badge
|
|
color="brand"
|
|
variant="soft"
|
|
size="small"
|
|
icon={<VerifiedOutlinedIcon sx={{ fontSize: 14 }} />}
|
|
sx={{ mb: 1 }}
|
|
>
|
|
Verified
|
|
</Badge>
|
|
)}
|
|
|
|
{/* Provider name */}
|
|
<Typography variant="label" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
|
|
{pkg.provider.name}
|
|
</Typography>
|
|
|
|
{/* Location + Rating */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
|
<LocationOnOutlinedIcon sx={{ fontSize: 14, color: 'text.secondary' }} aria-hidden />
|
|
<Typography variant="caption" color="text.secondary">
|
|
{pkg.provider.location}
|
|
</Typography>
|
|
</Box>
|
|
{pkg.provider.rating != null && (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
|
<StarRoundedIcon
|
|
sx={{ fontSize: 14, color: 'var(--fa-color-brand-500)' }}
|
|
aria-hidden
|
|
/>
|
|
<Typography variant="caption" color="text.secondary">
|
|
{pkg.provider.rating}
|
|
{pkg.provider.reviewCount != null && ` (${pkg.provider.reviewCount})`}
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
|
|
<Divider sx={{ mb: 1.5 }} />
|
|
|
|
{/* Package name + price */}
|
|
<Typography variant="h5" component="p">
|
|
{pkg.name}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Total package price
|
|
</Typography>
|
|
<Typography variant="h3" sx={{ color: 'primary.main', fontWeight: 700 }}>
|
|
{formatPrice(pkg.price)}
|
|
</Typography>
|
|
|
|
<Button
|
|
variant={pkg.provider.verified ? 'contained' : 'soft'}
|
|
color={pkg.provider.verified ? 'primary' : 'secondary'}
|
|
size="large"
|
|
fullWidth
|
|
onClick={() => onArrange(pkg.id)}
|
|
sx={{ mt: 2 }}
|
|
>
|
|
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
|
|
</Button>
|
|
</Box>
|
|
|
|
{/* Sections — with left accent borders on headings */}
|
|
<Box sx={{ px: 2.5, py: 2.5 }}>
|
|
{pkg.itemizedAvailable === false ? (
|
|
<Box sx={{ textAlign: 'center', py: 3 }}>
|
|
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
|
Itemised pricing not available for this provider.
|
|
</Typography>
|
|
</Box>
|
|
) : (
|
|
pkg.sections.map((section, sIdx) => (
|
|
<Box key={section.heading} sx={{ mb: sIdx < pkg.sections.length - 1 ? 3 : 0 }}>
|
|
{/* Section heading with left accent */}
|
|
<Box
|
|
sx={{
|
|
borderLeft: '3px solid',
|
|
borderLeftColor: 'var(--fa-color-brand-500)',
|
|
pl: 1.5,
|
|
mb: 1.5,
|
|
mt: sIdx > 0 ? 1 : 0,
|
|
}}
|
|
>
|
|
<Typography variant="h6" component="h3">
|
|
{section.heading}
|
|
</Typography>
|
|
</Box>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
|
{section.items.map((item) => (
|
|
<Box
|
|
key={item.name}
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: 2,
|
|
py: 1.5,
|
|
borderBottom: '1px solid',
|
|
borderColor: 'divider',
|
|
}}
|
|
>
|
|
<Box sx={{ minWidth: 0, flex: '1 1 50%', maxWidth: '60%' }}>
|
|
<Typography variant="body2" color="text.secondary" component="span">
|
|
{item.name}
|
|
</Typography>
|
|
{item.info && (
|
|
<Box component="span" sx={{ whiteSpace: 'nowrap' }}>
|
|
{'\u00A0'}
|
|
<Tooltip title={item.info} arrow placement="top">
|
|
<InfoOutlinedIcon
|
|
aria-label={`More information about ${item.name}`}
|
|
sx={{
|
|
fontSize: 14,
|
|
color: 'var(--fa-color-neutral-400)',
|
|
cursor: 'help',
|
|
verticalAlign: 'middle',
|
|
}}
|
|
/>
|
|
</Tooltip>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
<MobileCellValue value={item.value} />
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
))
|
|
)}
|
|
</Box>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ─── Component ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Package comparison page for the FA design system.
|
|
*
|
|
* Desktop: Full ComparisonTable with info card, floating verified badges,
|
|
* section tables with left accent borders.
|
|
* Mobile: Tabbed card view with horizontal chip rail.
|
|
*
|
|
* Share + Print utility actions in the page header.
|
|
*/
|
|
export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPageProps>(
|
|
(
|
|
{ packages, recommendedPackage, onArrange, onRemove, onBack, onShare, onPrint, navigation, sx },
|
|
ref,
|
|
) => {
|
|
const theme = useTheme();
|
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
|
const tablistId = useId();
|
|
|
|
const allPackages = React.useMemo(() => {
|
|
const result = [...packages];
|
|
if (recommendedPackage) {
|
|
result.push({ ...recommendedPackage, isRecommended: true });
|
|
}
|
|
return result;
|
|
}, [packages, recommendedPackage]);
|
|
|
|
const [activeTabIdx, setActiveTabIdx] = useState(0);
|
|
const activePackage = allPackages[activeTabIdx] ?? allPackages[0];
|
|
|
|
const providerCount = new Set(allPackages.map((p) => p.provider.name)).size;
|
|
const subtitle =
|
|
providerCount > 1
|
|
? `Comparing ${packages.length} package${packages.length !== 1 ? 's' : ''} from different providers`
|
|
: `Comparing ${packages.length} package${packages.length !== 1 ? 's' : ''}`;
|
|
|
|
return (
|
|
<Box ref={ref} sx={sx}>
|
|
<WizardLayout
|
|
variant="wide-form"
|
|
navigation={navigation}
|
|
showBackLink
|
|
backLabel="Back"
|
|
onBack={onBack}
|
|
>
|
|
{/* Page header with Share/Print actions */}
|
|
<Box sx={{ mb: { xs: 3, md: 5 } }}>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'flex-start',
|
|
justifyContent: 'space-between',
|
|
gap: 2,
|
|
flexWrap: 'wrap',
|
|
}}
|
|
>
|
|
<Box>
|
|
<Typography variant="h2" component="h1" sx={{ mb: 1 }}>
|
|
Compare packages
|
|
</Typography>
|
|
<Typography variant="body1" color="text.secondary" aria-live="polite">
|
|
{subtitle}
|
|
</Typography>
|
|
</Box>
|
|
|
|
{/* Share + Print */}
|
|
{(onShare || onPrint) && (
|
|
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
|
|
{onShare && (
|
|
<Button
|
|
variant="outlined"
|
|
color="secondary"
|
|
size="small"
|
|
startIcon={<ShareOutlinedIcon />}
|
|
onClick={onShare}
|
|
>
|
|
Share
|
|
</Button>
|
|
)}
|
|
{onPrint && (
|
|
<Button
|
|
variant="outlined"
|
|
color="secondary"
|
|
size="small"
|
|
startIcon={<PrintOutlinedIcon />}
|
|
onClick={onPrint}
|
|
>
|
|
Print
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Desktop: ComparisonTable */}
|
|
{!isMobile && (
|
|
<ComparisonTable packages={allPackages} onArrange={onArrange} onRemove={onRemove} />
|
|
)}
|
|
|
|
{/* Mobile: Tab rail + card view */}
|
|
{isMobile && allPackages.length > 0 && (
|
|
<>
|
|
{/* Tab rail — mini cards showing provider + package name */}
|
|
<Box
|
|
role="tablist"
|
|
id={tablistId}
|
|
aria-label="Packages to compare"
|
|
sx={{
|
|
display: 'flex',
|
|
gap: 1.5,
|
|
overflowX: 'auto',
|
|
pb: 1,
|
|
mb: 2.5,
|
|
scrollbarWidth: 'none',
|
|
'&::-webkit-scrollbar': { display: 'none' },
|
|
WebkitOverflowScrolling: 'touch',
|
|
}}
|
|
>
|
|
{allPackages.map((pkg, idx) => {
|
|
const isActive = idx === activeTabIdx;
|
|
return (
|
|
<Card
|
|
key={pkg.id}
|
|
role="tab"
|
|
aria-selected={isActive}
|
|
aria-controls={`comparison-tabpanel-${idx}`}
|
|
id={`comparison-tab-${idx}`}
|
|
variant="outlined"
|
|
selected={isActive}
|
|
padding="none"
|
|
onClick={() => setActiveTabIdx(idx)}
|
|
interactive
|
|
sx={{
|
|
flexShrink: 0,
|
|
minWidth: 150,
|
|
maxWidth: 200,
|
|
cursor: 'pointer',
|
|
...(pkg.isRecommended &&
|
|
!isActive && {
|
|
borderColor: 'var(--fa-color-brand-500)',
|
|
}),
|
|
}}
|
|
>
|
|
<Box sx={{ px: 2, py: 1.5 }}>
|
|
<Typography
|
|
variant="labelSm"
|
|
sx={{
|
|
fontWeight: 600,
|
|
display: 'block',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{pkg.isRecommended ? `★ ${pkg.provider.name}` : pkg.provider.name}
|
|
</Typography>
|
|
<Typography
|
|
variant="caption"
|
|
color="text.secondary"
|
|
sx={{
|
|
display: 'block',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{pkg.name}
|
|
</Typography>
|
|
</Box>
|
|
</Card>
|
|
);
|
|
})}
|
|
</Box>
|
|
|
|
{activePackage && (
|
|
<Box
|
|
role="tabpanel"
|
|
id={`comparison-tabpanel-${activeTabIdx}`}
|
|
aria-labelledby={`comparison-tab-${activeTabIdx}`}
|
|
>
|
|
<MobilePackageCard pkg={activePackage} onArrange={onArrange} />
|
|
</Box>
|
|
)}
|
|
</>
|
|
)}
|
|
</WizardLayout>
|
|
</Box>
|
|
);
|
|
},
|
|
);
|
|
|
|
ComparisonPage.displayName = 'ComparisonPage';
|
|
export default ComparisonPage;
|