From b7a2a4e136ee84a7b227519f13a89c7f02053a35 Mon Sep 17 00:00:00 2001 From: Richie Date: Thu, 9 Apr 2026 15:33:29 +1000 Subject: [PATCH] Extract ComparisonPackageCard molecule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../ComparisonPackageCard.stories.tsx | 163 ++++++++++ .../ComparisonPackageCard.tsx | 290 ++++++++++++++++++ .../molecules/ComparisonPackageCard/index.ts | 2 + .../pages/ComparisonPage/ComparisonPage.tsx | 269 +--------------- .../pages/ComparisonPage/ComparisonPageV1.tsx | 269 +--------------- 5 files changed, 461 insertions(+), 532 deletions(-) create mode 100644 src/components/molecules/ComparisonPackageCard/ComparisonPackageCard.stories.tsx create mode 100644 src/components/molecules/ComparisonPackageCard/ComparisonPackageCard.tsx create mode 100644 src/components/molecules/ComparisonPackageCard/index.ts diff --git a/src/components/molecules/ComparisonPackageCard/ComparisonPackageCard.stories.tsx b/src/components/molecules/ComparisonPackageCard/ComparisonPackageCard.stories.tsx new file mode 100644 index 0000000..7cd7b19 --- /dev/null +++ b/src/components/molecules/ComparisonPackageCard/ComparisonPackageCard.stories.tsx @@ -0,0 +1,163 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Box from '@mui/material/Box'; +import { ComparisonPackageCard } from './ComparisonPackageCard'; +import type { ComparisonPackage } from '../../organisms/ComparisonTable'; + +// ─── Mock data ────────────────────────────────────────────────────────────── + +const basePackage: 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: [ + { + 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: '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: 'included' } }, + ], + }, + { + 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: 'Saturday Service Fee', + info: 'Additional fee for Saturday services.', + value: { type: 'price', amount: 880 }, + }, + ], + }, + ], +}; + +const unverifiedPackage: ComparisonPackage = { + ...basePackage, + id: 'inglewood-everyday', + name: 'Everyday Funeral Package', + price: 7200, + provider: { + name: 'Inglewood Chapel', + location: 'Inglewood', + rating: 4.2, + reviewCount: 45, + verified: false, + }, +}; + +const recommendedPackage: ComparisonPackage = { + ...basePackage, + id: 'recommended-premium', + name: 'Premium Cremation Service', + price: 8450, + provider: { + name: 'H. Parsons Funeral Directors', + location: 'Wentworth', + rating: 4.9, + reviewCount: 203, + verified: true, + }, + isRecommended: true, +}; + +// ─── Meta ─────────────────────────────────────────────────────────────────── + +const meta: Meta = { + title: 'Molecules/ComparisonPackageCard', + component: ComparisonPackageCard, + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, + decorators: [ + (Story) => ( + + + + ), + ], + args: { + onArrange: (id) => alert(`Arrange: ${id}`), + }, +}; + +export default meta; +type Story = StoryObj; + +/** Verified provider — default appearance used in ComparisonPage mobile tab panel */ +export const Verified: Story = { + args: { + pkg: basePackage, + }, +}; + +/** Unverified provider — "Make Enquiry" CTA + soft button variant, no verified badge */ +export const Unverified: Story = { + args: { + pkg: unverifiedPackage, + }, +}; + +/** Recommended package — warm banner, selected card state, warm header background */ +export const Recommended: Story = { + args: { + pkg: recommendedPackage, + }, +}; + +/** Itemisation unavailable — used when a provider hasn't submitted an itemised breakdown */ +export const ItemizedUnavailable: Story = { + args: { + pkg: { + ...unverifiedPackage, + itemizedAvailable: false, + }, + }, +}; diff --git a/src/components/molecules/ComparisonPackageCard/ComparisonPackageCard.tsx b/src/components/molecules/ComparisonPackageCard/ComparisonPackageCard.tsx new file mode 100644 index 0000000..6039fc0 --- /dev/null +++ b/src/components/molecules/ComparisonPackageCard/ComparisonPackageCard.tsx @@ -0,0 +1,290 @@ +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; +} + +// ─── 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 ( + + {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 ( + + — + + ); + } +} + +// ─── 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( + ({ pkg, onArrange, sx }, ref) => { + 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'} + + + + + )} + + + + ))} + + + )) + )} + + + ); + }, +); + +ComparisonPackageCard.displayName = 'ComparisonPackageCard'; +export default ComparisonPackageCard; diff --git a/src/components/molecules/ComparisonPackageCard/index.ts b/src/components/molecules/ComparisonPackageCard/index.ts new file mode 100644 index 0000000..688050c --- /dev/null +++ b/src/components/molecules/ComparisonPackageCard/index.ts @@ -0,0 +1,2 @@ +export { ComparisonPackageCard, default } from './ComparisonPackageCard'; +export type { ComparisonPackageCardProps } from './ComparisonPackageCard'; diff --git a/src/components/pages/ComparisonPage/ComparisonPage.tsx b/src/components/pages/ComparisonPage/ComparisonPage.tsx index 178c43c..fd7e971 100644 --- a/src/components/pages/ComparisonPage/ComparisonPage.tsx +++ b/src/components/pages/ComparisonPage/ComparisonPage.tsx @@ -2,26 +2,16 @@ 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'; +import { ComparisonTable, type ComparisonPackage } from '../../organisms/ComparisonTable'; +import { ComparisonPackageCard } from '../../molecules/ComparisonPackageCard'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -47,259 +37,6 @@ export interface ComparisonPageProps { 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 ────────────────────────────────────────────────────────────── /** @@ -511,7 +248,7 @@ export const ComparisonPage = React.forwardRef - + )} diff --git a/src/components/pages/ComparisonPage/ComparisonPageV1.tsx b/src/components/pages/ComparisonPage/ComparisonPageV1.tsx index 1db1ead..477c0f2 100644 --- a/src/components/pages/ComparisonPage/ComparisonPageV1.tsx +++ b/src/components/pages/ComparisonPage/ComparisonPageV1.tsx @@ -2,26 +2,16 @@ 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'; +import { ComparisonTable, type ComparisonPackage } from '../../organisms/ComparisonTable'; +import { ComparisonPackageCard } from '../../molecules/ComparisonPackageCard'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -47,259 +37,6 @@ export interface ComparisonPageV1Props { 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 ────────────────────────────────────────────────────────────── /** @@ -505,7 +242,7 @@ export const ComparisonPageV1 = React.forwardRef - + )}