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>
);
},
};