Compare commits

..

8 Commits

Author SHA1 Message Date
f121ac7168 Strip AI tooling and working docs for dev push 2026-04-16 16:02:06 +10:00
9ac8e31516 Refine HomePage layout and add Locations dropdown to Navigation
- Navigation: add NavItem.children support for desktop dropdown + mobile collapsible sections. Wire Locations (Melbourne, Brisbane, Sydney, South Coast NSW, Central Coast NSW) before FAQ in all HomePage stories.
- HomePage hero: add italic "Trusted by thousands of families across Australia" tagline above h1, widen text container to 990px, use hero-couple.jpg background.
- HomePage features: rename "How it works" to "4 Reasons to use Funeral Arranger", add "Why Use Funeral Arranger" overline, remove placeholder body copy.
- HomePage testimonials: add "Funeral Arranger Reviews" overline above "What families are saying".
- HomePage CTA: promote "Start planning" to primary contained button (medium).
- FuneralFinderV3: remove header + divider so the form starts directly at "How can we help".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:00:28 +10:00
348f3912fd Promote hero h1 to display2 for visual hierarchy over section h2s
Hero heading was display3 (same as every section h2), giving no visual
distinction. Now display2 (52px/24px) vs display3 (40px/22px). Still
renders as semantic h1 for SEO.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:03:32 +10:00
9e627d88a6 Refine HomePage mobile spacing and typography scaling
Increase hero top padding, heading-to-subheading gap, and finder card
side padding on mobile. Scale body1 text to 14px on mobile under display3
headings for better hierarchy contrast. Centre-align "Why Use FA" section
on mobile. Remove arrow from "Start exploring" button.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:49:33 +10:00
ab25af2e67 Add external asset hosting for Chromatic image rendering
Migrate Gitea remotes to git.tensordesign.com.au. Add assetUrl() utility
that resolves image paths from Gitea ParsonsAssets repo when
STORYBOOK_ASSET_BASE is set, enabling images on Chromatic-published
Storybook while keeping local dev unchanged via staticDirs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:28:14 +10:00
5b2a41f4e4 Add /publish skill for consistent multi-target deployment
Pushes to backup (full), fa-dev + sheffield (stripped via worktree),
and Chromatic in one command. Replaces ad-hoc push workflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:50:41 +10:00
5e93f3a0d0 Promote HomePage V3 to production, redesign layout and refine FuneralFinder
Homepage: add "Why Use FA" text+image section and "Three Ways" feature cards,
reorder sections (logos carousel above discover), apply warm-grey alternating
backgrounds from Figma, unify all section headings to display3 serif, increase
section padding, fix heading hierarchy for SEO, and left-align testimonials.

FuneralFinder V3: responsive CTA (medium on mobile), shorten button to "Search",
bump reassurance text to caption variant, tighten location pin-to-text gap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:19:31 +10:00
e89ac360e8 Extract ComparisonColumnCard + ComparisonTabCard molecules, refine comparison UI
- New molecule: ComparisonColumnCard — desktop column header card extracted
  from ComparisonTable (~150 lines removed from organism)
- New molecule: ComparisonTabCard — mobile tab rail card extracted from
  ComparisonPage (shared by V1 and V2)
- CellValue "unknown" restyled: icon+text in neutral grey (was Badge),
  InfoOutlinedIcon on right at 14px matching item info icons
- Unverified provider story data: all items set to unknown across all
  story files (no dashes in essentials)
- Mobile tab rail: recommended badge (replaces star), package price,
  shadow/glow, center-on-select scroll, overflow clipping fixed
- ComparisonPackageCard: added shadow, reduced CTA button to medium
- ComparisonTable first column: inline info icon pattern (non-breaking
  space + nowrap span) prevents icon orphaning on line wrap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:10:17 +10:00
25 changed files with 1611 additions and 1068 deletions

3
.gitignore vendored
View File

@@ -28,6 +28,9 @@ docs/reference/how-to-work-with-both-tools.md
docs/reference/mcp-setup.md
docs/reference/retroactive-review-plan.md
# Deploy scripts (contain credentials)
scripts/
# Build logs
build-storybook.log

View File

