Add ComparisonPage V2 with recommended package on the left

Archive the current ComparisonPage as V1 (viewable under Archive/ in
Storybook) and build V2 as the new production version. In V2, the
recommended package is prepended instead of appended: it appears as the
first column on desktop and the first tab in the mobile rail. On mobile
the initially active tab is the first user-selected package, not the
recommendation — the recommended tab is surfaced as a visible suggestion
rather than the default view, which felt too upsell-y for the audience.

Both V1 and V2 now use a StarRoundedIcon (brand-600) in the mobile tab
label instead of a text star, so the "recommended" marker reads cleanly
against both selected and unselected tab backgrounds.

See decisions-log D040 for rationale.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 14:59:28 +10:00
parent c3c0beadb9
commit cd0f79f2f5
4 changed files with 1039 additions and 14 deletions

View File

@@ -303,13 +303,19 @@ function MobilePackageCard({
// ─── Component ────────────────────────────────────────────────────────────── // ─── Component ──────────────────────────────────────────────────────────────
/** /**
* Package comparison page for the FA design system. * Package comparison page for the FA design system (V2 — production).
* *
* Desktop: Full ComparisonTable with info card, floating verified badges, * Desktop: Full ComparisonTable with info card, floating verified badges,
* section tables with left accent borders. * section tables with left accent borders. **Recommended package appears as
* Mobile: Tabbed card view with horizontal chip rail. * the first (leftmost) column.**
* Mobile: Tabbed card view with horizontal tab rail. **Recommended package is
* the first tab in the rail, but the first user-selected package is the
* initially active tab** — the recommended tab is a suggestion, not the
* default view.
* *
* Share + Print utility actions in the page header. * Share + Print utility actions in the page header.
*
* See `ComparisonPageV1.tsx` for the archived V1 (recommended-last) layout.
*/ */
export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPageProps>( export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPageProps>(
( (
@@ -321,14 +327,18 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
const tablistId = useId(); const tablistId = useId();
const allPackages = React.useMemo(() => { const allPackages = React.useMemo(() => {
const result = [...packages]; const result: ComparisonPackage[] = [];
if (recommendedPackage) { if (recommendedPackage) {
result.push({ ...recommendedPackage, isRecommended: true }); result.push({ ...recommendedPackage, isRecommended: true });
} }
result.push(...packages);
return result; return result;
}, [packages, recommendedPackage]); }, [packages, recommendedPackage]);
const [activeTabIdx, setActiveTabIdx] = useState(0); // On mobile, default the active tab to the first user-selected package
// (not the recommended). Recommended is first in the rail as a suggestion.
const defaultTabIdx = recommendedPackage ? 1 : 0;
const [activeTabIdx, setActiveTabIdx] = useState(defaultTabIdx);
const activePackage = allPackages[activeTabIdx] ?? allPackages[0]; const activePackage = allPackages[activeTabIdx] ?? allPackages[0];
const providerCount = new Set(allPackages.map((p) => p.provider.name)).size; const providerCount = new Set(allPackages.map((p) => p.provider.name)).size;
@@ -446,18 +456,37 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
}} }}
> >
<Box sx={{ px: 2, py: 1.5 }}> <Box sx={{ px: 2, py: 1.5 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
mb: 0.25,
}}
>
{pkg.isRecommended && (
<StarRoundedIcon
aria-label="Recommended"
sx={{
fontSize: 16,
color: 'var(--fa-color-brand-600)',
flexShrink: 0,
}}
/>
)}
<Typography <Typography
variant="labelSm" variant="labelSm"
sx={{ sx={{
fontWeight: 600, fontWeight: 600,
display: 'block',
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
minWidth: 0,
}} }}
> >
{pkg.isRecommended ? `${pkg.provider.name}` : pkg.provider.name} {pkg.provider.name}
</Typography> </Typography>
</Box>
<Typography <Typography
variant="caption" variant="caption"
color="text.secondary" color="text.secondary"

View File

@@ -0,0 +1,474 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { ComparisonPageV1 } from './ComparisonPageV1';
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 ComparisonPageV1> = {
title: 'Archive/ComparisonPage V1',
component: ComparisonPageV1,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
args: {
navigation: defaultNav,
onShare: () => alert('Share'),
onPrint: () => window.print(),
},
};
export default meta;
type Story = StoryObj<typeof ComparisonPageV1>;
// --- 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 (
<ComparisonPageV1
{...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,520 @@
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 ComparisonPageV1 */
export interface ComparisonPageV1Props {
/** 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 ──────────────────────────────────────────────────────────────
/**
* **Archived — V1.** See `ComparisonPage.tsx` (V2) for the production version.
*
* Package comparison page for the FA design system.
*
* Desktop: Full ComparisonTable with info card, floating verified badges,
* section tables with left accent borders. Recommended package appears as the
* **last** column.
* Mobile: Tabbed card view with horizontal chip rail. Recommended package is
* the last tab.
*
* Share + Print utility actions in the page header.
*/
export const ComparisonPageV1 = React.forwardRef<HTMLDivElement, ComparisonPageV1Props>(
(
{ 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 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
mb: 0.25,
}}
>
{pkg.isRecommended && (
<StarRoundedIcon
aria-label="Recommended"
sx={{
fontSize: 16,
color: 'var(--fa-color-brand-600)',
flexShrink: 0,
}}
/>
)}
<Typography
variant="labelSm"
sx={{
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
}}
>
{pkg.provider.name}
</Typography>
</Box>
<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>
);
},
);
ComparisonPageV1.displayName = 'ComparisonPageV1';
export default ComparisonPageV1;

View File

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