Add package comparison feature: CompareBar, ComparisonTable, ComparisonPage
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>
This commit is contained in:
497
src/components/pages/ComparisonPage/ComparisonPage.tsx
Normal file
497
src/components/pages/ComparisonPage/ComparisonPage.tsx
Normal file
@@ -0,0 +1,497 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user