diff --git a/src/components/pages/ComparisonPage/ComparisonPage.tsx b/src/components/pages/ComparisonPage/ComparisonPage.tsx index 40c403f..178c43c 100644 --- a/src/components/pages/ComparisonPage/ComparisonPage.tsx +++ b/src/components/pages/ComparisonPage/ComparisonPage.tsx @@ -303,13 +303,19 @@ function MobilePackageCard({ // ─── 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, - * section tables with left accent borders. - * Mobile: Tabbed card view with horizontal chip rail. + * section tables with left accent borders. **Recommended package appears as + * 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. + * + * See `ComparisonPageV1.tsx` for the archived V1 (recommended-last) layout. */ export const ComparisonPage = React.forwardRef( ( @@ -321,14 +327,18 @@ export const ComparisonPage = React.forwardRef { - const result = [...packages]; + const result: ComparisonPackage[] = []; if (recommendedPackage) { result.push({ ...recommendedPackage, isRecommended: true }); } + result.push(...packages); return result; }, [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 providerCount = new Set(allPackages.map((p) => p.provider.name)).size; @@ -446,18 +456,37 @@ export const ComparisonPage = React.forwardRef - - {pkg.isRecommended ? `★ ${pkg.provider.name}` : pkg.provider.name} - + {pkg.isRecommended && ( + + )} + + {pkg.provider.name} + + ( + +); + +// ─── 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 = ( + } + items={[ + { label: 'FAQ', href: '/faq' }, + { label: 'Contact Us', href: '/contact' }, + { label: 'Log in', href: '/login' }, + ]} + /> +); + +const meta: Meta = { + 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; + +// --- 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 ( + alert(`Make arrangement for: ${id}`)} + onRemove={(id) => setPkgs(pkgs.filter((p) => p.id !== id))} + onBack={() => alert('Back to packages')} + /> + ); + }, +}; diff --git a/src/components/pages/ComparisonPage/ComparisonPageV1.tsx b/src/components/pages/ComparisonPage/ComparisonPageV1.tsx new file mode 100644 index 0000000..1db1ead --- /dev/null +++ b/src/components/pages/ComparisonPage/ComparisonPageV1.tsx @@ -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; +} + +// ─── 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 ( + + {formatPrice(value.amount)} + + ); + case 'allowance': + return ( + + {formatPrice(value.amount)}* + + ); + case 'complimentary': + return ( + + + + Complimentary + + + ); + case 'included': + return ( + + + + Included + + + ); + case 'poa': + return ( + + Price On Application + + ); + case 'unknown': + return ( + + Unknown + + ); + case 'unavailable': + return ( + + — + + ); + } +} + +// ─── Mobile card view ─────────────────────────────────────────────────────── + +function MobilePackageCard({ + pkg, + onArrange, +}: { + pkg: ComparisonPackage; + onArrange: (id: string) => void; +}) { + return ( + + {/* Recommended banner */} + {pkg.isRecommended && ( + + + Recommended + + + )} + + {/* Provider header */} + + {/* Verified badge */} + {pkg.provider.verified && ( + } + sx={{ mb: 1 }} + > + Verified + + )} + + {/* Provider name */} + + {pkg.provider.name} + + + {/* Location + Rating */} + + + + + {pkg.provider.location} + + + {pkg.provider.rating != null && ( + + + + {pkg.provider.rating} + {pkg.provider.reviewCount != null && ` (${pkg.provider.reviewCount})`} + + + )} + + + + + {/* Package name + price */} + + {pkg.name} + + + Total package price + + + {formatPrice(pkg.price)} + + + + + + {/* Sections — with left accent borders on headings */} + + {pkg.itemizedAvailable === false ? ( + + + Itemised pricing not available for this provider. + + + ) : ( + pkg.sections.map((section, sIdx) => ( + + {/* Section heading with left accent */} + 0 ? 1 : 0, + }} + > + + {section.heading} + + + + {section.items.map((item) => ( + + + + {item.name} + + {item.info && ( + + {'\u00A0'} + + + + + )} + + + + ))} + + + )) + )} + + + ); +} + +// ─── 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( + ( + { 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 ( + + + {/* Page header with Share/Print actions */} + + + + + Compare packages + + + {subtitle} + + + + {/* Share + Print */} + {(onShare || onPrint) && ( + + {onShare && ( + + )} + {onPrint && ( + + )} + + )} + + + + {/* Desktop: ComparisonTable */} + {!isMobile && ( + + )} + + {/* Mobile: Tab rail + card view */} + {isMobile && allPackages.length > 0 && ( + <> + {/* Tab rail — mini cards showing provider + package name */} + + {allPackages.map((pkg, idx) => { + const isActive = idx === activeTabIdx; + return ( + setActiveTabIdx(idx)} + interactive + sx={{ + flexShrink: 0, + minWidth: 150, + maxWidth: 200, + cursor: 'pointer', + ...(pkg.isRecommended && + !isActive && { + borderColor: 'var(--fa-color-brand-500)', + }), + }} + > + + + {pkg.isRecommended && ( + + )} + + {pkg.provider.name} + + + + {pkg.name} + + + + ); + })} + + + {activePackage && ( + + + + )} + + )} + + + ); + }, +); + +ComparisonPageV1.displayName = 'ComparisonPageV1'; +export default ComparisonPageV1; diff --git a/src/components/pages/ComparisonPage/index.ts b/src/components/pages/ComparisonPage/index.ts index be9942a..5963095 100644 --- a/src/components/pages/ComparisonPage/index.ts +++ b/src/components/pages/ComparisonPage/index.ts @@ -1,2 +1,4 @@ export { ComparisonPage, default } from './ComparisonPage'; export type { ComparisonPageProps } from './ComparisonPage'; +export { ComparisonPageV1 } from './ComparisonPageV1'; +export type { ComparisonPageV1Props } from './ComparisonPageV1';