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:
166
src/components/molecules/CompareBar/CompareBar.stories.tsx
Normal file
166
src/components/molecules/CompareBar/CompareBar.stories.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
};
|
||||
114
src/components/molecules/CompareBar/CompareBar.tsx
Normal file
114
src/components/molecules/CompareBar/CompareBar.tsx
Normal 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;
|
||||
2
src/components/molecules/CompareBar/index.ts
Normal file
2
src/components/molecules/CompareBar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { CompareBar, default } from './CompareBar';
|
||||
export type { CompareBarProps, CompareBarPackage } from './CompareBar';
|
||||
Reference in New Issue
Block a user