@@ -1,154 +0,0 @@
# Component Lifecycle
Every component follows this lifecycle. Skills are run in order — each stage must
pass before moving to the next. This prevents ad-hoc back-and-forth tweaking.
## The Stages
```
┌─────────────────────────────────────────────────────────────┐
│ 1. BUILD /build-atom, /build-molecule, /build-organism │
│ 2. STORIES /write-stories │
│ 3. INTERNAL QA /audit → /critique → /harden │
│ 4. FIX Fix all P0 and P1 issues from stage 3 │
│ 5. POLISH /polish → /typeset → /adapt │
│ 6. PRESENT Show to user in Storybook │
│ 7. ITERATE User feedback → targeted fixes (1-2 rounds) │
│ 8. NORMALIZE /normalize (cross-component consistency) │
│ 9. PREFLIGHT /preflight │
│ 10. COMMIT git add → commit → push │
└─────────────────────────────────────────────────────────────┘
```
## When to use each skill
### Stage 1 — BUILD
**Skill:** `/build-atom`, `/build-molecule`, `/build-organism`
**When:** Starting a new component. The skill handles reading memory files,
checking the registry, creating the file structure, and writing the code.
**Output:** Component .tsx + stories .tsx + index.ts
### Stage 2 — STORIES
**Skill:** `/write-stories`
**When:** If the build skill didn't produce comprehensive stories, or if stories
need updating after changes. Stories must cover: default, all variants, all
sizes, disabled, loading, error, long content, minimal content.
**Output:** Complete story coverage in Storybook
### Stage 3 — INTERNAL QA (run before showing to user)
Three skills, run in this order:
1. **`/audit`** — Technical quality (a11y, performance, theming, responsive, design).
Produces a score out of 20 and P0-P3 issues.
2. **`/critique`** — UX design review (hierarchy, emotion, cognitive load, composition).
Produces a score out of 40 and priority issues.
3. **`/harden`** — Edge cases (error states, empty states, loading, boundaries, disabled).
Ensures robustness for real-world data.
**Exit criteria:** No P0 issues remaining. P1 issues documented.
### Stage 4 — FIX
**No skill — just implementation work.**
**When:** Fix all P0 and P1 issues found in stage 3.
Then re-run the relevant check (e.g., if the fix was an a11y issue, re-run
`/audit` to verify). Don't re-run all three unless the fixes were broad.
**Exit criteria:** P0 = 0, P1 = 0 (or documented as intentional with rationale).
### Stage 5 — POLISH
Three skills, run as needed based on the component:
1. **`/polish`** — Visual alignment, spacing, transitions, copy, micro-details.
Run on every component.
2. **`/typeset`** — Typography: hierarchy, line length, weight, readability.
Run on text-heavy components (cards, forms, detail panels).
3. **`/adapt`** — Responsive: touch targets, overflow, mobile spacing.
Run on layout components (organisms, cards, navigation).
**Optional context-specific skills:**
- **`/quieter`** — Run on components that handle sensitive moments (pricing,
commitment steps, error messaging). Not needed for utility atoms.
- **`/clarify`** — Run on components with decision points or complex information
(FuneralFinder, ArrangementForm, PricingTable). Not needed for simple atoms.
### Stage 6 — PRESENT
**No skill — show in Storybook.**
**When:** All internal QA is done. The component should be in its best state
before the user sees it. Present with a brief summary of what it does, key
design decisions, and scores from audit/critique.
### Stage 7 — ITERATE
**No skill — targeted fixes from user feedback.**
**When:** User reviews in Storybook and gives feedback. This should be 1-2 rounds
max because stages 3-5 caught most issues. If feedback requires major changes,
go back to stage 1. Minor tweaks stay here.
**Exit criteria:** User approves.
### Stage 8 — NORMALIZE
**Skill:** `/normalize`
**When:** After user approval, run against the component's tier (e.g., `/normalize atoms`)
to check it's consistent with its peers. This catches: token access patterns (D031),
transition timing, focus styles, spacing methods, displayName, exports.
**Note:** This is a cross-component check, so it's most valuable after several
components in a tier are done. Can be batched.
### Stage 9 — PREFLIGHT
**Skill:** `/preflight`
**When:** Before committing. Verifies TypeScript, Storybook build, token sync,
hardcoded values, exports, ESLint, Prettier.
**Exit criteria:** All critical checks pass.
### Stage 10 — COMMIT
**No skill — git workflow.**
Stage, commit with descriptive message, push. Husky runs lint-staged automatically.
---
## Shorthand for quick reference
| Stage | Skill(s) | Who triggers | Blocking? |
|-------|----------|-------------|-----------|
| Build | /build-{tier} | User requests | — |
| Stories | /write-stories | Auto in build | — |
| Internal QA | /audit → /critique → /harden | Agent (auto) | P0 = blocking |
| Fix | — | Agent | Until P0/P1 = 0 |
| Polish | /polish + /typeset + /adapt | Agent (auto) | — |
| Present | — | Agent → User | — |
| Iterate | — | User feedback | 1-2 rounds |
| Normalize | /normalize | Agent (batch OK) | — |
| Preflight | /preflight | Agent (auto) | Critical = blocking |
| Commit | — | Agent | — |
**"Agent (auto)"** means I should run these proactively without being asked.
**"Agent (batch OK)"** means it can be deferred and run across multiple components.
---
## Which skills are optional vs required?
| Skill | Required for | Optional for |
|-------|-------------|-------------|
| /audit | All components | — |
| /critique | All molecules + organisms | Simple atoms (Button, Divider) |
| /harden | All interactive components | Display-only atoms (Typography, Badge) |
| /polish | All components | — |
| /typeset | Text-heavy components | Icon-only or structural components |
| /adapt | Layout components, organisms | Small inline atoms |
| /quieter | Sensitive context components | Utility atoms |
| /clarify | Decision-point components | Simple atoms |
| /normalize | All (batched by tier) | — |
| /preflight | All (before commit) | — |
---
## For existing components
Components built before this lifecycle was defined can be retroactively
reviewed using a condensed process:
1. `/normalize {tier}` — Scan the tier for consistency issues
2. `/audit {component}` — Score each component
3. Fix P0/P1 issues only (don't re-polish what's already working)
4. `/preflight` → commit
This is lighter than the full lifecycle because these components have already
been through user review and iteration.

View File

