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:
@@ -0,0 +1,159 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { ComparisonColumnCard } from './ComparisonColumnCard';
|
||||
import type { ComparisonPackage } from '../../organisms/ComparisonTable';
|
||||
|
||||
// ─── Mock data ──────────────────────────────────────────────────────────────
|
||||
|
||||
const verifiedPackage: ComparisonPackage = {
|
||||
id: 'wollongong-everyday',
|
||||
name: 'Everyday Funeral Package',
|
||||
price: 6966,
|
||||
provider: {
|
||||
name: 'Wollongong City Funerals',
|
||||
location: 'Wollongong',
|
||||
rating: 4.8,
|
||||
reviewCount: 122,
|
||||
verified: true,
|
||||
},
|
||||
sections: [],
|
||||
};
|
||||
|
||||
const unverifiedPackage: ComparisonPackage = {
|
||||
id: 'inglewood-everyday',
|
||||
name: 'Everyday Funeral Package',
|
||||
price: 7200,
|
||||
provider: {
|
||||
name: 'Inglewood Chapel',
|
||||
location: 'Inglewood',
|
||||
rating: 4.2,
|
||||
reviewCount: 45,
|
||||
verified: false,
|
||||
},
|
||||
sections: [],
|
||||
};
|
||||
|
||||
const recommendedPackage: ComparisonPackage = {
|
||||
id: 'recommended-premium',
|
||||
name: 'Premium Cremation Service',
|
||||
price: 8450,
|
||||
provider: {
|
||||
name: 'H. Parsons Funeral Directors',
|
||||
location: 'Wentworth',
|
||||
rating: 4.9,
|
||||
reviewCount: 203,
|
||||
verified: true,
|
||||
},
|
||||
sections: [],
|
||||
isRecommended: true,
|
||||
};
|
||||
|
||||
const longNamePackage: ComparisonPackage = {
|
||||
id: 'long-name',
|
||||
name: 'Comprehensive Premium Memorial & Cremation Service Package',
|
||||
price: 12500,
|
||||
provider: {
|
||||
name: 'The Very Long Name Funeral Services & Memorial Chapel Pty Ltd',
|
||||
location: 'Wollongong',
|
||||
rating: 4.6,
|
||||
reviewCount: 87,
|
||||
verified: true,
|
||||
},
|
||||
sections: [],
|
||||
};
|
||||
|
||||
const noRatingPackage: ComparisonPackage = {
|
||||
id: 'no-rating',
|
||||
name: 'Basic Funeral Package',
|
||||
price: 4200,
|
||||
provider: {
|
||||
name: 'New Provider',
|
||||
location: 'Sydney',
|
||||
verified: true,
|
||||
},
|
||||
sections: [],
|
||||
};
|
||||
|
||||
// ─── Meta ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const meta: Meta<typeof ComparisonColumnCard> = {
|
||||
title: 'Molecules/ComparisonColumnCard',
|
||||
component: ComparisonColumnCard,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<Box sx={{ maxWidth: 280, mx: 'auto', pt: 3 }}>
|
||||
<Story />
|
||||
</Box>
|
||||
),
|
||||
],
|
||||
args: {
|
||||
onArrange: (id) => alert(`Arrange: ${id}`),
|
||||
onRemove: (id) => alert(`Remove: ${id}`),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ComparisonColumnCard>;
|
||||
|
||||
/** Verified provider — floating "Verified" badge above card */
|
||||
export const Verified: Story = {
|
||||
args: {
|
||||
pkg: verifiedPackage,
|
||||
},
|
||||
};
|
||||
|
||||
/** Unverified provider — "Make Enquiry" CTA + soft button variant, no verified badge */
|
||||
export const Unverified: Story = {
|
||||
args: {
|
||||
pkg: unverifiedPackage,
|
||||
},
|
||||
};
|
||||
|
||||
/** Recommended package — copper banner, warm selected state, no Remove link */
|
||||
export const Recommended: Story = {
|
||||
args: {
|
||||
pkg: recommendedPackage,
|
||||
},
|
||||
};
|
||||
|
||||
/** Long provider name — truncated with tooltip on hover */
|
||||
export const LongName: Story = {
|
||||
args: {
|
||||
pkg: longNamePackage,
|
||||
},
|
||||
};
|
||||
|
||||
/** No rating — provider without rating/review data */
|
||||
export const NoRating: Story = {
|
||||
args: {
|
||||
pkg: noRatingPackage,
|
||||
},
|
||||
};
|
||||
|
||||
/** Side-by-side — multiple cards in a row (as used in ComparisonTable) */
|
||||
export const SideBySide: Story = {
|
||||
decorators: [
|
||||
() => (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 2, pt: 3 }}>
|
||||
<ComparisonColumnCard
|
||||
pkg={recommendedPackage}
|
||||
onArrange={(id) => alert(`Arrange: ${id}`)}
|
||||
/>
|
||||
<ComparisonColumnCard
|
||||
pkg={verifiedPackage}
|
||||
onArrange={(id) => alert(`Arrange: ${id}`)}
|
||||
onRemove={(id) => alert(`Remove: ${id}`)}
|
||||
/>
|
||||
<ComparisonColumnCard
|
||||
pkg={unverifiedPackage}
|
||||
onArrange={(id) => alert(`Arrange: ${id}`)}
|
||||
onRemove={(id) => alert(`Remove: ${id}`)}
|
||||
/>
|
||||
</Box>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
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 { Card } from '../../atoms/Card';
|
||||
import { Divider } from '../../atoms/Divider';
|
||||
import { Link } from '../../atoms/Link';
|
||||
import type { ComparisonPackage } from '../../organisms/ComparisonTable';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ComparisonColumnCardProps {
|
||||
/** 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;
|
||||
/** Called when the user clicks Remove — hidden when not provided or for recommended packages */
|
||||
onRemove?: (packageId: string) => void;
|
||||
/** MUI sx prop for outer wrapper overrides */
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatPrice(amount: number): string {
|
||||
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
|
||||
}
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Desktop column header card for the ComparisonTable.
|
||||
*
|
||||
* Shows provider info (verified badge, name, location, rating), package name,
|
||||
* total price, CTA button, and optional Remove link. The verified badge floats
|
||||
* above the card's top edge. Recommended packages get a copper banner and warm
|
||||
* selected card state.
|
||||
*
|
||||
* Used as the sticky header for each column in the desktop comparison grid.
|
||||
* Mobile comparison uses ComparisonPackageCard instead.
|
||||
*/
|
||||
export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonColumnCardProps>(
|
||||
({ pkg, onArrange, onRemove, sx }, ref) => {
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
role="columnheader"
|
||||
aria-label={pkg.isRecommended ? `${pkg.name} (Recommended)` : pkg.name}
|
||||
sx={[
|
||||
{
|
||||
position: 'relative',
|
||||
overflow: 'visible',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
>
|
||||
{/* Floating verified badge — overlaps card top edge */}
|
||||
{pkg.provider.verified && (
|
||||
<Badge
|
||||
color="brand"
|
||||
variant="soft"
|
||||
size="small"
|
||||
icon={<VerifiedOutlinedIcon sx={{ fontSize: 14 }} />}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
Verified
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Card
|
||||
variant="outlined"
|
||||
selected={pkg.isRecommended}
|
||||
padding="none"
|
||||
sx={{ overflow: 'hidden', flex: 1, display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
px: 2.5,
|
||||
py: 2.5,
|
||||
pt: pkg.provider.verified ? 3 : 2.5,
|
||||
gap: 0.5,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{/* Provider name (truncated with tooltip) */}
|
||||
<Tooltip
|
||||
title={pkg.provider.name}
|
||||
arrow
|
||||
placement="top"
|
||||
disableHoverListener={pkg.provider.name.length < 24}
|
||||
>
|
||||
<Typography
|
||||
variant="label"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{pkg.provider.name}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
|
||||
{/* Location */}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{pkg.provider.location}
|
||||
</Typography>
|
||||
|
||||
{/* Rating */}
|
||||
{pkg.provider.rating != null && (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<StarRoundedIcon
|
||||
sx={{ fontSize: 16, color: 'var(--fa-color-brand-500)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{pkg.provider.rating}
|
||||
{pkg.provider.reviewCount != null && ` (${pkg.provider.reviewCount})`}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ width: '100%', my: 1 }} />
|
||||
|
||||
<Typography variant="h6" component="p">
|
||||
{pkg.name}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
Total package price
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{ color: 'primary.main', fontWeight: 700 }}>
|
||||
{formatPrice(pkg.price)}
|
||||
</Typography>
|
||||
|
||||
{/* Spacer pushes CTA to bottom across all cards */}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
<Button
|
||||
variant={pkg.provider.verified ? 'contained' : 'soft'}
|
||||
color={pkg.provider.verified ? 'primary' : 'secondary'}
|
||||
size="medium"
|
||||
onClick={() => onArrange(pkg.id)}
|
||||
sx={{ mt: 1.5, px: 4 }}
|
||||
>
|
||||
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
|
||||
</Button>
|
||||
|
||||
{!pkg.isRecommended && onRemove && (
|
||||
<Link
|
||||
component="button"
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
underline="hover"
|
||||
onClick={() => onRemove(pkg.id)}
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
Remove
|
||||
</Link>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ComparisonColumnCard.displayName = 'ComparisonColumnCard';
|
||||
export default ComparisonColumnCard;
|
||||
2
src/components/molecules/ComparisonColumnCard/index.ts
Normal file
2
src/components/molecules/ComparisonColumnCard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ComparisonColumnCard, default } from './ComparisonColumnCard';
|
||||
export type { ComparisonColumnCardProps } from './ComparisonColumnCard';
|
||||
@@ -81,9 +81,18 @@ function CellValue({ value }: { value: ComparisonCellValue }) {
|
||||
);
|
||||
case 'unknown':
|
||||
return (
|
||||
<Badge color="default" variant="soft" size="small">
|
||||
Unknown
|
||||
</Badge>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, justifyContent: 'flex-end' }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: 'var(--fa-color-neutral-500)', fontWeight: 500 }}
|
||||
>
|
||||
Unknown
|
||||
</Typography>
|
||||
<InfoOutlinedIcon
|
||||
sx={{ fontSize: 14, color: 'var(--fa-color-neutral-500)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
case 'unavailable':
|
||||
return (
|
||||
@@ -118,7 +127,13 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
|
||||
variant="outlined"
|
||||
selected={pkg.isRecommended}
|
||||
padding="none"
|
||||
sx={[{ overflow: 'hidden' }, ...(Array.isArray(sx) ? sx : [sx])]}
|
||||
sx={[
|
||||
{
|
||||
overflow: 'hidden',
|
||||
boxShadow: 'var(--fa-shadow-sm)',
|
||||
},
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
>
|
||||
{/* Recommended banner */}
|
||||
{pkg.isRecommended && (
|
||||
@@ -204,7 +219,7 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
|
||||
<Button
|
||||
variant={pkg.provider.verified ? 'contained' : 'soft'}
|
||||
color={pkg.provider.verified ? 'primary' : 'secondary'}
|
||||
size="large"
|
||||
size="medium"
|
||||
fullWidth
|
||||
onClick={() => onArrange(pkg.id)}
|
||||
sx={{ mt: 2 }}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { ComparisonTabCard } from './ComparisonTabCard';
|
||||
import type { ComparisonPackage } from '../../organisms/ComparisonTable';
|
||||
|
||||
// ─── Mock data ──────────────────────────────────────────────────────────────
|
||||
|
||||
const verifiedPkg: ComparisonPackage = {
|
||||
id: 'wollongong-everyday',
|
||||
name: 'Everyday Funeral Package',
|
||||
price: 6966,
|
||||
provider: {
|
||||
name: 'Wollongong City Funerals',
|
||||
location: 'Wollongong',
|
||||
rating: 4.8,
|
||||
reviewCount: 122,
|
||||
verified: true,
|
||||
},
|
||||
sections: [],
|
||||
};
|
||||
|
||||
const recommendedPkg: ComparisonPackage = {
|
||||
id: 'recommended-premium',
|
||||
name: 'Premium Cremation Service',
|
||||
price: 8450,
|
||||
provider: {
|
||||
name: 'H. Parsons Funeral Directors',
|
||||
location: 'Wentworth',
|
||||
rating: 4.9,
|
||||
reviewCount: 203,
|
||||
verified: true,
|
||||
},
|
||||
sections: [],
|
||||
isRecommended: true,
|
||||
};
|
||||
|
||||
const unverifiedPkg: ComparisonPackage = {
|
||||
id: 'inglewood-everyday',
|
||||
name: 'Everyday Funeral Package',
|
||||
price: 7200,
|
||||
provider: {
|
||||
name: 'Inglewood Chapel',
|
||||
location: 'Inglewood',
|
||||
rating: 4.2,
|
||||
reviewCount: 45,
|
||||
verified: false,
|
||||
},
|
||||
sections: [],
|
||||
};
|
||||
|
||||
const longNamePkg: ComparisonPackage = {
|
||||
id: 'long-name',
|
||||
name: 'Comprehensive Premium Memorial & Cremation Service',
|
||||
price: 12500,
|
||||
provider: {
|
||||
name: 'The Very Long Name Funeral Services Pty Ltd',
|
||||
location: 'Wollongong',
|
||||
rating: 4.6,
|
||||
reviewCount: 87,
|
||||
verified: true,
|
||||
},
|
||||
sections: [],
|
||||
};
|
||||
|
||||
// ─── Meta ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const meta: Meta<typeof ComparisonTabCard> = {
|
||||
title: 'Molecules/ComparisonTabCard',
|
||||
component: ComparisonTabCard,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
},
|
||||
args: {
|
||||
isActive: false,
|
||||
hasRecommended: false,
|
||||
tabId: 'tab-0',
|
||||
tabPanelId: 'panel-0',
|
||||
onClick: () => alert('Tab clicked'),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ComparisonTabCard>;
|
||||
|
||||
/** Default inactive tab card */
|
||||
export const Default: Story = {
|
||||
args: { pkg: verifiedPkg },
|
||||
};
|
||||
|
||||
/** Active/selected state — elevated shadow */
|
||||
export const Active: Story = {
|
||||
args: { pkg: verifiedPkg, isActive: true },
|
||||
};
|
||||
|
||||
/** Recommended — badge + brand glow */
|
||||
export const Recommended: Story = {
|
||||
args: { pkg: recommendedPkg, hasRecommended: true },
|
||||
};
|
||||
|
||||
/** Recommended + active */
|
||||
export const RecommendedActive: Story = {
|
||||
args: { pkg: recommendedPkg, isActive: true, hasRecommended: true },
|
||||
};
|
||||
|
||||
/** Long name — truncated with ellipsis */
|
||||
export const LongName: Story = {
|
||||
args: { pkg: longNamePkg },
|
||||
};
|
||||
|
||||
/** Rail simulation — multiple cards as they appear in the mobile tab rail */
|
||||
export const Rail: Story = {
|
||||
decorators: [
|
||||
() => (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 1.5,
|
||||
overflowX: 'auto',
|
||||
py: 2,
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
<ComparisonTabCard
|
||||
pkg={recommendedPkg}
|
||||
isActive={false}
|
||||
hasRecommended
|
||||
tabId="tab-0"
|
||||
tabPanelId="panel-0"
|
||||
onClick={() => alert('Recommended')}
|
||||
/>
|
||||
<ComparisonTabCard
|
||||
pkg={verifiedPkg}
|
||||
isActive
|
||||
hasRecommended
|
||||
tabId="tab-1"
|
||||
tabPanelId="panel-1"
|
||||
onClick={() => alert('Wollongong')}
|
||||
/>
|
||||
<ComparisonTabCard
|
||||
pkg={unverifiedPkg}
|
||||
isActive={false}
|
||||
hasRecommended
|
||||
tabId="tab-2"
|
||||
tabPanelId="panel-2"
|
||||
onClick={() => alert('Inglewood')}
|
||||
/>
|
||||
</Box>
|
||||
),
|
||||
],
|
||||
};
|
||||
154
src/components/molecules/ComparisonTabCard/ComparisonTabCard.tsx
Normal file
154
src/components/molecules/ComparisonTabCard/ComparisonTabCard.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
import { Badge } from '../../atoms/Badge';
|
||||
import { Card } from '../../atoms/Card';
|
||||
import type { ComparisonPackage } from '../../organisms/ComparisonTable';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ComparisonTabCardProps {
|
||||
/** Package data to render */
|
||||
pkg: ComparisonPackage;
|
||||
/** Whether this tab is the currently active/selected one */
|
||||
isActive: boolean;
|
||||
/** Whether any package in the rail is recommended — controls spacer for alignment */
|
||||
hasRecommended: boolean;
|
||||
/** ARIA: id for the tab element */
|
||||
tabId: string;
|
||||
/** ARIA: id of the controlled tabpanel */
|
||||
tabPanelId: string;
|
||||
/** Called when the tab card is clicked */
|
||||
onClick: () => void;
|
||||
/** MUI sx prop for outer wrapper */
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatPrice(amount: number): string {
|
||||
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
|
||||
}
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mini tab card for the mobile ComparisonPage tab rail.
|
||||
*
|
||||
* Shows provider name, package name, and price. Recommended packages get a
|
||||
* floating badge (in normal flow with negative margin overlap) and a warm
|
||||
* brand glow. Non-recommended cards get a spacer to keep vertical alignment
|
||||
* when a recommended card is present in the rail.
|
||||
*
|
||||
* The page component owns scroll/centering behaviour — this is purely visual.
|
||||
*/
|
||||
export const ComparisonTabCard = React.forwardRef<HTMLDivElement, ComparisonTabCardProps>(
|
||||
({ pkg, isActive, hasRecommended, tabId, tabPanelId, onClick, sx }, ref) => {
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
sx={[
|
||||
{
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
},
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
>
|
||||
{/* Recommended badge in normal flow — overlaps card via negative mb */}
|
||||
{pkg.isRecommended ? (
|
||||
<Badge
|
||||
color="brand"
|
||||
variant="soft"
|
||||
size="small"
|
||||
sx={{
|
||||
mb: '-10px',
|
||||
zIndex: 1,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
Recommended
|
||||
</Badge>
|
||||
) : (
|
||||
// Spacer keeps cards aligned when a recommended card is present
|
||||
hasRecommended && <Box sx={{ height: 12 }} />
|
||||
)}
|
||||
|
||||
<Card
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={tabPanelId}
|
||||
id={tabId}
|
||||
variant="outlined"
|
||||
selected={isActive}
|
||||
padding="none"
|
||||
onClick={onClick}
|
||||
interactive
|
||||
sx={{
|
||||
width: 210,
|
||||
cursor: 'pointer',
|
||||
boxShadow: 'var(--fa-shadow-sm)',
|
||||
...(pkg.isRecommended && {
|
||||
borderColor: 'var(--fa-color-brand-500)',
|
||||
boxShadow: '0 0 12px rgba(186, 131, 78, 0.3)',
|
||||
}),
|
||||
...(isActive && {
|
||||
boxShadow: pkg.isRecommended
|
||||
? '0 0 14px rgba(186, 131, 78, 0.4)'
|
||||
: 'var(--fa-shadow-md)',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Box sx={{ px: 2, pt: 2.4, pb: 2 }}>
|
||||
<Typography
|
||||
variant="labelSm"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'block',
|
||||
mb: 0.25,
|
||||
}}
|
||||
>
|
||||
{pkg.provider.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{pkg.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
display: 'block',
|
||||
fontWeight: 600,
|
||||
color: 'primary.main',
|
||||
mt: 0.5,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{formatPrice(pkg.price)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ComparisonTabCard.displayName = 'ComparisonTabCard';
|
||||
export default ComparisonTabCard;
|
||||
2
src/components/molecules/ComparisonTabCard/index.ts
Normal file
2
src/components/molecules/ComparisonTabCard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ComparisonTabCard, default } from './ComparisonTabCard';
|
||||
export type { ComparisonTabCardProps } from './ComparisonTabCard';
|
||||
@@ -218,50 +218,33 @@ const pkgInglewood: ComparisonPackage = {
|
||||
{
|
||||
heading: 'Essentials',
|
||||
items: [
|
||||
{
|
||||
name: 'Allowance for Coffin',
|
||||
info: 'Allowance amount — upgrade options available.',
|
||||
value: { type: 'allowance', amount: 1800 },
|
||||
},
|
||||
{
|
||||
name: 'Cremation Certificate/Permit',
|
||||
info: 'Statutory medical referee fee.',
|
||||
value: { type: 'price', amount: 350 },
|
||||
},
|
||||
{
|
||||
name: 'Death Registration Certificate',
|
||||
info: 'Lodgement with NSW Registry.',
|
||||
value: { type: 'price', amount: 70 },
|
||||
},
|
||||
{
|
||||
name: 'Professional Service Fee',
|
||||
info: 'Coordination of arrangements.',
|
||||
value: { type: 'price', amount: 3980 },
|
||||
},
|
||||
{
|
||||
name: 'Transportation Service Fee',
|
||||
info: 'Transfer of the deceased.',
|
||||
value: { type: 'price', amount: 500 },
|
||||
},
|
||||
{ name: 'Allowance for Coffin', value: { type: 'unknown' } },
|
||||
{ name: 'Cremation Certificate/Permit', value: { type: 'unknown' } },
|
||||
{ name: 'Crematorium: Mackay Family Crematorium', value: { type: 'unknown' } },
|
||||
{ name: 'Death Registration Certificate', value: { type: 'unknown' } },
|
||||
{ name: 'Dressing Fee', value: { type: 'unknown' } },
|
||||
{ name: 'NSW Government Levy — Cremation', value: { type: 'unknown' } },
|
||||
{ name: 'Professional Mortuary Care', value: { type: 'unknown' } },
|
||||
{ name: 'Professional Service Fee', value: { type: 'unknown' } },
|
||||
{ name: 'Transportation Service Fee', value: { type: 'unknown' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Optionals',
|
||||
items: [
|
||||
{ name: 'Viewing Fee', info: 'One private family viewing.', value: { type: 'included' } },
|
||||
{ name: 'Flowers', info: 'Seasonal floral arrangements.', value: { type: 'poa' } },
|
||||
{
|
||||
name: 'Digital Recording of the Funeral Service',
|
||||
info: 'Professional video recording.',
|
||||
value: { type: 'price', amount: 250 },
|
||||
},
|
||||
{ name: 'Digital Recording of the Funeral Service', value: { type: 'unknown' } },
|
||||
{ name: 'Flowers', value: { type: 'unknown' } },
|
||||
{ name: 'Online Notice', value: { type: 'unknown' } },
|
||||
{ name: 'Viewing Fee', value: { type: 'unknown' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Extras',
|
||||
items: [
|
||||
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
|
||||
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
|
||||
{ name: 'Allowance for Celebrant', value: { type: 'unknown' } },
|
||||
{ name: 'Catering', value: { type: 'unknown' } },
|
||||
{ name: 'Newspaper Notice', value: { type: 'unknown' } },
|
||||
{ name: 'Saturday Service Fee', value: { type: 'unknown' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -3,15 +3,10 @@ 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 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 { Card } from '../../atoms/Card';
|
||||
import { Link } from '../../atoms/Link';
|
||||
import { Divider } from '../../atoms/Divider';
|
||||
import { ComparisonColumnCard } from '../../molecules/ComparisonColumnCard';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -120,9 +115,18 @@ function CellValue({ value }: { value: ComparisonCellValue }) {
|
||||
);
|
||||
case 'unknown':
|
||||
return (
|
||||
<Badge color="default" variant="soft" size="small">
|
||||
Unknown
|
||||
</Badge>
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: 'var(--fa-color-neutral-500)', fontWeight: 500 }}
|
||||
>
|
||||
Unknown
|
||||
</Typography>
|
||||
<InfoOutlinedIcon
|
||||
sx={{ fontSize: 14, color: 'var(--fa-color-neutral-500)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
case 'unavailable':
|
||||
return (
|
||||
@@ -273,157 +277,14 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
|
||||
</Typography>
|
||||
</Card>
|
||||
|
||||
{/* Package cards */}
|
||||
{/* Package column header cards */}
|
||||
{packages.map((pkg) => (
|
||||
<Box
|
||||
<ComparisonColumnCard
|
||||
key={pkg.id}
|
||||
role="columnheader"
|
||||
aria-label={pkg.isRecommended ? `${pkg.name} (Recommended)` : pkg.name}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
overflow: 'visible',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Floating verified badge — overlaps card top edge */}
|
||||
{pkg.provider.verified && (
|
||||
<Badge
|
||||
color="brand"
|
||||
variant="soft"
|
||||
size="small"
|
||||
icon={<VerifiedOutlinedIcon sx={{ fontSize: 14 }} />}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
Verified
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Card
|
||||
variant="outlined"
|
||||
selected={pkg.isRecommended}
|
||||
padding="none"
|
||||
sx={{ overflow: 'hidden', flex: 1, display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
px: 2.5,
|
||||
py: 2.5,
|
||||
pt: pkg.provider.verified ? 3 : 2.5,
|
||||
gap: 0.5,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{/* Provider name (truncated with tooltip) */}
|
||||
<Tooltip
|
||||
title={pkg.provider.name}
|
||||
arrow
|
||||
placement="top"
|
||||
disableHoverListener={pkg.provider.name.length < 24}
|
||||
>
|
||||
<Typography
|
||||
variant="label"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{pkg.provider.name}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
|
||||
{/* Location */}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{pkg.provider.location}
|
||||
</Typography>
|
||||
|
||||
{/* Rating */}
|
||||
{pkg.provider.rating != null && (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<StarRoundedIcon
|
||||
sx={{ fontSize: 16, color: 'var(--fa-color-brand-500)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{pkg.provider.rating}
|
||||
{pkg.provider.reviewCount != null && ` (${pkg.provider.reviewCount})`}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ width: '100%', my: 1 }} />
|
||||
|
||||
<Typography variant="h6" component="p">
|
||||
{pkg.name}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
Total package price
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{ color: 'primary.main', fontWeight: 700 }}>
|
||||
{formatPrice(pkg.price)}
|
||||
</Typography>
|
||||
|
||||
{/* Spacer pushes CTA to bottom across all cards */}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
<Button
|
||||
variant={pkg.provider.verified ? 'contained' : 'soft'}
|
||||
color={pkg.provider.verified ? 'primary' : 'secondary'}
|
||||
size="medium"
|
||||
onClick={() => onArrange(pkg.id)}
|
||||
sx={{ mt: 1.5, px: 4 }}
|
||||
>
|
||||
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
|
||||
</Button>
|
||||
|
||||
{!pkg.isRecommended && (
|
||||
<Link
|
||||
component="button"
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
underline="hover"
|
||||
onClick={() => onRemove(pkg.id)}
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
Remove
|
||||
</Link>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
pkg={pkg}
|
||||
onArrange={onArrange}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -449,30 +310,30 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
|
||||
<Box
|
||||
role="cell"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
px: 3,
|
||||
py: 2,
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 0 }}>
|
||||
<Typography variant="body2" color="text.secondary" component="span">
|
||||
{item.name}
|
||||
</Typography>
|
||||
{item.info && (
|
||||
<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',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -216,50 +216,33 @@ const pkgInglewood: ComparisonPackage = {
|
||||
{
|
||||
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 },
|
||||
},
|
||||
{ name: 'Allowance for Coffin', value: { type: 'unknown' } },
|
||||
{ name: 'Cremation Certificate/Permit', value: { type: 'unknown' } },
|
||||
{ name: 'Crematorium', value: { type: 'unknown' } },
|
||||
{ name: 'Death Registration Certificate', value: { type: 'unknown' } },
|
||||
{ name: 'Dressing Fee', value: { type: 'unknown' } },
|
||||
{ name: 'NSW Government Levy — Cremation', value: { type: 'unknown' } },
|
||||
{ name: 'Professional Mortuary Care', value: { type: 'unknown' } },
|
||||
{ name: 'Professional Service Fee', value: { type: 'unknown' } },
|
||||
{ name: 'Transportation Service Fee', value: { type: 'unknown' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
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 },
|
||||
},
|
||||
{ name: 'Digital Recording', value: { type: 'unknown' } },
|
||||
{ name: 'Flowers', value: { type: 'unknown' } },
|
||||
{ name: 'Online Notice', value: { type: 'unknown' } },
|
||||
{ name: 'Viewing Fee', value: { type: 'unknown' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Extras',
|
||||
items: [
|
||||
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
|
||||
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
|
||||
{ name: 'Allowance for Celebrant', value: { type: 'unknown' } },
|
||||
{ name: 'Catering', value: { type: 'unknown' } },
|
||||
{ name: 'Newspaper Notice', value: { type: 'unknown' } },
|
||||
{ name: 'Saturday Service Fee', value: { type: 'unknown' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -62,6 +61,8 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
|
||||
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: ComparisonPackage[] = [];
|
||||
@@ -84,6 +85,34 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
|
||||
? `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(() => {
|
||||
// Small delay to allow layout to settle
|
||||
const timer = setTimeout(() => scrollToCenter(defaultTabIdx), 50);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box ref={ref} sx={sx}>
|
||||
<WizardLayout
|
||||
@@ -151,8 +180,9 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
|
||||
{/* 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"
|
||||
@@ -160,86 +190,30 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
|
||||
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 && (
|
||||
|
||||
@@ -216,50 +216,33 @@ const pkgInglewood: ComparisonPackage = {
|
||||
{
|
||||
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 },
|
||||
},
|
||||
{ name: 'Allowance for Coffin', value: { type: 'unknown' } },
|
||||
{ name: 'Cremation Certificate/Permit', value: { type: 'unknown' } },
|
||||
{ name: 'Crematorium', value: { type: 'unknown' } },
|
||||
{ name: 'Death Registration Certificate', value: { type: 'unknown' } },
|
||||
{ name: 'Dressing Fee', value: { type: 'unknown' } },
|
||||
{ name: 'NSW Government Levy — Cremation', value: { type: 'unknown' } },
|
||||
{ name: 'Professional Mortuary Care', value: { type: 'unknown' } },
|
||||
{ name: 'Professional Service Fee', value: { type: 'unknown' } },
|
||||
{ name: 'Transportation Service Fee', value: { type: 'unknown' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
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 },
|
||||
},
|
||||
{ name: 'Digital Recording', value: { type: 'unknown' } },
|
||||
{ name: 'Flowers', value: { type: 'unknown' } },
|
||||
{ name: 'Online Notice', value: { type: 'unknown' } },
|
||||
{ name: 'Viewing Fee', value: { type: 'unknown' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Extras',
|
||||
items: [
|
||||
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
|
||||
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
|
||||
{ name: 'Allowance for Celebrant', value: { type: 'unknown' } },
|
||||
{ name: 'Catering', value: { type: 'unknown' } },
|
||||
{ name: 'Newspaper Notice', value: { type: 'unknown' } },
|
||||
{ name: 'Saturday Service Fee', value: { type: 'unknown' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user