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:
516
src/components/organisms/ComparisonTable/ComparisonTable.tsx
Normal file
516
src/components/organisms/ComparisonTable/ComparisonTable.tsx
Normal file
@@ -0,0 +1,516 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
||||
import StarRoundedIcon from '@mui/icons-material/StarRounded';
|
||||
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
import { Button } from '../../atoms/Button';
|
||||
import { Badge } from '../../atoms/Badge';
|
||||
import { Card } from '../../atoms/Card';
|
||||
import { Link } from '../../atoms/Link';
|
||||
import { Divider } from '../../atoms/Divider';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Cell value types for the comparison table */
|
||||
export type ComparisonCellValue =
|
||||
| { type: 'price'; amount: number }
|
||||
| { type: 'allowance'; amount: number }
|
||||
| { type: 'complimentary' }
|
||||
| { type: 'included' }
|
||||
| { type: 'poa' }
|
||||
| { type: 'unknown' }
|
||||
| { type: 'unavailable' };
|
||||
|
||||
export interface ComparisonLineItem {
|
||||
name: string;
|
||||
info?: string;
|
||||
value: ComparisonCellValue;
|
||||
}
|
||||
|
||||
export interface ComparisonSection {
|
||||
heading: string;
|
||||
items: ComparisonLineItem[];
|
||||
}
|
||||
|
||||
export interface ComparisonProvider {
|
||||
name: string;
|
||||
location: string;
|
||||
logoUrl?: string;
|
||||
rating?: number;
|
||||
reviewCount?: number;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export interface ComparisonPackage {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
provider: ComparisonProvider;
|
||||
sections: ComparisonSection[];
|
||||
isRecommended?: boolean;
|
||||
itemizedAvailable?: boolean;
|
||||
}
|
||||
|
||||
export interface ComparisonTableProps {
|
||||
packages: ComparisonPackage[];
|
||||
onArrange: (packageId: string) => void;
|
||||
onRemove: (packageId: string) => void;
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatPrice(amount: number): string {
|
||||
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
|
||||
}
|
||||
|
||||
function CellValue({ value }: { value: ComparisonCellValue }) {
|
||||
switch (value.type) {
|
||||
case 'price':
|
||||
return (
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
|
||||
{formatPrice(value.amount)}
|
||||
</Typography>
|
||||
);
|
||||
case 'allowance':
|
||||
return (
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
|
||||
{formatPrice(value.amount)}*
|
||||
</Typography>
|
||||
);
|
||||
case 'complimentary':
|
||||
return (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<CheckCircleOutlineIcon
|
||||
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: 'var(--fa-color-feedback-success)', fontWeight: 500 }}
|
||||
>
|
||||
Complimentary
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
case 'included':
|
||||
return (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<CheckCircleOutlineIcon
|
||||
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: 'var(--fa-color-feedback-success)', fontWeight: 500 }}
|
||||
>
|
||||
Included
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
case 'poa':
|
||||
return (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||
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)' }}>
|
||||
—
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildMergedSections(
|
||||
packages: ComparisonPackage[],
|
||||
): { heading: string; items: { name: string; info?: string }[] }[] {
|
||||
const sectionMap = new Map<string, { name: string; info?: string }[]>();
|
||||
const sectionOrder: string[] = [];
|
||||
|
||||
for (const pkg of packages) {
|
||||
if (pkg.itemizedAvailable === false) continue;
|
||||
for (const section of pkg.sections) {
|
||||
if (!sectionMap.has(section.heading)) {
|
||||
sectionMap.set(section.heading, []);
|
||||
sectionOrder.push(section.heading);
|
||||
}
|
||||
const existing = sectionMap.get(section.heading)!;
|
||||
for (const item of section.items) {
|
||||
if (!existing.some((e) => e.name === item.name)) {
|
||||
existing.push({ name: item.name, info: item.info });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sectionOrder.map((heading) => ({
|
||||
heading,
|
||||
items: sectionMap.get(heading) ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
function lookupValue(
|
||||
pkg: ComparisonPackage,
|
||||
sectionHeading: string,
|
||||
itemName: string,
|
||||
): ComparisonCellValue {
|
||||
if (pkg.itemizedAvailable === false) return { type: 'unavailable' };
|
||||
const section = pkg.sections.find((s) => s.heading === sectionHeading);
|
||||
if (!section) return { type: 'unavailable' };
|
||||
const item = section.items.find((i) => i.name === itemName);
|
||||
if (!item) return { type: 'unavailable' };
|
||||
return item.value;
|
||||
}
|
||||
|
||||
/** Section heading with left accent border */
|
||||
function SectionHeading({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'var(--fa-color-surface-subtle)',
|
||||
px: 3,
|
||||
py: 2.5,
|
||||
borderLeft: '3px solid',
|
||||
borderLeftColor: 'var(--fa-color-brand-500)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" component="h3">
|
||||
{children}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Reusable bordered table wrapper */
|
||||
const tableSx = {
|
||||
display: 'grid',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 'var(--fa-card-border-radius-default)',
|
||||
overflow: 'hidden',
|
||||
bgcolor: 'background.paper',
|
||||
};
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Side-by-side package comparison table for the FA design system.
|
||||
*
|
||||
* Info card in top-left column, floating verified badges above cards,
|
||||
* section tables with left accent borders, no reviews table (rating in cards).
|
||||
*
|
||||
* Desktop only — ComparisonPage handles the mobile card view.
|
||||
*/
|
||||
export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableProps>(
|
||||
({ packages, onArrange, onRemove, sx }, ref) => {
|
||||
const colCount = packages.length + 1;
|
||||
const mergedSections = buildMergedSections(packages);
|
||||
const gridCols = `minmax(220px, 280px) repeat(${packages.length}, minmax(200px, 1fr))`;
|
||||
const minW = packages.length > 3 ? 960 : packages.length > 2 ? 800 : 600;
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
role="table"
|
||||
aria-label="Package comparison"
|
||||
sx={[
|
||||
{
|
||||
display: { xs: 'none', md: 'block' },
|
||||
overflowX: 'auto',
|
||||
},
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
>
|
||||
<Box sx={{ minWidth: minW }}>
|
||||
{/* ── Package header cards ── */}
|
||||
<Box
|
||||
role="row"
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: gridCols,
|
||||
gap: 2,
|
||||
mb: 4,
|
||||
alignItems: 'stretch',
|
||||
pt: 3, // Room for floating verified badges
|
||||
}}
|
||||
>
|
||||
{/* Info card — stretches to match package card height, text at top */}
|
||||
<Card
|
||||
role="columnheader"
|
||||
variant="elevated"
|
||||
padding="default"
|
||||
sx={{
|
||||
bgcolor: 'var(--fa-color-surface-subtle)',
|
||||
alignSelf: 'stretch',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-start',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
>
|
||||
<Typography variant="label" sx={{ fontWeight: 700, display: 'block', mb: 1 }}>
|
||||
Package Comparison
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ lineHeight: 1.5, display: 'block' }}
|
||||
>
|
||||
Review and compare features side-by-side to find the right fit.
|
||||
</Typography>
|
||||
</Card>
|
||||
|
||||
{/* Package cards */}
|
||||
{packages.map((pkg) => (
|
||||
<Box
|
||||
key={pkg.id}
|
||||
role="columnheader"
|
||||
aria-label={pkg.isRecommended ? `${pkg.name} (Recommended)` : pkg.name}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
overflow: 'visible',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Floating verified badge — overlaps card top edge */}
|
||||
{pkg.provider.verified && (
|
||||
<Badge
|
||||
color="brand"
|
||||
variant="soft"
|
||||
size="small"
|
||||
icon={<VerifiedOutlinedIcon sx={{ fontSize: 14 }} />}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
Verified
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Card
|
||||
variant="outlined"
|
||||
selected={pkg.isRecommended}
|
||||
padding="none"
|
||||
sx={{ overflow: 'hidden', flex: 1, display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
px: 2.5,
|
||||
py: 2.5,
|
||||
pt: pkg.provider.verified ? 3 : 2.5,
|
||||
gap: 0.5,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{/* Provider name (truncated with tooltip) */}
|
||||
<Tooltip
|
||||
title={pkg.provider.name}
|
||||
arrow
|
||||
placement="top"
|
||||
disableHoverListener={pkg.provider.name.length < 24}
|
||||
>
|
||||
<Typography
|
||||
variant="label"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{pkg.provider.name}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
|
||||
{/* Location */}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{pkg.provider.location}
|
||||
</Typography>
|
||||
|
||||
{/* Rating */}
|
||||
{pkg.provider.rating != null && (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<StarRoundedIcon
|
||||
sx={{ fontSize: 16, color: 'var(--fa-color-brand-500)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{pkg.provider.rating}
|
||||
{pkg.provider.reviewCount != null && ` (${pkg.provider.reviewCount})`}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ width: '100%', my: 1 }} />
|
||||
|
||||
<Typography variant="h6" component="p">
|
||||
{pkg.name}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
Total package price
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{ color: 'primary.main', fontWeight: 700 }}>
|
||||
{formatPrice(pkg.price)}
|
||||
</Typography>
|
||||
|
||||
{/* Spacer pushes CTA to bottom across all cards */}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
<Button
|
||||
variant={pkg.provider.verified ? 'contained' : 'soft'}
|
||||
color={pkg.provider.verified ? 'primary' : 'secondary'}
|
||||
size="medium"
|
||||
onClick={() => onArrange(pkg.id)}
|
||||
sx={{ mt: 1.5, px: 4 }}
|
||||
>
|
||||
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
|
||||
</Button>
|
||||
|
||||
{!pkg.isRecommended && (
|
||||
<Link
|
||||
component="button"
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
underline="hover"
|
||||
onClick={() => onRemove(pkg.id)}
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
Remove
|
||||
</Link>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* ── Section tables (each separate with left accent headings) ── */}
|
||||
{mergedSections.map((section) => (
|
||||
<Box key={section.heading} sx={{ ...tableSx, gridTemplateColumns: gridCols, mb: 3 }}>
|
||||
<Box role="row" sx={{ gridColumn: `1 / ${colCount + 1}` }}>
|
||||
<SectionHeading>{section.heading}</SectionHeading>
|
||||
</Box>
|
||||
|
||||
{section.items.map((item) => (
|
||||
<Box
|
||||
key={item.name}
|
||||
role="row"
|
||||
sx={{
|
||||
gridColumn: `1 / ${colCount + 1}`,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'subgrid',
|
||||
transition: 'background-color 0.15s ease',
|
||||
'&:hover': { bgcolor: 'var(--fa-color-brand-50)' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
role="cell"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
px: 3,
|
||||
py: 2,
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 0 }}>
|
||||
{item.name}
|
||||
</Typography>
|
||||
{item.info && (
|
||||
<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',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{packages.map((pkg) => (
|
||||
<Box
|
||||
key={pkg.id}
|
||||
role="cell"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
px: 2,
|
||||
py: 2,
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderLeft: '1px solid',
|
||||
borderLeftColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<CellValue value={lookupValue(pkg, section.heading, item.name)} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{packages.some((p) => p.itemizedAvailable === false) && mergedSections.length > 0 && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||
* Some providers have not provided an itemised pricing breakdown. Their items are
|
||||
shown as "—" above.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ComparisonTable.displayName = 'ComparisonTable';
|
||||
export default ComparisonTable;
|
||||
Reference in New Issue
Block a user