@@ -1,203 +0,0 @@
# FuneralFinder — Flow Logic Reference
Technical reference for the FuneralFinder stepped search widget.
Use this when modifying the flow, adding steps, or integrating with a backend.
## Architecture Overview
The widget is a **single React component** with internal state. No external state
management required. The parent only needs to provide `funeralTypes`, optional
`themeOptions`, and an `onSearch` callback.
```
┌─────────────────────────────────────────┐
│ Header (h2 display + subheading) │
│ ───────────────────────────────── │
│ │
│ CompletedRows (stack of answered steps)│
│ │
│ Active Step (one at a time, Collapse) │
│ Step 1 │ Step 2 │ Step 3 │ Step 4 │
│ │
│ ─── always visible ─────────────────── │
│ Location input │
│ [Find funeral providers] CTA │
│ Free to use · No obligation │
└─────────────────────────────────────────┘
```
## State
| State variable | Type | Default | Purpose |
|---|---|---|---|
| `intent` | `'arrange' \| 'preplan' \| null` | `null` | Step 1 answer |
| `planningFor` | `'myself' \| 'someone-else' \| null` | `null` | Step 2 answer (preplan only) |
| `typeSelection` | `string \| null` | `null` | Step 3 answer — funeral type ID or `'all'` |
| `servicePref` | `'with-service' \| 'without-service' \| 'either'` | `'either'` | Step 4 answer |
| `serviceAnswered` | `boolean` | `false` | Whether step 4 was explicitly answered |
| `selectedThemes` | `string[]` | `[]` | Optional theme filter IDs (multi-select) |
| `location` | `string` | `''` | Location input value |
| `locationError` | `string` | `''` | Validation error for location |
| `showIntentPrompt` | `boolean` | `false` | Show nudge when CTA clicked without intent |
| `editingStep` | `number \| null` | `null` | Which step is being re-edited (via "Change") |
## Step Flow
### Active Step Calculation
```typescript
const activeStep = (() => {
if (editingStep !== null) return editingStep; // User clicked "Change"
if (!intent) return 1; // Need intent
if (needsPlanningFor && !planningFor) return 2; // Need planning-for (preplan only)
if (!typeSelection) return 3; // Need funeral type
if (showServiceStep && !serviceAnswered) return 4; // Need service pref
return 0; // All complete
})();
```
`activeStep === 0` means all optional steps are answered. Only CompletedRows +
location + CTA are visible.
### Step Details
| Step | Question | Options | Auto-advances? | Conditional? |
|---|---|---|---|---|
| 1 | How can we help you today? | Arrange now / Pre-plan | Yes, on click | Always shown |
| 2 | Who are you planning for? | Myself / Someone else | Yes, on click | Only when `intent === 'preplan'` |
| 3 | What type of funeral? | TypeCards + Explore All + theme chips | Yes, on type card click | Always shown |
| 4 | Would you like a service? | With / No / Flexible (chips) | Yes, on chip click | Only when selected type has `hasServiceOption: true` |
### Auto-advance Mechanic
Steps 1, 2, and 4 auto-advance because selecting an option sets the state and
clears `editingStep`. The `activeStep` recalculation on the next render
determines the new step.
Step 3 also auto-advances when a type card is clicked. Theme preferences within
step 3 are optional — they're captured at whatever state they're in when the
type card click triggers collapse.
### Editing (reverting to a previous step)
Clicking "Change" on a CompletedRow calls `revertTo(stepNumber)`, which sets
`editingStep`. This overrides the `activeStep` calculation, reopening that step.
When the user makes a new selection, the handler clears `editingStep` and the
flow recalculates.
**Key behaviour:** Editing a step does NOT reset downstream answers. If you
change from Cremation to Burial (both have `hasServiceOption`), the service
preference carries forward. If you change to a type without `hasServiceOption`
(or to "Explore all"), `servicePref` resets to `'either'` and `serviceAnswered`
resets to `false`.
## CTA and Search Logic
### Minimum Requirements
The CTA button is **always visible and always enabled** (except during loading).
Minimum search requirements: **intent + location (3+ chars)**.
### Submit Behaviour
```
User clicks "Find funeral providers"
├─ intent is null?
│ → Show intent prompt (role="alert"), keep step 1 visible
│ → Return (don't search)
├─ location < 3 chars?
│ → Show error on location input
│ → Return (don't search)
└─ Both present?
→ Call onSearch() with smart defaults for missing optional fields
```
### Smart Defaults
| Field | If not explicitly answered | Default value |
|---|---|---|
| `funeralTypeId` | User didn't select a type | `null` (= show all types) |
| `servicePreference` | User didn't answer service step | `'either'` (= show all) |
| `themes` | User didn't select any themes | `[]` (= no filter) |
| `planningFor` | User on preplan path but didn't answer step 2 | `undefined` |
This means a user can: select intent → type location → click CTA. Everything
else defaults to "show all."
### Search Params Shape
```typescript
interface FuneralSearchParams {
intent: 'arrange' | 'preplan';
planningFor?: 'myself' | 'someone-else'; // Only on preplan path
funeralTypeId: string | null; // null = all types
servicePreference: 'with-service' | 'without-service' | 'either';
themes: string[]; // May be empty
location: string; // Trimmed, 3+ chars
}
```
## Conditional Logic Map
```
intent === 'preplan'
└─ Shows step 2 (planning-for)
typeSelection !== 'all' && selectedType.hasServiceOption === true
└─ Shows step 4 (service preference)
typeSelection !== null
└─ CompletedRow for type shows (with theme summary if any selected)
serviceAnswered && showServiceStep
└─ CompletedRow for service shows
themeOptions.length > 0
└─ Theme chips appear within step 3 (always, not gated by type selection)
loading === true
└─ CTA button shows spinner, button disabled
```
## Props Reference
| Prop | Type | Default | Notes |
|---|---|---|---|
| `funeralTypes` | `FuneralTypeOption[]` | required | Each has `id`, `label`, optional `description`, `note`, `hasServiceOption` |
| `themeOptions` | `ThemeOption[]` | `[]` | Each has `id`, `label`. Shown as optional chips in step 3 |
| `onSearch` | `(params: FuneralSearchParams) => void` | — | Called on valid submit |
| `loading` | `boolean` | `false` | Shows spinner on CTA, disables button |
| `heading` | `string` | `'Find funeral directors near you'` | Main h2 heading |
| `subheading` | `string` | `'Tell us a little about...'` | Below heading |
| `showExploreAll` | `boolean` | `true` | Show "Explore all options" TypeCard |
| `sx` | `SxProps<Theme>` | — | MUI sx override on root card |
## Sub-components (internal)
| Component | Purpose | Used in |
|---|---|---|
| `StepHeading` | Centered bodyLg heading with bottom margin | Steps 1-4 |
| `ChoiceCard` | Full-width radio card with label + description | Steps 1, 2 |
| `TypeCard` | Compact radio card with label + optional description/note | Step 3 |
| `CompletedRow` | Summary row: question + bold answer + "Change" link | All completed steps |
## Adding a New Step
1. Add state variable(s) for the new step's answer
2. Add a condition in `activeStep` calculation (between existing steps)
3. Add a `<Collapse in={activeStep === N}>` block in the render
4. Add a `<Collapse>` for the CompletedRow (with appropriate visibility condition)
5. Include the new data in `handleSubmit``onSearch()` params
6. Update `FuneralSearchParams` type
## Known Limitations (deferred)
- **No progress indicator** — users can't see how many steps remain
- **No roving tabindex** — radiogroups use button elements with `role="radio"` but
arrow-key navigation between options is not implemented
- **No location autocomplete** — free text input only, validated on length
- **CSS vars used directly** — some styling uses `var(--fa-*)` tokens instead of
MUI theme paths; works but doesn't support dynamic theme switching

View File

@@ -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>
),
],
};

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
export { ComparisonColumnCard, default } from './ComparisonColumnCard';
export type { ComparisonColumnCardProps } from './ComparisonColumnCard';

View File

@@ -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 }}

View File

