The mobile package card was previously duplicated inline in both ComparisonPage (V2) and ComparisonPageV1 — same ~250-line component pasted twice. Extract it as a proper molecule so card-level tweaks land in one file and both pages stay in sync. New molecule: src/components/molecules/ComparisonPackageCard/ with component, stories (Verified, Unverified, Recommended, ItemizedUnavailable), and index. API reuses the existing ComparisonPackage type from ComparisonTable. Both pages drop their inline MobilePackageCard + MobileCellValue helpers and a handful of now-unused imports (Tooltip, Badge, Divider, several icons, ComparisonCellValue type). The desktop column header inside ComparisonTable is left inline — it's tightly coupled to the grid/sticky behaviour and has a floating verified badge + Remove link that differ meaningfully from the mobile card. Extracting both variants into one molecule would need an awkward variant prop for marginal gain. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
291 lines
11 KiB
TypeScript
291 lines
11 KiB
TypeScript
import React from 'react';
|
|
import Box from '@mui/material/Box';
|
|
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 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 type { ComparisonPackage, ComparisonCellValue } from '../../organisms/ComparisonTable';
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
export interface ComparisonPackageCardProps {
|
|
/** Package data to render — same shape used by ComparisonTable */
|
|
pkg: ComparisonPackage;
|
|
/** Called when the user clicks the CTA (Make Arrangement / Make Enquiry) */
|
|
onArrange: (packageId: string) => void;
|
|
/** MUI sx prop for container overrides */
|
|
sx?: SxProps<Theme>;
|
|
}
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
function formatPrice(amount: number): string {
|
|
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
|
|
}
|
|
|
|
function CellValue({ 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>
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Component ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Mobile package card for the ComparisonPage mobile tab panel view.
|
|
*
|
|
* Full-width card with provider header (verified badge, name, location, rating,
|
|
* package name, price, CTA) and the package's itemised sections below. Used as
|
|
* the content of each mobile tabpanel — one card visible at a time, selected
|
|
* via the tab rail.
|
|
*
|
|
* Shared by ComparisonPage (V2) and ComparisonPageV1 so that card-level tweaks
|
|
* land in a single file.
|
|
*/
|
|
export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, ComparisonPackageCardProps>(
|
|
({ pkg, onArrange, sx }, ref) => {
|
|
return (
|
|
<Card
|
|
ref={ref}
|
|
variant="outlined"
|
|
selected={pkg.isRecommended}
|
|
padding="none"
|
|
sx={[{ overflow: 'hidden' }, ...(Array.isArray(sx) ? sx : [sx])]}
|
|
>
|
|
{/* 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>
|
|
<CellValue value={item.value} />
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
))
|
|
)}
|
|
</Box>
|
|
</Card>
|
|
);
|
|
},
|
|
);
|
|
|
|
ComparisonPackageCard.displayName = 'ComparisonPackageCard';
|
|
export default ComparisonPackageCard;
|