Extract ComparisonColumnCard + ComparisonTabCard molecules, refine comparison UI

- New molecule: ComparisonColumnCard — desktop column header card extracted
  from ComparisonTable (~150 lines removed from organism)
- New molecule: ComparisonTabCard — mobile tab rail card extracted from
  ComparisonPage (shared by V1 and V2)
- CellValue "unknown" restyled: icon+text in neutral grey (was Badge),
  InfoOutlinedIcon on right at 14px matching item info icons
- Unverified provider story data: all items set to unknown across all
  story files (no dashes in essentials)
- Mobile tab rail: recommended badge (replaces star), package price,
  shadow/glow, center-on-select scroll, overflow clipping fixed
- ComparisonPackageCard: added shadow, reduced CTA button to medium
- ComparisonTable first column: inline info icon pattern (non-breaking
  space + nowrap span) prevents icon orphaning on line wrap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 21:10:17 +10:00
parent b7a2a4e136
commit e89ac360e8
15 changed files with 2079 additions and 438 deletions

View File

@@ -1,17 +1,16 @@
import React, { useId, useState } from 'react';
import React, { useId, useState, useRef, useCallback } from 'react';
import Box from '@mui/material/Box';
import useMediaQuery from '@mui/material/useMediaQuery';
import { useTheme } from '@mui/material/styles';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
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 { Card } from '../../atoms/Card';
import { WizardLayout } from '../../templates/WizardLayout';
import { ComparisonTable, type ComparisonPackage } from '../../organisms/ComparisonTable';
import { ComparisonPackageCard } from '../../molecules/ComparisonPackageCard';
import { ComparisonTabCard } from '../../molecules/ComparisonTabCard';
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -60,6 +59,8 @@ export const ComparisonPageV1 = React.forwardRef<HTMLDivElement, ComparisonPageV
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const tablistId = useId();
const railRef = useRef<HTMLDivElement>(null);
const tabRefs = useRef<(HTMLDivElement | null)[]>([]);
const allPackages = React.useMemo(() => {
const result = [...packages];
@@ -78,6 +79,33 @@ export const ComparisonPageV1 = React.forwardRef<HTMLDivElement, ComparisonPageV
? `Comparing ${packages.length} package${packages.length !== 1 ? 's' : ''} from different providers`
: `Comparing ${packages.length} package${packages.length !== 1 ? 's' : ''}`;
const hasRecommended = allPackages.some((p) => p.isRecommended);
const scrollToCenter = useCallback((idx: number) => {
const tab = tabRefs.current[idx];
if (tab && railRef.current) {
const rail = railRef.current;
const tabCenter = tab.offsetLeft + tab.offsetWidth / 2;
const railCenter = rail.offsetWidth / 2;
rail.scrollTo({ left: tabCenter - railCenter, behavior: 'smooth' });
}
}, []);
const handleTabClick = useCallback(
(idx: number) => {
setActiveTabIdx(idx);
scrollToCenter(idx);
},
[scrollToCenter],
);
// Center the default tab on mount
React.useEffect(() => {
const timer = setTimeout(() => scrollToCenter(0), 50);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Box ref={ref} sx={sx}>
<WizardLayout
@@ -145,8 +173,9 @@ export const ComparisonPageV1 = React.forwardRef<HTMLDivElement, ComparisonPageV
{/* Mobile: Tab rail + card view */}
{isMobile && allPackages.length > 0 && (
<>
{/* Tab rail — mini cards showing provider + package name */}
{/* Tab rail — mini cards showing provider + package + price */}
<Box
ref={railRef}
role="tablist"
id={tablistId}
aria-label="Packages to compare"
@@ -154,86 +183,30 @@ export const ComparisonPageV1 = React.forwardRef<HTMLDivElement, ComparisonPageV
display: 'flex',
gap: 1.5,
overflowX: 'auto',
pb: 1,
mb: 2.5,
py: 2,
px: 2,
mx: -2,
mt: 1,
mb: 3,
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>
);
})}
{allPackages.map((pkg, idx) => (
<ComparisonTabCard
key={pkg.id}
ref={(el: HTMLDivElement | null) => {
tabRefs.current[idx] = el;
}}
pkg={pkg}
isActive={idx === activeTabIdx}
hasRecommended={hasRecommended}
tabId={`comparison-tab-${idx}`}
tabPanelId={`comparison-tabpanel-${idx}`}
onClick={() => handleTabClick(idx)}
/>
))}
</Box>
{activePackage && (