From 52fd0f199a33b891ab65cf1f6367506962965a83 Mon Sep 17 00:00:00 2001 From: Richie Date: Tue, 7 Apr 2026 01:17:34 +1000 Subject: [PATCH] Add package comparison feature: CompareBar, ComparisonTable, ComparisonPage New components for side-by-side funeral package comparison: - CompareBar molecule: floating bottom pill with fraction badge (1/3, 2/3, 3/3), contextual copy, Compare CTA. For ProvidersStep and PackagesStep. - ComparisonTable organism: CSS Grid comparison with info card, floating verified badges, separate section tables (Essentials/Optionals/Extras) with left accent borders, row hover, horizontal scroll on narrow desktops, font hierarchy. - ComparisonPage: WizardLayout wide-form with Share/Print actions. Desktop shows ComparisonTable, mobile shows mini-card tab rail + single package card view. Recommended package as separate prop (D038). Also fixes PackageDetail: adds priceLabel pass-through (D039), updates stories to Essentials/Optionals/Extras section naming (D035). Decisions: D035-D039 logged. Audits: CompareBar 18/20, ComparisonTable 17/20. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/memory/component-registry.md | 5 +- docs/memory/decisions-log.md | 40 ++ docs/memory/session-log.md | 75 +++ .../CompareBar/CompareBar.stories.tsx | 166 ++++++ .../molecules/CompareBar/CompareBar.tsx | 114 ++++ src/components/molecules/CompareBar/index.ts | 2 + .../ComparisonTable.stories.tsx | 373 +++++++++++++ .../ComparisonTable/ComparisonTable.tsx | 516 ++++++++++++++++++ .../organisms/ComparisonTable/index.ts | 9 + .../PackageDetail/PackageDetail.stories.tsx | 164 +++--- .../organisms/PackageDetail/PackageDetail.tsx | 3 + .../ComparisonPage/ComparisonPage.stories.tsx | 474 ++++++++++++++++ .../pages/ComparisonPage/ComparisonPage.tsx | 497 +++++++++++++++++ src/components/pages/ComparisonPage/index.ts | 2 + 14 files changed, 2359 insertions(+), 81 deletions(-) create mode 100644 src/components/molecules/CompareBar/CompareBar.stories.tsx create mode 100644 src/components/molecules/CompareBar/CompareBar.tsx create mode 100644 src/components/molecules/CompareBar/index.ts create mode 100644 src/components/organisms/ComparisonTable/ComparisonTable.stories.tsx create mode 100644 src/components/organisms/ComparisonTable/ComparisonTable.tsx create mode 100644 src/components/organisms/ComparisonTable/index.ts create mode 100644 src/components/pages/ComparisonPage/ComparisonPage.stories.tsx create mode 100644 src/components/pages/ComparisonPage/ComparisonPage.tsx create mode 100644 src/components/pages/ComparisonPage/index.ts diff --git a/docs/memory/component-registry.md b/docs/memory/component-registry.md index 33d51c9..995c796 100644 --- a/docs/memory/component-registry.md +++ b/docs/memory/component-registry.md @@ -53,6 +53,7 @@ duplicates) and MUST update it after completing one. | LineItem | done | Typography + Tooltip + InfoOutlinedIcon | Name + optional info tooltip + optional price. Supports allowance asterisk, total variant (bold + top border). Font weight 500 (D019), prices text.secondary for readability hierarchy. Audit: 19/20. | | ProviderCardCompact | done | Card (outlined) + Typography | Horizontal compact provider card — image left, name + location + rating right. Used at top of Package Select page. Separate from vertical ProviderCard. | | CartButton | done | Button + DialogShell + LineItem + Divider + Typography | Outlined pill trigger: receipt icon + "Your Plan" + formatted total in brand colour. Click opens DialogShell with items grouped by section via LineItem, total row. Mobile: icon + price only. Lives in WizardLayout `runningTotal` slot. | +| CompareBar | done | Badge + Button + IconButton + Typography + Paper + Slide | Floating comparison basket pill. Fixed bottom, slide-up/down. Package count badge + provider names + remove buttons + Compare CTA. Max 3 user packages. Disabled CTA when <2. Inline error for max-reached. Mobile: compact count + CTA only. Audit: 18/20. | ## Organisms @@ -60,7 +61,8 @@ duplicates) and MUST update it after completing one. |-----------|--------|-------------|-------| | ServiceSelector | done | ServiceOption × n + Typography + Button | Single-select service panel for arrangement flow. Heading + subheading + ServiceOption list (radiogroup) + optional continue Button. Manages selection state via selectedId/onSelect. maxDescriptionLines pass-through. | | PricingTable | planned | PriceCard × n + Typography | Comparative pricing display | -| PackageDetail | done | LineItem × n + Typography + Button + Divider | Right-side package detail panel. Warm header band (surface.warm) with "Package" overline, name, price (brand colour), Make Arrangement + Compare (with loading) buttons. Sections (before total) + total + extras (after total, with subtext). T&C grey footer. Audit: 19/20. Maps to Figma Package Select (5405:181955). | +| PackageDetail | done | LineItem × n + Typography + Button + Divider | Right-side package detail panel. Warm header band (surface.warm) with "Package" overline, name, price (brand colour), Make Arrangement + Compare (with loading) buttons. Sections: Essentials + Optionals (before total) + total + Extras (after total, with subtext). `priceLabel` pass-through to LineItem (D039). T&C grey footer. Audit: 19/20. | +| ComparisonTable | done | Typography + Button + Badge + Link + Tooltip | Side-by-side package comparison CSS Grid. Sticky header cards with provider info + price + CTA. Row-merged sections (union of all items). 7 cell value types (discriminated union D036). Recommended column with warm bg + badge. Verified → "Make Arrangement", unverified → "Make Enquiry". ARIA table roles. Desktop only (mobile in ComparisonPage). Audit: 17/20. | | FuneralFinder (V3) | done | Typography + Button + Divider + Select + MenuItem + OutlinedInput + custom StatusCard/SectionLabel | **Production version.** Hero search widget — clean form with status cards. Standard card container (surface.raised, card shadow). "How Can We Help" section: two side-by-side StatusCards (Immediate Need default-selected / Pre-planning) — white bg, neutral border, brand border + warm bg when selected, stack on mobile. "Funeral Type" Select + "Location" OutlinedInput with pin icon — standard outlined fields, no focus ring (per design). Overline section labels (text.secondary). CTA "Find Funeral Directors →" always active — validates on click, scrolls to first missing field. Required: status + location. Funeral type defaults to "show all". Dividers after header and before CTA. WAI-ARIA roving tabindex on radiogroup. aria-labelledby via useId(). Critique: 33/40 (Good). Audit: 18/20 (Excellent). | | FuneralFinder V1 | archived | Typography + Button + Chip + Input + Divider + Link + custom ChoiceCard/TypeCard/CompletedRow/StepHeading | Archived — viewable in Storybook under Archive/. Stepped conversational flow. Audit: 14/20. Critique: 29/40. | | FuneralFinder V2 | archived | Typography + Button + Input + Divider + Select + MenuItem + custom StepCircle | Archived — viewable in Storybook under Archive/. Quick-form with step circles. Audit: 18/20. Critique: 33/40. | @@ -100,6 +102,7 @@ duplicates) and MUST update it after completing one. | ConfirmationStep | done | WizardLayout (centered-form) + Button | Wizard step 15 — confirmation. Terminal page. At-need: "submitted" + callback. Pre-planning: "saved" + return-anytime. Muted success icon. | | UnverifiedProviderStep | done | WizardLayout (list-detail) + ProviderCardCompact + ProviderCard + Badge + Button + Divider + Typography | Unverified provider detail. Left: compact card + "Listing" badge + available info (conditional dl) + verified recommendations. Right: warm header band + detail rows + "Make an Enquiry" CTA. Graceful degradation (no data → straight to enquiry). 4 story variants. | | HomePage | done | FuneralFinderV3/V4 (via finderSlot) + ProviderCardCompact + Button + Typography + Accordion + Divider + Navigation (prop) + Footer (prop) | Marketing landing page. 4 archived versions: V1 (split hero), V2 (full-bleed parsonshero.png), V3 (hero-3.png + updated copy + logo bar + venue photos + warm CTA gradient), V4 (same as V3 but with FuneralFinderV4 stepped form via finderSlot). `finderSlot` prop allows swapping finder widget. Light grey footer (surface.subtle). | +| ComparisonPage | done | WizardLayout (wide-form) + ComparisonTable + Chip + Card + LineItem + Typography + Button + Divider | Package comparison page. Desktop: full ComparisonTable with sticky headers. Mobile: tabbed card view with horizontal chip rail (role="tablist") + single package card (role="tabpanel"). Recommended package as additional column/tab (separate prop D038). Back link, help bar. | ## Future enhancements diff --git a/docs/memory/decisions-log.md b/docs/memory/decisions-log.md index 6033ddf..1161b66 100644 --- a/docs/memory/decisions-log.md +++ b/docs/memory/decisions-log.md @@ -293,3 +293,43 @@ contradict a previous one. **Rationale:** P0/P1 are the issues that affect usability and accessibility. P2/P3 are cosmetic — not worth the risk of changing approved components. Interleaving ensures the foundation is solid before building on it, without dedicating entire sessions to review. **Affects:** Session workflow, CLAUDE.md startup procedure, docs/reference/retroactive-review-plan.md **Alternatives considered:** Dedicated review sessions — rejected as less efficient. Full P0-P3 fixes — rejected as too risky for approved components. + +### D035 — Package sections standardised to Essentials / Optionals / Extras +**Date:** 2026-04-06 +**Category:** component +**Decision:** Package data uses three sections: **Essentials** (priced core items), **Optionals** (complimentary inclusions), **Extras** (additional-cost items after the total). Replaces the previous "Complimentary Items" naming. +**Rationale:** Matches the real-world package structure from FA's provider data (see reference image). "Optionals" better communicates that these are included-but-not-mandatory items, while "Complimentary" is a price label on individual items, not a section name. +**Affects:** PackageDetail stories, ComparisonTable sections, ComparisonPage mobile cards +**Alternatives considered:** "Inclusions" instead of "Optionals" — rejected as it overlaps with Essentials (which are also inclusions). + +### D036 — ComparisonCellValue uses discriminated union type +**Date:** 2026-04-06 +**Category:** architecture +**Decision:** Cell values in ComparisonTable use a tagged union type (`{ type: 'price' | 'allowance' | 'complimentary' | 'included' | 'poa' | 'unknown' | 'unavailable' }`) rather than flat optional props. +**Rationale:** Ensures exhaustive pattern matching in CellValue renderer — the TypeScript compiler catches missing cases. Clearer than a flat `{ price?: number; priceLabel?: string; isAllowance?: boolean }` which has ambiguous combinations. Each value type maps to a distinct visual treatment. +**Affects:** ComparisonTable, ComparisonPage mobile card view +**Alternatives considered:** Reusing PackageLineItem from PackageDetail — rejected as it conflates "how data is stored" with "how data is displayed". The comparison needs explicit cell state (e.g. "unavailable" vs "unknown"). + +### D037 — Mobile comparison uses chip tabs, not horizontal scroll table +**Date:** 2026-04-06 +**Category:** component +**Decision:** ComparisonPage renders a chip-based tab rail + single card view on mobile, rather than a horizontally scrollable table. +**Rationale:** Wide comparison tables on small screens create "hidden column" problems — users can't see all packages at once and may miss columns. Card view with tabs matches mental model of reviewing one option at a time. Lower cognitive load for FA's grief-sensitive audience. Tab rail provides quick switching. ARIA tablist/tabpanel semantics. +**Affects:** ComparisonPage mobile layout +**Alternatives considered:** Horizontal scroll table — rejected for poor usability on small screens. Accordion per package — rejected as it hides content behind extra taps. + +### D038 — Recommended package is a separate prop, not mixed into packages array +**Date:** 2026-04-06 +**Category:** architecture +**Decision:** ComparisonPage accepts `recommendedPackage` as a separate prop from `packages`. The page merges it as the last column with `isRecommended: true`. +**Rationale:** Keeps the user-selected array clean and unambiguous. The recommendation source is explicit (server-side logic). The page controls placement (always last column/tab). Prevents accidental removal of the recommended package by the user (no Remove button). +**Affects:** ComparisonPage props, ComparisonTable isRecommended column +**Alternatives considered:** Including recommended in the packages array with a flag — rejected as it mixes user selections with system recommendations. + +### D039 — PackageLineItem gains priceLabel for consistency with LineItem +**Date:** 2026-04-06 +**Category:** component +**Decision:** Added `priceLabel?: string` to `PackageLineItem` interface in PackageDetail, passed through to LineItem molecule. +**Rationale:** LineItem already supports `priceLabel` for custom price text ("Complimentary", "Price On Application", "TBC"). PackageDetail's type was missing this field, forcing workarounds. Adding it enables the Optionals section to display "Complimentary" labels and Extras to show "Price On Application". +**Affects:** PackageDetail component + stories, any consumer of PackageLineItem type +**Alternatives considered:** None — this was a straightforward type parity fix. diff --git a/docs/memory/session-log.md b/docs/memory/session-log.md index 9016060..12eab3f 100644 --- a/docs/memory/session-log.md +++ b/docs/memory/session-log.md @@ -26,6 +26,81 @@ Each entry follows this structure: ## Sessions +### Session 2026-04-07 — Package comparison iteration (Figma-informed) + +**Agent(s):** Claude Opus 4.6 (1M context) + +**Work completed:** +- **ComparisonTable major iteration** from Figma feedback: + - Dark info card → soft grey info card (surface.subtle, no border), stretches to match card heights, text at top + - Provider cards: no logos, floating verified badge (VerifiedOutlinedIcon, consistent with ProviderCard/MiniCard/MapPopup), rating in cards (body2 size) + - Separate bordered tables per section (Essentials, Optionals, Extras) with left accent borders (3px brand-500) + - Reviews section removed (rating lives in cards) + - Horizontal scroll on narrow desktops (minWidth enforcement) + - Cards: flex stretch + spacer for CTA bottom-alignment across mixed verified/unverified + - Row hover highlight (brand-50), font hierarchy (labels text.secondary, values fontWeight 600) +- **ComparisonPage iteration:** + - Share + Print buttons in page header (onShare, onPrint props) + - Mobile verified badge (VerifiedOutlinedIcon in soft brand Badge) + - Mobile section headings with left accent borders + - Mobile item rows: 60% max-width for names, inline info icons with nowrap binding + - Mobile tab rail: mini Card components (provider name + package name) replacing Chips + - Navigation included by default in all stories +- **CompareBar simplified:** + - Fraction badge (1/3, 2/3, 3/3) + - Contextual copy: "Add another to compare" / "Ready to compare" + - Removed package names and remove buttons from pill +- **Figma integration:** + - Created `/capture-to-figma` skill — captures Storybook stories to Parsons Figma file + - Created `/figma-ideas` skill — fetches Figma designs and proposes adaptations + - Successfully captured ComparisonPage to Figma (node 6041-25005) + - Applied user's Figma tweaks (node 6047-25005) back to code +- **Cleanup:** Removed Figma capture script from preview-head.html, Prettier formatting pass + +**Decisions made:** +- Info card uses surface.subtle (not dark), stretches to match cards — less visually competing +- Verified badge uses VerifiedOutlinedIcon (consistent with rest of system), floating above cards +- Rating lives in card headers, no separate Reviews table +- Section tables separated with left accent borders (3px brand-500) +- Mobile tab rail uses mini Cards (provider + package name) not Chips +- Share/Print are optional props on ComparisonPage + +**Next steps:** +- Commit all work +- Wire CompareBar into PackagesStep/ProvidersStep (state management) +- Consider comparison state persistence (URL params or context) + +--- + +### Session 2026-04-06b — Package comparison feature + +**Agent(s):** Claude Opus 4.6 (1M context) + +**Work completed:** +- **PackageDetail fix (D039):** Added `priceLabel?: string` to `PackageLineItem` interface, passed through to LineItem. Updated stories to use Essentials/Optionals/Extras sections with realistic funeral data (D035). "Complimentary Items" → "Optionals". +- **CompareBar molecule (new):** Floating comparison basket pill. Fixed bottom, slide-up/down animation. Badge count + provider names + remove × buttons + Compare CTA. Max 3 user packages. Disabled CTA when <2. Inline `role="alert"` error for max-reached. Mobile: compact count + CTA only. Audit: 18/20 (P2s fixed: error visible on mobile, removed redundant aria-disabled). +- **ComparisonTable organism (new):** CSS Grid side-by-side comparison. Sticky header cards with provider logo/name/location/rating + package name + price + CTA. Row-merged sections via `buildMergedSections` union algorithm. 7 cell value types via discriminated union (D036). Recommended column with warm bg + Badge. Verified → "Make Arrangement", unverified → "Make Enquiry". ARIA `role="table"` + `role="row"` + `role="columnheader"` + `role="cell"`. Desktop only. Audit: 17/20 (P2s fixed: aria-label on recommended column, rowheader on section headings, token-based zebra striping). +- **ComparisonPage page (new):** WizardLayout (wide-form). Desktop: full ComparisonTable. Mobile: chip tab rail (`role="tablist"`) + single MobilePackageCard (`role="tabpanel"`). Recommended package as separate prop, merged as last column/tab. Back link, help bar. +- **Stories:** 6 CompareBar stories (Default, SinglePackage, ThreePackages, WithError, Empty, Interactive), 5 ComparisonTable stories (Default, TwoPackages, WithRecommended, MixedVerified, MissingData), 5 ComparisonPage stories (Default, TwoPackages, WithRecommended, MobileView, FullPage with Navigation). +- **Quality gates:** TypeScript ✓, ESLint ✓, Storybook build ✓. CompareBar audit 18/20, ComparisonTable audit 17/20. + +**Decisions made:** +- D035: Package sections standardised to Essentials/Optionals/Extras +- D036: ComparisonCellValue uses discriminated union for exhaustive rendering +- D037: Mobile comparison uses chip tabs + card view, not horizontal scroll table +- D038: Recommended package is a separate prop, always additional to user selections +- D039: PackageLineItem gains priceLabel for consistency with LineItem molecule + +**Open questions:** +- None + +**Next steps:** +- Visual review in Storybook (user + Playwright screenshots) +- Wire CompareBar into PackagesStep (state management for comparison basket) +- Consider adding CompareBar to WizardLayout as a slot or portal + +--- + ### Session 2026-04-06 — Retroactive review completion **Agent(s):** Claude Opus 4.6 (1M context) diff --git a/src/components/molecules/CompareBar/CompareBar.stories.tsx b/src/components/molecules/CompareBar/CompareBar.stories.tsx new file mode 100644 index 0000000..0b486dc --- /dev/null +++ b/src/components/molecules/CompareBar/CompareBar.stories.tsx @@ -0,0 +1,166 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import Box from '@mui/material/Box'; +import { CompareBar } from './CompareBar'; +import type { CompareBarPackage } from './CompareBar'; +import { Button } from '../../atoms/Button'; +import { Typography } from '../../atoms/Typography'; + +const samplePackages: CompareBarPackage[] = [ + { id: '1', name: 'Everyday Funeral Package', providerName: 'Wollongong City Funerals' }, + { id: '2', name: 'Traditional Cremation Service', providerName: 'Mackay Family Funerals' }, + { id: '3', name: 'Essential Burial Package', providerName: 'Inglewood Chapel' }, +]; + +const meta: Meta = { + title: 'Molecules/CompareBar', + component: CompareBar, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + }, + decorators: [ + (Story) => ( + + + The compare bar floats at the bottom of the viewport. + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +// --- Default (2 packages) --------------------------------------------------- + +/** Two packages selected — "2 packages ready to compare" */ +export const Default: Story = { + args: { + packages: samplePackages.slice(0, 2), + onCompare: () => alert('Compare clicked'), + }, +}; + +// --- Single Package ---------------------------------------------------------- + +/** One package — "Add another package to compare", CTA disabled */ +export const SinglePackage: Story = { + args: { + packages: samplePackages.slice(0, 1), + onCompare: () => alert('Compare clicked'), + }, +}; + +// --- Three Packages (Maximum) ------------------------------------------------ + +/** Maximum 3 packages */ +export const ThreePackages: Story = { + args: { + packages: samplePackages, + onCompare: () => alert('Compare clicked'), + }, +}; + +// --- With Error -------------------------------------------------------------- + +/** Error message when user tries to add a 4th package */ +export const WithError: Story = { + args: { + packages: samplePackages, + onCompare: () => alert('Compare clicked'), + error: 'Maximum 3 packages', + }, +}; + +// --- Empty (Hidden) ---------------------------------------------------------- + +/** No packages — bar is hidden */ +export const Empty: Story = { + args: { + packages: [], + onCompare: () => {}, + }, +}; + +// --- Interactive Demo -------------------------------------------------------- + +/** Interactive demo — add packages and see the bar update */ +export const Interactive: Story = { + render: () => { + const [selected, setSelected] = useState([]); + const [error, setError] = useState(); + + const allPackages = [ + ...samplePackages, + { id: '4', name: 'Catholic Service', providerName: "St Mary's Funeral Services" }, + ]; + + const handleToggle = (pkg: CompareBarPackage) => { + const isSelected = selected.some((s) => s.id === pkg.id); + if (isSelected) { + setSelected(selected.filter((s) => s.id !== pkg.id)); + setError(undefined); + } else { + if (selected.length >= 3) { + setError('Maximum 3 packages'); + setTimeout(() => setError(undefined), 3000); + return; + } + setSelected([...selected, pkg]); + setError(undefined); + } + }; + + return ( + + + Select packages to compare + + + {allPackages.map((pkg) => { + const isSelected = selected.some((s) => s.id === pkg.id); + return ( + + + {pkg.name} + + {pkg.providerName} + + + + + ); + })} + + + alert(`Comparing: ${selected.map((s) => s.name).join(', ')}`)} + error={error} + /> + + ); + }, +}; diff --git a/src/components/molecules/CompareBar/CompareBar.tsx b/src/components/molecules/CompareBar/CompareBar.tsx new file mode 100644 index 0000000..76795a6 --- /dev/null +++ b/src/components/molecules/CompareBar/CompareBar.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import Paper from '@mui/material/Paper'; +import Slide from '@mui/material/Slide'; +import CompareArrowsIcon from '@mui/icons-material/CompareArrows'; +import type { SxProps, Theme } from '@mui/material/styles'; +import { Typography } from '../../atoms/Typography'; +import { Button } from '../../atoms/Button'; +import { Badge } from '../../atoms/Badge'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +/** A package in the comparison basket */ +export interface CompareBarPackage { + /** Unique package ID */ + id: string; + /** Package display name */ + name: string; + /** Provider name */ + providerName: string; +} + +/** Props for the CompareBar molecule */ +export interface CompareBarProps { + /** Packages currently in the comparison basket (max 3 user-selected) */ + packages: CompareBarPackage[]; + /** Called when user clicks "Compare" CTA */ + onCompare: () => void; + /** Error/status message shown inline (e.g. "Maximum 3 packages") */ + error?: string; + /** MUI sx prop for the root wrapper */ + sx?: SxProps; +} + +// ─── Component ─────────────────────────────────────────────────────────────── + +/** + * Floating comparison basket pill for the FA design system. + * + * Shows a fraction badge (1/3, 2/3, 3/3), contextual copy, and a Compare CTA. + * Present on both ProvidersStep and PackagesStep. + * + * Composes Badge + Button + Typography. + */ +export const CompareBar = React.forwardRef( + ({ packages, onCompare, error, sx }, ref) => { + const count = packages.length; + const visible = count > 0; + const canCompare = count >= 2; + + const statusText = count === 1 ? 'Add another to compare' : 'Ready to compare'; + + return ( + + ({ + position: 'fixed', + bottom: theme.spacing(3), + left: '50%', + transform: 'translateX(-50%)', + zIndex: theme.zIndex.snackbar, + borderRadius: '9999px', + display: 'flex', + alignItems: 'center', + gap: 1.5, + px: 2.5, + py: 1.25, + maxWidth: { xs: 'calc(100vw - 32px)', md: 420 }, + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + > + {/* Fraction badge — 1/3, 2/3, 3/3 */} + + {count}/3 + + + {/* Status text */} + + {error || statusText} + + + {/* Compare CTA */} + + + + ); + }, +); + +CompareBar.displayName = 'CompareBar'; +export default CompareBar; diff --git a/src/components/molecules/CompareBar/index.ts b/src/components/molecules/CompareBar/index.ts new file mode 100644 index 0000000..0d1564b --- /dev/null +++ b/src/components/molecules/CompareBar/index.ts @@ -0,0 +1,2 @@ +export { CompareBar, default } from './CompareBar'; +export type { CompareBarProps, CompareBarPackage } from './CompareBar'; diff --git a/src/components/organisms/ComparisonTable/ComparisonTable.stories.tsx b/src/components/organisms/ComparisonTable/ComparisonTable.stories.tsx new file mode 100644 index 0000000..fbec700 --- /dev/null +++ b/src/components/organisms/ComparisonTable/ComparisonTable.stories.tsx @@ -0,0 +1,373 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Box from '@mui/material/Box'; +import { ComparisonTable } from './ComparisonTable'; +import type { ComparisonPackage } from './ComparisonTable'; + +const DEMO_LOGO = 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=72&h=72&fit=crop'; + +// ─── 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: Mackay Family 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 of the deceased.', + 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 of the deceased.', + value: { type: 'price', amount: 440 }, + }, + { + name: 'Professional Service Fee', + info: 'Coordination of all funeral 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 of the Funeral Service', + 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 — upgrade options available.', + value: { type: 'allowance', amount: 1500 }, + }, + { + name: 'Cremation Certificate/Permit', + info: 'Statutory medical referee fee.', + value: { type: 'price', amount: 350 }, + }, + { + name: 'Crematorium: Mackay Family 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: 'included' } }, + { + 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: 2430.35 }, + }, + { + name: 'Transportation Service Fee', + info: 'Transfer of the deceased.', + value: { type: 'price', amount: 0 }, + }, + ], + }, + { + heading: 'Optionals', + items: [ + { + name: 'Digital Recording of the Funeral Service', + info: 'Professional video recording.', + value: { type: 'unknown' }, + }, + { name: 'Online Notice', info: 'Online death notice.', value: { type: 'included' } }, + { name: 'Viewing Fee', info: 'One private family viewing.', value: { type: 'included' } }, + { name: 'Flowers', info: 'Seasonal floral arrangements.', value: { type: 'included' } }, + ], + }, + { + heading: 'Extras', + items: [ + { + name: 'Allowance for Celebrant', + info: 'Professional celebrant or MC.', + value: { type: 'allowance', amount: 450 }, + }, + { name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } }, + { + name: 'Saturday Service Fee', + info: 'Additional fee for Saturday services.', + 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 — 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 }, + }, + ], + }, + { + 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 }, + }, + ], + }, + { + 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 = { + ...pkgWollongong, + id: 'recommended-premium', + name: 'Premium Cremation Service', + price: 8450, + isRecommended: true, + provider: { + name: 'H. Parsons Funeral Directors', + location: 'Wentworth', + logoUrl: DEMO_LOGO, + rating: 4.9, + reviewCount: 203, + verified: true, + }, +}; + +const pkgNoItemised: ComparisonPackage = { + id: 'no-data', + name: 'Basic Cremation', + price: 4500, + provider: { + name: 'Smith & Sons', + location: 'Bankstown', + verified: false, + }, + sections: [], + itemizedAvailable: false, +}; + +// ─── Meta ─────────────────────────────────────────────────────────────────── + +const meta: Meta = { + title: 'Organisms/ComparisonTable', + component: ComparisonTable, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +// --- Default (3 packages) ---------------------------------------------------- + +/** Three packages from different providers — full comparison */ +export const Default: Story = { + args: { + packages: [pkgWollongong, pkgMackay, pkgInglewood], + onArrange: (id) => alert(`Arrange: ${id}`), + onRemove: (id) => alert(`Remove: ${id}`), + }, +}; + +// --- Two Packages ------------------------------------------------------------ + +/** Minimal two-column comparison */ +export const TwoPackages: Story = { + args: { + packages: [pkgWollongong, pkgMackay], + onArrange: (id) => alert(`Arrange: ${id}`), + onRemove: (id) => alert(`Remove: ${id}`), + }, +}; + +// --- With Recommended -------------------------------------------------------- + +/** 3 user + 1 recommended = 4 columns — recommended has warm bg + badge */ +export const WithRecommended: Story = { + args: { + packages: [pkgWollongong, pkgMackay, pkgInglewood, pkgRecommended], + onArrange: (id) => alert(`Arrange: ${id}`), + onRemove: (id) => alert(`Remove: ${id}`), + }, +}; + +// --- Mixed Verified/Unverified ----------------------------------------------- + +/** Mix of verified (Make Arrangement) and unverified (Make Enquiry) providers */ +export const MixedVerified: Story = { + args: { + packages: [pkgWollongong, pkgInglewood], + onArrange: (id) => alert(`Arrange: ${id}`), + onRemove: (id) => alert(`Remove: ${id}`), + }, +}; + +// --- Missing Itemised Data --------------------------------------------------- + +/** One provider has no itemised breakdown — cells show "—" */ +export const MissingData: Story = { + args: { + packages: [pkgWollongong, pkgNoItemised, pkgMackay], + onArrange: (id) => alert(`Arrange: ${id}`), + onRemove: (id) => alert(`Remove: ${id}`), + }, +}; diff --git a/src/components/organisms/ComparisonTable/ComparisonTable.tsx b/src/components/organisms/ComparisonTable/ComparisonTable.tsx new file mode 100644 index 0000000..befe712 --- /dev/null +++ b/src/components/organisms/ComparisonTable/ComparisonTable.tsx @@ -0,0 +1,516 @@ +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 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'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +/** Cell value types for the comparison table */ +export type ComparisonCellValue = + | { type: 'price'; amount: number } + | { type: 'allowance'; amount: number } + | { type: 'complimentary' } + | { type: 'included' } + | { type: 'poa' } + | { type: 'unknown' } + | { type: 'unavailable' }; + +export interface ComparisonLineItem { + name: string; + info?: string; + value: ComparisonCellValue; +} + +export interface ComparisonSection { + heading: string; + items: ComparisonLineItem[]; +} + +export interface ComparisonProvider { + name: string; + location: string; + logoUrl?: string; + rating?: number; + reviewCount?: number; + verified: boolean; +} + +export interface ComparisonPackage { + id: string; + name: string; + price: number; + provider: ComparisonProvider; + sections: ComparisonSection[]; + isRecommended?: boolean; + itemizedAvailable?: boolean; +} + +export interface ComparisonTableProps { + packages: ComparisonPackage[]; + onArrange: (packageId: string) => void; + onRemove: (packageId: string) => void; + 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 ( + + — + + ); + } +} + +function buildMergedSections( + packages: ComparisonPackage[], +): { heading: string; items: { name: string; info?: string }[] }[] { + const sectionMap = new Map(); + const sectionOrder: string[] = []; + + for (const pkg of packages) { + if (pkg.itemizedAvailable === false) continue; + for (const section of pkg.sections) { + if (!sectionMap.has(section.heading)) { + sectionMap.set(section.heading, []); + sectionOrder.push(section.heading); + } + const existing = sectionMap.get(section.heading)!; + for (const item of section.items) { + if (!existing.some((e) => e.name === item.name)) { + existing.push({ name: item.name, info: item.info }); + } + } + } + } + + return sectionOrder.map((heading) => ({ + heading, + items: sectionMap.get(heading) ?? [], + })); +} + +function lookupValue( + pkg: ComparisonPackage, + sectionHeading: string, + itemName: string, +): ComparisonCellValue { + if (pkg.itemizedAvailable === false) return { type: 'unavailable' }; + const section = pkg.sections.find((s) => s.heading === sectionHeading); + if (!section) return { type: 'unavailable' }; + const item = section.items.find((i) => i.name === itemName); + if (!item) return { type: 'unavailable' }; + return item.value; +} + +/** Section heading with left accent border */ +function SectionHeading({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} + +/** Reusable bordered table wrapper */ +const tableSx = { + display: 'grid', + border: '1px solid', + borderColor: 'divider', + borderRadius: 'var(--fa-card-border-radius-default)', + overflow: 'hidden', + bgcolor: 'background.paper', +}; + +// ─── Component ────────────────────────────────────────────────────────────── + +/** + * Side-by-side package comparison table for the FA design system. + * + * Info card in top-left column, floating verified badges above cards, + * section tables with left accent borders, no reviews table (rating in cards). + * + * Desktop only — ComparisonPage handles the mobile card view. + */ +export const ComparisonTable = React.forwardRef( + ({ packages, onArrange, onRemove, sx }, ref) => { + const colCount = packages.length + 1; + const mergedSections = buildMergedSections(packages); + const gridCols = `minmax(220px, 280px) repeat(${packages.length}, minmax(200px, 1fr))`; + const minW = packages.length > 3 ? 960 : packages.length > 2 ? 800 : 600; + + return ( + + + {/* ── Package header cards ── */} + + {/* Info card — stretches to match package card height, text at top */} + + + Package Comparison + + + Review and compare features side-by-side to find the right fit. + + + + {/* Package cards */} + {packages.map((pkg) => ( + + {/* Floating verified badge — overlaps card top edge */} + {pkg.provider.verified && ( + } + sx={{ + position: 'absolute', + top: -12, + left: '50%', + transform: 'translateX(-50%)', + zIndex: 1, + boxShadow: '0 1px 3px rgba(0,0,0,0.1)', + }} + > + Verified + + )} + + + {pkg.isRecommended && ( + + + Recommended + + + )} + + + {/* Provider name (truncated with tooltip) */} + + + {pkg.provider.name} + + + + {/* Location */} + + {pkg.provider.location} + + + {/* Rating */} + {pkg.provider.rating != null && ( + + + + {pkg.provider.rating} + {pkg.provider.reviewCount != null && ` (${pkg.provider.reviewCount})`} + + + )} + + + + + {pkg.name} + + + + Total package price + + + {formatPrice(pkg.price)} + + + {/* Spacer pushes CTA to bottom across all cards */} + + + + + {!pkg.isRecommended && ( + onRemove(pkg.id)} + sx={{ mt: 0.5 }} + > + Remove + + )} + + + + ))} + + + {/* ── Section tables (each separate with left accent headings) ── */} + {mergedSections.map((section) => ( + + + {section.heading} + + + {section.items.map((item) => ( + + + + {item.name} + + {item.info && ( + + + + )} + + + {packages.map((pkg) => ( + + + + ))} + + ))} + + ))} + + {packages.some((p) => p.itemizedAvailable === false) && mergedSections.length > 0 && ( + + * Some providers have not provided an itemised pricing breakdown. Their items are + shown as "—" above. + + )} + + + ); + }, +); + +ComparisonTable.displayName = 'ComparisonTable'; +export default ComparisonTable; diff --git a/src/components/organisms/ComparisonTable/index.ts b/src/components/organisms/ComparisonTable/index.ts new file mode 100644 index 0000000..f73c3ca --- /dev/null +++ b/src/components/organisms/ComparisonTable/index.ts @@ -0,0 +1,9 @@ +export { ComparisonTable, default } from './ComparisonTable'; +export type { + ComparisonTableProps, + ComparisonPackage, + ComparisonProvider, + ComparisonSection, + ComparisonLineItem, + ComparisonCellValue, +} from './ComparisonTable'; diff --git a/src/components/organisms/PackageDetail/PackageDetail.stories.tsx b/src/components/organisms/PackageDetail/PackageDetail.stories.tsx index 7e1c535..13dc7b5 100644 --- a/src/components/organisms/PackageDetail/PackageDetail.stories.tsx +++ b/src/components/organisms/PackageDetail/PackageDetail.stories.tsx @@ -14,98 +14,102 @@ const DEMO_IMAGE = 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400&h=300&fit=crop'; const essentials = [ - { - name: 'Accommodation', - price: 1500, - info: 'Refrigerated holding of the deceased prior to the funeral service.', - }, - { - name: 'Death Registration Certificate', - price: 1500, - info: 'Lodgement of death registration with NSW Registry of Births, Deaths & Marriages.', - }, - { - name: 'Doctor Fee for Cremation', - price: 1500, - info: 'Statutory medical referee fee required for all cremations in NSW.', - }, - { - name: 'NSW Government Levy — Cremation', - price: 1500, - info: 'NSW Government cremation levy as set by the Department of Health.', - }, - { - name: 'Professional Mortuary Care', - price: 1500, - info: 'Preparation and care of the deceased.', - }, - { - name: 'Professional Service Fee', - price: 1500, - info: 'Coordination of all funeral arrangements and services.', - }, { name: 'Allowance for Coffin', - price: 1500, + price: 1750, isAllowance: true, info: 'Allowance amount — upgrade options available during arrangement.', }, { - name: 'Allowance for Crematorium', - price: 1500, - isAllowance: true, - info: 'Allowance for crematorium fees — varies by location.', + name: 'Cremation Certificate/Permit', + price: 350, + info: 'Statutory medical referee fee required for all cremations in NSW.', }, { - name: 'Allowance for Hearse', - price: 1500, - isAllowance: true, - info: 'Allowance for hearse transfer — distance surcharges may apply.', + name: 'Crematorium: Mackay Family Crematorium', + price: 660, + info: 'Cremation facility fees at the selected crematorium.', + }, + { + name: 'Death Registration Certificate', + price: 70, + info: 'Lodgement of death registration with NSW Registry of Births, Deaths & Marriages.', + }, + { + name: 'Dressing Fee', + price: 0, + priceLabel: 'Complimentary', + info: 'Dressing and preparation of the deceased — included at no charge.', + }, + { + name: 'NSW Government Levy — Cremation', + price: 45.1, + info: 'NSW Government cremation levy as set by the Department of Health.', + }, + { + name: 'Professional Mortuary Care', + price: 440, + info: 'Preparation and care of the deceased.', + }, + { + name: 'Professional Service Fee', + price: 3650.9, + info: 'Coordination of all funeral arrangements and services.', + }, + { + name: 'Transportation Service Fee', + price: 0, + priceLabel: 'Complimentary', + info: 'Transfer of the deceased to the funeral home — included in this package.', }, ]; -const complimentary = [ +const optionals = [ { - name: 'Dressing Fee', - info: 'Dressing and preparation of the deceased — included at no charge.', + name: 'Digital Recording of the Funeral Service', + priceLabel: 'Complimentary', + info: 'Professional video recording of the funeral service.', + }, + { + name: 'Online Notice', + priceLabel: 'Complimentary', + info: 'Online death notice published on the funeral home website.', + }, + { + name: 'Viewing Fee', + priceLabel: 'Complimentary', + info: 'One private family viewing — included at no charge.', + }, + { + name: 'Webstreaming of the Funeral Service', + priceLabel: 'Complimentary', + info: 'Live webstream of the funeral service for remote attendees.', }, - { name: 'Viewing Fee', info: 'One private family viewing — included at no charge.' }, ]; const extras = { heading: 'Extras', items: [ { - name: 'Allowance for Flowers', - price: 1500, - isAllowance: true, - info: 'Seasonal floral arrangements for the service.', - }, - { - name: 'Allowance for Master of Ceremonies', - price: 1500, + name: 'Allowance for Celebrant', + price: 550, isAllowance: true, info: 'Professional celebrant or MC for the funeral service.', }, { - name: 'After Business Hours Service Surcharge', - price: 1500, - info: 'Additional fee for services held outside standard business hours.', + name: 'Catering', + priceLabel: 'Price On Application', + info: 'Catering for the wake or post-service gathering.', }, { - name: 'After Hours Prayers', - price: 1500, - info: 'Evening prayer service at the funeral home.', + name: 'Newspaper Notice', + priceLabel: 'Price On Application', + info: 'Published death notice in local or national newspaper.', }, { - name: 'Coffin Bearing by Funeral Directors', - price: 1500, - info: 'Professional pallbearing by funeral directors.', - }, - { - name: 'Digital Recording', - price: 1500, - info: 'Professional video recording of the funeral service.', + name: 'Saturday Service Fee', + price: 880, + info: 'Additional fee for services held on a Saturday.', }, ], }; @@ -169,16 +173,16 @@ type Story = StoryObj; // --- Default ----------------------------------------------------------------- -/** Full package detail panel — Essentials, Complimentary, Total, then Extras */ +/** Full package detail panel — Essentials, Optionals, Total, then Extras */ export const Default: Story = { args: { - name: 'Everyday Funeral Package', - price: 900, + name: 'Traditional Family Cremation Service', + price: 6966, sections: [ { heading: 'Essentials', items: essentials }, - { heading: 'Complimentary Items', items: complimentary }, + { heading: 'Optionals', items: optionals }, ], - total: 2700, + total: 6966, extras, terms: termsText, onArrange: () => alert('Make Arrangement clicked'), @@ -191,10 +195,10 @@ export const Default: Story = { /** Compare button in loading state — adding to comparison cart */ export const CompareLoading: Story = { args: { - name: 'Everyday Funeral Package', - price: 900, + name: 'Traditional Family Cremation Service', + price: 6966, sections: [{ heading: 'Essentials', items: essentials.slice(0, 4) }], - total: 6000, + total: 6966, onArrange: () => alert('Make Arrangement'), onCompare: () => {}, compareLoading: true, @@ -203,16 +207,16 @@ export const CompareLoading: Story = { // --- Without Extras ---------------------------------------------------------- -/** Simpler package with essentials and complimentary only */ +/** Simpler package with essentials and optionals only — no extras */ export const WithoutExtras: Story = { args: { - name: 'Essential Funeral Package', - price: 600, + name: 'Essential Cremation Package', + price: 4850, sections: [ { heading: 'Essentials', items: essentials.slice(0, 6) }, - { heading: 'Complimentary Items', items: complimentary }, + { heading: 'Optionals', items: optionals.slice(0, 2) }, ], - total: 9000, + total: 4850, terms: termsText, onArrange: () => alert('Make Arrangement'), onCompare: () => alert('Compare'), @@ -332,9 +336,9 @@ export const PackageSelectPage: Story = { price={packages.find((p) => p.id === selectedPkg)?.price ?? 0} sections={[ { heading: 'Essentials', items: essentials }, - { heading: 'Complimentary Items', items: complimentary }, + { heading: 'Optionals', items: optionals }, ]} - total={2700} + total={6966} extras={extras} terms={termsText} onArrange={() => alert(`Making arrangement for: ${selectedPkg}`)} diff --git a/src/components/organisms/PackageDetail/PackageDetail.tsx b/src/components/organisms/PackageDetail/PackageDetail.tsx index a3e99db..535a185 100644 --- a/src/components/organisms/PackageDetail/PackageDetail.tsx +++ b/src/components/organisms/PackageDetail/PackageDetail.tsx @@ -19,6 +19,8 @@ export interface PackageLineItem { price?: number; /** Whether this is an allowance (shows asterisk) */ isAllowance?: boolean; + /** Custom price display — overrides formatted price (e.g. "Complimentary", "Price On Application") */ + priceLabel?: string; } /** A section of items within a package (e.g. "Essentials", "Complimentary Items") */ @@ -83,6 +85,7 @@ function SectionBlock({ section, subtext }: { section: PackageSection; subtext?: info={item.info} price={item.price} isAllowance={item.isAllowance} + priceLabel={item.priceLabel} /> ))} diff --git a/src/components/pages/ComparisonPage/ComparisonPage.stories.tsx b/src/components/pages/ComparisonPage/ComparisonPage.stories.tsx new file mode 100644 index 0000000..3a6dfce --- /dev/null +++ b/src/components/pages/ComparisonPage/ComparisonPage.stories.tsx @@ -0,0 +1,474 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import Box from '@mui/material/Box'; +import { ComparisonPage } from './ComparisonPage'; +import type { ComparisonPackage } from '../../organisms/ComparisonTable'; +import { Navigation } from '../../organisms/Navigation'; + +const DEMO_LOGO = 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=72&h=72&fit=crop'; + +const FALogoNav = () => ( + +); + +// ─── 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: 'Pages/ComparisonPage', + component: ComparisonPage, + 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/ComparisonPage.tsx b/src/components/pages/ComparisonPage/ComparisonPage.tsx new file mode 100644 index 0000000..40c403f --- /dev/null +++ b/src/components/pages/ComparisonPage/ComparisonPage.tsx @@ -0,0 +1,497 @@ +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 ComparisonPage */ +export interface ComparisonPageProps { + /** 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 ────────────────────────────────────────────────────────────── + +/** + * Package comparison page for the FA design system. + * + * Desktop: Full ComparisonTable with info card, floating verified badges, + * section tables with left accent borders. + * Mobile: Tabbed card view with horizontal chip rail. + * + * Share + Print utility actions in the page header. + */ +export const ComparisonPage = 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.provider.name} + + + {pkg.name} + + + + ); + })} + + + {activePackage && ( + + + + )} + + )} + + + ); + }, +); + +ComparisonPage.displayName = 'ComparisonPage'; +export default ComparisonPage; diff --git a/src/components/pages/ComparisonPage/index.ts b/src/components/pages/ComparisonPage/index.ts new file mode 100644 index 0000000..be9942a --- /dev/null +++ b/src/components/pages/ComparisonPage/index.ts @@ -0,0 +1,2 @@ +export { ComparisonPage, default } from './ComparisonPage'; +export type { ComparisonPageProps } from './ComparisonPage';