@@ -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>
),
],
};

View 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;

View File

@@ -0,0 +1,2 @@
export { ComparisonTabCard, default } from './ComparisonTabCard';
export type { ComparisonTabCardProps } from './ComparisonTabCard';

View File

@@ -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' } },
],
},
],

View File

@@ -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>

View File

@@ -41,10 +41,6 @@ export interface FuneralFinderV3Props {
onSearch?: (params: FuneralFinderV3SearchParams) => void;
/** Shows loading state on the CTA */
loading?: boolean;
/** Optional heading override */
heading?: string;
/** Optional subheading override */
subheading?: string;
/** MUI sx override for the root container */
sx?: SxProps<Theme>;
}
@@ -251,13 +247,7 @@ const selectMenuProps = {
*/
export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3Props>(
(props, ref) => {
const {
onSearch,
loading = false,
heading = 'Find funeral directors near you',
subheading,
sx,
} = props;
const { onSearch, loading = false, sx } = props;
// ─── IDs for aria-labelledby ──────────────────────────────
const id = React.useId();
@@ -392,29 +382,6 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* ── Header ──────────────────────────────────────────── */}
<Box sx={{ textAlign: 'center' }}>
<Typography
variant="h3"
component="h2"
sx={{
fontFamily: 'var(--fa-font-family-display)',
fontWeight: 600,
fontSize: { xs: '1.25rem', sm: '1.5rem' },
mb: subheading ? 1 : 0,
}}
>
{heading}
</Typography>
{subheading && (
<Typography variant="body2" color="text.secondary">
{subheading}
</Typography>
)}
</Box>
<Divider />
{/* ── How can we help ─────────────────────────────────── */}
<Box ref={statusSectionRef}>
<SectionLabel id={statusLabelId}>How Can We Help</SectionLabel>
@@ -561,7 +528,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 +544,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 +585,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 }}
>

View File

