Compare commits
2 Commits
593cd82122
...
5e93f3a0d0
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e93f3a0d0 | |||
| e89ac360e8 |
122
docs/memory/component-registry.md
Normal file
122
docs/memory/component-registry.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Component registry
|
||||
|
||||
Tracks the status, specification, and key details of every component in the
|
||||
design system. Agents MUST check this before building a component (to avoid
|
||||
duplicates) and MUST update it after completing one.
|
||||
|
||||
## Status definitions
|
||||
|
||||
- **planned**: Component is identified but not yet started
|
||||
- **in-progress**: Component is being built
|
||||
- **review**: Component is built, awaiting human review
|
||||
- **done**: Component is reviewed and approved
|
||||
- **needs-revision**: Component needs changes based on review feedback
|
||||
|
||||
## Atoms
|
||||
|
||||
| Component | Status | Variants | Tokens used | Notes |
|
||||
|-----------|--------|----------|-------------|-------|
|
||||
| Button | done | contained, soft, outlined, text × xs, small, medium, large × primary, secondary + loading, underline, fullWidth | button.height/paddingX/paddingY/fontSize/iconSize/iconGap/borderRadius, color.interactive.*, color.brand.100-300, color.neutral.200-700 | Primary interactive element. Merges Text Button from Figma. Soft variant = Figma's Secondary/Brand & Secondary/Grey. |
|
||||
| IconButton | done | default, primary, secondary, error × small, medium, large | Reuses button.height/iconSize tokens, color.interactive.*, color.neutral.* | Icon-only button (close, menu, actions). Wraps MUI IconButton. Rounded rect, brand hover, focus ring. |
|
||||
| Typography | done | displayHero, display1-3, displaySm, h1-h6, bodyLg, body1, body2, bodyXs, labelLg, label, labelSm, caption, captionSm, overline, overlineSm + maxLines, gutterBottom | typography.* (all semantic typography tokens), fontFamily.body, fontFamily.display | Text display system. Thin MUI wrapper with maxLines truncation. |
|
||||
| Input | done | medium, small × default, hover, focus, error, success, disabled + startIcon, endIcon, required, multiline | input.height/paddingX/paddingY/fontSize/borderRadius/gap/iconSize, color.neutral.300-400, color.brand.500, color.feedback.error/success, color.text.secondary | External label pattern, branded focus ring, two sizes aligned with Button. Adds startIcon/endIcon and success state beyond Figma. |
|
||||
| Badge | done | soft, filled × default, brand, success, warning, error, info × small, medium + icon | badge.height/paddingX/fontSize/iconSize/iconGap/borderRadius, color.feedback.*, color.brand.200/700 | Status indicator pill. Soft (tonal) or filled (solid). 6 colours, 2 sizes, optional leading icon. |
|
||||
| Icon | planned | various sizes | | Icon wrapper component |
|
||||
| Avatar | planned | image, initials, icon × small, medium, large | | User/entity representation |
|
||||
| Divider | done | horizontal, vertical × fullWidth, inset, middle + text, flexItem | color.border.default (via palette.divider) | Visual separator. Wraps MUI Divider. Supports text children and orientation. |
|
||||
| Chip | done | filled, outlined × small, medium × clickable, deletable, selected × default, primary | chip.height/paddingX/fontSize/iconSize/deleteIconSize/iconGap/borderRadius, color.neutral.200-700, color.brand.200-700 | Interactive tag. Wraps MUI Chip with FA tokens. Selected state promotes to brand colour. Filled uses soft tonal bg (like Badge). |
|
||||
| Card | done | elevated, outlined × default, compact, none padding × interactive × selected | card.borderRadius/padding/shadow/border/background, color.surface.raised/subtle/warm, color.border.default/brand, shadow.md/lg | Content container. Elevated (shadow) or outlined (border). Interactive adds hover bg fill + shadow lift. Selected adds brand border + warm bg. Three padding presets. |
|
||||
| Switch | done | bordered style × checked, unchecked, disabled | switch.track.width/height/borderRadius, switch.thumb.size, color.interactive.*, color.neutral.400 | Toggle for add-ons/options. Wraps MUI Switch. Bordered pill, brand.500 fill when active. From Parsons 1.0 Figma Style One. |
|
||||
| Radio | done | checked, unchecked, disabled | radio.size/dotSize, color.interactive.*, color.neutral.400 | Single-select option. Wraps MUI Radio. Brand.500 fill when selected. From Parsons 1.0 Figma. |
|
||||
| MapPin | done | name+price (two-line), name-only, price-only × verified, unverified × default, active | mapPin.paddingX/borderRadius/nub.size, color.brand.100-900, color.neutral.100-800 | Two-line label map marker: name (bold, truncated 180px) + "From $X" (centred, semibold). Name optional for price-only variant. Verified = brand palette, unverified = grey. Active inverts + scale. Pure CSS. role="button" + keyboard + focus ring. |
|
||||
| ColourToggle | planned | inactive, hover, active, locked × single, two-colour × desktop, mobile | | Circular colour swatch picker for products. Custom component. Deferred until product detail organisms. |
|
||||
| Slider | planned | single, range × desktop, mobile | | Price range filter. Wraps MUI Slider. Deferred until search/filtering molecules. |
|
||||
| Link | done | underline: hover/always/none × any MUI colour | color.text.brand (copper brand.600, 4.8:1), color.interactive.active | Navigation text link. Wraps MUI Link. Copper default, underline on hover, focus ring. |
|
||||
| Collapse | done | in/out × unmountOnExit | (none — uses MUI defaults) | Progressive disclosure wrapper. Thin MUI Collapse wrapper with unmountOnExit default. Slide-down animation for wizard field reveal. |
|
||||
| DialogShell | done | open/closed × with/without back button × with/without footer | (theme defaults — borderRadius, palette) | Standard dialog container. Header (title + optional back + close), divider, scrollable body, optional footer. Used by FilterPanel and ArrangementDialog. |
|
||||
| ToggleButtonGroup | done | exclusive single-select × small, medium, large × error × fullWidth + descriptions | color.neutral.100-200, color.brand.50/100, color.interactive.focus, color.feedback.error | Button-select for binary/small-set choices. Fieldset/legend a11y, external label, helper/error text. Brand styling on selected. |
|
||||
|
||||
## Molecules
|
||||
|
||||
| Component | Status | Composed of | Notes |
|
||||
|-----------|--------|-------------|-------|
|
||||
| MiniCard | done | Card + Typography + Badge + Tooltip | Compact vertical card for grids, recommendations, map popups. Image + title + optional price/badges/chips/meta (location, rating, capacity). Verified = icon-only circle badge in image. Hierarchy: title → meta → price → badges → chips. Truncated title shows tooltip. 3 component tokens. Audit: 20/20. |
|
||||
| MapPopup | done | Paper + Typography + Tooltip | Floating map popup anchored to MapPin. Clickable card (onClick). Image + name (1 line, tooltip) + meta + price. Verified = icon-only circle badge in image (matches MiniCard). Hierarchy matches MiniCard. Nub + drop-shadow. 260px wide. |
|
||||
| FormField | planned | Input + Typography (label) + Typography (helper) | Standard form field with label and validation |
|
||||
| ProviderCard | done | Card + Typography + Badge + Tooltip | Provider listing card. Verified: image + logo (64px rounded rect) + "Verified" badge. Unverified: text-only with top accent bar. Capability badges with info icon + tooltip. Price split typography. No footer. 4 component tokens. |
|
||||
| VenueCard | done | Card + Typography | Venue listing card. Always has photo + location + capacity ("X guests") + price ("From $X"). No verification tiers, no logo, no badges. 3 component tokens. Critique: 33/40. |
|
||||
| MapCard | planned | Card + Typography + Badge | Compact horizontal map popup card. Deferred until map integration. |
|
||||
| ServiceOption | done | Card (interactive, selected) + Typography | Selectable service option for arrangement flow. Heading + optional price (right-aligned) + optional description. role="radio" + aria-checked. Disabled state with opacity token. Maps to Figma ListItemPurchaseOption. |
|
||||
| SearchBar | done | Input + IconButton + Button | Search input with optional submit button. Enter-to-submit, progressive clear button, inline loading spinner. Guards empty submissions, refocuses after clear. role="search" landmark. Critique: 35/40. |
|
||||
| AddOnOption | done | Card (interactive, selected) + Typography + Switch | Toggleable add-on for arrangement flow extras. Heading + optional price + description + Switch. Click-anywhere toggle. Maps to Figma ListItemAddItem (2350:40658). |
|
||||
| StepIndicator | done | Typography + Box | Horizontal segmented progress bar. Brand gold for completed/current steps, grey for upcoming. Responsive bar height (10px/6px). Maps to Figma Progress Bar - Steps (2375:47468). |
|
||||
| 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. |
|
||||
| ComparisonPackageCard | done | Card + Badge + Button + Divider + Typography + Tooltip + LocationOnOutlinedIcon + VerifiedOutlinedIcon + CheckCircleOutlineIcon + InfoOutlinedIcon | Mobile full-width package card for ComparisonPage tabpanels. Provider header (verified badge, name, location, rating, package name, price, CTA) + itemised sections with left-accent headings. Shadow (shadow-sm). Medium button. Reuses `ComparisonPackage` type from ComparisonTable. Shared by ComparisonPage V2 and V1 (extracted 2026-04-09). |
|
||||
| ComparisonColumnCard | done | Card + Badge + Button + Divider + Typography + Tooltip + Link + StarRoundedIcon + VerifiedOutlinedIcon | Desktop column header card for ComparisonTable. Floating verified badge, recommended banner, provider name (truncated+tooltip), location, rating, package name, price, CTA, optional Remove link. Extracted from ComparisonTable (2026-04-12). |
|
||||
| ComparisonTabCard | done | Card + Badge + Typography | Mobile tab rail card for ComparisonPage. Provider name + package name + price. Recommended badge in normal flow with negative margin overlap + brand glow. Fixed 210px width. Shared by V1 and V2 (extracted 2026-04-12). |
|
||||
|
||||
## Organisms
|
||||
|
||||
| Component | Status | Composed of | Notes |
|
||||
|-----------|--------|-------------|-------|
|
||||
| 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: 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. |
|
||||
| FuneralFinder V4 | archived | Typography + Button + Input + Divider + Select + MenuItem + custom StepIndicator/FieldError | Archived. Based on V2 with: 3 numbered steps (48px circles, outline-to-fill + tick), ungated location field, no heading/subheading, "Search" CTA, inline copper error messages. |
|
||||
| ArrangementForm | planned | StepIndicator + ServiceSelector + AddOnOption + Button + Typography | Multi-step arrangement wizard. Deferred — build remaining atoms/molecules first. |
|
||||
| Navigation | done | AppBar + Link + IconButton + Button + Divider + Drawer | Responsive site header. Desktop: logo left, links right, optional CTA. Mobile: hamburger + drawer with nav items, CTA, help footer. Sticky, grey surface bg (surface.subtle). Real FA logo from brandassets/. Maps to Figma Main Nav (14:108) + Mobile Header (2391:41508). |
|
||||
| Footer | done | Link × n + Typography + Divider + Container + Grid | Light grey (surface.subtle) site footer — matches header. Logo + tagline + contact (phone/email) + link group columns + legal bar. Semantic HTML (footer, nav, ul). Critique: 38/40 (Excellent). |
|
||||
|
||||
## Templates
|
||||
|
||||
| Component | Status | Composed of | Notes |
|
||||
|-----------|--------|-------------|-------|
|
||||
| WizardLayout | done | Container + Box + Link + Typography + Navigation (slot) + StepIndicator (slot) | Page-level layout for arrangement wizard. 5 variants: centered-form, list-map, list-detail, grid-sidebar, detail-toggles. Nav slot, sticky help bar, optional back link, optional progress stepper + running total. `<main>` landmark wrapper. |
|
||||
|
||||
## Pages
|
||||
|
||||
| Component | Status | Composed of | Notes |
|
||||
|-----------|--------|-------------|-------|
|
||||
| IntroStep | done | WizardLayout (centered-form) + ToggleButtonGroup × 2 + Collapse + Typography + Button + Divider | Wizard step 1 — entry point. forWhom (Myself/Someone else) + hasPassedAway (Yes/No) with progressive disclosure. Auto-sets hasPassedAway="no" for "Myself". `<form>` wrapper, aria-live subheading, grief-sensitive copy. Pure presentation. Audit: 18/20 → 20/20 after fixes. |
|
||||
| ProvidersStep | done | WizardLayout (list-map) + ProviderCard + SearchBar + Chip + Typography + Button | Wizard step 2 — provider selection. List-map split: provider cards w/ radiogroup + search + filter chips (left), map slot (right). aria-live results count, back link. ProviderCard extended with HTML/ARIA passthrough. Audit: 18/20. |
|
||||
| PackagesStep | done | WizardLayout (list-detail) + ProviderCardCompact + ServiceOption + PackageDetail + Badge + TextField + Typography + Button | Wizard step 3 — package selection. List-detail split: compact provider + budget filter + package list w/ radiogroup (left), PackageDetail breakdown (right). "Most Popular" badge. Mobile Continue button. |
|
||||
| ~~PreviewStep~~ | removed | — | Replaced by ArrangementDialog organism (D-E). Package preview + "what's next" checklist now in the dialog's preview step. |
|
||||
| ~~AuthGateStep~~ | removed | — | Replaced by ArrangementDialog organism (D-E). SSO/email auth flow now in the dialog's auth step. |
|
||||
| DateTimeStep | done | WizardLayout (centered-form) + Input + TextField (date) + RadioGroup + Collapse + Divider + Button + Link | Wizard step 6 — details & scheduling. Deceased name (Input atom, external label) + preferred dates (up to 3, progressive disclosure) + time-of-day radios. Service tradition removed (flows from provider/package). Dividers between sections. Grief-sensitive labels. Save-and-exit CTA. |
|
||||
| VenueStep | done | WizardLayout (centered-form) + VenueCard + AddOnOption + Collapse + Chip + TextField + Divider + Button | Wizard step 7a — venue browsing. Click-to-navigate card grid with search/filters. Leads to VenueDetailStep. |
|
||||
| VenueDetailStep | done | WizardLayout (detail-toggles) + ImageGallery + Card + Chip + Typography + Button + Divider | Wizard step 7b — venue detail. Two-panel: gallery/description/features/location (left), name/meta/price/CTA/religions (right). Informational service preview. |
|
||||
| VenueServicesStep | done | WizardLayout (centered-form) + AddOnOption + Card + Typography + Button + Divider | Wizard step 7c — venue services. Compact venue card, availability notices, AddOnOption toggles with "View more" for long descriptions. Follows VenueDetailStep. |
|
||||
| CrematoriumStep | done | WizardLayout (centered-form) + Card + Badge + ToggleButtonGroup + Typography + Button + Divider | Wizard step 8 — crematorium. Two variants: Service & Cremation (compact card + witness Yes/No toggle), Cremation Only (compact card + "Cremation Only" badge + "Included in Package" notice). Single pre-selected crematorium, no multi-select. |
|
||||
| CemeteryStep | done | WizardLayout (centered-form) + ToggleButtonGroup + Collapse + TextField (select) + Typography + Button + Divider | Wizard step 9 — cemetery. ToggleButtonGroups (Yes/No/Not sure) with progressive disclosure. Own plot → locate dropdown. No plot → preference? → select dropdown. No card grid. |
|
||||
| CoffinsStep | done | WizardLayout (grid-sidebar) + Card + Badge + Collapse + Slider + TextField + Pagination + Divider + Link | Wizard step 10 — coffin browsing. Grid-sidebar: filter sidebar (categories with expandable subcategories, dual-knob price slider with editable inputs, sort by) + 3-col card grid. CoffinCard with thumbnail hover preview. Equal-height cards, subtle bg for white-bg product photos. Card click → CoffinDetailsStep (no Continue). 20/page max. Conditional allowance info bubble. |
|
||||
| CoffinDetailsStep | done | WizardLayout (detail-toggles) + ImageGallery + Divider + Button | Wizard step 11 — coffin detail. Two-panel: gallery + product details dl (left), name + description + colour swatches + allowance-aware price + CTA (right). Allowance logic: fully covered / partially covered / no allowance. Colour selection does not affect price. |
|
||||
| ~~AdditionalServicesStep~~ | removed | — | Replaced by IncludedServicesStep + ExtrasStep. Split for clearer distinction between free inclusions and paid extras. |
|
||||
| IncludedServicesStep | done | WizardLayout (centered-form) + AddOnOption + RadioGroup + Collapse + Divider + Button | Wizard step 12a — included services. Package inclusions at no additional cost: dressing, viewing (with same-venue sub-option), prayers/vigil, funeral announcement. Sub-options render inside parent card. |
|
||||
| ExtrasStep | done | WizardLayout (centered-form) + AddOnOption + Card + Switch + RadioGroup + Collapse + Divider + Button | Wizard step 12b — optional extras. Lead-gen interest capture: catering, music (inline live musician toggle + musician type), coffin bearing (toggle + bearer type), newspaper notice. POA via `priceLabel`. Tally of priced selections. No nested cards. |
|
||||
| SummaryStep | done | WizardLayout (centered-form) + Card + Paper + DialogShell + Button + Link + Divider | Wizard step 13 — plan review. Visual cart layout: arrangement details (2-col grid), compact cards with thumbnails for provider/venue/crematorium/coffin, checklist for included services, priced list for extras. Allowance display (fully covered vs remaining). Share dialog (multi-email). Location pin icons. Full-width CTA. |
|
||||
| PaymentStep | done | WizardLayout (centered-form) + ToggleButtonGroup + Paper + Collapse + Checkbox + Divider + Button | Wizard step 14 — payment. Plan (full/deposit) + method (card/bank). PayWay iframe slot. Bank transfer details. Terms checkbox. |
|
||||
| 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 (V2) | done | WizardLayout (wide-form) + ComparisonTable + Card + Typography + Button + Divider + StarRoundedIcon | **Production version.** Package comparison page. Desktop: full ComparisonTable with sticky headers, **recommended package as first (leftmost) column**. Mobile: tabbed card view with horizontal tab rail (role="tablist") + single package card (role="tabpanel"); **recommended tab is first in rail, first user-selected package is initially active**. Recommended package as separate prop (D038). Star icon (brand-600) marks recommended in mobile tab labels. Share + Print in page header. Back link, help bar. |
|
||||
| ComparisonPage V1 | archived | WizardLayout + ComparisonTable + Card + Typography + Button + Divider | Archived — viewable in Storybook under Archive/. Recommended package as **last** column/tab. Same component tree as V2. |
|
||||
|
||||
## Future enhancements
|
||||
|
||||
Deferred items that should be addressed when the relevant components or patterns
|
||||
are needed. Check this section before building new components — an item here may
|
||||
be relevant to your current work.
|
||||
|
||||
| Item | Relates to | Trigger | Notes |
|
||||
|------|-----------|---------|-------|
|
||||
| Destructive button colours | Button | When building delete/cancel flows | `color="error"` already works via MUI palette. May need `soft` variant styling for error/warning/success colours. |
|
||||
| Link-as-button | Button | When building Navigation or link-heavy pages | Use MUI's `component="a"` or `href` prop. May warrant a separate Link atom or a `Button` story showing the pattern. |
|
||||
| ~~IconButton atom~~ | ~~IconButton~~ | ~~Resolved~~ | ~~Built as atom. Rounded rect, 3 sizes, 4 colours, focus ring.~~ |
|
||||
| ~~Google Fonts loading~~ | ~~Typography~~ | ~~Resolved~~ | ~~Added to .storybook/preview-head.html and index.html~~ |
|
||||
1074
docs/memory/session-log.md
Normal file
1074
docs/memory/session-log.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { ComparisonColumnCard } from './ComparisonColumnCard';
|
||||
import type { ComparisonPackage } from '../../organisms/ComparisonTable';
|
||||
|
||||
// ─── Mock data ──────────────────────────────────────────────────────────────
|
||||
|
||||
const verifiedPackage: ComparisonPackage = {
|
||||
id: 'wollongong-everyday',
|
||||
name: 'Everyday Funeral Package',
|
||||
price: 6966,
|
||||
provider: {
|
||||
name: 'Wollongong City Funerals',
|
||||
location: 'Wollongong',
|
||||
rating: 4.8,
|
||||
reviewCount: 122,
|
||||
verified: true,
|
||||
},
|
||||
sections: [],
|
||||
};
|
||||
|
||||
const unverifiedPackage: ComparisonPackage = {
|
||||
id: 'inglewood-everyday',
|
||||
name: 'Everyday Funeral Package',
|
||||
price: 7200,
|
||||
provider: {
|
||||
name: 'Inglewood Chapel',
|
||||
location: 'Inglewood',
|
||||
rating: 4.2,
|
||||
reviewCount: 45,
|
||||
verified: false,
|
||||
},
|
||||
sections: [],
|
||||
};
|
||||
|
||||
const recommendedPackage: ComparisonPackage = {
|
||||
id: 'recommended-premium',
|
||||
name: 'Premium Cremation Service',
|
||||
price: 8450,
|
||||
provider: {
|
||||
name: 'H. Parsons Funeral Directors',
|
||||
location: 'Wentworth',
|
||||
rating: 4.9,
|
||||
reviewCount: 203,
|
||||
verified: true,
|
||||
},
|
||||
sections: [],
|
||||
isRecommended: true,
|
||||
};
|
||||
|
||||
const longNamePackage: ComparisonPackage = {
|
||||
id: 'long-name',
|
||||
name: 'Comprehensive Premium Memorial & Cremation Service Package',
|
||||
price: 12500,
|
||||
provider: {
|
||||
name: 'The Very Long Name Funeral Services & Memorial Chapel Pty Ltd',
|
||||
location: 'Wollongong',
|
||||
rating: 4.6,
|
||||
reviewCount: 87,
|
||||
verified: true,
|
||||
},
|
||||
sections: [],
|
||||
};
|
||||
|
||||
const noRatingPackage: ComparisonPackage = {
|
||||
id: 'no-rating',
|
||||
name: 'Basic Funeral Package',
|
||||
price: 4200,
|
||||
provider: {
|
||||
name: 'New Provider',
|
||||
location: 'Sydney',
|
||||
verified: true,
|
||||
},
|
||||
sections: [],
|
||||
};
|
||||
|
||||
// ─── Meta ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const meta: Meta<typeof ComparisonColumnCard> = {
|
||||
title: 'Molecules/ComparisonColumnCard',
|
||||
component: ComparisonColumnCard,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<Box sx={{ maxWidth: 280, mx: 'auto', pt: 3 }}>
|
||||
<Story />
|
||||
</Box>
|
||||
),
|
||||
],
|
||||
args: {
|
||||
onArrange: (id) => alert(`Arrange: ${id}`),
|
||||
onRemove: (id) => alert(`Remove: ${id}`),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ComparisonColumnCard>;
|
||||
|
||||
/** Verified provider — floating "Verified" badge above card */
|
||||
export const Verified: Story = {
|
||||
args: {
|
||||
pkg: verifiedPackage,
|
||||
},
|
||||
};
|
||||
|
||||
/** Unverified provider — "Make Enquiry" CTA + soft button variant, no verified badge */
|
||||
export const Unverified: Story = {
|
||||
args: {
|
||||
pkg: unverifiedPackage,
|
||||
},
|
||||
};
|
||||
|
||||
/** Recommended package — copper banner, warm selected state, no Remove link */
|
||||
export const Recommended: Story = {
|
||||
args: {
|
||||
pkg: recommendedPackage,
|
||||
},
|
||||
};
|
||||
|
||||
/** Long provider name — truncated with tooltip on hover */
|
||||
export const LongName: Story = {
|
||||
args: {
|
||||
pkg: longNamePackage,
|
||||
},
|
||||
};
|
||||
|
||||
/** No rating — provider without rating/review data */
|
||||
export const NoRating: Story = {
|
||||
args: {
|
||||
pkg: noRatingPackage,
|
||||
},
|
||||
};
|
||||
|
||||
/** Side-by-side — multiple cards in a row (as used in ComparisonTable) */
|
||||
export const SideBySide: Story = {
|
||||
decorators: [
|
||||
() => (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 2, pt: 3 }}>
|
||||
<ComparisonColumnCard
|
||||
pkg={recommendedPackage}
|
||||
onArrange={(id) => alert(`Arrange: ${id}`)}
|
||||
/>
|
||||
<ComparisonColumnCard
|
||||
pkg={verifiedPackage}
|
||||
onArrange={(id) => alert(`Arrange: ${id}`)}
|
||||
onRemove={(id) => alert(`Remove: ${id}`)}
|
||||
/>
|
||||
<ComparisonColumnCard
|
||||
pkg={unverifiedPackage}
|
||||
onArrange={(id) => alert(`Arrange: ${id}`)}
|
||||
onRemove={(id) => alert(`Remove: ${id}`)}
|
||||
/>
|
||||
</Box>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import StarRoundedIcon from '@mui/icons-material/StarRounded';
|
||||
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
import { Button } from '../../atoms/Button';
|
||||
import { Badge } from '../../atoms/Badge';
|
||||
import { Card } from '../../atoms/Card';
|
||||
import { Divider } from '../../atoms/Divider';
|
||||
import { Link } from '../../atoms/Link';
|
||||
import type { ComparisonPackage } from '../../organisms/ComparisonTable';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ComparisonColumnCardProps {
|
||||
/** Package data to render — same shape used by ComparisonTable */
|
||||
pkg: ComparisonPackage;
|
||||
/** Called when the user clicks the CTA (Make Arrangement / Make Enquiry) */
|
||||
onArrange: (packageId: string) => void;
|
||||
/** Called when the user clicks Remove — hidden when not provided or for recommended packages */
|
||||
onRemove?: (packageId: string) => void;
|
||||
/** MUI sx prop for outer wrapper overrides */
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatPrice(amount: number): string {
|
||||
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
|
||||
}
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Desktop column header card for the ComparisonTable.
|
||||
*
|
||||
* Shows provider info (verified badge, name, location, rating), package name,
|
||||
* total price, CTA button, and optional Remove link. The verified badge floats
|
||||
* above the card's top edge. Recommended packages get a copper banner and warm
|
||||
* selected card state.
|
||||
*
|
||||
* Used as the sticky header for each column in the desktop comparison grid.
|
||||
* Mobile comparison uses ComparisonPackageCard instead.
|
||||
*/
|
||||
export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonColumnCardProps>(
|
||||
({ pkg, onArrange, onRemove, sx }, ref) => {
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
role="columnheader"
|
||||
aria-label={pkg.isRecommended ? `${pkg.name} (Recommended)` : pkg.name}
|
||||
sx={[
|
||||
{
|
||||
position: 'relative',
|
||||
overflow: 'visible',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
>
|
||||
{/* Floating verified badge — overlaps card top edge */}
|
||||
{pkg.provider.verified && (
|
||||
<Badge
|
||||
color="brand"
|
||||
variant="soft"
|
||||
size="small"
|
||||
icon={<VerifiedOutlinedIcon sx={{ fontSize: 14 }} />}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
Verified
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Card
|
||||
variant="outlined"
|
||||
selected={pkg.isRecommended}
|
||||
padding="none"
|
||||
sx={{ overflow: 'hidden', flex: 1, display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
{pkg.isRecommended && (
|
||||
<Box sx={{ bgcolor: 'var(--fa-color-brand-600)', py: 0.75, textAlign: 'center' }}>
|
||||
<Typography
|
||||
variant="labelSm"
|
||||
sx={{
|
||||
color: 'var(--fa-color-white)',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.05em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Recommended
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
px: 2.5,
|
||||
py: 2.5,
|
||||
pt: pkg.provider.verified ? 3 : 2.5,
|
||||
gap: 0.5,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{/* Provider name (truncated with tooltip) */}
|
||||
<Tooltip
|
||||
title={pkg.provider.name}
|
||||
arrow
|
||||
placement="top"
|
||||
disableHoverListener={pkg.provider.name.length < 24}
|
||||
>
|
||||
<Typography
|
||||
variant="label"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{pkg.provider.name}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
|
||||
{/* Location */}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{pkg.provider.location}
|
||||
</Typography>
|
||||
|
||||
{/* Rating */}
|
||||
{pkg.provider.rating != null && (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<StarRoundedIcon
|
||||
sx={{ fontSize: 16, color: 'var(--fa-color-brand-500)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{pkg.provider.rating}
|
||||
{pkg.provider.reviewCount != null && ` (${pkg.provider.reviewCount})`}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ width: '100%', my: 1 }} />
|
||||
|
||||
<Typography variant="h6" component="p">
|
||||
{pkg.name}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
Total package price
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{ color: 'primary.main', fontWeight: 700 }}>
|
||||
{formatPrice(pkg.price)}
|
||||
</Typography>
|
||||
|
||||
{/* Spacer pushes CTA to bottom across all cards */}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
<Button
|
||||
variant={pkg.provider.verified ? 'contained' : 'soft'}
|
||||
color={pkg.provider.verified ? 'primary' : 'secondary'}
|
||||
size="medium"
|
||||
onClick={() => onArrange(pkg.id)}
|
||||
sx={{ mt: 1.5, px: 4 }}
|
||||
>
|
||||
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
|
||||
</Button>
|
||||
|
||||
{!pkg.isRecommended && onRemove && (
|
||||
<Link
|
||||
component="button"
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
underline="hover"
|
||||
onClick={() => onRemove(pkg.id)}
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
Remove
|
||||
</Link>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ComparisonColumnCard.displayName = 'ComparisonColumnCard';
|
||||
export default ComparisonColumnCard;
|
||||
2
src/components/molecules/ComparisonColumnCard/index.ts
Normal file
2
src/components/molecules/ComparisonColumnCard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ComparisonColumnCard, default } from './ComparisonColumnCard';
|
||||
export type { ComparisonColumnCardProps } from './ComparisonColumnCard';
|
||||
@@ -81,9 +81,18 @@ function CellValue({ value }: { value: ComparisonCellValue }) {
|
||||
);
|
||||
case 'unknown':
|
||||
return (
|
||||
<Badge color="default" variant="soft" size="small">
|
||||
Unknown
|
||||
</Badge>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, justifyContent: 'flex-end' }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: 'var(--fa-color-neutral-500)', fontWeight: 500 }}
|
||||
>
|
||||
Unknown
|
||||
</Typography>
|
||||
<InfoOutlinedIcon
|
||||
sx={{ fontSize: 14, color: 'var(--fa-color-neutral-500)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
case 'unavailable':
|
||||
return (
|
||||
@@ -118,7 +127,13 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
|
||||
variant="outlined"
|
||||
selected={pkg.isRecommended}
|
||||
padding="none"
|
||||
sx={[{ overflow: 'hidden' }, ...(Array.isArray(sx) ? sx : [sx])]}
|
||||
sx={[
|
||||
{
|
||||
overflow: 'hidden',
|
||||
boxShadow: 'var(--fa-shadow-sm)',
|
||||
},
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
>
|
||||
{/* Recommended banner */}
|
||||
{pkg.isRecommended && (
|
||||
@@ -204,7 +219,7 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
|
||||
<Button
|
||||
variant={pkg.provider.verified ? 'contained' : 'soft'}
|
||||
color={pkg.provider.verified ? 'primary' : 'secondary'}
|
||||
size="large"
|
||||
size="medium"
|
||||
fullWidth
|
||||
onClick={() => onArrange(pkg.id)}
|
||||
sx={{ mt: 2 }}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { ComparisonTabCard } from './ComparisonTabCard';
|
||||
import type { ComparisonPackage } from '../../organisms/ComparisonTable';
|
||||
|
||||
// ─── Mock data ──────────────────────────────────────────────────────────────
|
||||
|
||||
const verifiedPkg: ComparisonPackage = {
|
||||
id: 'wollongong-everyday',
|
||||
name: 'Everyday Funeral Package',
|
||||
price: 6966,
|
||||
provider: {
|
||||
name: 'Wollongong City Funerals',
|
||||
location: 'Wollongong',
|
||||
rating: 4.8,
|
||||
reviewCount: 122,
|
||||
verified: true,
|
||||
},
|
||||
sections: [],
|
||||
};
|
||||
|
||||
const recommendedPkg: ComparisonPackage = {
|
||||
id: 'recommended-premium',
|
||||
name: 'Premium Cremation Service',
|
||||
price: 8450,
|
||||
provider: {
|
||||
name: 'H. Parsons Funeral Directors',
|
||||
location: 'Wentworth',
|
||||
rating: 4.9,
|
||||
reviewCount: 203,
|
||||
verified: true,
|
||||
},
|
||||
sections: [],
|
||||
isRecommended: true,
|
||||
};
|
||||
|
||||
const unverifiedPkg: ComparisonPackage = {
|
||||
id: 'inglewood-everyday',
|
||||
name: 'Everyday Funeral Package',
|
||||
price: 7200,
|
||||
provider: {
|
||||
name: 'Inglewood Chapel',
|
||||
location: 'Inglewood',
|
||||
rating: 4.2,
|
||||
reviewCount: 45,
|
||||
verified: false,
|
||||
},
|
||||
sections: [],
|
||||
};
|
||||
|
||||
const longNamePkg: ComparisonPackage = {
|
||||
id: 'long-name',
|
||||
name: 'Comprehensive Premium Memorial & Cremation Service',
|
||||
price: 12500,
|
||||
provider: {
|
||||
name: 'The Very Long Name Funeral Services Pty Ltd',
|
||||
location: 'Wollongong',
|
||||
rating: 4.6,
|
||||
reviewCount: 87,
|
||||
verified: true,
|
||||
},
|
||||
sections: [],
|
||||
};
|
||||
|
||||
// ─── Meta ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const meta: Meta<typeof ComparisonTabCard> = {
|
||||
title: 'Molecules/ComparisonTabCard',
|
||||
component: ComparisonTabCard,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
},
|
||||
args: {
|
||||
isActive: false,
|
||||
hasRecommended: false,
|
||||
tabId: 'tab-0',
|
||||
tabPanelId: 'panel-0',
|
||||
onClick: () => alert('Tab clicked'),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ComparisonTabCard>;
|
||||
|
||||
/** Default inactive tab card */
|
||||
export const Default: Story = {
|
||||
args: { pkg: verifiedPkg },
|
||||
};
|
||||
|
||||
/** Active/selected state — elevated shadow */
|
||||
export const Active: Story = {
|
||||
args: { pkg: verifiedPkg, isActive: true },
|
||||
};
|
||||
|
||||
/** Recommended — badge + brand glow */
|
||||
export const Recommended: Story = {
|
||||
args: { pkg: recommendedPkg, hasRecommended: true },
|
||||
};
|
||||
|
||||
/** Recommended + active */
|
||||
export const RecommendedActive: Story = {
|
||||
args: { pkg: recommendedPkg, isActive: true, hasRecommended: true },
|
||||
};
|
||||
|
||||
/** Long name — truncated with ellipsis */
|
||||
export const LongName: Story = {
|
||||
args: { pkg: longNamePkg },
|
||||
};
|
||||
|
||||
/** Rail simulation — multiple cards as they appear in the mobile tab rail */
|
||||
export const Rail: Story = {
|
||||
decorators: [
|
||||
() => (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 1.5,
|
||||
overflowX: 'auto',
|
||||
py: 2,
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
<ComparisonTabCard
|
||||
pkg={recommendedPkg}
|
||||
isActive={false}
|
||||
hasRecommended
|
||||
tabId="tab-0"
|
||||
tabPanelId="panel-0"
|
||||
onClick={() => alert('Recommended')}
|
||||
/>
|
||||
<ComparisonTabCard
|
||||
pkg={verifiedPkg}
|
||||
isActive
|
||||
hasRecommended
|
||||
tabId="tab-1"
|
||||
tabPanelId="panel-1"
|
||||
onClick={() => alert('Wollongong')}
|
||||
/>
|
||||
<ComparisonTabCard
|
||||
pkg={unverifiedPkg}
|
||||
isActive={false}
|
||||
hasRecommended
|
||||
tabId="tab-2"
|
||||
tabPanelId="panel-2"
|
||||
onClick={() => alert('Inglewood')}
|
||||
/>
|
||||
</Box>
|
||||
),
|
||||
],
|
||||
};
|
||||
154
src/components/molecules/ComparisonTabCard/ComparisonTabCard.tsx
Normal file
154
src/components/molecules/ComparisonTabCard/ComparisonTabCard.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
import { Badge } from '../../atoms/Badge';
|
||||
import { Card } from '../../atoms/Card';
|
||||
import type { ComparisonPackage } from '../../organisms/ComparisonTable';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ComparisonTabCardProps {
|
||||
/** Package data to render */
|
||||
pkg: ComparisonPackage;
|
||||
/** Whether this tab is the currently active/selected one */
|
||||
isActive: boolean;
|
||||
/** Whether any package in the rail is recommended — controls spacer for alignment */
|
||||
hasRecommended: boolean;
|
||||
/** ARIA: id for the tab element */
|
||||
tabId: string;
|
||||
/** ARIA: id of the controlled tabpanel */
|
||||
tabPanelId: string;
|
||||
/** Called when the tab card is clicked */
|
||||
onClick: () => void;
|
||||
/** MUI sx prop for outer wrapper */
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatPrice(amount: number): string {
|
||||
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
|
||||
}
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mini tab card for the mobile ComparisonPage tab rail.
|
||||
*
|
||||
* Shows provider name, package name, and price. Recommended packages get a
|
||||
* floating badge (in normal flow with negative margin overlap) and a warm
|
||||
* brand glow. Non-recommended cards get a spacer to keep vertical alignment
|
||||
* when a recommended card is present in the rail.
|
||||
*
|
||||
* The page component owns scroll/centering behaviour — this is purely visual.
|
||||
*/
|
||||
export const ComparisonTabCard = React.forwardRef<HTMLDivElement, ComparisonTabCardProps>(
|
||||
({ pkg, isActive, hasRecommended, tabId, tabPanelId, onClick, sx }, ref) => {
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
sx={[
|
||||
{
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
},
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
>
|
||||
{/* Recommended badge in normal flow — overlaps card via negative mb */}
|
||||
{pkg.isRecommended ? (
|
||||
<Badge
|
||||
color="brand"
|
||||
variant="soft"
|
||||
size="small"
|
||||
sx={{
|
||||
mb: '-10px',
|
||||
zIndex: 1,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
Recommended
|
||||
</Badge>
|
||||
) : (
|
||||
// Spacer keeps cards aligned when a recommended card is present
|
||||
hasRecommended && <Box sx={{ height: 12 }} />
|
||||
)}
|
||||
|
||||
<Card
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={tabPanelId}
|
||||
id={tabId}
|
||||
variant="outlined"
|
||||
selected={isActive}
|
||||
padding="none"
|
||||
onClick={onClick}
|
||||
interactive
|
||||
sx={{
|
||||
width: 210,
|
||||
cursor: 'pointer',
|
||||
boxShadow: 'var(--fa-shadow-sm)',
|
||||
...(pkg.isRecommended && {
|
||||
borderColor: 'var(--fa-color-brand-500)',
|
||||
boxShadow: '0 0 12px rgba(186, 131, 78, 0.3)',
|
||||
}),
|
||||
...(isActive && {
|
||||
boxShadow: pkg.isRecommended
|
||||
? '0 0 14px rgba(186, 131, 78, 0.4)'
|
||||
: 'var(--fa-shadow-md)',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Box sx={{ px: 2, pt: 2.4, pb: 2 }}>
|
||||
<Typography
|
||||
variant="labelSm"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'block',
|
||||
mb: 0.25,
|
||||
}}
|
||||
>
|
||||
{pkg.provider.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{pkg.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
display: 'block',
|
||||
fontWeight: 600,
|
||||
color: 'primary.main',
|
||||
mt: 0.5,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{formatPrice(pkg.price)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ComparisonTabCard.displayName = 'ComparisonTabCard';
|
||||
export default ComparisonTabCard;
|
||||
2
src/components/molecules/ComparisonTabCard/index.ts
Normal file
2
src/components/molecules/ComparisonTabCard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ComparisonTabCard, default } from './ComparisonTabCard';
|
||||
export type { ComparisonTabCardProps } from './ComparisonTabCard';
|
||||
@@ -218,50 +218,33 @@ const pkgInglewood: ComparisonPackage = {
|
||||
{
|
||||
heading: 'Essentials',
|
||||
items: [
|
||||
{
|
||||
name: 'Allowance for Coffin',
|
||||
info: 'Allowance amount — upgrade options available.',
|
||||
value: { type: 'allowance', amount: 1800 },
|
||||
},
|
||||
{
|
||||
name: 'Cremation Certificate/Permit',
|
||||
info: 'Statutory medical referee fee.',
|
||||
value: { type: 'price', amount: 350 },
|
||||
},
|
||||
{
|
||||
name: 'Death Registration Certificate',
|
||||
info: 'Lodgement with NSW Registry.',
|
||||
value: { type: 'price', amount: 70 },
|
||||
},
|
||||
{
|
||||
name: 'Professional Service Fee',
|
||||
info: 'Coordination of arrangements.',
|
||||
value: { type: 'price', amount: 3980 },
|
||||
},
|
||||
{
|
||||
name: 'Transportation Service Fee',
|
||||
info: 'Transfer of the deceased.',
|
||||
value: { type: 'price', amount: 500 },
|
||||
},
|
||||
{ name: 'Allowance for Coffin', value: { type: 'unknown' } },
|
||||
{ name: 'Cremation Certificate/Permit', value: { type: 'unknown' } },
|
||||
{ name: 'Crematorium: Mackay Family Crematorium', value: { type: 'unknown' } },
|
||||
{ name: 'Death Registration Certificate', value: { type: 'unknown' } },
|
||||
{ name: 'Dressing Fee', value: { type: 'unknown' } },
|
||||
{ name: 'NSW Government Levy — Cremation', value: { type: 'unknown' } },
|
||||
{ name: 'Professional Mortuary Care', value: { type: 'unknown' } },
|
||||
{ name: 'Professional Service Fee', value: { type: 'unknown' } },
|
||||
{ name: 'Transportation Service Fee', value: { type: 'unknown' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Optionals',
|
||||
items: [
|
||||
{ name: 'Viewing Fee', info: 'One private family viewing.', value: { type: 'included' } },
|
||||
{ name: 'Flowers', info: 'Seasonal floral arrangements.', value: { type: 'poa' } },
|
||||
{
|
||||
name: 'Digital Recording of the Funeral Service',
|
||||
info: 'Professional video recording.',
|
||||
value: { type: 'price', amount: 250 },
|
||||
},
|
||||
{ name: 'Digital Recording of the Funeral Service', value: { type: 'unknown' } },
|
||||
{ name: 'Flowers', value: { type: 'unknown' } },
|
||||
{ name: 'Online Notice', value: { type: 'unknown' } },
|
||||
{ name: 'Viewing Fee', value: { type: 'unknown' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Extras',
|
||||
items: [
|
||||
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
|
||||
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
|
||||
{ name: 'Allowance for Celebrant', value: { type: 'unknown' } },
|
||||
{ name: 'Catering', value: { type: 'unknown' } },
|
||||
{ name: 'Newspaper Notice', value: { type: 'unknown' } },
|
||||
{ name: 'Saturday Service Fee', value: { type: 'unknown' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -3,15 +3,10 @@ import Box from '@mui/material/Box';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
||||
import StarRoundedIcon from '@mui/icons-material/StarRounded';
|
||||
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
import { Button } from '../../atoms/Button';
|
||||
import { Badge } from '../../atoms/Badge';
|
||||
import { Card } from '../../atoms/Card';
|
||||
import { Link } from '../../atoms/Link';
|
||||
import { Divider } from '../../atoms/Divider';
|
||||
import { ComparisonColumnCard } from '../../molecules/ComparisonColumnCard';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -120,9 +115,18 @@ function CellValue({ value }: { value: ComparisonCellValue }) {
|
||||
);
|
||||
case 'unknown':
|
||||
return (
|
||||
<Badge color="default" variant="soft" size="small">
|
||||
Unknown
|
||||
</Badge>
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: 'var(--fa-color-neutral-500)', fontWeight: 500 }}
|
||||
>
|
||||
Unknown
|
||||
</Typography>
|
||||
<InfoOutlinedIcon
|
||||
sx={{ fontSize: 14, color: 'var(--fa-color-neutral-500)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
case 'unavailable':
|
||||
return (
|
||||
@@ -273,157 +277,14 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
|
||||
</Typography>
|
||||
</Card>
|
||||
|
||||
{/* Package cards */}
|
||||
{/* Package column header cards */}
|
||||
{packages.map((pkg) => (
|
||||
<Box
|
||||
<ComparisonColumnCard
|
||||
key={pkg.id}
|
||||
role="columnheader"
|
||||
aria-label={pkg.isRecommended ? `${pkg.name} (Recommended)` : pkg.name}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
overflow: 'visible',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Floating verified badge — overlaps card top edge */}
|
||||
{pkg.provider.verified && (
|
||||
<Badge
|
||||
color="brand"
|
||||
variant="soft"
|
||||
size="small"
|
||||
icon={<VerifiedOutlinedIcon sx={{ fontSize: 14 }} />}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
Verified
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Card
|
||||
variant="outlined"
|
||||
selected={pkg.isRecommended}
|
||||
padding="none"
|
||||
sx={{ overflow: 'hidden', flex: 1, display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
{pkg.isRecommended && (
|
||||
<Box
|
||||
sx={{ bgcolor: 'var(--fa-color-brand-600)', py: 0.75, textAlign: 'center' }}
|
||||
>
|
||||
<Typography
|
||||
variant="labelSm"
|
||||
sx={{
|
||||
color: 'var(--fa-color-white)',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.05em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Recommended
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
px: 2.5,
|
||||
py: 2.5,
|
||||
pt: pkg.provider.verified ? 3 : 2.5,
|
||||
gap: 0.5,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{/* Provider name (truncated with tooltip) */}
|
||||
<Tooltip
|
||||
title={pkg.provider.name}
|
||||
arrow
|
||||
placement="top"
|
||||
disableHoverListener={pkg.provider.name.length < 24}
|
||||
>
|
||||
<Typography
|
||||
variant="label"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{pkg.provider.name}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
|
||||
{/* Location */}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{pkg.provider.location}
|
||||
</Typography>
|
||||
|
||||
{/* Rating */}
|
||||
{pkg.provider.rating != null && (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<StarRoundedIcon
|
||||
sx={{ fontSize: 16, color: 'var(--fa-color-brand-500)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{pkg.provider.rating}
|
||||
{pkg.provider.reviewCount != null && ` (${pkg.provider.reviewCount})`}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ width: '100%', my: 1 }} />
|
||||
|
||||
<Typography variant="h6" component="p">
|
||||
{pkg.name}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
Total package price
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{ color: 'primary.main', fontWeight: 700 }}>
|
||||
{formatPrice(pkg.price)}
|
||||
</Typography>
|
||||
|
||||
{/* Spacer pushes CTA to bottom across all cards */}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
<Button
|
||||
variant={pkg.provider.verified ? 'contained' : 'soft'}
|
||||
color={pkg.provider.verified ? 'primary' : 'secondary'}
|
||||
size="medium"
|
||||
onClick={() => onArrange(pkg.id)}
|
||||
sx={{ mt: 1.5, px: 4 }}
|
||||
>
|
||||
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
|
||||
</Button>
|
||||
|
||||
{!pkg.isRecommended && (
|
||||
<Link
|
||||
component="button"
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
underline="hover"
|
||||
onClick={() => onRemove(pkg.id)}
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
Remove
|
||||
</Link>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
pkg={pkg}
|
||||
onArrange={onArrange}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -449,30 +310,30 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
|
||||
<Box
|
||||
role="cell"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
px: 3,
|
||||
py: 2,
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 0 }}>
|
||||
<Typography variant="body2" color="text.secondary" component="span">
|
||||
{item.name}
|
||||
</Typography>
|
||||
{item.info && (
|
||||
<Tooltip title={item.info} arrow placement="top">
|
||||
<InfoOutlinedIcon
|
||||
aria-label={`More information about ${item.name}`}
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
color: 'var(--fa-color-neutral-400)',
|
||||
cursor: 'help',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Box component="span" sx={{ whiteSpace: 'nowrap' }}>
|
||||
{'\u00A0'}
|
||||
<Tooltip title={item.info} arrow placement="top">
|
||||
<InfoOutlinedIcon
|
||||
aria-label={`More information about ${item.name}`}
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
color: 'var(--fa-color-neutral-400)',
|
||||
cursor: 'help',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -561,7 +561,7 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
|
||||
placeholder="Enter suburb or postcode"
|
||||
inputRef={locationInputRef}
|
||||
startAdornment={
|
||||
<InputAdornment position="start" sx={{ ml: 0.5 }}>
|
||||
<InputAdornment position="start" sx={{ ml: 0.25, mr: -0.5 }}>
|
||||
<LocationOnOutlinedIcon
|
||||
sx={{
|
||||
fontSize: 20,
|
||||
@@ -577,6 +577,7 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
|
||||
...fieldBaseSx,
|
||||
'& .MuiOutlinedInput-input': {
|
||||
...fieldInputStyles,
|
||||
pl: 0.75,
|
||||
'&::placeholder': {
|
||||
color: 'var(--fa-color-text-disabled)',
|
||||
opacity: 1,
|
||||
@@ -617,12 +618,12 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
|
||||
loading={loading}
|
||||
endIcon={!loading ? <ArrowForwardIcon /> : undefined}
|
||||
onClick={handleSubmit}
|
||||
sx={{ minHeight: 52 }}
|
||||
sx={{ minHeight: { xs: 40, sm: 52 }, fontSize: { xs: '0.875rem', sm: undefined } }}
|
||||
>
|
||||
Search Local Providers
|
||||
Search
|
||||
</Button>
|
||||
<Typography
|
||||
variant="captionSm"
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: 'center', display: 'block', mt: 1.5 }}
|
||||
>
|
||||
|
||||
@@ -216,50 +216,33 @@ const pkgInglewood: ComparisonPackage = {
|
||||
{
|
||||
heading: 'Essentials',
|
||||
items: [
|
||||
{
|
||||
name: 'Allowance for Coffin',
|
||||
info: 'Allowance amount.',
|
||||
value: { type: 'allowance', amount: 1800 },
|
||||
},
|
||||
{
|
||||
name: 'Cremation Certificate/Permit',
|
||||
info: 'Medical referee fee.',
|
||||
value: { type: 'price', amount: 350 },
|
||||
},
|
||||
{
|
||||
name: 'Death Registration Certificate',
|
||||
info: 'NSW Registry.',
|
||||
value: { type: 'price', amount: 70 },
|
||||
},
|
||||
{
|
||||
name: 'Professional Service Fee',
|
||||
info: 'Coordination.',
|
||||
value: { type: 'price', amount: 3980 },
|
||||
},
|
||||
{
|
||||
name: 'Transportation Service Fee',
|
||||
info: 'Transfer.',
|
||||
value: { type: 'price', amount: 500 },
|
||||
},
|
||||
{ name: 'Allowance for Coffin', value: { type: 'unknown' } },
|
||||
{ name: 'Cremation Certificate/Permit', value: { type: 'unknown' } },
|
||||
{ name: 'Crematorium', value: { type: 'unknown' } },
|
||||
{ name: 'Death Registration Certificate', value: { type: 'unknown' } },
|
||||
{ name: 'Dressing Fee', value: { type: 'unknown' } },
|
||||
{ name: 'NSW Government Levy — Cremation', value: { type: 'unknown' } },
|
||||
{ name: 'Professional Mortuary Care', value: { type: 'unknown' } },
|
||||
{ name: 'Professional Service Fee', value: { type: 'unknown' } },
|
||||
{ name: 'Transportation Service Fee', value: { type: 'unknown' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Optionals',
|
||||
items: [
|
||||
{ name: 'Viewing Fee', info: 'Private family viewing.', value: { type: 'included' } },
|
||||
{ name: 'Flowers', info: 'Floral arrangements.', value: { type: 'poa' } },
|
||||
{
|
||||
name: 'Digital Recording',
|
||||
info: 'Video recording.',
|
||||
value: { type: 'price', amount: 250 },
|
||||
},
|
||||
{ name: 'Digital Recording', value: { type: 'unknown' } },
|
||||
{ name: 'Flowers', value: { type: 'unknown' } },
|
||||
{ name: 'Online Notice', value: { type: 'unknown' } },
|
||||
{ name: 'Viewing Fee', value: { type: 'unknown' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Extras',
|
||||
items: [
|
||||
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
|
||||
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
|
||||
{ name: 'Allowance for Celebrant', value: { type: 'unknown' } },
|
||||
{ name: 'Catering', value: { type: 'unknown' } },
|
||||
{ name: 'Newspaper Notice', value: { type: 'unknown' } },
|
||||
{ name: 'Saturday Service Fee', value: { type: 'unknown' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import React, { useId, useState } from 'react';
|
||||
import React, { useId, useState, useRef, useCallback } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import StarRoundedIcon from '@mui/icons-material/StarRounded';
|
||||
import ShareOutlinedIcon from '@mui/icons-material/ShareOutlined';
|
||||
import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
import { Button } from '../../atoms/Button';
|
||||
import { Card } from '../../atoms/Card';
|
||||
import { WizardLayout } from '../../templates/WizardLayout';
|
||||
import { ComparisonTable, type ComparisonPackage } from '../../organisms/ComparisonTable';
|
||||
import { ComparisonPackageCard } from '../../molecules/ComparisonPackageCard';
|
||||
import { ComparisonTabCard } from '../../molecules/ComparisonTabCard';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -62,6 +61,8 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const tablistId = useId();
|
||||
const railRef = useRef<HTMLDivElement>(null);
|
||||
const tabRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
const allPackages = React.useMemo(() => {
|
||||
const result: ComparisonPackage[] = [];
|
||||
@@ -84,6 +85,34 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
|
||||
? `Comparing ${packages.length} package${packages.length !== 1 ? 's' : ''} from different providers`
|
||||
: `Comparing ${packages.length} package${packages.length !== 1 ? 's' : ''}`;
|
||||
|
||||
const hasRecommended = allPackages.some((p) => p.isRecommended);
|
||||
|
||||
const scrollToCenter = useCallback((idx: number) => {
|
||||
const tab = tabRefs.current[idx];
|
||||
if (tab && railRef.current) {
|
||||
const rail = railRef.current;
|
||||
const tabCenter = tab.offsetLeft + tab.offsetWidth / 2;
|
||||
const railCenter = rail.offsetWidth / 2;
|
||||
rail.scrollTo({ left: tabCenter - railCenter, behavior: 'smooth' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTabClick = useCallback(
|
||||
(idx: number) => {
|
||||
setActiveTabIdx(idx);
|
||||
scrollToCenter(idx);
|
||||
},
|
||||
[scrollToCenter],
|
||||
);
|
||||
|
||||
// Center the default tab on mount
|
||||
React.useEffect(() => {
|
||||
// Small delay to allow layout to settle
|
||||
const timer = setTimeout(() => scrollToCenter(defaultTabIdx), 50);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box ref={ref} sx={sx}>
|
||||
<WizardLayout
|
||||
@@ -151,8 +180,9 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
|
||||
{/* Mobile: Tab rail + card view */}
|
||||
{isMobile && allPackages.length > 0 && (
|
||||
<>
|
||||
{/* Tab rail — mini cards showing provider + package name */}
|
||||
{/* Tab rail — mini cards showing provider + package + price */}
|
||||
<Box
|
||||
ref={railRef}
|
||||
role="tablist"
|
||||
id={tablistId}
|
||||
aria-label="Packages to compare"
|
||||
@@ -160,86 +190,30 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
|
||||
display: 'flex',
|
||||
gap: 1.5,
|
||||
overflowX: 'auto',
|
||||
pb: 1,
|
||||
mb: 2.5,
|
||||
py: 2,
|
||||
px: 2,
|
||||
mx: -2,
|
||||
mt: 1,
|
||||
mb: 3,
|
||||
scrollbarWidth: 'none',
|
||||
'&::-webkit-scrollbar': { display: 'none' },
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
{allPackages.map((pkg, idx) => {
|
||||
const isActive = idx === activeTabIdx;
|
||||
return (
|
||||
<Card
|
||||
key={pkg.id}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={`comparison-tabpanel-${idx}`}
|
||||
id={`comparison-tab-${idx}`}
|
||||
variant="outlined"
|
||||
selected={isActive}
|
||||
padding="none"
|
||||
onClick={() => setActiveTabIdx(idx)}
|
||||
interactive
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
minWidth: 150,
|
||||
maxWidth: 200,
|
||||
cursor: 'pointer',
|
||||
...(pkg.isRecommended &&
|
||||
!isActive && {
|
||||
borderColor: 'var(--fa-color-brand-500)',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Box sx={{ px: 2, py: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
mb: 0.25,
|
||||
}}
|
||||
>
|
||||
{pkg.isRecommended && (
|
||||
<StarRoundedIcon
|
||||
aria-label="Recommended"
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
color: 'var(--fa-color-brand-600)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Typography
|
||||
variant="labelSm"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{pkg.provider.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{pkg.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{allPackages.map((pkg, idx) => (
|
||||
<ComparisonTabCard
|
||||
key={pkg.id}
|
||||
ref={(el: HTMLDivElement | null) => {
|
||||
tabRefs.current[idx] = el;
|
||||
}}
|
||||
pkg={pkg}
|
||||
isActive={idx === activeTabIdx}
|
||||
hasRecommended={hasRecommended}
|
||||
tabId={`comparison-tab-${idx}`}
|
||||
tabPanelId={`comparison-tabpanel-${idx}`}
|
||||
onClick={() => handleTabClick(idx)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{activePackage && (
|
||||
|
||||
@@ -216,50 +216,33 @@ const pkgInglewood: ComparisonPackage = {
|
||||
{
|
||||
heading: 'Essentials',
|
||||
items: [
|
||||
{
|
||||
name: 'Allowance for Coffin',
|
||||
info: 'Allowance amount.',
|
||||
value: { type: 'allowance', amount: 1800 },
|
||||
},
|
||||
{
|
||||
name: 'Cremation Certificate/Permit',
|
||||
info: 'Medical referee fee.',
|
||||
value: { type: 'price', amount: 350 },
|
||||
},
|
||||
{
|
||||
name: 'Death Registration Certificate',
|
||||
info: 'NSW Registry.',
|
||||
value: { type: 'price', amount: 70 },
|
||||
},
|
||||
{
|
||||
name: 'Professional Service Fee',
|
||||
info: 'Coordination.',
|
||||
value: { type: 'price', amount: 3980 },
|
||||
},
|
||||
{
|
||||
name: 'Transportation Service Fee',
|
||||
info: 'Transfer.',
|
||||
value: { type: 'price', amount: 500 },
|
||||
},
|
||||
{ name: 'Allowance for Coffin', value: { type: 'unknown' } },
|
||||
{ name: 'Cremation Certificate/Permit', value: { type: 'unknown' } },
|
||||
{ name: 'Crematorium', value: { type: 'unknown' } },
|
||||
{ name: 'Death Registration Certificate', value: { type: 'unknown' } },
|
||||
{ name: 'Dressing Fee', value: { type: 'unknown' } },
|
||||
{ name: 'NSW Government Levy — Cremation', value: { type: 'unknown' } },
|
||||
{ name: 'Professional Mortuary Care', value: { type: 'unknown' } },
|
||||
{ name: 'Professional Service Fee', value: { type: 'unknown' } },
|
||||
{ name: 'Transportation Service Fee', value: { type: 'unknown' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Optionals',
|
||||
items: [
|
||||
{ name: 'Viewing Fee', info: 'Private family viewing.', value: { type: 'included' } },
|
||||
{ name: 'Flowers', info: 'Floral arrangements.', value: { type: 'poa' } },
|
||||
{
|
||||
name: 'Digital Recording',
|
||||
info: 'Video recording.',
|
||||
value: { type: 'price', amount: 250 },
|
||||
},
|
||||
{ name: 'Digital Recording', value: { type: 'unknown' } },
|
||||
{ name: 'Flowers', value: { type: 'unknown' } },
|
||||
{ name: 'Online Notice', value: { type: 'unknown' } },
|
||||
{ name: 'Viewing Fee', value: { type: 'unknown' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Extras',
|
||||
items: [
|
||||
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
|
||||
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
|
||||
{ name: 'Allowance for Celebrant', value: { type: 'unknown' } },
|
||||
{ name: 'Catering', value: { type: 'unknown' } },
|
||||
{ name: 'Newspaper Notice', value: { type: 'unknown' } },
|
||||
{ name: 'Saturday Service Fee', value: { type: 'unknown' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import React, { useId, useState } from 'react';
|
||||
import React, { useId, useState, useRef, useCallback } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import StarRoundedIcon from '@mui/icons-material/StarRounded';
|
||||
import ShareOutlinedIcon from '@mui/icons-material/ShareOutlined';
|
||||
import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
import { Button } from '../../atoms/Button';
|
||||
import { Card } from '../../atoms/Card';
|
||||
import { WizardLayout } from '../../templates/WizardLayout';
|
||||
import { ComparisonTable, type ComparisonPackage } from '../../organisms/ComparisonTable';
|
||||
import { ComparisonPackageCard } from '../../molecules/ComparisonPackageCard';
|
||||
import { ComparisonTabCard } from '../../molecules/ComparisonTabCard';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -60,6 +59,8 @@ export const ComparisonPageV1 = React.forwardRef<HTMLDivElement, ComparisonPageV
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const tablistId = useId();
|
||||
const railRef = useRef<HTMLDivElement>(null);
|
||||
const tabRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
const allPackages = React.useMemo(() => {
|
||||
const result = [...packages];
|
||||
@@ -78,6 +79,33 @@ export const ComparisonPageV1 = React.forwardRef<HTMLDivElement, ComparisonPageV
|
||||
? `Comparing ${packages.length} package${packages.length !== 1 ? 's' : ''} from different providers`
|
||||
: `Comparing ${packages.length} package${packages.length !== 1 ? 's' : ''}`;
|
||||
|
||||
const hasRecommended = allPackages.some((p) => p.isRecommended);
|
||||
|
||||
const scrollToCenter = useCallback((idx: number) => {
|
||||
const tab = tabRefs.current[idx];
|
||||
if (tab && railRef.current) {
|
||||
const rail = railRef.current;
|
||||
const tabCenter = tab.offsetLeft + tab.offsetWidth / 2;
|
||||
const railCenter = rail.offsetWidth / 2;
|
||||
rail.scrollTo({ left: tabCenter - railCenter, behavior: 'smooth' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTabClick = useCallback(
|
||||
(idx: number) => {
|
||||
setActiveTabIdx(idx);
|
||||
scrollToCenter(idx);
|
||||
},
|
||||
[scrollToCenter],
|
||||
);
|
||||
|
||||
// Center the default tab on mount
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => scrollToCenter(0), 50);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box ref={ref} sx={sx}>
|
||||
<WizardLayout
|
||||
@@ -145,8 +173,9 @@ export const ComparisonPageV1 = React.forwardRef<HTMLDivElement, ComparisonPageV
|
||||
{/* Mobile: Tab rail + card view */}
|
||||
{isMobile && allPackages.length > 0 && (
|
||||
<>
|
||||
{/* Tab rail — mini cards showing provider + package name */}
|
||||
{/* Tab rail — mini cards showing provider + package + price */}
|
||||
<Box
|
||||
ref={railRef}
|
||||
role="tablist"
|
||||
id={tablistId}
|
||||
aria-label="Packages to compare"
|
||||
@@ -154,86 +183,30 @@ export const ComparisonPageV1 = React.forwardRef<HTMLDivElement, ComparisonPageV
|
||||
display: 'flex',
|
||||
gap: 1.5,
|
||||
overflowX: 'auto',
|
||||
pb: 1,
|
||||
mb: 2.5,
|
||||
py: 2,
|
||||
px: 2,
|
||||
mx: -2,
|
||||
mt: 1,
|
||||
mb: 3,
|
||||
scrollbarWidth: 'none',
|
||||
'&::-webkit-scrollbar': { display: 'none' },
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
{allPackages.map((pkg, idx) => {
|
||||
const isActive = idx === activeTabIdx;
|
||||
return (
|
||||
<Card
|
||||
key={pkg.id}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={`comparison-tabpanel-${idx}`}
|
||||
id={`comparison-tab-${idx}`}
|
||||
variant="outlined"
|
||||
selected={isActive}
|
||||
padding="none"
|
||||
onClick={() => setActiveTabIdx(idx)}
|
||||
interactive
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
minWidth: 150,
|
||||
maxWidth: 200,
|
||||
cursor: 'pointer',
|
||||
...(pkg.isRecommended &&
|
||||
!isActive && {
|
||||
borderColor: 'var(--fa-color-brand-500)',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Box sx={{ px: 2, py: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
mb: 0.25,
|
||||
}}
|
||||
>
|
||||
{pkg.isRecommended && (
|
||||
<StarRoundedIcon
|
||||
aria-label="Recommended"
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
color: 'var(--fa-color-brand-600)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Typography
|
||||
variant="labelSm"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{pkg.provider.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{pkg.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{allPackages.map((pkg, idx) => (
|
||||
<ComparisonTabCard
|
||||
key={pkg.id}
|
||||
ref={(el: HTMLDivElement | null) => {
|
||||
tabRefs.current[idx] = el;
|
||||
}}
|
||||
pkg={pkg}
|
||||
isActive={idx === activeTabIdx}
|
||||
hasRecommended={hasRecommended}
|
||||
tabId={`comparison-tab-${idx}`}
|
||||
tabPanelId={`comparison-tabpanel-${idx}`}
|
||||
onClick={() => handleTabClick(idx)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{activePackage && (
|
||||
|
||||
@@ -380,16 +380,109 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
)}
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════════════
|
||||
Section 2c: Discover — Map + Featured Providers (V2)
|
||||
Section 2b: Partner Logos Carousel
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
{partnerLogos.length > 0 && (
|
||||
<Box
|
||||
component="section"
|
||||
aria-labelledby="partners-heading"
|
||||
sx={{
|
||||
bgcolor: 'var(--fa-color-surface-default)',
|
||||
borderBottom: '1px solid #ebe0d4',
|
||||
pt: { xs: 22, md: 28 },
|
||||
pb: { xs: 10, md: 14 },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg">
|
||||
<Typography
|
||||
variant="overline"
|
||||
component="h2"
|
||||
id="partners-heading"
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
color: 'var(--fa-color-brand-600)',
|
||||
mb: { xs: 6, md: 10 },
|
||||
}}
|
||||
>
|
||||
{partnerTrustLine}
|
||||
</Typography>
|
||||
</Container>
|
||||
|
||||
{/* Carousel track */}
|
||||
<Box
|
||||
role="presentation"
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
'&::before, &::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 80,
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&::before': {
|
||||
left: 0,
|
||||
background: 'linear-gradient(to right, #fff, transparent)',
|
||||
},
|
||||
'&::after': {
|
||||
right: 0,
|
||||
background: 'linear-gradient(to left, #fff, transparent)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
aria-label="Partner funeral directors"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: { xs: 8, md: 12 },
|
||||
alignItems: 'center',
|
||||
width: 'max-content',
|
||||
animation: 'logoScroll 35s linear infinite',
|
||||
'@keyframes logoScroll': {
|
||||
'0%': { transform: 'translateX(0)' },
|
||||
'100%': { transform: 'translateX(-50%)' },
|
||||
},
|
||||
'&:hover': { animationPlayState: 'paused' },
|
||||
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
|
||||
}}
|
||||
>
|
||||
{[...partnerLogos, ...partnerLogos].map((logo, i) => (
|
||||
<Box
|
||||
key={`${logo.alt}-${i}`}
|
||||
component="img"
|
||||
src={logo.src}
|
||||
alt={i < partnerLogos.length ? logo.alt : ''}
|
||||
aria-hidden={i >= partnerLogos.length ? true : undefined}
|
||||
sx={{
|
||||
height: { xs: 46, md: 55 },
|
||||
maxWidth: { xs: 140, md: 184 },
|
||||
width: 'auto',
|
||||
objectFit: 'contain',
|
||||
filter: 'grayscale(100%) brightness(1.2)',
|
||||
opacity: 0.4,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════════════
|
||||
Section 2c: Discover — Map + Featured Providers
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
{featuredProviders.length > 0 && (
|
||||
<Box
|
||||
component="section"
|
||||
aria-labelledby="discover-heading"
|
||||
sx={{
|
||||
bgcolor: 'var(--fa-color-surface-subtle)',
|
||||
pt: { xs: 22, md: 28 },
|
||||
pb: { xs: 8, md: 12 },
|
||||
bgcolor: '#fdfbf9',
|
||||
pt: { xs: 10, md: 14 },
|
||||
pb: { xs: 10, md: 14 },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg">
|
||||
@@ -486,93 +579,208 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
)}
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════════════
|
||||
Section 3: Partner Logos Carousel
|
||||
Section 3b: Why Use FA — Text + Image
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
{partnerLogos.length > 0 && (
|
||||
<Box
|
||||
component="section"
|
||||
aria-label="Trusted partners"
|
||||
sx={{
|
||||
bgcolor: 'var(--fa-color-surface-cool)',
|
||||
pt: { xs: 10, md: 13 },
|
||||
pb: { xs: 8, md: 10 },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg">
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: 'center', mb: { xs: 4, md: 6 } }}
|
||||
>
|
||||
{partnerTrustLine}
|
||||
</Typography>
|
||||
</Container>
|
||||
|
||||
{/* Carousel track */}
|
||||
<Box
|
||||
component="section"
|
||||
aria-labelledby="why-fa-heading"
|
||||
sx={{
|
||||
bgcolor: 'var(--fa-color-surface-default)',
|
||||
borderTop: '1px solid #f3efea',
|
||||
borderBottom: '1px solid #f3efea',
|
||||
py: { xs: 10, md: 14 },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg">
|
||||
<Box
|
||||
role="presentation"
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
'&::before, &::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 80,
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&::before': {
|
||||
left: 0,
|
||||
background:
|
||||
'linear-gradient(to right, var(--fa-color-surface-cool), transparent)',
|
||||
},
|
||||
'&::after': {
|
||||
right: 0,
|
||||
background:
|
||||
'linear-gradient(to left, var(--fa-color-surface-cool), transparent)',
|
||||
},
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' },
|
||||
gap: { xs: 4, md: 8 },
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Text */}
|
||||
<Box>
|
||||
<Typography
|
||||
variant="overline"
|
||||
component="div"
|
||||
sx={{ color: 'var(--fa-color-brand-600)', mb: 1.5 }}
|
||||
>
|
||||
Why Use Funeral Arranger
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="display3"
|
||||
component="h2"
|
||||
id="why-fa-heading"
|
||||
sx={{ mb: 2.5, color: 'text.primary' }}
|
||||
>
|
||||
Making an impossible time a little easier
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Funeral planning doesn’t have to be overwhelming. Whether a loved one has
|
||||
just passed, is imminent, or you’re pre-planning the future for yourself.
|
||||
Compare transparent pricing from local funeral directors. Explore the service
|
||||
options, coffins and more to personalise a funeral plan in clear, easy steps.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Image */}
|
||||
<Box
|
||||
aria-label="Partner funeral directors"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: { xs: 8, md: 12 },
|
||||
alignItems: 'center',
|
||||
width: 'max-content',
|
||||
animation: 'logoScroll 35s linear infinite',
|
||||
'@keyframes logoScroll': {
|
||||
'0%': { transform: 'translateX(0)' },
|
||||
'100%': { transform: 'translateX(-50%)' },
|
||||
borderRadius: 'var(--fa-border-radius-lg, 12px)',
|
||||
overflow: 'hidden',
|
||||
'& img': {
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
display: 'block',
|
||||
},
|
||||
'&:hover': { animationPlayState: 'paused' },
|
||||
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
|
||||
}}
|
||||
>
|
||||
{[...partnerLogos, ...partnerLogos].map((logo, i) => (
|
||||
<Box
|
||||
key={`${logo.alt}-${i}`}
|
||||
component="img"
|
||||
src={logo.src}
|
||||
alt={i < partnerLogos.length ? logo.alt : ''}
|
||||
aria-hidden={i >= partnerLogos.length ? true : undefined}
|
||||
sx={{
|
||||
height: { xs: 46, md: 55 },
|
||||
maxWidth: { xs: 140, md: 184 },
|
||||
width: 'auto',
|
||||
objectFit: 'contain',
|
||||
filter: 'grayscale(100%) brightness(1.2)',
|
||||
opacity: 0.4,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<img
|
||||
src="/brandassets/images/Homepage/people.png"
|
||||
alt="Family planning together with care and confidence"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════════════
|
||||
Section 3c: What You Can Do Here — Three Feature Cards
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
<Box
|
||||
component="section"
|
||||
aria-labelledby="what-you-can-do-heading"
|
||||
sx={{
|
||||
bgcolor: '#f8f5f1',
|
||||
py: { xs: 10, md: 14 },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg">
|
||||
<Box sx={{ textAlign: 'center', mb: { xs: 5, md: 8 } }}>
|
||||
<Typography
|
||||
variant="overline"
|
||||
component="div"
|
||||
sx={{ color: 'var(--fa-color-brand-600)', mb: 1.5 }}
|
||||
>
|
||||
What You Can Do Here
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="display3"
|
||||
component="h2"
|
||||
id="what-you-can-do-heading"
|
||||
sx={{ color: 'text.primary' }}
|
||||
>
|
||||
Three ways we can help you today
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', md: 'repeat(3, 1fr)' },
|
||||
gap: { xs: 3, md: 4 },
|
||||
}}
|
||||
>
|
||||
{/* Card 1: Compare pricing */}
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'var(--fa-color-surface-default)',
|
||||
borderRadius: 'var(--fa-card-border-radius-default, 8px)',
|
||||
boxShadow: 'var(--fa-shadow-md)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
height: 200,
|
||||
background:
|
||||
'linear-gradient(135deg, var(--fa-color-brand-100) 0%, var(--fa-color-brand-200) 100%)',
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ p: 3, flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="h5" component="h3" sx={{ mb: 1.5, color: 'text.primary' }}>
|
||||
Compare pricing
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3, flex: 1 }}>
|
||||
See verified, itemised prices from multiple funeral directors in your area
|
||||
side by side.
|
||||
</Typography>
|
||||
<Button variant="outlined" size="medium" fullWidth>
|
||||
Compare prices in my area
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Card 2: Find a funeral director */}
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'var(--fa-color-surface-default)',
|
||||
borderRadius: 'var(--fa-card-border-radius-default, 8px)',
|
||||
boxShadow: 'var(--fa-shadow-md)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
height: 200,
|
||||
background:
|
||||
'linear-gradient(135deg, var(--fa-color-sage-100, #E8EDEF) 0%, var(--fa-color-sage-200, #D0D8DD) 100%)',
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ p: 3, flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="h5" component="h3" sx={{ mb: 1.5, color: 'text.primary' }}>
|
||||
Find a funeral director
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3, flex: 1 }}>
|
||||
Browse rated, reviewed directors near you with profiles, photos, and contact
|
||||
details.
|
||||
</Typography>
|
||||
<Button variant="outlined" size="medium" fullWidth>
|
||||
Search near me
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Card 3: Arrange a funeral */}
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'var(--fa-color-surface-default)',
|
||||
borderRadius: 'var(--fa-card-border-radius-default, 8px)',
|
||||
boxShadow: 'var(--fa-shadow-md)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
height: 200,
|
||||
background:
|
||||
'linear-gradient(135deg, var(--fa-color-neutral-100) 0%, var(--fa-color-neutral-200) 100%)',
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ p: 3, flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="h5" component="h3" sx={{ mb: 1.5, color: 'text.primary' }}>
|
||||
Arrange a funeral
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3, flex: 1 }}>
|
||||
Build a fully customised quote — choose coffin, flowers, transport,
|
||||
venue, and more.
|
||||
</Typography>
|
||||
<Button variant="outlined" size="medium" fullWidth>
|
||||
Start building your quote
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════════════
|
||||
Section 4: Why Use Funeral Arranger (Features)
|
||||
@@ -583,7 +791,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
aria-labelledby="features-heading"
|
||||
sx={{
|
||||
bgcolor: 'var(--fa-color-surface-default)',
|
||||
py: { xs: 8, md: 12 },
|
||||
py: { xs: 10, md: 14 },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg">
|
||||
@@ -648,8 +856,8 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
component="section"
|
||||
aria-labelledby="reviews-heading"
|
||||
sx={{
|
||||
py: { xs: 8, md: 12 },
|
||||
bgcolor: 'var(--fa-color-surface-subtle)',
|
||||
py: { xs: 10, md: 14 },
|
||||
bgcolor: '#f8f5f1',
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="md">
|
||||
@@ -683,26 +891,29 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Editorial testimonials — alternating alignment with dividers */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{/* Editorial testimonials — left-aligned with dividers */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0,
|
||||
maxWidth: 560,
|
||||
mx: 'auto',
|
||||
}}
|
||||
>
|
||||
{testimonials.map((t, i) => {
|
||||
const isRight = i % 2 === 1;
|
||||
return (
|
||||
<React.Fragment key={`${t.name}-${i}`}>
|
||||
{i > 0 && <Divider sx={{ my: 4 }} />}
|
||||
<Box
|
||||
sx={{
|
||||
textAlign: isRight ? 'right' : 'left',
|
||||
maxWidth: '85%',
|
||||
ml: isRight ? 'auto' : 0,
|
||||
mr: isRight ? 0 : 'auto',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<FormatQuoteIcon
|
||||
sx={{
|
||||
fontSize: 32,
|
||||
color: 'var(--fa-color-brand-300)',
|
||||
transform: isRight ? 'scaleX(-1)' : 'none',
|
||||
mb: 1,
|
||||
}}
|
||||
/>
|
||||
@@ -750,7 +961,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
sx={{
|
||||
background:
|
||||
'linear-gradient(180deg, var(--fa-color-brand-100, #F5EDE4) 0%, var(--fa-color-surface-warm, #FEF9F5) 100%)',
|
||||
py: { xs: 8, md: 10 },
|
||||
py: { xs: 10, md: 14 },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="md" sx={{ textAlign: 'center' }}>
|
||||
@@ -777,17 +988,17 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
aria-labelledby="faq-heading"
|
||||
sx={{
|
||||
bgcolor: 'var(--fa-color-surface-default)',
|
||||
py: { xs: 8, md: 12 },
|
||||
py: { xs: 10, md: 14 },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg">
|
||||
<Typography
|
||||
variant="h2"
|
||||
variant="display3"
|
||||
component="h2"
|
||||
id="faq-heading"
|
||||
sx={{ textAlign: 'center', mb: { xs: 5, md: 8 }, color: 'text.primary' }}
|
||||
>
|
||||
FAQ
|
||||
Frequently Asked Questions
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ maxWidth: 700, mx: 'auto' }}>
|
||||
@@ -823,6 +1034,11 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))}
|
||||
<Box sx={{ textAlign: 'center', mt: 4 }}>
|
||||
<Button variant="text" size="medium">
|
||||
See more
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
@@ -240,7 +240,7 @@ const partnerLogos: PartnerLogo[] = [
|
||||
// ─── Meta ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const meta: Meta<typeof HomePage> = {
|
||||
title: 'Archive/HomePage V3',
|
||||
title: 'Pages/HomePage',
|
||||
component: HomePage,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
@@ -258,7 +258,7 @@ export const Default: Story = {
|
||||
navigation: nav,
|
||||
footer,
|
||||
heroImageUrl: '/brandassets/images/heroes/hero-3.png',
|
||||
heroHeading: 'Compare funeral directors pricing near you and arrange with confidence',
|
||||
heroHeading: 'Compare funeral director pricing near you and arrange with confidence',
|
||||
heroSubheading: 'Transparent pricing \u00B7 No hidden fees \u00B7 Arrange 24/7',
|
||||
stats: trustStats,
|
||||
featuredProviders,
|
||||
@@ -269,7 +269,7 @@ export const Default: Story = {
|
||||
}),
|
||||
onSelectFeaturedProvider: (id) => console.log('Featured provider:', id),
|
||||
partnerLogos,
|
||||
partnerTrustLine: 'Trusted by hundreds of verified funeral directors across Australia',
|
||||
partnerTrustLine: 'Verified funeral directors on Funeral Arranger',
|
||||
features,
|
||||
googleRating: 4.9,
|
||||
googleReviewCount: 2340,
|
||||
|
||||
Reference in New Issue
Block a user