- 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>
231 lines
8.3 KiB
TypeScript
231 lines
8.3 KiB
TypeScript
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 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 { WizardLayout } from '../../templates/WizardLayout';
|
|
import { ComparisonTable, type ComparisonPackage } from '../../organisms/ComparisonTable';
|
|
import { ComparisonPackageCard } from '../../molecules/ComparisonPackageCard';
|
|
import { ComparisonTabCard } from '../../molecules/ComparisonTabCard';
|
|
|
|
// ─── 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>;
|
|
}
|
|
|
|
// ─── 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 railRef = useRef<HTMLDivElement>(null);
|
|
const tabRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
|
|
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' : ''}`;
|
|
|
|
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
|
|
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 + price */}
|
|
<Box
|
|
ref={railRef}
|
|
role="tablist"
|
|
id={tablistId}
|
|
aria-label="Packages to compare"
|
|
sx={{
|
|
display: 'flex',
|
|
gap: 1.5,
|
|
overflowX: 'auto',
|
|
py: 2,
|
|
px: 2,
|
|
mx: -2,
|
|
mt: 1,
|
|
mb: 3,
|
|
scrollbarWidth: 'none',
|
|
'&::-webkit-scrollbar': { display: 'none' },
|
|
WebkitOverflowScrolling: 'touch',
|
|
}}
|
|
>
|
|
{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 && (
|
|
<Box
|
|
role="tabpanel"
|
|
id={`comparison-tabpanel-${activeTabIdx}`}
|
|
aria-labelledby={`comparison-tab-${activeTabIdx}`}
|
|
>
|
|
<ComparisonPackageCard pkg={activePackage} onArrange={onArrange} />
|
|
</Box>
|
|
)}
|
|
</>
|
|
)}
|
|
</WizardLayout>
|
|
</Box>
|
|
);
|
|
},
|
|
);
|
|
|
|
ComparisonPageV1.displayName = 'ComparisonPageV1';
|
|
export default ComparisonPageV1;
|