@@ -143,3 +143,28 @@ export const ExtendedNavigation: Story = {
ctaLabel: 'Start planning',
},
};
// --- With Dropdown -----------------------------------------------------------
/** Items with `children` render as a dropdown on desktop and a collapsible
* section in the mobile drawer */
export const WithDropdown: Story = {
args: {
logo: <FALogo />,
items: [
{
label: 'Locations',
children: [
{ label: 'Melbourne', href: '/locations/melbourne' },
{ label: 'Brisbane', href: '/locations/brisbane' },
{ label: 'Sydney', href: '/locations/sydney' },
{ label: 'South Coast NSW', href: '/locations/south-coast-nsw' },
{ label: 'Central Coast NSW', href: '/locations/central-coast-nsw' },
],
},
{ label: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' },
{ label: 'Log in', href: '/login' },
],
},
};

View File

@@ -6,9 +6,14 @@ import Drawer from '@mui/material/Drawer';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Collapse from '@mui/material/Collapse';
import useMediaQuery from '@mui/material/useMediaQuery';
import MenuIcon from '@mui/icons-material/Menu';
import CloseIcon from '@mui/icons-material/Close';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import type { SxProps, Theme } from '@mui/material/styles';
import { IconButton } from '../../atoms/IconButton';
import { Link } from '../../atoms/Link';
@@ -18,14 +23,16 @@ import { Divider } from '../../atoms/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
/** A navigation link item */
/** A navigation link item. May have children to render as a dropdown. */
export interface NavItem {
/** Display label */
label: string;
/** URL to navigate to */
href: string;
/** URL to navigate to (ignored when `children` is provided) */
href?: string;
/** Click handler (alternative to href for SPA navigation) */
onClick?: () => void;
/** Sub-items rendered as a dropdown (desktop) or collapsible (mobile) */
children?: NavItem[];
}
/** Props for the FA Navigation organism */
@@ -44,6 +51,163 @@ export interface NavigationProps {
sx?: SxProps<Theme>;
}
// ─── Desktop dropdown link ───────────────────────────────────────────────────
interface DesktopDropdownProps {
item: NavItem;
}
const DesktopDropdown: React.FC<DesktopDropdownProps> = ({ item }) => {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleOpen = (event: React.MouseEvent<HTMLElement>) => setAnchorEl(event.currentTarget);
const handleClose = () => setAnchorEl(null);
return (
<>
<Box
component="button"
type="button"
aria-haspopup="menu"
aria-expanded={open}
onClick={handleOpen}
sx={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
color: 'var(--fa-color-brand-900)',
fontFamily: 'inherit',
fontWeight: 600,
fontSize: '1rem',
'&:hover': {
color: 'primary.main',
},
}}
>
{item.label}
</Box>
<Menu
anchorEl={anchorEl}
open={open}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
slotProps={{
paper: {
sx: {
mt: 1,
minWidth: 200,
borderRadius: 'var(--fa-border-radius-md, 8px)',
boxShadow: '0 8px 24px rgba(0,0,0,0.08)',
},
},
}}
>
{item.children?.map((child) => (
<MenuItem
key={child.label}
component="a"
href={child.href}
onClick={(e: React.MouseEvent) => {
if (child.onClick) {
e.preventDefault();
child.onClick();
}
handleClose();
}}
sx={{
color: 'var(--fa-color-brand-900)',
fontWeight: 500,
py: 1.25,
'&:hover': {
bgcolor: 'var(--fa-color-brand-100)',
color: 'primary.main',
},
}}
>
{child.label}
</MenuItem>
))}
</Menu>
</>
);
};
// ─── Mobile collapsible item ─────────────────────────────────────────────────
interface MobileCollapsibleProps {
item: NavItem;
onItemClick: () => void;
}
const MobileCollapsible: React.FC<MobileCollapsibleProps> = ({ item, onItemClick }) => {
const [open, setOpen] = React.useState(false);
return (
<>
<ListItemButton
onClick={() => setOpen((prev) => !prev)}
aria-expanded={open}
sx={{
py: 1.5,
px: 3,
minHeight: 44,
'&:hover': {
bgcolor: 'var(--fa-color-brand-100)',
},
}}
>
<ListItemText
primary={item.label}
primaryTypographyProps={{
fontWeight: 500,
fontSize: '1rem',
}}
/>
{open ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</ListItemButton>
<Collapse in={open} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{item.children?.map((child) => (
<ListItemButton
key={child.label}
component="a"
href={child.href}
onClick={(e: React.MouseEvent) => {
if (child.onClick) {
e.preventDefault();
child.onClick();
}
onItemClick();
}}
sx={{
py: 1.25,
pl: 5,
pr: 3,
minHeight: 44,
'&:hover': {
bgcolor: 'var(--fa-color-brand-100)',
},
}}
>
<ListItemText
primary={child.label}
primaryTypographyProps={{
fontWeight: 400,
fontSize: '0.9375rem',
color: 'text.secondary',
}}
/>
</ListItemButton>
))}
</List>
</Collapse>
</>
);
};
// ─── Component ───────────────────────────────────────────────────────────────
/**
@@ -51,26 +215,13 @@ export interface NavigationProps {
*
* Responsive header with logo, navigation links, and optional CTA.
* Desktop shows links inline; mobile collapses to hamburger + drawer.
* Items with `children` render as a dropdown (desktop) or collapsible
* section (mobile).
*
* Maps to Figma "Main Nav" (14:108) desktop and "Mobile Header"
* (2391:41508) mobile patterns.
*
* Composes AppBar + Link + IconButton + Button + Divider + Drawer.
*
* Usage:
* ```tsx
* <Navigation
* logo={<img src="/logo.svg" alt="Funeral Arranger" height={40} />}
* onLogoClick={() => navigate('/')}
* items={[
* { label: 'FAQ', href: '/faq' },
* { label: 'Contact Us', href: '/contact' },
* { label: 'Log in', href: '/login' },
* ]}
* ctaLabel="Start planning"
* onCtaClick={() => navigate('/arrange')}
* />
* ```
* Composes AppBar + Link + IconButton + Button + Divider + Drawer + Menu.
*/
export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
({ logo, onLogoClick, items = [], ctaLabel, onCtaClick, sx }, ref) => {
@@ -78,6 +229,7 @@ export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down('md'));
const handleDrawerToggle = () => setDrawerOpen((prev) => !prev);
const closeDrawer = () => setDrawerOpen(false);
return (
<>
@@ -147,24 +299,28 @@ export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
aria-label="Main navigation"
sx={{ display: 'flex', alignItems: 'center', gap: 3.5 }}
>
{items.map((item) => (
<Link
key={item.label}
href={item.href}
onClick={item.onClick}
underline="hover"
sx={{
color: 'var(--fa-color-brand-900)',
fontWeight: 600,
fontSize: '1rem',
'&:hover': {
color: 'primary.main',
},
}}
>
{item.label}
</Link>
))}
{items.map((item) =>
item.children && item.children.length > 0 ? (
<DesktopDropdown key={item.label} item={item} />
) : (
<Link
key={item.label}
href={item.href}
onClick={item.onClick}
underline="hover"
sx={{
color: 'var(--fa-color-brand-900)',
fontWeight: 600,
fontSize: '1rem',
'&:hover': {
color: 'primary.main',
},
}}
>
{item.label}
</Link>
),
)}
{ctaLabel && (
<Button variant="contained" size="medium" onClick={onCtaClick}>
@@ -210,36 +366,40 @@ export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
{/* Nav items */}
<List component="nav" aria-label="Main navigation">
{items.map((item) => (
<ListItemButton
key={item.label}
component="a"
href={item.href}
onClick={(e: React.MouseEvent) => {
if (item.onClick) {
e.preventDefault();
item.onClick();
}
setDrawerOpen(false);
}}
sx={{
py: 1.5,
px: 3,
minHeight: 44,
'&:hover': {
bgcolor: 'var(--fa-color-brand-100)',
},
}}
>
<ListItemText
primary={item.label}
primaryTypographyProps={{
fontWeight: 500,
fontSize: '1rem',
{items.map((item) =>
item.children && item.children.length > 0 ? (
<MobileCollapsible key={item.label} item={item} onItemClick={closeDrawer} />
) : (
<ListItemButton
key={item.label}
component="a"
href={item.href}
onClick={(e: React.MouseEvent) => {
if (item.onClick) {
e.preventDefault();
item.onClick();
}
closeDrawer();
}}
/>
</ListItemButton>
))}
sx={{
py: 1.5,
px: 3,
minHeight: 44,
'&:hover': {
bgcolor: 'var(--fa-color-brand-100)',
},
}}
>
<ListItemText
primary={item.label}
primaryTypographyProps={{
fontWeight: 500,
fontSize: '1rem',
}}
/>
</ListItemButton>
),
)}
</List>
{ctaLabel && (
@@ -250,7 +410,7 @@ export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
fullWidth
onClick={() => {
if (onCtaClick) onCtaClick();
setDrawerOpen(false);
closeDrawer();
}}
>
{ctaLabel}

View File

@@ -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' } },
],
},
],

View File

@@ -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 && (

View File

@@ -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' } },
],
},
],

View File

@@ -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 && (

View File

@@ -40,6 +40,16 @@ const nav = (
<Navigation
logo={<FALogo />}
items={[
{
label: 'Locations',
children: [
{ label: 'Melbourne', href: '/locations/melbourne' },
{ label: 'Brisbane', href: '/locations/brisbane' },
{ label: 'Sydney', href: '/locations/sydney' },
{ label: 'South Coast NSW', href: '/locations/south-coast-nsw' },
{ label: 'Central Coast NSW', href: '/locations/central-coast-nsw' },
],
},
{ label: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' },
{ label: 'Log in', href: '/login' },

View File

@@ -13,6 +13,7 @@ import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
import { assetUrl } from '../../../utils/assetUrl';
import { Divider } from '../../atoms/Divider';
import { FuneralFinderV3, type FuneralFinderV3SearchParams } from '../../organisms/FuneralFinder';
@@ -185,8 +186,8 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
discoverMapSlot,
onSelectFeaturedProvider,
features = [],
featuresHeading = 'How it works',
featuresBody = 'Search local funeral directors, compare transparent pricing, and personalise a plan — all in your own time. No pressure, no hidden costs.',
featuresHeading = '4 Reasons to use Funeral Arranger',
featuresBody,
googleRating,
googleReviewCount,
testimonials = [],
@@ -240,21 +241,32 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
}}
>
<Container
maxWidth="md"
maxWidth={false}
sx={{
position: 'relative',
zIndex: 1,
textAlign: 'center',
pt: { xs: 8, md: 11 },
pb: 4,
maxWidth: 990,
pt: { xs: 10, md: 14 },
pb: { xs: 3, md: 4 },
}}
>
<Typography
variant="display3"
variant="body1"
sx={{
color: 'rgba(255,255,255,0.85)',
fontStyle: 'italic',
mb: 2,
}}
>
Trusted by thousands of families across Australia
</Typography>
<Typography
variant="display2"
component="h1"
id="hero-heading"
tabIndex={-1}
sx={{ mb: 3, color: 'var(--fa-color-white)' }}
sx={{ mb: 5, color: 'var(--fa-color-white)' }}
>
{heroHeading}
</Typography>
@@ -272,20 +284,14 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
position: 'relative',
zIndex: 2,
width: '100%',
px: 2,
pt: 2,
px: { xs: 3, md: 2 },
pt: 6,
pb: 0,
mb: { xs: -14, md: -18 },
}}
>
<Box sx={{ width: '100%', maxWidth: finderSlot ? 500 : 520, mx: 'auto' }}>
{finderSlot || (
<FuneralFinderV3
heading="Find your local providers"
onSearch={onSearch}
loading={searchLoading}
/>
)}
{finderSlot || <FuneralFinderV3 onSearch={onSearch} loading={searchLoading} />}
</Box>
</Box>
</Box>
@@ -315,7 +321,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
}}
>
<Typography
variant="display3"
variant="display2"
component="h1"
id="hero-heading"
tabIndex={-1}
@@ -368,28 +374,115 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
}}
>
<Box sx={{ maxWidth: 620, mx: 'auto' }}>
{finderSlot || (
<FuneralFinderV3
heading="Find your local providers"
onSearch={onSearch}
loading={searchLoading}
/>
)}
{finderSlot || <FuneralFinderV3 onSearch={onSearch} loading={searchLoading} />}
</Box>
</Box>
)}
{/* ═══════════════════════════════════════════════════════════════════
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">
@@ -405,7 +498,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
<Typography
variant="body1"
color="text.secondary"
sx={{ maxWidth: 520, mx: 'auto' }}
sx={{ maxWidth: 520, mx: 'auto', fontSize: { xs: '0.875rem', md: '1rem' } }}
>
From trusted local providers to personalised options, find the right care near
you.
@@ -478,7 +571,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
{/* CTA */}
<Box sx={{ textAlign: 'center', mt: 4 }}>
<Button variant="text" size="medium" onClick={onCtaClick}>
Start exploring &rarr;
Start exploring
</Button>
</Box>
</Container>
@@ -486,93 +579,212 @@ 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 sx={{ textAlign: { xs: 'center', md: 'left' } }}>
<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"
sx={{ fontSize: { xs: '0.875rem', md: '1rem' } }}
>
Funeral planning doesn&rsquo;t have to be overwhelming. Whether a loved one has
just passed, is imminent, or you&rsquo;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={assetUrl('/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 &mdash; 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,26 +795,35 @@ 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">
<Box sx={{ textAlign: 'center', mb: { xs: 5, md: 8 } }}>
<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="features-heading"
sx={{ mb: 2.5, color: 'text.primary' }}
sx={{ mb: featuresBody ? 2.5 : 0, color: 'text.primary' }}
>
{featuresHeading}
</Typography>
<Typography
variant="body1"
color="text.secondary"
sx={{ maxWidth: 560, mx: 'auto' }}
>
{featuresBody}
</Typography>
{featuresBody && (
<Typography
variant="body1"
color="text.secondary"
sx={{ maxWidth: 560, mx: 'auto', fontSize: { xs: '0.875rem', md: '1rem' } }}
>
{featuresBody}
</Typography>
)}
</Box>
<Box
@@ -648,11 +869,22 @@ 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">
<Typography
variant="overline"
component="div"
sx={{
textAlign: 'center',
color: 'var(--fa-color-brand-600)',
mb: 1.5,
}}
>
Funeral Arranger Reviews
</Typography>
<Typography
variant="display3"
component="h2"
@@ -683,26 +915,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 +985,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' }}>
@@ -762,7 +997,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
>
{ctaHeading}
</Typography>
<Button variant="text" size="large" onClick={onCtaClick}>
<Button variant="contained" size="medium" onClick={onCtaClick}>
{ctaButtonLabel}
</Button>
</Container>
@@ -777,17 +1012,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' }}>
@@ -808,7 +1043,13 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
}}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0, py: 1.5 }}>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
<Typography
variant="body1"
sx={{
fontWeight: 500,
fontSize: { xs: '0.875rem', md: '1rem' },
}}
>
{item.question}
</Typography>
</AccordionSummary>
@@ -823,6 +1064,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>

