Compare commits
1 Commits
main
...
593cd82122
| Author | SHA1 | Date | |
|---|---|---|---|
| 593cd82122 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -28,9 +28,6 @@ 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
|
||||
|
||||
|
||||
154
docs/reference/component-lifecycle.md
Normal file
154
docs/reference/component-lifecycle.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# 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.
|
||||
203
docs/reference/funeral-finder-logic.md
Normal file
203
docs/reference/funeral-finder-logic.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# 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
|
||||
@@ -1,159 +0,0 @@
|
||||
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>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -1,205 +0,0 @@
|
||||
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;
|
||||
@@ -1,2 +0,0 @@
|
||||
export { ComparisonColumnCard, default } from './ComparisonColumnCard';
|
||||
export type { ComparisonColumnCardProps } from './ComparisonColumnCard';
|
||||
@@ -81,18 +81,9 @@ function CellValue({ value }: { value: ComparisonCellValue }) {
|
||||
);
|
||||
case 'unknown':
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, justifyContent: 'flex-end' }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: 'var(--fa-color-neutral-500)', fontWeight: 500 }}
|
||||
>
|
||||
<Badge color="default" variant="soft" size="small">
|
||||
Unknown
|
||||
</Typography>
|
||||
<InfoOutlinedIcon
|
||||
sx={{ fontSize: 14, color: 'var(--fa-color-neutral-500)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
</Box>
|
||||
</Badge>
|
||||
);
|
||||
case 'unavailable':
|
||||
return (
|
||||
@@ -127,13 +118,7 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
|
||||
variant="outlined"
|
||||
selected={pkg.isRecommended}
|
||||
padding="none"
|
||||
sx={[
|
||||
{
|
||||
overflow: 'hidden',
|
||||
boxShadow: 'var(--fa-shadow-sm)',
|
||||
},
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
sx={[{ overflow: 'hidden' }, ...(Array.isArray(sx) ? sx : [sx])]}
|
||||
>
|
||||
{/* Recommended banner */}
|
||||
{pkg.isRecommended && (
|
||||
@@ -219,7 +204,7 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
|
||||
<Button
|
||||
variant={pkg.provider.verified ? 'contained' : 'soft'}
|
||||
color={pkg.provider.verified ? 'primary' : 'secondary'}
|
||||
size="medium"
|
||||
size="large"
|
||||
fullWidth
|
||||
onClick={() => onArrange(pkg.id)}
|
||||
sx={{ mt: 2 }}
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
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>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -1,154 +0,0 @@
|
||||
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;
|
||||
@@ -1,2 +0,0 @@
|
||||
export { ComparisonTabCard, default } from './ComparisonTabCard';
|
||||
export type { ComparisonTabCardProps } from './ComparisonTabCard';
|
||||
@@ -218,33 +218,50 @@ const pkgInglewood: ComparisonPackage = {
|
||||
{
|
||||
heading: 'Essentials',
|
||||
items: [
|
||||
{ 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' } },
|
||||
{
|
||||
name: 'Allowance for Coffin',
|
||||
info: 'Allowance amount — upgrade options available.',
|
||||
value: { type: 'allowance', amount: 1800 },
|
||||
},
|
||||
{
|
||||
name: 'Cremation Certificate/Permit',
|
||||
info: 'Statutory medical referee fee.',
|
||||
value: { type: 'price', amount: 350 },
|
||||
},
|
||||
{
|
||||
name: 'Death Registration Certificate',
|
||||
info: 'Lodgement with NSW Registry.',
|
||||
value: { type: 'price', amount: 70 },
|
||||
},
|
||||
{
|
||||
name: 'Professional Service Fee',
|
||||
info: 'Coordination of arrangements.',
|
||||
value: { type: 'price', amount: 3980 },
|
||||
},
|
||||
{
|
||||
name: 'Transportation Service Fee',
|
||||
info: 'Transfer of the deceased.',
|
||||
value: { type: 'price', amount: 500 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Optionals',
|
||||
items: [
|
||||
{ name: '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' } },
|
||||
{ name: 'Viewing Fee', info: 'One private family viewing.', value: { type: 'included' } },
|
||||
{ name: 'Flowers', info: 'Seasonal floral arrangements.', value: { type: 'poa' } },
|
||||
{
|
||||
name: 'Digital Recording of the Funeral Service',
|
||||
info: 'Professional video recording.',
|
||||
value: { type: 'price', amount: 250 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Extras',
|
||||
items: [
|
||||
{ name: 'Allowance for Celebrant', value: { type: 'unknown' } },
|
||||
{ name: 'Catering', value: { type: 'unknown' } },
|
||||
{ name: 'Newspaper Notice', value: { type: 'unknown' } },
|
||||
{ name: 'Saturday Service Fee', value: { type: 'unknown' } },
|
||||
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
|
||||
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -3,10 +3,15 @@ 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 { ComparisonColumnCard } from '../../molecules/ComparisonColumnCard';
|
||||
import { Link } from '../../atoms/Link';
|
||||
import { Divider } from '../../atoms/Divider';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -115,18 +120,9 @@ function CellValue({ value }: { value: ComparisonCellValue }) {
|
||||
);
|
||||
case 'unknown':
|
||||
return (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: 'var(--fa-color-neutral-500)', fontWeight: 500 }}
|
||||
>
|
||||
<Badge color="default" variant="soft" size="small">
|
||||
Unknown
|
||||
</Typography>
|
||||
<InfoOutlinedIcon
|
||||
sx={{ fontSize: 14, color: 'var(--fa-color-neutral-500)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
</Box>
|
||||
</Badge>
|
||||
);
|
||||
case 'unavailable':
|
||||
return (
|
||||
@@ -277,14 +273,157 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
|
||||
</Typography>
|
||||
</Card>
|
||||
|
||||
{/* Package column header cards */}
|
||||
{/* Package cards */}
|
||||
{packages.map((pkg) => (
|
||||
<ComparisonColumnCard
|
||||
<Box
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
onArrange={onArrange}
|
||||
onRemove={onRemove}
|
||||
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>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -310,18 +449,19 @@ 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" component="span">
|
||||
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 0 }}>
|
||||
{item.name}
|
||||
</Typography>
|
||||
{item.info && (
|
||||
<Box component="span" sx={{ whiteSpace: 'nowrap' }}>
|
||||
{'\u00A0'}
|
||||
<Tooltip title={item.info} arrow placement="top">
|
||||
<InfoOutlinedIcon
|
||||
aria-label={`More information about ${item.name}`}
|
||||
@@ -329,11 +469,10 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
|
||||
fontSize: 14,
|
||||
color: 'var(--fa-color-neutral-400)',
|
||||
cursor: 'help',
|
||||
verticalAlign: 'middle',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -41,6 +41,10 @@ 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>;
|
||||
}
|
||||
@@ -247,7 +251,13 @@ const selectMenuProps = {
|
||||
*/
|
||||
export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3Props>(
|
||||
(props, ref) => {
|
||||
const { onSearch, loading = false, sx } = props;
|
||||
const {
|
||||
onSearch,
|
||||
loading = false,
|
||||
heading = 'Find funeral directors near you',
|
||||
subheading,
|
||||
sx,
|
||||
} = props;
|
||||
|
||||
// ─── IDs for aria-labelledby ──────────────────────────────
|
||||
const id = React.useId();
|
||||
@@ -382,6 +392,29 @@ 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>
|
||||
@@ -528,7 +561,7 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
|
||||
placeholder="Enter suburb or postcode"
|
||||
inputRef={locationInputRef}
|
||||
startAdornment={
|
||||
<InputAdornment position="start" sx={{ ml: 0.25, mr: -0.5 }}>
|
||||
<InputAdornment position="start" sx={{ ml: 0.5 }}>
|
||||
<LocationOnOutlinedIcon
|
||||
sx={{
|
||||
fontSize: 20,
|
||||
@@ -544,7 +577,6 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
|
||||
...fieldBaseSx,
|
||||
'& .MuiOutlinedInput-input': {
|
||||
...fieldInputStyles,
|
||||
pl: 0.75,
|
||||
'&::placeholder': {
|
||||
color: 'var(--fa-color-text-disabled)',
|
||||
opacity: 1,
|
||||
@@ -585,12 +617,12 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
|
||||
loading={loading}
|
||||
endIcon={!loading ? <ArrowForwardIcon /> : undefined}
|
||||
onClick={handleSubmit}
|
||||
sx={{ minHeight: { xs: 40, sm: 52 }, fontSize: { xs: '0.875rem', sm: undefined } }}
|
||||
sx={{ minHeight: 52 }}
|
||||
>
|
||||
Search
|
||||
Search Local Providers
|
||||
</Button>
|
||||
<Typography
|
||||
variant="caption"
|
||||
variant="captionSm"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: 'center', display: 'block', mt: 1.5 }}
|
||||
>
|
||||
|
||||
@@ -143,28 +143,3 @@ 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' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,14 +6,9 @@ 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';
|
||||
@@ -23,16 +18,14 @@ import { Divider } from '../../atoms/Divider';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A navigation link item. May have children to render as a dropdown. */
|
||||
/** A navigation link item */
|
||||
export interface NavItem {
|
||||
/** Display label */
|
||||
label: string;
|
||||
/** URL to navigate to (ignored when `children` is provided) */
|
||||
href?: string;
|
||||
/** URL to navigate to */
|
||||
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 */
|
||||
@@ -51,163 +44,6 @@ 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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -215,13 +51,26 @@ const MobileCollapsible: React.FC<MobileCollapsibleProps> = ({ item, onItemClick
|
||||
*
|
||||
* 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 + Menu.
|
||||
* 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')}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
|
||||
({ logo, onLogoClick, items = [], ctaLabel, onCtaClick, sx }, ref) => {
|
||||
@@ -229,7 +78,6 @@ 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 (
|
||||
<>
|
||||
@@ -299,10 +147,7 @@ export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
|
||||
aria-label="Main navigation"
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 3.5 }}
|
||||
>
|
||||
{items.map((item) =>
|
||||
item.children && item.children.length > 0 ? (
|
||||
<DesktopDropdown key={item.label} item={item} />
|
||||
) : (
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
@@ -319,8 +164,7 @@ export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
),
|
||||
)}
|
||||
))}
|
||||
|
||||
{ctaLabel && (
|
||||
<Button variant="contained" size="medium" onClick={onCtaClick}>
|
||||
@@ -366,10 +210,7 @@ export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
|
||||
|
||||
{/* Nav items */}
|
||||
<List component="nav" aria-label="Main navigation">
|
||||
{items.map((item) =>
|
||||
item.children && item.children.length > 0 ? (
|
||||
<MobileCollapsible key={item.label} item={item} onItemClick={closeDrawer} />
|
||||
) : (
|
||||
{items.map((item) => (
|
||||
<ListItemButton
|
||||
key={item.label}
|
||||
component="a"
|
||||
@@ -379,7 +220,7 @@ export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
|
||||
e.preventDefault();
|
||||
item.onClick();
|
||||
}
|
||||
closeDrawer();
|
||||
setDrawerOpen(false);
|
||||
}}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
@@ -398,8 +239,7 @@ export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
),
|
||||
)}
|
||||
))}
|
||||
</List>
|
||||
|
||||
{ctaLabel && (
|
||||
@@ -410,7 +250,7 @@ export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
if (onCtaClick) onCtaClick();
|
||||
closeDrawer();
|
||||
setDrawerOpen(false);
|
||||
}}
|
||||
>
|
||||
{ctaLabel}
|
||||
|
||||
@@ -216,33 +216,50 @@ const pkgInglewood: ComparisonPackage = {
|
||||
{
|
||||
heading: 'Essentials',
|
||||
items: [
|
||||
{ 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' } },
|
||||
{
|
||||
name: 'Allowance for Coffin',
|
||||
info: 'Allowance amount.',
|
||||
value: { type: 'allowance', amount: 1800 },
|
||||
},
|
||||
{
|
||||
name: 'Cremation Certificate/Permit',
|
||||
info: 'Medical referee fee.',
|
||||
value: { type: 'price', amount: 350 },
|
||||
},
|
||||
{
|
||||
name: 'Death Registration Certificate',
|
||||
info: 'NSW Registry.',
|
||||
value: { type: 'price', amount: 70 },
|
||||
},
|
||||
{
|
||||
name: 'Professional Service Fee',
|
||||
info: 'Coordination.',
|
||||
value: { type: 'price', amount: 3980 },
|
||||
},
|
||||
{
|
||||
name: 'Transportation Service Fee',
|
||||
info: 'Transfer.',
|
||||
value: { type: 'price', amount: 500 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Optionals',
|
||||
items: [
|
||||
{ name: 'Digital Recording', value: { type: 'unknown' } },
|
||||
{ name: 'Flowers', value: { type: 'unknown' } },
|
||||
{ name: 'Online Notice', value: { type: 'unknown' } },
|
||||
{ name: 'Viewing Fee', value: { type: 'unknown' } },
|
||||
{ name: 'Viewing Fee', info: 'Private family viewing.', value: { type: 'included' } },
|
||||
{ name: 'Flowers', info: 'Floral arrangements.', value: { type: 'poa' } },
|
||||
{
|
||||
name: 'Digital Recording',
|
||||
info: 'Video recording.',
|
||||
value: { type: 'price', amount: 250 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Extras',
|
||||
items: [
|
||||
{ name: 'Allowance for Celebrant', value: { type: 'unknown' } },
|
||||
{ name: 'Catering', value: { type: 'unknown' } },
|
||||
{ name: 'Newspaper Notice', value: { type: 'unknown' } },
|
||||
{ name: 'Saturday Service Fee', value: { type: 'unknown' } },
|
||||
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
|
||||
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import React, { useId, useState, useRef, useCallback } from 'react';
|
||||
import React, { useId, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import 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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -61,8 +62,6 @@ 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[] = [];
|
||||
@@ -85,34 +84,6 @@ 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
|
||||
@@ -180,9 +151,8 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
|
||||
{/* Mobile: Tab rail + card view */}
|
||||
{isMobile && allPackages.length > 0 && (
|
||||
<>
|
||||
{/* Tab rail — mini cards showing provider + package + price */}
|
||||
{/* Tab rail — mini cards showing provider + package name */}
|
||||
<Box
|
||||
ref={railRef}
|
||||
role="tablist"
|
||||
id={tablistId}
|
||||
aria-label="Packages to compare"
|
||||
@@ -190,30 +160,86 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
|
||||
display: 'flex',
|
||||
gap: 1.5,
|
||||
overflowX: 'auto',
|
||||
py: 2,
|
||||
px: 2,
|
||||
mx: -2,
|
||||
mt: 1,
|
||||
mb: 3,
|
||||
pb: 1,
|
||||
mb: 2.5,
|
||||
scrollbarWidth: 'none',
|
||||
'&::-webkit-scrollbar': { display: 'none' },
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
{allPackages.map((pkg, idx) => (
|
||||
<ComparisonTabCard
|
||||
{allPackages.map((pkg, idx) => {
|
||||
const isActive = idx === activeTabIdx;
|
||||
return (
|
||||
<Card
|
||||
key={pkg.id}
|
||||
ref={(el: HTMLDivElement | null) => {
|
||||
tabRefs.current[idx] = el;
|
||||
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,
|
||||
}}
|
||||
pkg={pkg}
|
||||
isActive={idx === activeTabIdx}
|
||||
hasRecommended={hasRecommended}
|
||||
tabId={`comparison-tab-${idx}`}
|
||||
tabPanelId={`comparison-tabpanel-${idx}`}
|
||||
onClick={() => handleTabClick(idx)}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{activePackage && (
|
||||
|
||||
@@ -216,33 +216,50 @@ const pkgInglewood: ComparisonPackage = {
|
||||
{
|
||||
heading: 'Essentials',
|
||||
items: [
|
||||
{ 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' } },
|
||||
{
|
||||
name: 'Allowance for Coffin',
|
||||
info: 'Allowance amount.',
|
||||
value: { type: 'allowance', amount: 1800 },
|
||||
},
|
||||
{
|
||||
name: 'Cremation Certificate/Permit',
|
||||
info: 'Medical referee fee.',
|
||||
value: { type: 'price', amount: 350 },
|
||||
},
|
||||
{
|
||||
name: 'Death Registration Certificate',
|
||||
info: 'NSW Registry.',
|
||||
value: { type: 'price', amount: 70 },
|
||||
},
|
||||
{
|
||||
name: 'Professional Service Fee',
|
||||
info: 'Coordination.',
|
||||
value: { type: 'price', amount: 3980 },
|
||||
},
|
||||
{
|
||||
name: 'Transportation Service Fee',
|
||||
info: 'Transfer.',
|
||||
value: { type: 'price', amount: 500 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Optionals',
|
||||
items: [
|
||||
{ name: 'Digital Recording', value: { type: 'unknown' } },
|
||||
{ name: 'Flowers', value: { type: 'unknown' } },
|
||||
{ name: 'Online Notice', value: { type: 'unknown' } },
|
||||
{ name: 'Viewing Fee', value: { type: 'unknown' } },
|
||||
{ name: 'Viewing Fee', info: 'Private family viewing.', value: { type: 'included' } },
|
||||
{ name: 'Flowers', info: 'Floral arrangements.', value: { type: 'poa' } },
|
||||
{
|
||||
name: 'Digital Recording',
|
||||
info: 'Video recording.',
|
||||
value: { type: 'price', amount: 250 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Extras',
|
||||
items: [
|
||||
{ name: 'Allowance for Celebrant', value: { type: 'unknown' } },
|
||||
{ name: 'Catering', value: { type: 'unknown' } },
|
||||
{ name: 'Newspaper Notice', value: { type: 'unknown' } },
|
||||
{ name: 'Saturday Service Fee', value: { type: 'unknown' } },
|
||||
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
|
||||
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import React, { useId, useState, useRef, useCallback } from 'react';
|
||||
import React, { useId, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import 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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -59,8 +60,6 @@ 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];
|
||||
@@ -79,33 +78,6 @@ 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
|
||||
@@ -173,9 +145,8 @@ export const ComparisonPageV1 = React.forwardRef<HTMLDivElement, ComparisonPageV
|
||||
{/* Mobile: Tab rail + card view */}
|
||||
{isMobile && allPackages.length > 0 && (
|
||||
<>
|
||||
{/* Tab rail — mini cards showing provider + package + price */}
|
||||
{/* Tab rail — mini cards showing provider + package name */}
|
||||
<Box
|
||||
ref={railRef}
|
||||
role="tablist"
|
||||
id={tablistId}
|
||||
aria-label="Packages to compare"
|
||||
@@ -183,30 +154,86 @@ export const ComparisonPageV1 = React.forwardRef<HTMLDivElement, ComparisonPageV
|
||||
display: 'flex',
|
||||
gap: 1.5,
|
||||
overflowX: 'auto',
|
||||
py: 2,
|
||||
px: 2,
|
||||
mx: -2,
|
||||
mt: 1,
|
||||
mb: 3,
|
||||
pb: 1,
|
||||
mb: 2.5,
|
||||
scrollbarWidth: 'none',
|
||||
'&::-webkit-scrollbar': { display: 'none' },
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
{allPackages.map((pkg, idx) => (
|
||||
<ComparisonTabCard
|
||||
{allPackages.map((pkg, idx) => {
|
||||
const isActive = idx === activeTabIdx;
|
||||
return (
|
||||
<Card
|
||||
key={pkg.id}
|
||||
ref={(el: HTMLDivElement | null) => {
|
||||
tabRefs.current[idx] = el;
|
||||
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,
|
||||
}}
|
||||
pkg={pkg}
|
||||
isActive={idx === activeTabIdx}
|
||||
hasRecommended={hasRecommended}
|
||||
tabId={`comparison-tab-${idx}`}
|
||||
tabPanelId={`comparison-tabpanel-${idx}`}
|
||||
onClick={() => handleTabClick(idx)}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{activePackage && (
|
||||
|
||||
@@ -40,16 +40,6 @@ 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' },
|
||||
|
||||
@@ -13,7 +13,6 @@ 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';
|
||||
|
||||
@@ -186,8 +185,8 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
discoverMapSlot,
|
||||
onSelectFeaturedProvider,
|
||||
features = [],
|
||||
featuresHeading = '4 Reasons to use Funeral Arranger',
|
||||
featuresBody,
|
||||
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.',
|
||||
googleRating,
|
||||
googleReviewCount,
|
||||
testimonials = [],
|
||||
@@ -241,32 +240,21 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
}}
|
||||
>
|
||||
<Container
|
||||
maxWidth={false}
|
||||
maxWidth="md"
|
||||
sx={{
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
textAlign: 'center',
|
||||
maxWidth: 990,
|
||||
pt: { xs: 10, md: 14 },
|
||||
pb: { xs: 3, md: 4 },
|
||||
pt: { xs: 8, md: 11 },
|
||||
pb: 4,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
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"
|
||||
variant="display3"
|
||||
component="h1"
|
||||
id="hero-heading"
|
||||
tabIndex={-1}
|
||||
sx={{ mb: 5, color: 'var(--fa-color-white)' }}
|
||||
sx={{ mb: 3, color: 'var(--fa-color-white)' }}
|
||||
>
|
||||
{heroHeading}
|
||||
</Typography>
|
||||
@@ -284,14 +272,20 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
width: '100%',
|
||||
px: { xs: 3, md: 2 },
|
||||
pt: 6,
|
||||
px: 2,
|
||||
pt: 2,
|
||||
pb: 0,
|
||||
mb: { xs: -14, md: -18 },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: '100%', maxWidth: finderSlot ? 500 : 520, mx: 'auto' }}>
|
||||
{finderSlot || <FuneralFinderV3 onSearch={onSearch} loading={searchLoading} />}
|
||||
{finderSlot || (
|
||||
<FuneralFinderV3
|
||||
heading="Find your local providers"
|
||||
onSearch={onSearch}
|
||||
loading={searchLoading}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -321,7 +315,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="display2"
|
||||
variant="display3"
|
||||
component="h1"
|
||||
id="hero-heading"
|
||||
tabIndex={-1}
|
||||
@@ -374,115 +368,28 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
}}
|
||||
>
|
||||
<Box sx={{ maxWidth: 620, mx: 'auto' }}>
|
||||
{finderSlot || <FuneralFinderV3 onSearch={onSearch} loading={searchLoading} />}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════════════
|
||||
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,
|
||||
}}
|
||||
{finderSlot || (
|
||||
<FuneralFinderV3
|
||||
heading="Find your local providers"
|
||||
onSearch={onSearch}
|
||||
loading={searchLoading}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════════════
|
||||
Section 2c: Discover — Map + Featured Providers
|
||||
Section 2c: Discover — Map + Featured Providers (V2)
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
{featuredProviders.length > 0 && (
|
||||
<Box
|
||||
component="section"
|
||||
aria-labelledby="discover-heading"
|
||||
sx={{
|
||||
bgcolor: '#fdfbf9',
|
||||
pt: { xs: 10, md: 14 },
|
||||
pb: { xs: 10, md: 14 },
|
||||
bgcolor: 'var(--fa-color-surface-subtle)',
|
||||
pt: { xs: 22, md: 28 },
|
||||
pb: { xs: 8, md: 12 },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg">
|
||||
@@ -498,7 +405,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{ maxWidth: 520, mx: 'auto', fontSize: { xs: '0.875rem', md: '1rem' } }}
|
||||
sx={{ maxWidth: 520, mx: 'auto' }}
|
||||
>
|
||||
From trusted local providers to personalised options, find the right care near
|
||||
you.
|
||||
@@ -571,7 +478,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
{/* CTA */}
|
||||
<Box sx={{ textAlign: 'center', mt: 4 }}>
|
||||
<Button variant="text" size="medium" onClick={onCtaClick}>
|
||||
Start exploring
|
||||
Start exploring →
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
@@ -579,212 +486,93 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
)}
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════════════
|
||||
Section 3b: Why Use FA — Text + Image
|
||||
Section 3: Partner Logos Carousel
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
{partnerLogos.length > 0 && (
|
||||
<Box
|
||||
component="section"
|
||||
aria-labelledby="why-fa-heading"
|
||||
aria-label="Trusted partners"
|
||||
sx={{
|
||||
bgcolor: 'var(--fa-color-surface-default)',
|
||||
borderTop: '1px solid #f3efea',
|
||||
borderBottom: '1px solid #f3efea',
|
||||
py: { xs: 10, md: 14 },
|
||||
bgcolor: 'var(--fa-color-surface-cool)',
|
||||
pt: { xs: 10, md: 13 },
|
||||
pb: { xs: 8, md: 10 },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg">
|
||||
<Box
|
||||
sx={{
|
||||
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' } }}
|
||||
sx={{ textAlign: 'center', mb: { xs: 4, md: 6 } }}
|
||||
>
|
||||
Funeral planning doesn’t have to be overwhelming. Whether a loved one has
|
||||
just passed, is imminent, or you’re pre-planning the future for yourself.
|
||||
Compare transparent pricing from local funeral directors. Explore the service
|
||||
options, coffins and more to personalise a funeral plan in clear, easy steps.
|
||||
{partnerTrustLine}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
{/* Image */}
|
||||
{/* Carousel track */}
|
||||
<Box
|
||||
role="presentation"
|
||||
sx={{
|
||||
borderRadius: 'var(--fa-border-radius-lg, 12px)',
|
||||
overflow: 'hidden',
|
||||
'& img': {
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
display: 'block',
|
||||
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)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={assetUrl('/images/Homepage/people.png')}
|
||||
alt="Family planning together with care and confidence"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════════════
|
||||
Section 3c: What You Can Do Here — Three Feature Cards
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
<Box
|
||||
component="section"
|
||||
aria-labelledby="what-you-can-do-heading"
|
||||
aria-label="Partner funeral directors"
|
||||
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',
|
||||
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: 200,
|
||||
background:
|
||||
'linear-gradient(135deg, var(--fa-color-brand-100) 0%, var(--fa-color-brand-200) 100%)',
|
||||
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 sx={{ p: 3, flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="h5" component="h3" sx={{ mb: 1.5, color: 'text.primary' }}>
|
||||
Compare pricing
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3, flex: 1 }}>
|
||||
See verified, itemised prices from multiple funeral directors in your area
|
||||
side by side.
|
||||
</Typography>
|
||||
<Button variant="outlined" size="medium" fullWidth>
|
||||
Compare prices in my area
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Card 2: Find a funeral director */}
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'var(--fa-color-surface-default)',
|
||||
borderRadius: 'var(--fa-card-border-radius-default, 8px)',
|
||||
boxShadow: 'var(--fa-shadow-md)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
height: 200,
|
||||
background:
|
||||
'linear-gradient(135deg, var(--fa-color-sage-100, #E8EDEF) 0%, var(--fa-color-sage-200, #D0D8DD) 100%)',
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ p: 3, flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="h5" component="h3" sx={{ mb: 1.5, color: 'text.primary' }}>
|
||||
Find a funeral director
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3, flex: 1 }}>
|
||||
Browse rated, reviewed directors near you with profiles, photos, and contact
|
||||
details.
|
||||
</Typography>
|
||||
<Button variant="outlined" size="medium" fullWidth>
|
||||
Search near me
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Card 3: Arrange a funeral */}
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'var(--fa-color-surface-default)',
|
||||
borderRadius: 'var(--fa-card-border-radius-default, 8px)',
|
||||
boxShadow: 'var(--fa-shadow-md)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
height: 200,
|
||||
background:
|
||||
'linear-gradient(135deg, var(--fa-color-neutral-100) 0%, var(--fa-color-neutral-200) 100%)',
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ p: 3, flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="h5" component="h3" sx={{ mb: 1.5, color: 'text.primary' }}>
|
||||
Arrange a funeral
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3, flex: 1 }}>
|
||||
Build a fully customised quote — choose coffin, flowers, transport,
|
||||
venue, and more.
|
||||
</Typography>
|
||||
<Button variant="outlined" size="medium" fullWidth>
|
||||
Start building your quote
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════════════
|
||||
Section 4: Why Use Funeral Arranger (Features)
|
||||
@@ -795,35 +583,26 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
aria-labelledby="features-heading"
|
||||
sx={{
|
||||
bgcolor: 'var(--fa-color-surface-default)',
|
||||
py: { xs: 10, md: 14 },
|
||||
py: { xs: 8, md: 12 },
|
||||
}}
|
||||
>
|
||||
<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: featuresBody ? 2.5 : 0, color: 'text.primary' }}
|
||||
sx={{ mb: 2.5, color: 'text.primary' }}
|
||||
>
|
||||
{featuresHeading}
|
||||
</Typography>
|
||||
{featuresBody && (
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{ maxWidth: 560, mx: 'auto', fontSize: { xs: '0.875rem', md: '1rem' } }}
|
||||
sx={{ maxWidth: 560, mx: 'auto' }}
|
||||
>
|
||||
{featuresBody}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
@@ -869,22 +648,11 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
component="section"
|
||||
aria-labelledby="reviews-heading"
|
||||
sx={{
|
||||
py: { xs: 10, md: 14 },
|
||||
bgcolor: '#f8f5f1',
|
||||
py: { xs: 8, md: 12 },
|
||||
bgcolor: 'var(--fa-color-surface-subtle)',
|
||||
}}
|
||||
>
|
||||
<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"
|
||||
@@ -915,29 +683,26 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Editorial testimonials — left-aligned with dividers */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0,
|
||||
maxWidth: 560,
|
||||
mx: 'auto',
|
||||
}}
|
||||
>
|
||||
{/* Editorial testimonials — alternating alignment with dividers */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{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: 'left',
|
||||
textAlign: isRight ? 'right' : 'left',
|
||||
maxWidth: '85%',
|
||||
ml: isRight ? 'auto' : 0,
|
||||
mr: isRight ? 0 : 'auto',
|
||||
}}
|
||||
>
|
||||
<FormatQuoteIcon
|
||||
sx={{
|
||||
fontSize: 32,
|
||||
color: 'var(--fa-color-brand-300)',
|
||||
transform: isRight ? 'scaleX(-1)' : 'none',
|
||||
mb: 1,
|
||||
}}
|
||||
/>
|
||||
@@ -985,7 +750,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: 10, md: 14 },
|
||||
py: { xs: 8, md: 10 },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="md" sx={{ textAlign: 'center' }}>
|
||||
@@ -997,7 +762,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
>
|
||||
{ctaHeading}
|
||||
</Typography>
|
||||
<Button variant="contained" size="medium" onClick={onCtaClick}>
|
||||
<Button variant="text" size="large" onClick={onCtaClick}>
|
||||
{ctaButtonLabel}
|
||||
</Button>
|
||||
</Container>
|
||||
@@ -1012,17 +777,17 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
aria-labelledby="faq-heading"
|
||||
sx={{
|
||||
bgcolor: 'var(--fa-color-surface-default)',
|
||||
py: { xs: 10, md: 14 },
|
||||
py: { xs: 8, md: 12 },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg">
|
||||
<Typography
|
||||
variant="display3"
|
||||
variant="h2"
|
||||
component="h2"
|
||||
id="faq-heading"
|
||||
sx={{ textAlign: 'center', mb: { xs: 5, md: 8 }, color: 'text.primary' }}
|
||||
>
|
||||
Frequently Asked Questions
|
||||
FAQ
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ maxWidth: 700, mx: 'auto' }}>
|
||||
@@ -1043,13 +808,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
}}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0, py: 1.5 }}>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
fontSize: { xs: '0.875rem', md: '1rem' },
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
{item.question}
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
@@ -1064,11 +823,6 @@ 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>
|
||||
|
||||
@@ -8,7 +8,6 @@ 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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -42,16 +41,6 @@ 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' },
|
||||
@@ -242,7 +231,7 @@ export const Default: Story = {
|
||||
args: {
|
||||
navigation: nav,
|
||||
footer,
|
||||
heroImageUrl: assetUrl('/images/heroes/parsonshero.png'),
|
||||
heroImageUrl: '/brandassets/images/heroes/parsonshero.png',
|
||||
stats: trustStats,
|
||||
featuredProviders,
|
||||
onSelectFeaturedProvider: (id) => console.log('Featured provider:', id),
|
||||
|
||||
@@ -9,7 +9,6 @@ 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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -38,16 +37,6 @@ 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' },
|
||||
@@ -188,8 +177,8 @@ const featuredProviders: FeaturedProvider[] = [
|
||||
name: 'H.Parsons Funeral Directors',
|
||||
location: 'Wollongong, NSW',
|
||||
verified: true,
|
||||
imageUrl: assetUrl('/images/venues/hparsons-funeral-home-kiama/01.jpg'),
|
||||
logoUrl: assetUrl('/images/providers/hparsons-funeral-directors/logo.png'),
|
||||
imageUrl: '/brandassets/images/venues/hparsons-funeral-home-kiama/01.jpg',
|
||||
logoUrl: '/brandassets/images/providers/hparsons-funeral-directors/logo.png',
|
||||
rating: 4.6,
|
||||
reviewCount: 7,
|
||||
startingPrice: 900,
|
||||
@@ -199,8 +188,8 @@ const featuredProviders: FeaturedProvider[] = [
|
||||
name: 'Rankins Funerals',
|
||||
location: 'Wollongong, NSW',
|
||||
verified: true,
|
||||
imageUrl: assetUrl('/images/venues/rankins-funeral-home-warrawong/01.jpg'),
|
||||
logoUrl: assetUrl('/images/providers/rankins-funerals/logo.png'),
|
||||
imageUrl: '/brandassets/images/venues/rankins-funeral-home-warrawong/01.jpg',
|
||||
logoUrl: '/brandassets/images/providers/rankins-funerals/logo.png',
|
||||
rating: 4.8,
|
||||
reviewCount: 23,
|
||||
startingPrice: 1200,
|
||||
@@ -210,8 +199,8 @@ const featuredProviders: FeaturedProvider[] = [
|
||||
name: 'Easy Funerals',
|
||||
location: 'Sydney, NSW',
|
||||
verified: true,
|
||||
imageUrl: assetUrl('/images/venues/lakeside-memorial-park-chapel/01.jpg'),
|
||||
logoUrl: assetUrl('/images/providers/easy-funerals/logo.png'),
|
||||
imageUrl: '/brandassets/images/venues/lakeside-memorial-park-chapel/01.jpg',
|
||||
logoUrl: '/brandassets/images/providers/easy-funerals/logo.png',
|
||||
rating: 4.5,
|
||||
reviewCount: 42,
|
||||
startingPrice: 850,
|
||||
@@ -220,30 +209,30 @@ const featuredProviders: FeaturedProvider[] = [
|
||||
|
||||
const partnerLogos: PartnerLogo[] = [
|
||||
{
|
||||
src: assetUrl('/images/providers/hparsons-funeral-directors/logo.png'),
|
||||
src: '/brandassets/images/providers/hparsons-funeral-directors/logo.png',
|
||||
alt: 'H.Parsons Funeral Directors',
|
||||
},
|
||||
{ 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/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/killick-family-funerals/logo.png'),
|
||||
src: '/brandassets/images/providers/killick-family-funerals/logo.png',
|
||||
alt: 'Killick Family Funerals',
|
||||
},
|
||||
{
|
||||
src: assetUrl('/images/providers/kenneallys-funerals/logo.png'),
|
||||
src: '/brandassets/images/providers/kenneallys-funerals/logo.png',
|
||||
alt: "Kenneally's Funerals",
|
||||
},
|
||||
{
|
||||
src: assetUrl('/images/providers/wollongong-city-funerals/logo.png'),
|
||||
src: '/brandassets/images/providers/wollongong-city-funerals/logo.png',
|
||||
alt: 'Wollongong City Funerals',
|
||||
},
|
||||
{
|
||||
src: assetUrl('/images/providers/hparsons-funeral-directors-shoalhaven/logo.png'),
|
||||
src: '/brandassets/images/providers/hparsons-funeral-directors-shoalhaven/logo.png',
|
||||
alt: 'H.Parsons Shoalhaven',
|
||||
},
|
||||
{
|
||||
src: assetUrl('/images/providers/mackay-family-funerals/logo.webp'),
|
||||
src: '/brandassets/images/providers/mackay-family-funerals/logo.webp',
|
||||
alt: 'Mackay Family Funerals',
|
||||
},
|
||||
];
|
||||
@@ -251,7 +240,7 @@ const partnerLogos: PartnerLogo[] = [
|
||||
// ─── Meta ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const meta: Meta<typeof HomePage> = {
|
||||
title: 'Pages/HomePage',
|
||||
title: 'Archive/HomePage V3',
|
||||
component: HomePage,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
@@ -268,19 +257,19 @@ export const Default: Story = {
|
||||
args: {
|
||||
navigation: nav,
|
||||
footer,
|
||||
heroImageUrl: assetUrl('/images/heroes/hero-couple.jpg'),
|
||||
heroHeading: 'Compare funeral director pricing near you and arrange with confidence',
|
||||
heroImageUrl: '/brandassets/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',
|
||||
stats: trustStats,
|
||||
featuredProviders,
|
||||
discoverMapSlot: React.createElement('img', {
|
||||
src: assetUrl('/images/placeholder/map.png'),
|
||||
src: '/brandassets/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: 'Verified funeral directors on Funeral Arranger',
|
||||
partnerTrustLine: 'Trusted by hundreds of verified funeral directors across Australia',
|
||||
features,
|
||||
googleRating: 4.9,
|
||||
googleReviewCount: 2340,
|
||||
|
||||
@@ -10,7 +10,6 @@ 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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -39,16 +38,6 @@ 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' },
|
||||
@@ -189,8 +178,8 @@ const featuredProviders: FeaturedProvider[] = [
|
||||
name: 'H.Parsons Funeral Directors',
|
||||
location: 'Wollongong, NSW',
|
||||
verified: true,
|
||||
imageUrl: assetUrl('/images/venues/hparsons-funeral-home-kiama/01.jpg'),
|
||||
logoUrl: assetUrl('/images/providers/hparsons-funeral-directors/logo.png'),
|
||||
imageUrl: '/brandassets/images/venues/hparsons-funeral-home-kiama/01.jpg',
|
||||
logoUrl: '/brandassets/images/providers/hparsons-funeral-directors/logo.png',
|
||||
rating: 4.6,
|
||||
reviewCount: 7,
|
||||
startingPrice: 900,
|
||||
@@ -200,8 +189,8 @@ const featuredProviders: FeaturedProvider[] = [
|
||||
name: 'Rankins Funerals',
|
||||
location: 'Wollongong, NSW',
|
||||
verified: true,
|
||||
imageUrl: assetUrl('/images/venues/rankins-funeral-home-warrawong/01.jpg'),
|
||||
logoUrl: assetUrl('/images/providers/rankins-funerals/logo.png'),
|
||||
imageUrl: '/brandassets/images/venues/rankins-funeral-home-warrawong/01.jpg',
|
||||
logoUrl: '/brandassets/images/providers/rankins-funerals/logo.png',
|
||||
rating: 4.8,
|
||||
reviewCount: 23,
|
||||
startingPrice: 1200,
|
||||
@@ -211,8 +200,8 @@ const featuredProviders: FeaturedProvider[] = [
|
||||
name: 'Easy Funerals',
|
||||
location: 'Sydney, NSW',
|
||||
verified: true,
|
||||
imageUrl: assetUrl('/images/venues/lakeside-memorial-park-chapel/01.jpg'),
|
||||
logoUrl: assetUrl('/images/providers/easy-funerals/logo.png'),
|
||||
imageUrl: '/brandassets/images/venues/lakeside-memorial-park-chapel/01.jpg',
|
||||
logoUrl: '/brandassets/images/providers/easy-funerals/logo.png',
|
||||
rating: 4.5,
|
||||
reviewCount: 42,
|
||||
startingPrice: 850,
|
||||
@@ -221,30 +210,30 @@ const featuredProviders: FeaturedProvider[] = [
|
||||
|
||||
const partnerLogos: PartnerLogo[] = [
|
||||
{
|
||||
src: assetUrl('/images/providers/hparsons-funeral-directors/logo.png'),
|
||||
src: '/brandassets/images/providers/hparsons-funeral-directors/logo.png',
|
||||
alt: 'H.Parsons Funeral Directors',
|
||||
},
|
||||
{ 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/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/killick-family-funerals/logo.png'),
|
||||
src: '/brandassets/images/providers/killick-family-funerals/logo.png',
|
||||
alt: 'Killick Family Funerals',
|
||||
},
|
||||
{
|
||||
src: assetUrl('/images/providers/kenneallys-funerals/logo.png'),
|
||||
src: '/brandassets/images/providers/kenneallys-funerals/logo.png',
|
||||
alt: "Kenneally's Funerals",
|
||||
},
|
||||
{
|
||||
src: assetUrl('/images/providers/wollongong-city-funerals/logo.png'),
|
||||
src: '/brandassets/images/providers/wollongong-city-funerals/logo.png',
|
||||
alt: 'Wollongong City Funerals',
|
||||
},
|
||||
{
|
||||
src: assetUrl('/images/providers/hparsons-funeral-directors-shoalhaven/logo.png'),
|
||||
src: '/brandassets/images/providers/hparsons-funeral-directors-shoalhaven/logo.png',
|
||||
alt: 'H.Parsons Shoalhaven',
|
||||
},
|
||||
{
|
||||
src: assetUrl('/images/providers/mackay-family-funerals/logo.webp'),
|
||||
src: '/brandassets/images/providers/mackay-family-funerals/logo.webp',
|
||||
alt: 'Mackay Family Funerals',
|
||||
},
|
||||
];
|
||||
@@ -269,7 +258,7 @@ export const Default: Story = {
|
||||
args: {
|
||||
navigation: nav,
|
||||
footer,
|
||||
heroImageUrl: assetUrl('/images/heroes/hero-3.png'),
|
||||
heroImageUrl: '/brandassets/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, {
|
||||
@@ -278,7 +267,7 @@ export const Default: Story = {
|
||||
stats: trustStats,
|
||||
featuredProviders,
|
||||
discoverMapSlot: React.createElement('img', {
|
||||
src: assetUrl('/images/placeholder/map.png'),
|
||||
src: '/brandassets/images/placeholder/map.png',
|
||||
alt: 'Map showing provider locations',
|
||||
style: { width: '100%', height: '100%', objectFit: 'cover' },
|
||||
}),
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
/**
|
||||
* 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}`;
|
||||
};
|
||||
Reference in New Issue
Block a user