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:
2026-04-07 01:17:34 +10:00
parent eb26242ece
commit 52fd0f199a
14 changed files with 2359 additions and 81 deletions

View File

@@ -0,0 +1,166 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { CompareBar } from './CompareBar';
import type { CompareBarPackage } from './CompareBar';
import { Button } from '../../atoms/Button';
import { Typography } from '../../atoms/Typography';
const samplePackages: CompareBarPackage[] = [
{ id: '1', name: 'Everyday Funeral Package', providerName: 'Wollongong City Funerals' },
{ id: '2', name: 'Traditional Cremation Service', providerName: 'Mackay Family Funerals' },
{ id: '3', name: 'Essential Burial Package', providerName: 'Inglewood Chapel' },
];
const meta: Meta<typeof CompareBar> = {
title: 'Molecules/CompareBar',
component: CompareBar,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
decorators: [
(Story) => (
<Box sx={{ minHeight: '100vh', p: 4, bgcolor: 'var(--fa-color-surface-subtle)' }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
The compare bar floats at the bottom of the viewport.
</Typography>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof CompareBar>;
// --- Default (2 packages) ---------------------------------------------------
/** Two packages selected — "2 packages ready to compare" */
export const Default: Story = {
args: {
packages: samplePackages.slice(0, 2),
onCompare: () => alert('Compare clicked'),
},
};
// --- Single Package ----------------------------------------------------------
/** One package — "Add another package to compare", CTA disabled */
export const SinglePackage: Story = {
args: {
packages: samplePackages.slice(0, 1),
onCompare: () => alert('Compare clicked'),
},
};
// --- Three Packages (Maximum) ------------------------------------------------
/** Maximum 3 packages */
export const ThreePackages: Story = {
args: {
packages: samplePackages,
onCompare: () => alert('Compare clicked'),
},
};
// --- With Error --------------------------------------------------------------
/** Error message when user tries to add a 4th package */
export const WithError: Story = {
args: {
packages: samplePackages,
onCompare: () => alert('Compare clicked'),
error: 'Maximum 3 packages',
},
};
// --- Empty (Hidden) ----------------------------------------------------------
/** No packages — bar is hidden */
export const Empty: Story = {
args: {
packages: [],
onCompare: () => {},
},
};
// --- Interactive Demo --------------------------------------------------------
/** Interactive demo — add packages and see the bar update */
export const Interactive: Story = {
render: () => {
const [selected, setSelected] = useState<CompareBarPackage[]>([]);
const [error, setError] = useState<string>();
const allPackages = [
...samplePackages,
{ id: '4', name: 'Catholic Service', providerName: "St Mary's Funeral Services" },
];
const handleToggle = (pkg: CompareBarPackage) => {
const isSelected = selected.some((s) => s.id === pkg.id);
if (isSelected) {
setSelected(selected.filter((s) => s.id !== pkg.id));
setError(undefined);
} else {
if (selected.length >= 3) {
setError('Maximum 3 packages');
setTimeout(() => setError(undefined), 3000);
return;
}
setSelected([...selected, pkg]);
setError(undefined);
}
};
return (
<Box sx={{ pb: 12 }}>
<Typography variant="h4" sx={{ mb: 3 }}>
Select packages to compare
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{allPackages.map((pkg) => {
const isSelected = selected.some((s) => s.id === pkg.id);
return (
<Box
key={pkg.id}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
p: 2,
border: '1px solid',
borderColor: isSelected ? 'primary.main' : 'divider',
borderRadius: 'var(--fa-card-border-radius-default)',
bgcolor: isSelected ? 'var(--fa-color-surface-warm)' : 'background.paper',
}}
>
<Box>
<Typography variant="label">{pkg.name}</Typography>
<Typography variant="body2" color="text.secondary">
{pkg.providerName}
</Typography>
</Box>
<Button
variant={isSelected ? 'outlined' : 'soft'}
color="secondary"
size="small"
onClick={() => handleToggle(pkg)}
>
{isSelected ? 'Remove' : 'Compare'}
</Button>
</Box>
);
})}
</Box>
<CompareBar
packages={selected}
onCompare={() => alert(`Comparing: ${selected.map((s) => s.name).join(', ')}`)}
error={error}
/>
</Box>
);
},
};

View File

@@ -0,0 +1,114 @@
import React from 'react';
import Paper from '@mui/material/Paper';
import Slide from '@mui/material/Slide';
import CompareArrowsIcon from '@mui/icons-material/CompareArrows';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Badge } from '../../atoms/Badge';
// ─── Types ───────────────────────────────────────────────────────────────────
/** A package in the comparison basket */
export interface CompareBarPackage {
/** Unique package ID */
id: string;
/** Package display name */
name: string;
/** Provider name */
providerName: string;
}
/** Props for the CompareBar molecule */
export interface CompareBarProps {
/** Packages currently in the comparison basket (max 3 user-selected) */
packages: CompareBarPackage[];
/** Called when user clicks "Compare" CTA */
onCompare: () => void;
/** Error/status message shown inline (e.g. "Maximum 3 packages") */
error?: string;
/** MUI sx prop for the root wrapper */
sx?: SxProps<Theme>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Floating comparison basket pill for the FA design system.
*
* Shows a fraction badge (1/3, 2/3, 3/3), contextual copy, and a Compare CTA.
* Present on both ProvidersStep and PackagesStep.
*
* Composes Badge + Button + Typography.
*/
export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
({ packages, onCompare, error, sx }, ref) => {
const count = packages.length;
const visible = count > 0;
const canCompare = count >= 2;
const statusText = count === 1 ? 'Add another to compare' : 'Ready to compare';
return (
<Slide direction="up" in={visible} mountOnEnter unmountOnExit>
<Paper
ref={ref}
elevation={8}
role="status"
aria-live="polite"
aria-label={`${count} of 3 packages selected for comparison`}
sx={[
(theme: Theme) => ({
position: 'fixed',
bottom: theme.spacing(3),
left: '50%',
transform: 'translateX(-50%)',
zIndex: theme.zIndex.snackbar,
borderRadius: '9999px',
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 2.5,
py: 1.25,
maxWidth: { xs: 'calc(100vw - 32px)', md: 420 },
}),
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Fraction badge — 1/3, 2/3, 3/3 */}
<Badge color="brand" variant="soft" size="small" sx={{ flexShrink: 0 }}>
{count}/3
</Badge>
{/* Status text */}
<Typography
variant="body2"
role={error ? 'alert' : undefined}
sx={{
fontWeight: 500,
whiteSpace: 'nowrap',
color: error ? 'var(--fa-color-text-brand)' : 'text.primary',
}}
>
{error || statusText}
</Typography>
{/* Compare CTA */}
<Button
variant="contained"
size="small"
startIcon={<CompareArrowsIcon />}
onClick={onCompare}
disabled={!canCompare}
sx={{ flexShrink: 0, borderRadius: '9999px' }}
>
Compare
</Button>
</Paper>
</Slide>
);
},
);
CompareBar.displayName = 'CompareBar';
export default CompareBar;

View File

@@ -0,0 +1,2 @@
export { CompareBar, default } from './CompareBar';
export type { CompareBarProps, CompareBarPackage } from './CompareBar';

View File

@@ -0,0 +1,373 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { ComparisonTable } from './ComparisonTable';
import type { ComparisonPackage } from './ComparisonTable';
const DEMO_LOGO = 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=72&h=72&fit=crop';
// ─── Mock data ──────────────────────────────────────────────────────────────
const pkgWollongong: ComparisonPackage = {
id: 'wollongong-everyday',
name: 'Everyday Funeral Package',
price: 6966,
provider: {
name: 'Wollongong City Funerals',
location: 'Wollongong',
logoUrl: DEMO_LOGO,
rating: 4.8,
reviewCount: 122,
verified: true,
},
sections: [
{
heading: 'Essentials',
items: [
{
name: 'Allowance for Coffin',
info: 'Allowance amount — upgrade options available.',
value: { type: 'allowance', amount: 1750 },
},
{
name: 'Cremation Certificate/Permit',
info: 'Statutory medical referee fee.',
value: { type: 'price', amount: 350 },
},
{
name: 'Crematorium: Mackay Family Crematorium',
info: 'Cremation facility fees.',
value: { type: 'price', amount: 660 },
},
{
name: 'Death Registration Certificate',
info: 'Lodgement with NSW Registry.',
value: { type: 'price', amount: 70 },
},
{
name: 'Dressing Fee',
info: 'Dressing and preparation of the deceased.',
value: { type: 'complimentary' },
},
{
name: 'NSW Government Levy — Cremation',
info: 'NSW Government cremation levy.',
value: { type: 'price', amount: 45.1 },
},
{
name: 'Professional Mortuary Care',
info: 'Preparation and care of the deceased.',
value: { type: 'price', amount: 440 },
},
{
name: 'Professional Service Fee',
info: 'Coordination of all funeral arrangements.',
value: { type: 'price', amount: 3650.9 },
},
{
name: 'Transportation Service Fee',
info: 'Transfer of the deceased.',
value: { type: 'complimentary' },
},
],
},
{
heading: 'Optionals',
items: [
{
name: 'Digital Recording of the Funeral Service',
info: 'Professional video recording.',
value: { type: 'complimentary' },
},
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'complimentary' } },
{
name: 'Viewing Fee',
info: 'One private family viewing.',
value: { type: 'complimentary' },
},
{
name: 'Flowers',
info: 'Seasonal floral arrangements.',
value: { type: 'complimentary' },
},
],
},
{
heading: 'Extras',
items: [
{
name: 'Allowance for Celebrant',
info: 'Professional celebrant or MC.',
value: { type: 'allowance', amount: 550 },
},
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
{
name: 'Saturday Service Fee',
info: 'Additional fee for Saturday services.',
value: { type: 'price', amount: 880 },
},
],
},
],
};
const pkgMackay: ComparisonPackage = {
id: 'mackay-everyday',
name: 'Everyday Funeral Package',
price: 5495.45,
provider: {
name: 'Mackay Family Funerals',
location: 'Inglewood',
logoUrl: DEMO_LOGO,
rating: 4.6,
reviewCount: 87,
verified: true,
},
sections: [
{
heading: 'Essentials',
items: [
{
name: 'Allowance for Coffin',
info: 'Allowance amount — upgrade options available.',
value: { type: 'allowance', amount: 1500 },
},
{
name: 'Cremation Certificate/Permit',
info: 'Statutory medical referee fee.',
value: { type: 'price', amount: 350 },
},
{
name: 'Crematorium: Mackay Family Crematorium',
info: 'Cremation facility fees.',
value: { type: 'price', amount: 660 },
},
{
name: 'Death Registration Certificate',
info: 'Lodgement with NSW Registry.',
value: { type: 'price', amount: 70 },
},
{ name: 'Dressing Fee', info: 'Dressing and preparation.', value: { type: 'included' } },
{
name: 'NSW Government Levy — Cremation',
info: 'NSW Government cremation levy.',
value: { type: 'price', amount: 45.1 },
},
{
name: 'Professional Mortuary Care',
info: 'Preparation and care.',
value: { type: 'price', amount: 440 },
},
{
name: 'Professional Service Fee',
info: 'Coordination of arrangements.',
value: { type: 'price', amount: 2430.35 },
},
{
name: 'Transportation Service Fee',
info: 'Transfer of the deceased.',
value: { type: 'price', amount: 0 },
},
],
},
{
heading: 'Optionals',
items: [
{
name: 'Digital Recording of the Funeral Service',
info: 'Professional video recording.',
value: { type: 'unknown' },
},
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'included' } },
{ name: 'Viewing Fee', info: 'One private family viewing.', value: { type: 'included' } },
{ name: 'Flowers', info: 'Seasonal floral arrangements.', value: { type: 'included' } },
],
},
{
heading: 'Extras',
items: [
{
name: 'Allowance for Celebrant',
info: 'Professional celebrant or MC.',
value: { type: 'allowance', amount: 450 },
},
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
{
name: 'Saturday Service Fee',
info: 'Additional fee for Saturday services.',
value: { type: 'price', amount: 750 },
},
],
},
],
};
const pkgInglewood: ComparisonPackage = {
id: 'inglewood-everyday',
name: 'Everyday Funeral Package',
price: 7200,
provider: {
name: 'Inglewood Chapel',
location: 'Inglewood',
logoUrl: DEMO_LOGO,
rating: 4.2,
reviewCount: 45,
verified: false,
},
sections: [
{
heading: 'Essentials',
items: [
{
name: 'Allowance for Coffin',
info: 'Allowance amount — upgrade options available.',
value: { type: 'allowance', amount: 1800 },
},
{
name: 'Cremation Certificate/Permit',
info: 'Statutory medical referee fee.',
value: { type: 'price', amount: 350 },
},
{
name: 'Death Registration Certificate',
info: 'Lodgement with NSW Registry.',
value: { type: 'price', amount: 70 },
},
{
name: 'Professional Service Fee',
info: 'Coordination of arrangements.',
value: { type: 'price', amount: 3980 },
},
{
name: 'Transportation Service Fee',
info: 'Transfer of the deceased.',
value: { type: 'price', amount: 500 },
},
],
},
{
heading: 'Optionals',
items: [
{ name: 'Viewing Fee', info: 'One private family viewing.', value: { type: 'included' } },
{ name: 'Flowers', info: 'Seasonal floral arrangements.', value: { type: 'poa' } },
{
name: 'Digital Recording of the Funeral Service',
info: 'Professional video recording.',
value: { type: 'price', amount: 250 },
},
],
},
{
heading: 'Extras',
items: [
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
],
},
],
};
const pkgRecommended: ComparisonPackage = {
...pkgWollongong,
id: 'recommended-premium',
name: 'Premium Cremation Service',
price: 8450,
isRecommended: true,
provider: {
name: 'H. Parsons Funeral Directors',
location: 'Wentworth',
logoUrl: DEMO_LOGO,
rating: 4.9,
reviewCount: 203,
verified: true,
},
};
const pkgNoItemised: ComparisonPackage = {
id: 'no-data',
name: 'Basic Cremation',
price: 4500,
provider: {
name: 'Smith & Sons',
location: 'Bankstown',
verified: false,
},
sections: [],
itemizedAvailable: false,
};
// ─── Meta ───────────────────────────────────────────────────────────────────
const meta: Meta<typeof ComparisonTable> = {
title: 'Organisms/ComparisonTable',
component: ComparisonTable,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
decorators: [
(Story) => (
<Box sx={{ p: { xs: 2, md: 4 }, maxWidth: 1200, mx: 'auto' }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof ComparisonTable>;
// --- Default (3 packages) ----------------------------------------------------
/** Three packages from different providers — full comparison */
export const Default: Story = {
args: {
packages: [pkgWollongong, pkgMackay, pkgInglewood],
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
},
};
// --- Two Packages ------------------------------------------------------------
/** Minimal two-column comparison */
export const TwoPackages: Story = {
args: {
packages: [pkgWollongong, pkgMackay],
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
},
};
// --- With Recommended --------------------------------------------------------
/** 3 user + 1 recommended = 4 columns — recommended has warm bg + badge */
export const WithRecommended: Story = {
args: {
packages: [pkgWollongong, pkgMackay, pkgInglewood, pkgRecommended],
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
},
};
// --- Mixed Verified/Unverified -----------------------------------------------
/** Mix of verified (Make Arrangement) and unverified (Make Enquiry) providers */
export const MixedVerified: Story = {
args: {
packages: [pkgWollongong, pkgInglewood],
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
},
};
// --- Missing Itemised Data ---------------------------------------------------
/** One provider has no itemised breakdown — cells show "—" */
export const MissingData: Story = {
args: {
packages: [pkgWollongong, pkgNoItemised, pkgMackay],
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
},
};

View 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;

View File

@@ -0,0 +1,9 @@
export { ComparisonTable, default } from './ComparisonTable';
export type {
ComparisonTableProps,
ComparisonPackage,
ComparisonProvider,
ComparisonSection,
ComparisonLineItem,
ComparisonCellValue,
} from './ComparisonTable';

View File

@@ -14,98 +14,102 @@ const DEMO_IMAGE =
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400&h=300&fit=crop';
const essentials = [
{
name: 'Accommodation',
price: 1500,
info: 'Refrigerated holding of the deceased prior to the funeral service.',
},
{
name: 'Death Registration Certificate',
price: 1500,
info: 'Lodgement of death registration with NSW Registry of Births, Deaths & Marriages.',
},
{
name: 'Doctor Fee for Cremation',
price: 1500,
info: 'Statutory medical referee fee required for all cremations in NSW.',
},
{
name: 'NSW Government Levy — Cremation',
price: 1500,
info: 'NSW Government cremation levy as set by the Department of Health.',
},
{
name: 'Professional Mortuary Care',
price: 1500,
info: 'Preparation and care of the deceased.',
},
{
name: 'Professional Service Fee',
price: 1500,
info: 'Coordination of all funeral arrangements and services.',
},
{
name: 'Allowance for Coffin',
price: 1500,
price: 1750,
isAllowance: true,
info: 'Allowance amount — upgrade options available during arrangement.',
},
{
name: 'Allowance for Crematorium',
price: 1500,
isAllowance: true,
info: 'Allowance for crematorium fees — varies by location.',
name: 'Cremation Certificate/Permit',
price: 350,
info: 'Statutory medical referee fee required for all cremations in NSW.',
},
{
name: 'Allowance for Hearse',
price: 1500,
isAllowance: true,
info: 'Allowance for hearse transfer — distance surcharges may apply.',
name: 'Crematorium: Mackay Family Crematorium',
price: 660,
info: 'Cremation facility fees at the selected crematorium.',
},
{
name: 'Death Registration Certificate',
price: 70,
info: 'Lodgement of death registration with NSW Registry of Births, Deaths & Marriages.',
},
{
name: 'Dressing Fee',
price: 0,
priceLabel: 'Complimentary',
info: 'Dressing and preparation of the deceased — included at no charge.',
},
{
name: 'NSW Government Levy — Cremation',
price: 45.1,
info: 'NSW Government cremation levy as set by the Department of Health.',
},
{
name: 'Professional Mortuary Care',
price: 440,
info: 'Preparation and care of the deceased.',
},
{
name: 'Professional Service Fee',
price: 3650.9,
info: 'Coordination of all funeral arrangements and services.',
},
{
name: 'Transportation Service Fee',
price: 0,
priceLabel: 'Complimentary',
info: 'Transfer of the deceased to the funeral home — included in this package.',
},
];
const complimentary = [
const optionals = [
{
name: 'Dressing Fee',
info: 'Dressing and preparation of the deceased — included at no charge.',
name: 'Digital Recording of the Funeral Service',
priceLabel: 'Complimentary',
info: 'Professional video recording of the funeral service.',
},
{
name: 'Online Notice',
priceLabel: 'Complimentary',
info: 'Online death notice published on the funeral home website.',
},
{
name: 'Viewing Fee',
priceLabel: 'Complimentary',
info: 'One private family viewing — included at no charge.',
},
{
name: 'Webstreaming of the Funeral Service',
priceLabel: 'Complimentary',
info: 'Live webstream of the funeral service for remote attendees.',
},
{ name: 'Viewing Fee', info: 'One private family viewing — included at no charge.' },
];
const extras = {
heading: 'Extras',
items: [
{
name: 'Allowance for Flowers',
price: 1500,
isAllowance: true,
info: 'Seasonal floral arrangements for the service.',
},
{
name: 'Allowance for Master of Ceremonies',
price: 1500,
name: 'Allowance for Celebrant',
price: 550,
isAllowance: true,
info: 'Professional celebrant or MC for the funeral service.',
},
{
name: 'After Business Hours Service Surcharge',
price: 1500,
info: 'Additional fee for services held outside standard business hours.',
name: 'Catering',
priceLabel: 'Price On Application',
info: 'Catering for the wake or post-service gathering.',
},
{
name: 'After Hours Prayers',
price: 1500,
info: 'Evening prayer service at the funeral home.',
name: 'Newspaper Notice',
priceLabel: 'Price On Application',
info: 'Published death notice in local or national newspaper.',
},
{
name: 'Coffin Bearing by Funeral Directors',
price: 1500,
info: 'Professional pallbearing by funeral directors.',
},
{
name: 'Digital Recording',
price: 1500,
info: 'Professional video recording of the funeral service.',
name: 'Saturday Service Fee',
price: 880,
info: 'Additional fee for services held on a Saturday.',
},
],
};
@@ -169,16 +173,16 @@ type Story = StoryObj<typeof PackageDetail>;
// --- Default -----------------------------------------------------------------
/** Full package detail panel — Essentials, Complimentary, Total, then Extras */
/** Full package detail panel — Essentials, Optionals, Total, then Extras */
export const Default: Story = {
args: {
name: 'Everyday Funeral Package',
price: 900,
name: 'Traditional Family Cremation Service',
price: 6966,
sections: [
{ heading: 'Essentials', items: essentials },
{ heading: 'Complimentary Items', items: complimentary },
{ heading: 'Optionals', items: optionals },
],
total: 2700,
total: 6966,
extras,
terms: termsText,
onArrange: () => alert('Make Arrangement clicked'),
@@ -191,10 +195,10 @@ export const Default: Story = {
/** Compare button in loading state — adding to comparison cart */
export const CompareLoading: Story = {
args: {
name: 'Everyday Funeral Package',
price: 900,
name: 'Traditional Family Cremation Service',
price: 6966,
sections: [{ heading: 'Essentials', items: essentials.slice(0, 4) }],
total: 6000,
total: 6966,
onArrange: () => alert('Make Arrangement'),
onCompare: () => {},
compareLoading: true,
@@ -203,16 +207,16 @@ export const CompareLoading: Story = {
// --- Without Extras ----------------------------------------------------------
/** Simpler package with essentials and complimentary only */
/** Simpler package with essentials and optionals only — no extras */
export const WithoutExtras: Story = {
args: {
name: 'Essential Funeral Package',
price: 600,
name: 'Essential Cremation Package',
price: 4850,
sections: [
{ heading: 'Essentials', items: essentials.slice(0, 6) },
{ heading: 'Complimentary Items', items: complimentary },
{ heading: 'Optionals', items: optionals.slice(0, 2) },
],
total: 9000,
total: 4850,
terms: termsText,
onArrange: () => alert('Make Arrangement'),
onCompare: () => alert('Compare'),
@@ -332,9 +336,9 @@ export const PackageSelectPage: Story = {
price={packages.find((p) => p.id === selectedPkg)?.price ?? 0}
sections={[
{ heading: 'Essentials', items: essentials },
{ heading: 'Complimentary Items', items: complimentary },
{ heading: 'Optionals', items: optionals },
]}
total={2700}
total={6966}
extras={extras}
terms={termsText}
onArrange={() => alert(`Making arrangement for: ${selectedPkg}`)}

View File

@@ -19,6 +19,8 @@ export interface PackageLineItem {
price?: number;
/** Whether this is an allowance (shows asterisk) */
isAllowance?: boolean;
/** Custom price display — overrides formatted price (e.g. "Complimentary", "Price On Application") */
priceLabel?: string;
}
/** A section of items within a package (e.g. "Essentials", "Complimentary Items") */
@@ -83,6 +85,7 @@ function SectionBlock({ section, subtext }: { section: PackageSection; subtext?:
info={item.info}
price={item.price}
isAllowance={item.isAllowance}
priceLabel={item.priceLabel}
/>
))}
</Box>

View File

@@ -0,0 +1,474 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { ComparisonPage } from './ComparisonPage';
import type { ComparisonPackage } from '../../organisms/ComparisonTable';
import { Navigation } from '../../organisms/Navigation';
const DEMO_LOGO = 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=72&h=72&fit=crop';
const FALogoNav = () => (
<Box component="img" src="/brandlogo/logo-full.svg" alt="Funeral Arranger" sx={{ height: 28 }} />
);
// ─── Mock data ──────────────────────────────────────────────────────────────
const pkgWollongong: ComparisonPackage = {
id: 'wollongong-everyday',
name: 'Everyday Funeral Package',
price: 6966,
provider: {
name: 'Wollongong City Funerals',
location: 'Wollongong',
logoUrl: DEMO_LOGO,
rating: 4.8,
reviewCount: 122,
verified: true,
},
sections: [
{
heading: 'Essentials',
items: [
{
name: 'Allowance for Coffin',
info: 'Allowance amount — upgrade options available.',
value: { type: 'allowance', amount: 1750 },
},
{
name: 'Cremation Certificate/Permit',
info: 'Statutory medical referee fee.',
value: { type: 'price', amount: 350 },
},
{
name: 'Crematorium',
info: 'Cremation facility fees.',
value: { type: 'price', amount: 660 },
},
{
name: 'Death Registration Certificate',
info: 'Lodgement with NSW Registry.',
value: { type: 'price', amount: 70 },
},
{
name: 'Dressing Fee',
info: 'Dressing and preparation.',
value: { type: 'complimentary' },
},
{
name: 'NSW Government Levy — Cremation',
info: 'NSW Government cremation levy.',
value: { type: 'price', amount: 45.1 },
},
{
name: 'Professional Mortuary Care',
info: 'Preparation and care.',
value: { type: 'price', amount: 440 },
},
{
name: 'Professional Service Fee',
info: 'Coordination of arrangements.',
value: { type: 'price', amount: 3650.9 },
},
{
name: 'Transportation Service Fee',
info: 'Transfer of the deceased.',
value: { type: 'complimentary' },
},
],
},
{
heading: 'Optionals',
items: [
{
name: 'Digital Recording',
info: 'Professional video recording.',
value: { type: 'complimentary' },
},
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'complimentary' } },
{
name: 'Viewing Fee',
info: 'One private family viewing.',
value: { type: 'complimentary' },
},
{
name: 'Flowers',
info: 'Seasonal floral arrangements.',
value: { type: 'complimentary' },
},
],
},
{
heading: 'Extras',
items: [
{
name: 'Allowance for Celebrant',
info: 'Professional celebrant or MC.',
value: { type: 'allowance', amount: 550 },
},
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
{
name: 'Saturday Service Fee',
info: 'Additional fee for Saturday services.',
value: { type: 'price', amount: 880 },
},
],
},
],
};
const pkgMackay: ComparisonPackage = {
id: 'mackay-everyday',
name: 'Everyday Funeral Package',
price: 5495.45,
provider: {
name: 'Mackay Family Funerals',
location: 'Inglewood',
logoUrl: DEMO_LOGO,
rating: 4.6,
reviewCount: 87,
verified: true,
},
sections: [
{
heading: 'Essentials',
items: [
{
name: 'Allowance for Coffin',
info: 'Allowance amount.',
value: { type: 'allowance', amount: 1500 },
},
{
name: 'Cremation Certificate/Permit',
info: 'Medical referee fee.',
value: { type: 'price', amount: 350 },
},
{
name: 'Crematorium',
info: 'Cremation facility fees.',
value: { type: 'price', amount: 660 },
},
{
name: 'Death Registration Certificate',
info: 'NSW Registry.',
value: { type: 'price', amount: 70 },
},
{ name: 'Dressing Fee', info: 'Dressing and preparation.', value: { type: 'included' } },
{
name: 'NSW Government Levy — Cremation',
info: 'Government levy.',
value: { type: 'price', amount: 45.1 },
},
{
name: 'Professional Mortuary Care',
info: 'Preparation and care.',
value: { type: 'price', amount: 440 },
},
{
name: 'Professional Service Fee',
info: 'Coordination.',
value: { type: 'price', amount: 2430.35 },
},
{ name: 'Transportation Service Fee', info: 'Transfer.', value: { type: 'included' } },
],
},
{
heading: 'Optionals',
items: [
{ name: 'Digital Recording', info: 'Video recording.', value: { type: 'unknown' } },
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'included' } },
{ name: 'Viewing Fee', info: 'Private family viewing.', value: { type: 'included' } },
{ name: 'Flowers', info: 'Floral arrangements.', value: { type: 'included' } },
],
},
{
heading: 'Extras',
items: [
{
name: 'Allowance for Celebrant',
info: 'Celebrant or MC.',
value: { type: 'allowance', amount: 450 },
},
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
{
name: 'Saturday Service Fee',
info: 'Saturday surcharge.',
value: { type: 'price', amount: 750 },
},
],
},
],
};
const pkgInglewood: ComparisonPackage = {
id: 'inglewood-everyday',
name: 'Everyday Funeral Package',
price: 7200,
provider: {
name: 'Inglewood Chapel',
location: 'Inglewood',
logoUrl: DEMO_LOGO,
rating: 4.2,
reviewCount: 45,
verified: false,
},
sections: [
{
heading: 'Essentials',
items: [
{
name: 'Allowance for Coffin',
info: 'Allowance amount.',
value: { type: 'allowance', amount: 1800 },
},
{
name: 'Cremation Certificate/Permit',
info: 'Medical referee fee.',
value: { type: 'price', amount: 350 },
},
{
name: 'Death Registration Certificate',
info: 'NSW Registry.',
value: { type: 'price', amount: 70 },
},
{
name: 'Professional Service Fee',
info: 'Coordination.',
value: { type: 'price', amount: 3980 },
},
{
name: 'Transportation Service Fee',
info: 'Transfer.',
value: { type: 'price', amount: 500 },
},
],
},
{
heading: 'Optionals',
items: [
{ name: 'Viewing Fee', info: 'Private family viewing.', value: { type: 'included' } },
{ name: 'Flowers', info: 'Floral arrangements.', value: { type: 'poa' } },
{
name: 'Digital Recording',
info: 'Video recording.',
value: { type: 'price', amount: 250 },
},
],
},
{
heading: 'Extras',
items: [
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
],
},
],
};
const pkgRecommended: ComparisonPackage = {
id: 'recommended-premium',
name: 'Premium Cremation Service',
price: 8450,
provider: {
name: 'H. Parsons Funeral Directors',
location: 'Wentworth',
logoUrl: DEMO_LOGO,
rating: 4.9,
reviewCount: 203,
verified: true,
},
sections: [
{
heading: 'Essentials',
items: [
{
name: 'Allowance for Coffin',
info: 'Premium coffin allowance.',
value: { type: 'allowance', amount: 2500 },
},
{
name: 'Cremation Certificate/Permit',
info: 'Medical referee fee.',
value: { type: 'price', amount: 350 },
},
{
name: 'Crematorium',
info: 'Premium crematorium.',
value: { type: 'price', amount: 850 },
},
{
name: 'Death Registration Certificate',
info: 'NSW Registry.',
value: { type: 'price', amount: 70 },
},
{
name: 'Dressing Fee',
info: 'Dressing and preparation.',
value: { type: 'complimentary' },
},
{
name: 'NSW Government Levy — Cremation',
info: 'Government levy.',
value: { type: 'price', amount: 45.1 },
},
{
name: 'Professional Mortuary Care',
info: 'Full preparation and care.',
value: { type: 'price', amount: 580 },
},
{
name: 'Professional Service Fee',
info: 'Full coordination.',
value: { type: 'price', amount: 4054.9 },
},
{
name: 'Transportation Service Fee',
info: 'Premium transfer.',
value: { type: 'complimentary' },
},
],
},
{
heading: 'Optionals',
items: [
{
name: 'Digital Recording',
info: 'HD video recording.',
value: { type: 'complimentary' },
},
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'complimentary' } },
{ name: 'Viewing Fee', info: 'Private family viewing.', value: { type: 'complimentary' } },
{ name: 'Flowers', info: 'Premium floral arrangements.', value: { type: 'complimentary' } },
{ name: 'Webstreaming', info: 'HD live webstream.', value: { type: 'complimentary' } },
],
},
{
heading: 'Extras',
items: [
{
name: 'Allowance for Celebrant',
info: 'Premium celebrant.',
value: { type: 'allowance', amount: 700 },
},
{
name: 'Catering',
info: 'Full catering included.',
value: { type: 'price', amount: 1200 },
},
{
name: 'Newspaper Notice',
info: 'Published death notice.',
value: { type: 'price', amount: 350 },
},
{
name: 'Saturday Service Fee',
info: 'No Saturday surcharge.',
value: { type: 'complimentary' },
},
],
},
],
};
// ─── Meta ───────────────────────────────────────────────────────────────────
const defaultNav = (
<Navigation
logo={<FALogoNav />}
items={[
{ label: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' },
{ label: 'Log in', href: '/login' },
]}
/>
);
const meta: Meta<typeof ComparisonPage> = {
title: 'Pages/ComparisonPage',
component: ComparisonPage,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
args: {
navigation: defaultNav,
onShare: () => alert('Share'),
onPrint: () => window.print(),
},
};
export default meta;
type Story = StoryObj<typeof ComparisonPage>;
// --- Default (3 packages, desktop) -------------------------------------------
/** Three packages from different providers */
export const Default: Story = {
args: {
packages: [pkgWollongong, pkgMackay, pkgInglewood],
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
onBack: () => alert('Back'),
},
};
// --- Two Packages ------------------------------------------------------------
/** Minimal two-package comparison */
export const TwoPackages: Story = {
args: {
packages: [pkgWollongong, pkgMackay],
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
onBack: () => alert('Back'),
},
};
// --- With Recommended --------------------------------------------------------
/** 3 user packages + 1 recommended — recommended shown as additional column/tab */
export const WithRecommended: Story = {
args: {
packages: [pkgWollongong, pkgMackay, pkgInglewood],
recommendedPackage: pkgRecommended,
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
onBack: () => alert('Back'),
},
};
// --- Mobile View -------------------------------------------------------------
/** Mobile viewport — shows tabbed card view */
export const MobileView: Story = {
parameters: {
viewport: { defaultViewport: 'mobile1' },
},
args: {
packages: [pkgWollongong, pkgMackay, pkgInglewood],
recommendedPackage: pkgRecommended,
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
onBack: () => alert('Back'),
},
};
// --- Interactive (with remove) -----------------------------------------------
/** Interactive — remove packages from comparison */
export const Interactive: Story = {
render: (args) => {
const [pkgs, setPkgs] = useState([pkgWollongong, pkgMackay, pkgInglewood]);
return (
<ComparisonPage
{...args}
packages={pkgs}
recommendedPackage={pkgRecommended}
onArrange={(id) => alert(`Make arrangement for: ${id}`)}
onRemove={(id) => setPkgs(pkgs.filter((p) => p.id !== id))}
onBack={() => alert('Back to packages')}
/>
);
},
};

View 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;

View File

@@ -0,0 +1,2 @@
export { ComparisonPage, default } from './ComparisonPage';
export type { ComparisonPageProps } from './ComparisonPage';