View File

@@ -8,6 +8,7 @@ import { HomePage } from './HomePage';
import type { FeaturedProvider, TrustStat } from './HomePage';
import { Navigation } from '../../organisms/Navigation';
import { Footer } from '../../organisms/Footer';
import { assetUrl } from '../../../utils/assetUrl';
// ─── Shared helpers ──────────────────────────────────────────────────────────
@@ -41,6 +42,16 @@ const nav = (
<Navigation
logo={<FALogo />}
items={[
{
label: 'Locations',
children: [
{ label: 'Melbourne', href: '/locations/melbourne' },
{ label: 'Brisbane', href: '/locations/brisbane' },
{ label: 'Sydney', href: '/locations/sydney' },
{ label: 'South Coast NSW', href: '/locations/south-coast-nsw' },
{ label: 'Central Coast NSW', href: '/locations/central-coast-nsw' },
],
},
{ label: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' },
{ label: 'Log in', href: '/login' },
@@ -231,7 +242,7 @@ export const Default: Story = {
args: {
navigation: nav,
footer,
heroImageUrl: '/brandassets/images/heroes/parsonshero.png',
heroImageUrl: assetUrl('/images/heroes/parsonshero.png'),
stats: trustStats,
featuredProviders,
onSelectFeaturedProvider: (id) => console.log('Featured provider:', id),

View File

@@ -9,6 +9,7 @@ import type { FeaturedProvider, TrustStat, PartnerLogo } from './HomePage';
import React from 'react';
import { Navigation } from '../../organisms/Navigation';
import { Footer } from '../../organisms/Footer';
import { assetUrl } from '../../../utils/assetUrl';
// ─── Shared helpers ──────────────────────────────────────────────────────────
@@ -37,6 +38,16 @@ const nav = (
<Navigation
logo={<FALogo />}
items={[
{
label: 'Locations',
children: [
{ label: 'Melbourne', href: '/locations/melbourne' },
{ label: 'Brisbane', href: '/locations/brisbane' },
{ label: 'Sydney', href: '/locations/sydney' },
{ label: 'South Coast NSW', href: '/locations/south-coast-nsw' },
{ label: 'Central Coast NSW', href: '/locations/central-coast-nsw' },
],
},
{ label: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' },
{ label: 'Log in', href: '/login' },
@@ -177,8 +188,8 @@ const featuredProviders: FeaturedProvider[] = [
name: 'H.Parsons Funeral Directors',
location: 'Wollongong, NSW',
verified: true,
imageUrl: '/brandassets/images/venues/hparsons-funeral-home-kiama/01.jpg',
logoUrl: '/brandassets/images/providers/hparsons-funeral-directors/logo.png',
imageUrl: assetUrl('/images/venues/hparsons-funeral-home-kiama/01.jpg'),
logoUrl: assetUrl('/images/providers/hparsons-funeral-directors/logo.png'),
rating: 4.6,
reviewCount: 7,
startingPrice: 900,
@@ -188,8 +199,8 @@ const featuredProviders: FeaturedProvider[] = [
name: 'Rankins Funerals',
location: 'Wollongong, NSW',
verified: true,
imageUrl: '/brandassets/images/venues/rankins-funeral-home-warrawong/01.jpg',
logoUrl: '/brandassets/images/providers/rankins-funerals/logo.png',
imageUrl: assetUrl('/images/venues/rankins-funeral-home-warrawong/01.jpg'),
logoUrl: assetUrl('/images/providers/rankins-funerals/logo.png'),
rating: 4.8,
reviewCount: 23,
startingPrice: 1200,
@@ -199,8 +210,8 @@ const featuredProviders: FeaturedProvider[] = [
name: 'Easy Funerals',
location: 'Sydney, NSW',
verified: true,
imageUrl: '/brandassets/images/venues/lakeside-memorial-park-chapel/01.jpg',
logoUrl: '/brandassets/images/providers/easy-funerals/logo.png',
imageUrl: assetUrl('/images/venues/lakeside-memorial-park-chapel/01.jpg'),
logoUrl: assetUrl('/images/providers/easy-funerals/logo.png'),
rating: 4.5,
reviewCount: 42,
startingPrice: 850,
@@ -209,30 +220,30 @@ const featuredProviders: FeaturedProvider[] = [
const partnerLogos: PartnerLogo[] = [
{
src: '/brandassets/images/providers/hparsons-funeral-directors/logo.png',
src: assetUrl('/images/providers/hparsons-funeral-directors/logo.png'),
alt: 'H.Parsons Funeral Directors',
},
{ src: '/brandassets/images/providers/rankins-funerals/logo.png', alt: 'Rankins Funerals' },
{ src: '/brandassets/images/providers/easy-funerals/logo.png', alt: 'Easy Funerals' },
{ src: '/brandassets/images/providers/lady-anne-funerals/logo.png', alt: 'Lady Anne Funerals' },
{ src: assetUrl('/images/providers/rankins-funerals/logo.png'), alt: 'Rankins Funerals' },
{ src: assetUrl('/images/providers/easy-funerals/logo.png'), alt: 'Easy Funerals' },
{ src: assetUrl('/images/providers/lady-anne-funerals/logo.png'), alt: 'Lady Anne Funerals' },
{
src: '/brandassets/images/providers/killick-family-funerals/logo.png',
src: assetUrl('/images/providers/killick-family-funerals/logo.png'),
alt: 'Killick Family Funerals',
},
{
src: '/brandassets/images/providers/kenneallys-funerals/logo.png',
src: assetUrl('/images/providers/kenneallys-funerals/logo.png'),
alt: "Kenneally's Funerals",
},
{
src: '/brandassets/images/providers/wollongong-city-funerals/logo.png',
src: assetUrl('/images/providers/wollongong-city-funerals/logo.png'),
alt: 'Wollongong City Funerals',
},
{
src: '/brandassets/images/providers/hparsons-funeral-directors-shoalhaven/logo.png',
src: assetUrl('/images/providers/hparsons-funeral-directors-shoalhaven/logo.png'),
alt: 'H.Parsons Shoalhaven',
},
{
src: '/brandassets/images/providers/mackay-family-funerals/logo.webp',
src: assetUrl('/images/providers/mackay-family-funerals/logo.webp'),
alt: 'Mackay Family Funerals',
},
];
@@ -240,7 +251,7 @@ const partnerLogos: PartnerLogo[] = [
// ─── Meta ────────────────────────────────────────────────────────────────────
const meta: Meta<typeof HomePage> = {
title: 'Archive/HomePage V3',
title: 'Pages/HomePage',
component: HomePage,
parameters: {
layout: 'fullscreen',
@@ -257,19 +268,19 @@ export const Default: Story = {
args: {
navigation: nav,
footer,
heroImageUrl: '/brandassets/images/heroes/hero-3.png',
heroHeading: 'Compare funeral directors pricing near you and arrange with confidence',
heroImageUrl: assetUrl('/images/heroes/hero-couple.jpg'),
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,
discoverMapSlot: React.createElement('img', {
src: '/brandassets/images/placeholder/map.png',
src: assetUrl('/images/placeholder/map.png'),
alt: 'Map showing provider locations',
style: { width: '100%', height: '100%', objectFit: 'cover' },
}),
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,

View File

@@ -10,6 +10,7 @@ import { FuneralFinderV4 } from '../../organisms/FuneralFinder/FuneralFinderV4';
import React from 'react';
import { Navigation } from '../../organisms/Navigation';
import { Footer } from '../../organisms/Footer';
import { assetUrl } from '../../../utils/assetUrl';
// ─── Shared helpers ──────────────────────────────────────────────────────────
@@ -38,6 +39,16 @@ const nav = (
<Navigation
logo={<FALogo />}
items={[
{
label: 'Locations',
children: [
{ label: 'Melbourne', href: '/locations/melbourne' },
{ label: 'Brisbane', href: '/locations/brisbane' },
{ label: 'Sydney', href: '/locations/sydney' },
{ label: 'South Coast NSW', href: '/locations/south-coast-nsw' },
{ label: 'Central Coast NSW', href: '/locations/central-coast-nsw' },
],
},
{ label: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' },
{ label: 'Log in', href: '/login' },
@@ -178,8 +189,8 @@ const featuredProviders: FeaturedProvider[] = [
name: 'H.Parsons Funeral Directors',
location: 'Wollongong, NSW',
verified: true,
imageUrl: '/brandassets/images/venues/hparsons-funeral-home-kiama/01.jpg',
logoUrl: '/brandassets/images/providers/hparsons-funeral-directors/logo.png',
imageUrl: assetUrl('/images/venues/hparsons-funeral-home-kiama/01.jpg'),
logoUrl: assetUrl('/images/providers/hparsons-funeral-directors/logo.png'),
rating: 4.6,
reviewCount: 7,
startingPrice: 900,
@@ -189,8 +200,8 @@ const featuredProviders: FeaturedProvider[] = [
name: 'Rankins Funerals',
location: 'Wollongong, NSW',
verified: true,
imageUrl: '/brandassets/images/venues/rankins-funeral-home-warrawong/01.jpg',
logoUrl: '/brandassets/images/providers/rankins-funerals/logo.png',
imageUrl: assetUrl('/images/venues/rankins-funeral-home-warrawong/01.jpg'),
logoUrl: assetUrl('/images/providers/rankins-funerals/logo.png'),
rating: 4.8,
reviewCount: 23,
startingPrice: 1200,
@@ -200,8 +211,8 @@ const featuredProviders: FeaturedProvider[] = [
name: 'Easy Funerals',
location: 'Sydney, NSW',
verified: true,
imageUrl: '/brandassets/images/venues/lakeside-memorial-park-chapel/01.jpg',
logoUrl: '/brandassets/images/providers/easy-funerals/logo.png',
imageUrl: assetUrl('/images/venues/lakeside-memorial-park-chapel/01.jpg'),
logoUrl: assetUrl('/images/providers/easy-funerals/logo.png'),
rating: 4.5,
reviewCount: 42,
startingPrice: 850,
@@ -210,30 +221,30 @@ const featuredProviders: FeaturedProvider[] = [
const partnerLogos: PartnerLogo[] = [
{
src: '/brandassets/images/providers/hparsons-funeral-directors/logo.png',
src: assetUrl('/images/providers/hparsons-funeral-directors/logo.png'),
alt: 'H.Parsons Funeral Directors',
},
{ src: '/brandassets/images/providers/rankins-funerals/logo.png', alt: 'Rankins Funerals' },
{ src: '/brandassets/images/providers/easy-funerals/logo.png', alt: 'Easy Funerals' },
{ src: '/brandassets/images/providers/lady-anne-funerals/logo.png', alt: 'Lady Anne Funerals' },
{ src: assetUrl('/images/providers/rankins-funerals/logo.png'), alt: 'Rankins Funerals' },
{ src: assetUrl('/images/providers/easy-funerals/logo.png'), alt: 'Easy Funerals' },
{ src: assetUrl('/images/providers/lady-anne-funerals/logo.png'), alt: 'Lady Anne Funerals' },
{
src: '/brandassets/images/providers/killick-family-funerals/logo.png',
src: assetUrl('/images/providers/killick-family-funerals/logo.png'),
alt: 'Killick Family Funerals',
},
{
src: '/brandassets/images/providers/kenneallys-funerals/logo.png',
src: assetUrl('/images/providers/kenneallys-funerals/logo.png'),
alt: "Kenneally's Funerals",
},
{
src: '/brandassets/images/providers/wollongong-city-funerals/logo.png',
src: assetUrl('/images/providers/wollongong-city-funerals/logo.png'),
alt: 'Wollongong City Funerals',
},
{
src: '/brandassets/images/providers/hparsons-funeral-directors-shoalhaven/logo.png',
src: assetUrl('/images/providers/hparsons-funeral-directors-shoalhaven/logo.png'),
alt: 'H.Parsons Shoalhaven',
},
{
src: '/brandassets/images/providers/mackay-family-funerals/logo.webp',
src: assetUrl('/images/providers/mackay-family-funerals/logo.webp'),
alt: 'Mackay Family Funerals',
},
];
@@ -258,7 +269,7 @@ export const Default: Story = {
args: {
navigation: nav,
footer,
heroImageUrl: '/brandassets/images/heroes/hero-3.png',
heroImageUrl: assetUrl('/images/heroes/hero-3.png'),
heroHeading: 'Compare funeral directors pricing near you and arrange with confidence',
heroSubheading: 'Transparent pricing \u00B7 No hidden fees \u00B7 Arrange 24/7',
finderSlot: React.createElement(FuneralFinderV4, {
@@ -267,7 +278,7 @@ export const Default: Story = {
stats: trustStats,
featuredProviders,
discoverMapSlot: React.createElement('img', {
src: '/brandassets/images/placeholder/map.png',
src: assetUrl('/images/placeholder/map.png'),
alt: 'Map showing provider locations',
style: { width: '100%', height: '100%', objectFit: 'cover' },
}),

10
src/utils/assetUrl.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* Resolves a static asset path. In local dev the path is served by Storybook's
* staticDirs; when STORYBOOK_ASSET_BASE is set (e.g. Chromatic builds) it
* prepends the external host URL so images load from Gitea.
*/
export const assetUrl = (path: string): string => {
const base =
typeof import.meta !== 'undefined' ? (import.meta.env?.STORYBOOK_ASSET_BASE ?? '') : '';
return `${base}${path}`;
};