Compare commits

..

1 Commits

Author SHA1 Message Date
db9d1ba603 Clean repo for shared dev — remove AI tooling and working docs
Removed: .claude/ agents/skills, CLAUDE.md, GEMINI.md, QUICKSTART.md,
.mcp.json, bootstrap.sh, docs/memory/, docs/reference/impeccable/,
docs/reference/vercel/, retroactive-review-plan, mcp-setup
2026-04-02 10:55:59 +11:00
49 changed files with 698 additions and 5735 deletions

21
.gitignore vendored
View File

@@ -8,28 +8,9 @@ tokens/export/
.DS_Store .DS_Store
*.tgz *.tgz
# AI agent tooling # Claude / Playwright artifacts
.playwright-mcp/ .playwright-mcp/
.claude/ .claude/
.agent/
.mcp.json
CLAUDE.md
AGENTS.md
GEMINI.md
QUICKSTART.md
bootstrap.sh
# Memory and reference docs (not for sharing)
docs/memory/
docs/reference/impeccable/
docs/reference/vercel/
docs/reference/cross-tool-workflow.md
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 logs
build-storybook.log build-storybook.log

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

View 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

View File

@@ -1,164 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { MapPin } from './MapPin';
const meta: Meta<typeof MapPin> = {
title: 'Atoms/MapPin',
component: MapPin,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: {
default: 'map',
values: [{ name: 'map', value: '#E5E3DF' }],
},
},
argTypes: {
onClick: { action: 'clicked' },
},
};
export default meta;
type Story = StoryObj<typeof MapPin>;
/** Verified provider with name and price — warm brand label */
export const VerifiedWithPrice: Story = {
args: {
name: 'H.Parsons Funeral Directors',
price: 900,
verified: true,
},
};
/** Unverified provider — neutral grey label */
export const UnverifiedWithPrice: Story = {
args: {
name: 'Smith & Sons Funerals',
price: 1200,
verified: false,
},
};
/** Active/selected state — inverted colours, slight scale-up */
export const Active: Story = {
args: {
name: 'H.Parsons Funeral Directors',
price: 900,
verified: true,
active: true,
},
};
/** Active unverified */
export const ActiveUnverified: Story = {
args: {
name: 'Smith & Sons Funerals',
price: 1200,
verified: false,
active: true,
},
};
/** Name only — no price line */
export const NameOnly: Story = {
args: {
name: 'Lady Anne Funerals',
verified: true,
},
};
/** Name only, unverified */
export const NameOnlyUnverified: Story = {
args: {
name: 'Local Funeral Services',
},
};
/** Price-only pill — no name, verified */
export const PriceOnly: Story = {
args: {
price: 900,
verified: true,
},
};
/** Price-only pill — unverified */
export const PriceOnlyUnverified: Story = {
args: {
price: 1200,
},
};
/** Price-only pill — active */
export const PriceOnlyActive: Story = {
args: {
price: 900,
verified: true,
active: true,
},
};
/** Custom price label */
export const CustomPriceLabel: Story = {
args: {
name: 'Premium Services',
priceLabel: 'POA',
verified: true,
},
};
/** Long name — truncated with ellipsis at 180px max */
export const LongName: Story = {
args: {
name: 'Botanical Funerals by Ian Allison',
price: 1200,
verified: true,
},
};
/** Map simulation — multiple pins on a mock map background */
export const MapSimulation: Story = {
decorators: [
(Story) => (
<Box
sx={{
position: 'relative',
width: 700,
height: 450,
bgcolor: '#E5E3DF',
borderRadius: 2,
overflow: 'hidden',
}}
>
<Story />
</Box>
),
],
render: () => (
<>
{/* Verified providers */}
<Box sx={{ position: 'absolute', top: 60, left: 80 }}>
<MapPin name="H.Parsons" price={900} verified onClick={() => {}} />
</Box>
<Box sx={{ position: 'absolute', top: 150, left: 280 }}>
<MapPin name="Lady Anne Funerals" price={1450} verified active onClick={() => {}} />
</Box>
<Box sx={{ position: 'absolute', top: 260, left: 140 }}>
<MapPin name="Mackay Family" price={2200} verified onClick={() => {}} />
</Box>
{/* Unverified providers */}
<Box sx={{ position: 'absolute', top: 100, left: 450 }}>
<MapPin name="Smith & Sons" price={1100} onClick={() => {}} />
</Box>
<Box sx={{ position: 'absolute', top: 300, left: 400 }}>
<MapPin name="Local Provider" onClick={() => {}} />
</Box>
{/* Name only verified */}
<Box sx={{ position: 'absolute', top: 40, left: 500 }}>
<MapPin name="Kenneallys" verified onClick={() => {}} />
</Box>
</>
),
};

View File

@@ -1,218 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import type { SxProps, Theme } from '@mui/material/styles';
// ─── Types ──────────────────────────────────────────────────────────────────
/** Props for the FA MapPin atom */
export interface MapPinProps {
/** Provider or venue name — omit for a price-only pill */
name?: string;
/** Starting package price in dollars — shown as "From $X" */
price?: number;
/** Custom price label (e.g. "POA") — overrides formatted price */
priceLabel?: string;
/** Whether this provider/venue is verified (brand colour vs neutral) */
verified?: boolean;
/** Whether this pin is currently active/selected */
active?: boolean;
/** Click handler */
onClick?: (e: React.MouseEvent) => void;
/** MUI sx prop for the root element */
sx?: SxProps<Theme>;
}
// ─── Constants ──────────────────────────────────────────────────────────────
const PIN_PX = 'var(--fa-map-pin-padding-x)';
const PIN_RADIUS = 'var(--fa-map-pin-border-radius)';
const NUB_SIZE = 'var(--fa-map-pin-nub-size)';
const MAX_WIDTH = 180;
// ─── Colour sets ────────────────────────────────────────────────────────────
const colours = {
verified: {
bg: 'var(--fa-color-brand-100)',
name: 'var(--fa-color-brand-900)',
price: 'var(--fa-color-brand-600)',
activeBg: 'var(--fa-color-brand-700)',
activeName: 'var(--fa-color-white)',
activePrice: 'var(--fa-color-brand-200)',
nub: 'var(--fa-color-brand-100)',
activeNub: 'var(--fa-color-brand-700)',
border: 'var(--fa-color-brand-300)',
activeBorder: 'var(--fa-color-brand-700)',
},
unverified: {
bg: 'var(--fa-color-neutral-100)',
name: 'var(--fa-color-neutral-800)',
price: 'var(--fa-color-neutral-500)',
activeBg: 'var(--fa-color-neutral-700)',
activeName: 'var(--fa-color-white)',
activePrice: 'var(--fa-color-neutral-200)',
nub: 'var(--fa-color-neutral-100)',
activeNub: 'var(--fa-color-neutral-700)',
border: 'var(--fa-color-neutral-300)',
activeBorder: 'var(--fa-color-neutral-700)',
},
} as const;
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Map marker pin for the FA design system.
*
* Two-line label marker showing provider name and starting package
* price. Renders as a rounded pill with a downward nub pointing to
* the exact map location.
*
* - **Line 1**: Provider name (bold, truncated)
* - **Line 2**: "From $X" (smaller, secondary colour) — optional
*
* Visual distinction:
* - **Verified** providers: warm brand palette (gold bg, copper text)
* - **Unverified** providers: neutral grey palette
* - **Active/selected**: inverted colours (dark bg, white text) + scale-up
*
* Designed for use as custom HTML markers in Mapbox GL / Google Maps.
* Pure CSS — no canvas, no SVG dependency.
*
* Usage:
* ```tsx
* <MapPin name="H.Parsons" price={900} verified onClick={...} />
* <MapPin name="Smith & Sons" /> {/* Name only, unverified *\/}
* <MapPin price={900} verified /> {/* Price-only pill, no name *\/}
* <MapPin name="H.Parsons" price={900} verified active />
* ```
*/
export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
({ name, price, priceLabel, verified = false, active = false, onClick, sx }, ref) => {
const palette = verified ? colours.verified : colours.unverified;
const hasPrice = price != null || priceLabel != null;
const priceText =
priceLabel ?? (price != null ? `From $${price.toLocaleString('en-AU')}` : undefined);
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.key === 'Enter' || e.key === ' ') && onClick) {
e.preventDefault();
onClick(e as unknown as React.MouseEvent);
}
};
return (
<Box
ref={ref}
role="button"
tabIndex={0}
aria-label={`${name ?? (verified ? 'Verified' : 'Unverified') + ' provider'}${hasPrice ? `, packages from $${price?.toLocaleString('en-AU') ?? priceLabel}` : ''}${verified ? ', verified' : ''}${active ? ' (selected)' : ''}`}
onClick={onClick}
onKeyDown={handleKeyDown}
sx={[
{
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
cursor: 'pointer',
transition: 'transform 150ms ease-in-out',
transform: active ? 'scale(1.08)' : 'scale(1)',
'&:hover': {
transform: 'scale(1.08)',
},
'&:focus-visible': {
outline: 'none',
'& > .MapPin-label': {
outline: '2px solid var(--fa-color-interactive-focus)',
outlineOffset: '2px',
},
},
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Label pill */}
<Box
className="MapPin-label"
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
maxWidth: MAX_WIDTH,
py: 0.5,
px: PIN_PX,
borderRadius: PIN_RADIUS,
backgroundColor: active ? palette.activeBg : palette.bg,
border: '1px solid',
borderColor: active ? palette.activeBorder : palette.border,
boxShadow: active ? 'var(--fa-shadow-md)' : 'var(--fa-shadow-sm)',
transition:
'background-color 150ms ease-in-out, border-color 150ms ease-in-out, box-shadow 150ms ease-in-out',
}}
>
{/* Name */}
{name && (
<Box
component="span"
sx={{
fontSize: 12,
fontWeight: 700,
fontFamily: (t: Theme) => t.typography.fontFamily,
lineHeight: 1.3,
color: active ? palette.activeName : palette.name,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '100%',
transition: 'color 150ms ease-in-out',
}}
>
{name}
</Box>
)}
{/* Price line */}
{hasPrice && (
<Box
component="span"
sx={{
fontSize: !name ? 12 : 11,
fontWeight: !name ? 700 : 600,
fontFamily: (t: Theme) => t.typography.fontFamily,
lineHeight: 1.2,
color: !name
? active
? palette.activeName
: palette.name
: active
? palette.activePrice
: palette.price,
whiteSpace: 'nowrap',
transition: 'color 150ms ease-in-out',
}}
>
{priceText}
</Box>
)}
</Box>
{/* Nub — downward pointer */}
<Box
aria-hidden
sx={{
width: 0,
height: 0,
borderLeft: `${NUB_SIZE} solid transparent`,
borderRight: `${NUB_SIZE} solid transparent`,
borderTop: `${NUB_SIZE} solid`,
borderTopColor: active ? palette.activeNub : palette.nub,
mt: '-1px',
}}
/>
</Box>
);
},
);
MapPin.displayName = 'MapPin';
export default MapPin;

View File

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

View File

@@ -1,166 +0,0 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { CompareBar } from './CompareBar';
import type { CompareBarPackage } from './CompareBar';
import { Button } from '../../atoms/Button';
import { Typography } from '../../atoms/Typography';
const samplePackages: CompareBarPackage[] = [
{ id: '1', name: 'Everyday Funeral Package', providerName: 'Wollongong City Funerals' },
{ id: '2', name: 'Traditional Cremation Service', providerName: 'Mackay Family Funerals' },
{ id: '3', name: 'Essential Burial Package', providerName: 'Inglewood Chapel' },
];
const meta: Meta<typeof CompareBar> = {
title: 'Molecules/CompareBar',
component: CompareBar,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
decorators: [
(Story) => (
<Box sx={{ minHeight: '100vh', p: 4, bgcolor: 'var(--fa-color-surface-subtle)' }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
The compare bar floats at the bottom of the viewport.
</Typography>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof CompareBar>;
// --- Default (2 packages) ---------------------------------------------------
/** Two packages selected — "2 packages ready to compare" */
export const Default: Story = {
args: {
packages: samplePackages.slice(0, 2),
onCompare: () => alert('Compare clicked'),
},
};
// --- Single Package ----------------------------------------------------------
/** One package — "Add another package to compare", CTA disabled */
export const SinglePackage: Story = {
args: {
packages: samplePackages.slice(0, 1),
onCompare: () => alert('Compare clicked'),
},
};
// --- Three Packages (Maximum) ------------------------------------------------
/** Maximum 3 packages */
export const ThreePackages: Story = {
args: {
packages: samplePackages,
onCompare: () => alert('Compare clicked'),
},
};
// --- With Error --------------------------------------------------------------
/** Error message when user tries to add a 4th package */
export const WithError: Story = {
args: {
packages: samplePackages,
onCompare: () => alert('Compare clicked'),
error: 'Maximum 3 packages',
},
};
// --- Empty (Hidden) ----------------------------------------------------------
/** No packages — bar is hidden */
export const Empty: Story = {
args: {
packages: [],
onCompare: () => {},
},
};
// --- Interactive Demo --------------------------------------------------------
/** Interactive demo — add packages and see the bar update */
export const Interactive: Story = {
render: () => {
const [selected, setSelected] = useState<CompareBarPackage[]>([]);
const [error, setError] = useState<string>();
const allPackages = [
...samplePackages,
{ id: '4', name: 'Catholic Service', providerName: "St Mary's Funeral Services" },
];
const handleToggle = (pkg: CompareBarPackage) => {
const isSelected = selected.some((s) => s.id === pkg.id);
if (isSelected) {
setSelected(selected.filter((s) => s.id !== pkg.id));
setError(undefined);
} else {
if (selected.length >= 3) {
setError('Maximum 3 packages');
setTimeout(() => setError(undefined), 3000);
return;
}
setSelected([...selected, pkg]);
setError(undefined);
}
};
return (
<Box sx={{ pb: 12 }}>
<Typography variant="h4" sx={{ mb: 3 }}>
Select packages to compare
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{allPackages.map((pkg) => {
const isSelected = selected.some((s) => s.id === pkg.id);
return (
<Box
key={pkg.id}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
p: 2,
border: '1px solid',
borderColor: isSelected ? 'primary.main' : 'divider',
borderRadius: 'var(--fa-card-border-radius-default)',
bgcolor: isSelected ? 'var(--fa-color-surface-warm)' : 'background.paper',
}}
>
<Box>
<Typography variant="label">{pkg.name}</Typography>
<Typography variant="body2" color="text.secondary">
{pkg.providerName}
</Typography>
</Box>
<Button
variant={isSelected ? 'outlined' : 'soft'}
color="secondary"
size="small"
onClick={() => handleToggle(pkg)}
>
{isSelected ? 'Remove' : 'Compare'}
</Button>
</Box>
);
})}
</Box>
<CompareBar
packages={selected}
onCompare={() => alert(`Comparing: ${selected.map((s) => s.name).join(', ')}`)}
error={error}
/>
</Box>
);
},
};

View File

@@ -1,114 +0,0 @@
import React from 'react';
import Paper from '@mui/material/Paper';
import Slide from '@mui/material/Slide';
import CompareArrowsIcon from '@mui/icons-material/CompareArrows';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Badge } from '../../atoms/Badge';
// ─── Types ───────────────────────────────────────────────────────────────────
/** A package in the comparison basket */
export interface CompareBarPackage {
/** Unique package ID */
id: string;
/** Package display name */
name: string;
/** Provider name */
providerName: string;
}
/** Props for the CompareBar molecule */
export interface CompareBarProps {
/** Packages currently in the comparison basket (max 3 user-selected) */
packages: CompareBarPackage[];
/** Called when user clicks "Compare" CTA */
onCompare: () => void;
/** Error/status message shown inline (e.g. "Maximum 3 packages") */
error?: string;
/** MUI sx prop for the root wrapper */
sx?: SxProps<Theme>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Floating comparison basket pill for the FA design system.
*
* Shows a fraction badge (1/3, 2/3, 3/3), contextual copy, and a Compare CTA.
* Present on both ProvidersStep and PackagesStep.
*
* Composes Badge + Button + Typography.
*/
export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
({ packages, onCompare, error, sx }, ref) => {
const count = packages.length;
const visible = count > 0;
const canCompare = count >= 2;
const statusText = count === 1 ? 'Add another to compare' : 'Ready to compare';
return (
<Slide direction="up" in={visible} mountOnEnter unmountOnExit>
<Paper
ref={ref}
elevation={8}
role="status"
aria-live="polite"
aria-label={`${count} of 3 packages selected for comparison`}
sx={[
(theme: Theme) => ({
position: 'fixed',
bottom: theme.spacing(3),
left: '50%',
transform: 'translateX(-50%)',
zIndex: theme.zIndex.snackbar,
borderRadius: '9999px',
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 2.5,
py: 1.25,
maxWidth: { xs: 'calc(100vw - 32px)', md: 420 },
}),
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Fraction badge — 1/3, 2/3, 3/3 */}
<Badge color="brand" variant="soft" size="small" sx={{ flexShrink: 0 }}>
{count}/3
</Badge>
{/* Status text */}
<Typography
variant="body2"
role={error ? 'alert' : undefined}
sx={{
fontWeight: 500,
whiteSpace: 'nowrap',
color: error ? 'var(--fa-color-text-brand)' : 'text.primary',
}}
>
{error || statusText}
</Typography>
{/* Compare CTA */}
<Button
variant="contained"
size="small"
startIcon={<CompareArrowsIcon />}
onClick={onCompare}
disabled={!canCompare}
sx={{ flexShrink: 0, borderRadius: '9999px' }}
>
Compare
</Button>
</Paper>
</Slide>
);
},
);
CompareBar.displayName = 'CompareBar';
export default CompareBar;

View File

@@ -1,2 +0,0 @@
export { CompareBar, default } from './CompareBar';
export type { CompareBarProps, CompareBarPackage } from './CompareBar';

View File

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

View File

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

View File

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

View File

@@ -1,163 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { ComparisonPackageCard } from './ComparisonPackageCard';
import type { ComparisonPackage } from '../../organisms/ComparisonTable';
// ─── Mock data ──────────────────────────────────────────────────────────────
const basePackage: ComparisonPackage = {
id: 'wollongong-everyday',
name: 'Everyday Funeral Package',
price: 6966,
provider: {
name: 'Wollongong City Funerals',
location: 'Wollongong',
rating: 4.8,
reviewCount: 122,
verified: true,
},
sections: [
{
heading: 'Essentials',
items: [
{
name: 'Allowance for Coffin',
info: 'Allowance amount — upgrade options available.',
value: { type: 'allowance', amount: 1750 },
},
{
name: 'Cremation Certificate/Permit',
info: 'Statutory medical referee fee.',
value: { type: 'price', amount: 350 },
},
{
name: 'Crematorium',
info: 'Cremation facility fees.',
value: { type: 'price', amount: 660 },
},
{
name: 'Professional Service Fee',
info: 'Coordination of arrangements.',
value: { type: 'price', amount: 3650.9 },
},
{
name: 'Transportation Service Fee',
info: 'Transfer of the deceased.',
value: { type: 'complimentary' },
},
],
},
{
heading: 'Optionals',
items: [
{
name: 'Digital Recording',
info: 'Professional video recording.',
value: { type: 'complimentary' },
},
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'complimentary' } },
{ name: 'Viewing Fee', info: 'One private family viewing.', value: { type: 'included' } },
],
},
{
heading: 'Extras',
items: [
{
name: 'Allowance for Celebrant',
info: 'Professional celebrant or MC.',
value: { type: 'allowance', amount: 550 },
},
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
{
name: 'Saturday Service Fee',
info: 'Additional fee for Saturday services.',
value: { type: 'price', amount: 880 },
},
],
},
],
};
const unverifiedPackage: ComparisonPackage = {
...basePackage,
id: 'inglewood-everyday',
name: 'Everyday Funeral Package',
price: 7200,
provider: {
name: 'Inglewood Chapel',
location: 'Inglewood',
rating: 4.2,
reviewCount: 45,
verified: false,
},
};
const recommendedPackage: ComparisonPackage = {
...basePackage,
id: 'recommended-premium',
name: 'Premium Cremation Service',
price: 8450,
provider: {
name: 'H. Parsons Funeral Directors',
location: 'Wentworth',
rating: 4.9,
reviewCount: 203,
verified: true,
},
isRecommended: true,
};
// ─── Meta ───────────────────────────────────────────────────────────────────
const meta: Meta<typeof ComparisonPackageCard> = {
title: 'Molecules/ComparisonPackageCard',
component: ComparisonPackageCard,
tags: ['autodocs'],
parameters: {
layout: 'padded',
},
decorators: [
(Story) => (
<Box sx={{ maxWidth: 400, mx: 'auto' }}>
<Story />
</Box>
),
],
args: {
onArrange: (id) => alert(`Arrange: ${id}`),
},
};
export default meta;
type Story = StoryObj<typeof ComparisonPackageCard>;
/** Verified provider — default appearance used in ComparisonPage mobile tab panel */
export const Verified: Story = {
args: {
pkg: basePackage,
},
};
/** Unverified provider — "Make Enquiry" CTA + soft button variant, no verified badge */
export const Unverified: Story = {
args: {
pkg: unverifiedPackage,
},
};
/** Recommended package — warm banner, selected card state, warm header background */
export const Recommended: Story = {
args: {
pkg: recommendedPackage,
},
};
/** Itemisation unavailable — used when a provider hasn't submitted an itemised breakdown */
export const ItemizedUnavailable: Story = {
args: {
pkg: {
...unverifiedPackage,
itemizedAvailable: false,
},
},
};

View File

@@ -1,305 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Badge } from '../../atoms/Badge';
import { Divider } from '../../atoms/Divider';
import { Card } from '../../atoms/Card';
import type { ComparisonPackage, ComparisonCellValue } from '../../organisms/ComparisonTable';
// ─── Types ───────────────────────────────────────────────────────────────────
export interface ComparisonPackageCardProps {
/** Package data to render — same shape used by ComparisonTable */
pkg: ComparisonPackage;
/** Called when the user clicks the CTA (Make Arrangement / Make Enquiry) */
onArrange: (packageId: string) => void;
/** MUI sx prop for container overrides */
sx?: SxProps<Theme>;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function formatPrice(amount: number): string {
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
}
function CellValue({ value }: { value: ComparisonCellValue }) {
switch (value.type) {
case 'price':
return (
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'right' }}>
{formatPrice(value.amount)}
</Typography>
);
case 'allowance':
return (
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'right' }}>
{formatPrice(value.amount)}*
</Typography>
);
case 'complimentary':
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, justifyContent: 'flex-end' }}>
<CheckCircleOutlineIcon
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
aria-hidden
/>
<Typography variant="body2" sx={{ color: 'var(--fa-color-feedback-success)' }}>
Complimentary
</Typography>
</Box>
);
case 'included':
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, justifyContent: 'flex-end' }}>
<CheckCircleOutlineIcon
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
aria-hidden
/>
<Typography variant="body2" sx={{ color: 'var(--fa-color-feedback-success)' }}>
Included
</Typography>
</Box>
);
case 'poa':
return (
<Typography
variant="body2"
color="text.secondary"
sx={{ fontStyle: 'italic', textAlign: 'right' }}
>
Price On Application
</Typography>
);
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 }}
>
Unknown
</Typography>
<InfoOutlinedIcon
sx={{ fontSize: 14, color: 'var(--fa-color-neutral-500)' }}
aria-hidden
/>
</Box>
);
case 'unavailable':
return (
<Typography
variant="body2"
sx={{ color: 'var(--fa-color-neutral-400)', textAlign: 'right' }}
>
</Typography>
);
}
}
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Mobile package card for the ComparisonPage mobile tab panel view.
*
* Full-width card with provider header (verified badge, name, location, rating,
* package name, price, CTA) and the package's itemised sections below. Used as
* the content of each mobile tabpanel — one card visible at a time, selected
* via the tab rail.
*
* Shared by ComparisonPage (V2) and ComparisonPageV1 so that card-level tweaks
* land in a single file.
*/
export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, ComparisonPackageCardProps>(
({ pkg, onArrange, sx }, ref) => {
return (
<Card
ref={ref}
variant="outlined"
selected={pkg.isRecommended}
padding="none"
sx={[
{
overflow: 'hidden',
boxShadow: 'var(--fa-shadow-sm)',
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Recommended banner */}
{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>
)}
{/* Provider header */}
<Box
sx={{
bgcolor: pkg.isRecommended
? 'var(--fa-color-surface-warm)'
: 'var(--fa-color-surface-subtle)',
px: 2.5,
pt: 2.5,
pb: 2,
}}
>
{/* Verified badge */}
{pkg.provider.verified && (
<Badge
color="brand"
variant="soft"
size="small"
icon={<VerifiedOutlinedIcon sx={{ fontSize: 14 }} />}
sx={{ mb: 1 }}
>
Verified
</Badge>
)}
{/* Provider name */}
<Typography variant="label" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
{pkg.provider.name}
</Typography>
{/* Location + Rating */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<LocationOnOutlinedIcon sx={{ fontSize: 14, color: 'text.secondary' }} aria-hidden />
<Typography variant="caption" color="text.secondary">
{pkg.provider.location}
</Typography>
</Box>
{pkg.provider.rating != null && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<StarRoundedIcon
sx={{ fontSize: 14, color: 'var(--fa-color-brand-500)' }}
aria-hidden
/>
<Typography variant="caption" color="text.secondary">
{pkg.provider.rating}
{pkg.provider.reviewCount != null && ` (${pkg.provider.reviewCount})`}
</Typography>
</Box>
)}
</Box>
<Divider sx={{ mb: 1.5 }} />
{/* Package name + price */}
<Typography variant="h5" component="p">
{pkg.name}
</Typography>
<Typography variant="caption" color="text.secondary">
Total package price
</Typography>
<Typography variant="h3" sx={{ color: 'primary.main', fontWeight: 700 }}>
{formatPrice(pkg.price)}
</Typography>
<Button
variant={pkg.provider.verified ? 'contained' : 'soft'}
color={pkg.provider.verified ? 'primary' : 'secondary'}
size="medium"
fullWidth
onClick={() => onArrange(pkg.id)}
sx={{ mt: 2 }}
>
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
</Button>
</Box>
{/* Sections — with left accent borders on headings */}
<Box sx={{ px: 2.5, py: 2.5 }}>
{pkg.itemizedAvailable === false ? (
<Box sx={{ textAlign: 'center', py: 3 }}>
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
Itemised pricing not available for this provider.
</Typography>
</Box>
) : (
pkg.sections.map((section, sIdx) => (
<Box key={section.heading} sx={{ mb: sIdx < pkg.sections.length - 1 ? 3 : 0 }}>
{/* Section heading with left accent */}
<Box
sx={{
borderLeft: '3px solid',
borderLeftColor: 'var(--fa-color-brand-500)',
pl: 1.5,
mb: 1.5,
mt: sIdx > 0 ? 1 : 0,
}}
>
<Typography variant="h6" component="h3">
{section.heading}
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{section.items.map((item) => (
<Box
key={item.name}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
py: 1.5,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<Box sx={{ minWidth: 0, flex: '1 1 50%', maxWidth: '60%' }}>
<Typography variant="body2" color="text.secondary" component="span">
{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}`}
sx={{
fontSize: 14,
color: 'var(--fa-color-neutral-400)',
cursor: 'help',
verticalAlign: 'middle',
}}
/>
</Tooltip>
</Box>
)}
</Box>
<CellValue value={item.value} />
</Box>
))}
</Box>
</Box>
))
)}
</Box>
</Card>
);
},
);
ComparisonPackageCard.displayName = 'ComparisonPackageCard';
export default ComparisonPackageCard;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,138 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { MapPopup } from './MapPopup';
import { MapPin } from '../../atoms/MapPin';
// Placeholder images
const IMG_PROVIDER =
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400&h=200&fit=crop&auto=format';
const IMG_VENUE =
'https://images.unsplash.com/photo-1548625149-fc4a29cf7092?w=400&h=200&fit=crop&auto=format';
const meta: Meta<typeof MapPopup> = {
title: 'Molecules/MapPopup',
component: MapPopup,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: {
default: 'map',
values: [{ name: 'map', value: '#E5E3DF' }],
},
},
argTypes: {
onClick: { action: 'clicked' },
},
};
export default meta;
type Story = StoryObj<typeof MapPopup>;
/** Verified provider with image, price, location, and rating */
export const VerifiedProvider: Story = {
args: {
name: 'H.Parsons Funeral Directors',
imageUrl: IMG_PROVIDER,
price: 900,
location: 'Wollongong',
rating: 4.8,
verified: true,
},
};
/** Unverified provider — no image, no badge */
export const UnverifiedProvider: Story = {
args: {
name: 'Smith & Sons Funeral Services',
price: 1200,
location: 'Sutherland',
},
};
/** Venue popup — capacity instead of rating */
export const Venue: Story = {
args: {
name: 'Albany Creek Memorial Park — Garden Chapel',
imageUrl: IMG_VENUE,
price: 450,
location: 'Albany Creek',
capacity: 120,
},
};
/** Long name — truncated at 1 line, tooltip on hover */
export const LongName: Story = {
args: {
name: 'Botanical Funerals by Ian Allison — Sustainable & Eco-Friendly Services',
imageUrl: IMG_PROVIDER,
price: 1200,
location: 'Northern Beaches',
verified: true,
},
};
/** Minimal — just name */
export const Minimal: Story = {
args: {
name: 'Local Funeral Provider',
},
};
/** Verified without image — inline verified indicator */
export const VerifiedNoImage: Story = {
args: {
name: 'H.Parsons Funeral Directors',
price: 900,
location: 'Wollongong',
verified: true,
},
};
/** Custom price label */
export const CustomPriceLabel: Story = {
args: {
name: 'Premium Funeral Services',
imageUrl: IMG_PROVIDER,
priceLabel: 'Price on application',
location: 'Sydney CBD',
verified: true,
},
};
/** Pin + Popup composition — shows how they work together on a map */
export const WithPin: Story = {
decorators: [
(Story) => (
<Box
sx={{
position: 'relative',
width: 400,
height: 380,
bgcolor: '#E5E3DF',
borderRadius: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 0.5,
}}
>
<Story />
</Box>
),
],
render: () => (
<>
<MapPopup
name="H.Parsons Funeral Directors"
imageUrl={IMG_PROVIDER}
price={900}
location="Wollongong"
rating={4.8}
verified
onClick={() => {}}
/>
<MapPin name="H.Parsons" price={900} verified active />
</>
),
};

View File

@@ -1,301 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import Tooltip from '@mui/material/Tooltip';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
// ─── Types ──────────────────────────────────────────────────────────────────
/** Props for the FA MapPopup molecule */
export interface MapPopupProps {
/** Provider/venue name */
name: string;
/** Hero image URL */
imageUrl?: string;
/** Price in dollars — shown as "From $X" */
price?: number;
/** Custom price label (e.g. "POA") — overrides formatted price */
priceLabel?: string;
/** Location text (suburb, city) */
location?: string;
/** Average rating (e.g. 4.8) */
rating?: number;
/** Venue capacity */
capacity?: number;
/** Whether this provider is verified — shows icon badge in image */
verified?: boolean;
/** Click handler — entire card is clickable */
onClick?: () => void;
/** MUI sx prop for the root element */
sx?: SxProps<Theme>;
}
// ─── Constants ──────────────────────────────────────────────────────────────
const POPUP_WIDTH = 260;
const IMAGE_HEIGHT = 100;
const NUB_SIZE = 8;
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Map popup card for the FA design system.
*
* Floating card anchored to a MapPin on click. Shows a compact
* preview of a provider or venue — image, name, meta, and price.
* The entire card is clickable to navigate to the provider/venue.
*
* Content hierarchy matches MiniCard: **title → meta → price**.
* Truncated names show a tooltip on hover. Verified providers
* show an icon-only badge floating in the image.
*
* Designed for use as a custom popup in Mapbox GL / Google Maps.
* The parent map container handles positioning; this component
* handles content and styling only.
*
* Composes: Paper + Typography + Tooltip.
*
* Usage:
* ```tsx
* <MapPopup
* name="H.Parsons Funeral Directors"
* imageUrl="/images/parsons.jpg"
* price={900}
* location="Wollongong"
* rating={4.8}
* verified
* onClick={() => selectProvider(id)}
* />
* ```
*/
export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
(
{
name,
imageUrl,
price,
priceLabel,
location,
rating,
capacity,
verified = false,
onClick,
sx,
},
ref,
) => {
const hasMeta = location != null || rating != null || capacity != null;
const hasPrice = price != null || priceLabel != null;
// Detect name truncation for tooltip
const nameRef = React.useRef<HTMLElement>(null);
const [isTruncated, setIsTruncated] = React.useState(false);
React.useEffect(() => {
const el = nameRef.current;
if (el) {
setIsTruncated(el.scrollHeight > el.clientHeight + 1);
}
}, [name]);
return (
<Box
ref={ref}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
onClick={onClick}
onKeyDown={
onClick
? (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}
: undefined
}
aria-label={onClick ? `View ${name}` : undefined}
sx={[
{
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.15))',
cursor: onClick ? 'pointer' : 'default',
transition: 'transform 150ms ease-in-out',
'&:hover': onClick
? {
transform: 'scale(1.02)',
}
: undefined,
'&:focus-visible': {
outline: '2px solid var(--fa-color-interactive-focus)',
outlineOffset: '2px',
borderRadius: 'var(--fa-card-border-radius-default)',
},
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
<Paper
elevation={0}
sx={{
width: POPUP_WIDTH,
borderRadius: 'var(--fa-card-border-radius-default)',
overflow: 'hidden',
bgcolor: 'background.paper',
}}
>
{/* ── Image ── */}
{imageUrl && (
<Box
role="img"
aria-label={`Photo of ${name}`}
sx={{
position: 'relative',
height: IMAGE_HEIGHT,
backgroundImage: `url(${imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundColor: 'var(--fa-color-neutral-100)',
}}
>
{/* Verified icon badge — floating top-right */}
{verified && (
<Tooltip title="Verified provider" arrow placement="top">
<Box
sx={{
position: 'absolute',
top: 8,
right: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 24,
height: 24,
borderRadius: '50%',
backgroundColor: 'var(--fa-color-brand-600)',
color: 'var(--fa-color-white)',
boxShadow: 'var(--fa-shadow-sm)',
}}
>
<VerifiedOutlinedIcon sx={{ fontSize: 14 }} />
</Box>
</Tooltip>
)}
</Box>
)}
{/* ── Content ── */}
<Box sx={{ p: 1.5, display: 'flex', flexDirection: 'column', gap: '4px' }}>
{/* 1. Name — with tooltip when truncated */}
<Tooltip
title={isTruncated ? name : ''}
arrow
placement="top"
enterDelay={300}
disableHoverListener={!isTruncated}
>
<Typography ref={nameRef} variant="body2" sx={{ fontWeight: 600 }} maxLines={1}>
{name}
</Typography>
</Tooltip>
{/* 2. Meta row */}
{hasMeta && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
{location && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<LocationOnOutlinedIcon
sx={{ fontSize: 12, color: 'text.secondary' }}
aria-hidden
/>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
{location}
</Typography>
</Box>
)}
{rating != null && (
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}
aria-label={`Rated ${rating} out of 5`}
>
<StarRoundedIcon sx={{ fontSize: 12, color: 'warning.main' }} aria-hidden />
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
{rating}
</Typography>
</Box>
)}
{capacity != null && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<PeopleOutlinedIcon
sx={{ fontSize: 12, color: 'text.secondary' }}
aria-hidden
/>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
{capacity}
</Typography>
</Box>
)}
</Box>
)}
{/* 3. Price */}
{hasPrice && (
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
{priceLabel ? (
<Typography variant="caption" color="primary" sx={{ fontStyle: 'italic' }}>
{priceLabel}
</Typography>
) : (
<>
<Typography variant="caption" color="text.secondary">
From
</Typography>
<Typography variant="caption" color="primary" sx={{ fontWeight: 600 }}>
${price!.toLocaleString('en-AU')}
</Typography>
</>
)}
</Box>
)}
{/* Verified indicator (no-image fallback) */}
{verified && !imageUrl && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<VerifiedOutlinedIcon sx={{ fontSize: 14, color: 'var(--fa-color-brand-600)' }} />
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
Verified
</Typography>
</Box>
)}
</Box>
</Paper>
{/* Nub — downward pointer connecting to pin */}
<Box
aria-hidden
sx={{
width: 0,
height: 0,
borderLeft: `${NUB_SIZE}px solid transparent`,
borderRight: `${NUB_SIZE}px solid transparent`,
borderTop: `${NUB_SIZE}px solid`,
borderTopColor: 'background.paper',
mt: '-1px',
}}
/>
</Box>
);
},
);
MapPopup.displayName = 'MapPopup';
export default MapPopup;

View File

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

View File

@@ -1,166 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { MiniCard } from './MiniCard';
// Placeholder images for stories
const IMG_PROVIDER =
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400&h=240&fit=crop&auto=format';
const IMG_VENUE =
'https://images.unsplash.com/photo-1497366216548-37526070297c?w=400&h=240&fit=crop&auto=format';
const IMG_CHAPEL =
'https://images.unsplash.com/photo-1548625149-fc4a29cf7092?w=400&h=240&fit=crop&auto=format';
const IMG_GARDEN =
'https://images.unsplash.com/photo-1585320806297-9794b3e4eeae?w=400&h=240&fit=crop&auto=format';
const meta: Meta<typeof MiniCard> = {
title: 'Molecules/MiniCard',
component: MiniCard,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
decorators: [
(Story) => (
<Box sx={{ width: 240 }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof MiniCard>;
/** Default — verified provider with image, location, and price */
export const Default: Story = {
args: {
title: 'H.Parsons Funeral Directors',
imageUrl: IMG_PROVIDER,
verified: true,
price: 900,
location: 'Wollongong',
},
};
/** With all optional fields populated */
export const FullyLoaded: Story = {
args: {
title: 'H.Parsons Funeral Directors',
imageUrl: IMG_PROVIDER,
verified: true,
location: 'Wollongong',
rating: 4.8,
price: 900,
badges: [{ label: 'Online Arrangement', color: 'success' }],
chips: ['Burial', 'Cremation'],
},
};
/** Unverified provider — no badge in image */
export const Unverified: Story = {
args: {
title: 'Smith & Sons Funeral Services',
imageUrl: IMG_VENUE,
price: 1200,
location: 'Sutherland',
},
};
/** Venue card usage — capacity instead of rating */
export const Venue: Story = {
args: {
title: 'Albany Creek Memorial Park',
imageUrl: IMG_CHAPEL,
price: 450,
location: 'Albany Creek',
capacity: 120,
},
};
/** Package card usage — custom price label */
export const Package: Story = {
args: {
title: 'Essential Cremation Package',
imageUrl: IMG_GARDEN,
priceLabel: 'From $2,800',
badges: [{ label: 'Most Popular', color: 'brand' }],
},
};
/** Minimal — just title and image */
export const Minimal: Story = {
args: {
title: 'Lady Anne Funerals',
imageUrl: IMG_VENUE,
},
};
/** Selected state — brand border + warm background */
export const Selected: Story = {
args: {
title: 'H.Parsons Funeral Directors',
imageUrl: IMG_PROVIDER,
verified: true,
price: 900,
location: 'Wollongong',
selected: true,
},
};
/** Long title — truncated at 2 lines, hover tooltip shows full text */
export const LongTitle: Story = {
args: {
title: 'Botanical Funerals by Ian Allison — Sustainable & Eco-Friendly Services',
imageUrl: IMG_GARDEN,
verified: true,
location: 'Northern Beaches',
rating: 4.9,
price: 1200,
},
};
/** Multiple cards in a responsive grid — mix of verified and unverified */
export const Grid: Story = {
decorators: [
(Story) => (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: 2,
width: 680,
}}
>
<Story />
</Box>
),
],
render: () => (
<>
<MiniCard
title="H.Parsons Funeral Directors"
imageUrl={IMG_PROVIDER}
verified
location="Wollongong"
rating={4.8}
price={900}
chips={['Burial', 'Cremation']}
onClick={() => {}}
/>
<MiniCard
title="Albany Creek Memorial Park"
imageUrl={IMG_CHAPEL}
location="Albany Creek"
capacity={120}
price={450}
onClick={() => {}}
/>
<MiniCard
title="Lady Anne Funerals"
imageUrl={IMG_VENUE}
location="Sutherland Shire"
onClick={() => {}}
/>
</>
),
};

View File

@@ -1,311 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import type { SxProps, Theme } from '@mui/material/styles';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import { Card } from '../../atoms/Card';
import { Typography } from '../../atoms/Typography';
import { Badge } from '../../atoms/Badge';
import type { BadgeProps } from '../../atoms/Badge/Badge';
// ─── Types ───────────────────────────────────────────────────────────────────
/** A badge to render inside the MiniCard content area */
export interface MiniCardBadge {
/** Label text */
label: string;
/** Badge colour intent */
color?: BadgeProps['color'];
/** Badge variant */
variant?: BadgeProps['variant'];
/** Optional leading icon */
icon?: React.ReactNode;
}
/** Props for the FA MiniCard molecule */
export interface MiniCardProps {
/** Card title — provider name, venue name, package name, etc. */
title: string;
/** Hero image URL */
imageUrl: string;
/** Alt text for the image — defaults to title */
imageAlt?: string;
/** Whether this provider/venue is verified — shows icon badge in image */
verified?: boolean;
/** Price in dollars — shown as "From $X" */
price?: number;
/** Custom price label (e.g. "POA", "Included") — overrides formatted price */
priceLabel?: string;
/** Location text (suburb, city) */
location?: string;
/** Average rating (e.g. 4.8) */
rating?: number;
/** Venue capacity (e.g. 120) */
capacity?: number;
/** Badge items rendered after the price row */
badges?: MiniCardBadge[];
/** Chip labels rendered as small soft badges (after badges) */
chips?: string[];
/** Whether this card is currently selected */
selected?: boolean;
/** Click handler — entire card is clickable */
onClick?: () => void;
/** MUI sx prop for style overrides */
sx?: SxProps<Theme>;
}
// ─── Constants ──────────────────────────────────────────────────────────────
const IMAGE_HEIGHT = 'var(--fa-mini-card-image-height)';
const CONTENT_PADDING = 'var(--fa-mini-card-content-padding)';
const CONTENT_GAP = 'var(--fa-mini-card-content-gap)';
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Compact vertical card for the FA design system.
*
* A smaller, flexible card for displaying providers, venues, or packages
* in grids, recommendation rows, and map popups. Shows an image with
* a title and optional meta, price, badges, and chips.
*
* Content hierarchy: **title → meta → price → chips/badges**.
*
* Verified providers show a small icon-only badge floating in the
* image (top-right). Truncated titles show a tooltip on hover with
* the full text.
*
* Composes: Card + Typography + Badge + Tooltip.
*
* Usage:
* ```tsx
* <MiniCard
* title="H.Parsons Funeral Directors"
* imageUrl="/images/parsons.jpg"
* verified
* price={900}
* location="Wollongong"
* rating={4.8}
* onClick={() => navigate('/providers/parsons')}
* />
* ```
*/
export const MiniCard = React.forwardRef<HTMLDivElement, MiniCardProps>(
(
{
title,
imageUrl,
imageAlt,
verified = false,
price,
priceLabel,
location,
rating,
capacity,
badges,
chips,
selected = false,
onClick,
sx,
},
ref,
) => {
const hasMeta = location != null || rating != null || capacity != null;
const hasPrice = price != null || priceLabel != null;
// Detect title truncation for tooltip
const titleRef = React.useRef<HTMLElement>(null);
const [isTruncated, setIsTruncated] = React.useState(false);
React.useEffect(() => {
const el = titleRef.current;
if (el) {
setIsTruncated(el.scrollHeight > el.clientHeight + 1);
}
}, [title]);
return (
<Card
ref={ref}
interactive={!!onClick}
selected={selected}
padding="none"
onClick={onClick}
sx={[
{
overflow: 'hidden',
'&:hover': {
backgroundColor: 'background.paper',
},
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* ── Image ── */}
<Box
role="img"
aria-label={imageAlt ?? title}
sx={{
position: 'relative',
height: IMAGE_HEIGHT,
backgroundImage: `url(${imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundColor: 'var(--fa-color-neutral-100)',
}}
>
{/* Verified icon badge — floating top-right */}
{verified && (
<Tooltip title="Verified provider" arrow placement="top">
<Box
sx={{
position: 'absolute',
top: 8,
right: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 28,
height: 28,
borderRadius: '50%',
backgroundColor: 'var(--fa-color-brand-600)',
color: 'var(--fa-color-white)',
boxShadow: 'var(--fa-shadow-sm)',
}}
>
<VerifiedOutlinedIcon sx={{ fontSize: 16 }} />
</Box>
</Tooltip>
)}
</Box>
{/* ── Content ── */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: CONTENT_GAP,
p: CONTENT_PADDING,
}}
>
{/* 1. Title — with tooltip when truncated */}
<Tooltip
title={isTruncated ? title : ''}
arrow
placement="top"
enterDelay={300}
disableHoverListener={!isTruncated}
>
<Typography ref={titleRef} variant="h6" maxLines={2}>
{title}
</Typography>
</Tooltip>
{/* 2. Meta row: location / rating / capacity */}
{hasMeta && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
flexWrap: 'wrap',
}}
>
{location && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<LocationOnOutlinedIcon
sx={{ fontSize: 14, color: 'text.secondary' }}
aria-hidden
/>
<Typography variant="caption" color="text.secondary">
{location}
</Typography>
</Box>
)}
{rating != null && (
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
aria-label={`Rated ${rating} out of 5`}
>
<StarRoundedIcon sx={{ fontSize: 14, color: 'warning.main' }} aria-hidden />
<Typography variant="caption" color="text.secondary">
{rating}
</Typography>
</Box>
)}
{capacity != null && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<PeopleOutlinedIcon sx={{ fontSize: 14, color: 'text.secondary' }} aria-hidden />
<Typography variant="caption" color="text.secondary">
{capacity} guests
</Typography>
</Box>
)}
</Box>
)}
{/* 3. Price */}
{hasPrice && (
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
{priceLabel ? (
<Typography variant="body2" color="primary" sx={{ fontStyle: 'italic' }}>
{priceLabel}
</Typography>
) : (
<>
<Typography variant="caption" color="text.secondary">
From
</Typography>
<Typography
variant="body2"
component="span"
color="primary"
sx={{ fontWeight: 600 }}
>
${price!.toLocaleString('en-AU')}
</Typography>
</>
)}
</Box>
)}
{/* 4. Badges */}
{badges && badges.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{badges.map((badge) => (
<Badge
key={badge.label}
color={badge.color}
variant={badge.variant}
size="small"
icon={badge.icon}
>
{badge.label}
</Badge>
))}
</Box>
)}
{/* 5. Chips */}
{chips && chips.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{chips.map((chip) => (
<Badge key={chip} color="default" variant="soft" size="small">
{chip}
</Badge>
))}
</Box>
)}
</Box>
</Card>
);
},
);
MiniCard.displayName = 'MiniCard';
export default MiniCard;

View File

@@ -1,2 +0,0 @@
export { MiniCard, default } from './MiniCard';
export type { MiniCardProps, MiniCardBadge } from './MiniCard';

View File

@@ -1,356 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { ComparisonTable } from './ComparisonTable';
import type { ComparisonPackage } from './ComparisonTable';
const DEMO_LOGO = 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=72&h=72&fit=crop';
// ─── Mock data ──────────────────────────────────────────────────────────────
const pkgWollongong: ComparisonPackage = {
id: 'wollongong-everyday',
name: 'Everyday Funeral Package',
price: 6966,
provider: {
name: 'Wollongong City Funerals',
location: 'Wollongong',
logoUrl: DEMO_LOGO,
rating: 4.8,
reviewCount: 122,
verified: true,
},
sections: [
{
heading: 'Essentials',
items: [
{
name: 'Allowance for Coffin',
info: 'Allowance amount — upgrade options available.',
value: { type: 'allowance', amount: 1750 },
},
{
name: 'Cremation Certificate/Permit',
info: 'Statutory medical referee fee.',
value: { type: 'price', amount: 350 },
},
{
name: 'Crematorium: Mackay Family Crematorium',
info: 'Cremation facility fees.',
value: { type: 'price', amount: 660 },
},
{
name: 'Death Registration Certificate',
info: 'Lodgement with NSW Registry.',
value: { type: 'price', amount: 70 },
},
{
name: 'Dressing Fee',
info: 'Dressing and preparation of the deceased.',
value: { type: 'complimentary' },
},
{
name: 'NSW Government Levy — Cremation',
info: 'NSW Government cremation levy.',
value: { type: 'price', amount: 45.1 },
},
{
name: 'Professional Mortuary Care',
info: 'Preparation and care of the deceased.',
value: { type: 'price', amount: 440 },
},
{
name: 'Professional Service Fee',
info: 'Coordination of all funeral arrangements.',
value: { type: 'price', amount: 3650.9 },
},
{
name: 'Transportation Service Fee',
info: 'Transfer of the deceased.',
value: { type: 'complimentary' },
},
],
},
{
heading: 'Optionals',
items: [
{
name: 'Digital Recording of the Funeral Service',
info: 'Professional video recording.',
value: { type: 'complimentary' },
},
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'complimentary' } },
{
name: 'Viewing Fee',
info: 'One private family viewing.',
value: { type: 'complimentary' },
},
{
name: 'Flowers',
info: 'Seasonal floral arrangements.',
value: { type: 'complimentary' },
},
],
},
{
heading: 'Extras',
items: [
{
name: 'Allowance for Celebrant',
info: 'Professional celebrant or MC.',
value: { type: 'allowance', amount: 550 },
},
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
{
name: 'Saturday Service Fee',
info: 'Additional fee for Saturday services.',
value: { type: 'price', amount: 880 },
},
],
},
],
};
const pkgMackay: ComparisonPackage = {
id: 'mackay-everyday',
name: 'Everyday Funeral Package',
price: 5495.45,
provider: {
name: 'Mackay Family Funerals',
location: 'Inglewood',
logoUrl: DEMO_LOGO,
rating: 4.6,
reviewCount: 87,
verified: true,
},
sections: [
{
heading: 'Essentials',
items: [
{
name: 'Allowance for Coffin',
info: 'Allowance amount — upgrade options available.',
value: { type: 'allowance', amount: 1500 },
},
{
name: 'Cremation Certificate/Permit',
info: 'Statutory medical referee fee.',
value: { type: 'price', amount: 350 },
},
{
name: 'Crematorium: Mackay Family Crematorium',
info: 'Cremation facility fees.',
value: { type: 'price', amount: 660 },
},
{
name: 'Death Registration Certificate',
info: 'Lodgement with NSW Registry.',
value: { type: 'price', amount: 70 },
},
{ name: 'Dressing Fee', info: 'Dressing and preparation.', value: { type: 'included' } },
{
name: 'NSW Government Levy — Cremation',
info: 'NSW Government cremation levy.',
value: { type: 'price', amount: 45.1 },
},
{
name: 'Professional Mortuary Care',
info: 'Preparation and care.',
value: { type: 'price', amount: 440 },
},
{
name: 'Professional Service Fee',
info: 'Coordination of arrangements.',
value: { type: 'price', amount: 2430.35 },
},
{
name: 'Transportation Service Fee',
info: 'Transfer of the deceased.',
value: { type: 'price', amount: 0 },
},
],
},
{
heading: 'Optionals',
items: [
{
name: 'Digital Recording of the Funeral Service',
info: 'Professional video recording.',
value: { type: 'unknown' },
},
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'included' } },
{ name: 'Viewing Fee', info: 'One private family viewing.', value: { type: 'included' } },
{ name: 'Flowers', info: 'Seasonal floral arrangements.', value: { type: 'included' } },
],
},
{
heading: 'Extras',
items: [
{
name: 'Allowance for Celebrant',
info: 'Professional celebrant or MC.',
value: { type: 'allowance', amount: 450 },
},
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
{
name: 'Saturday Service Fee',
info: 'Additional fee for Saturday services.',
value: { type: 'price', amount: 750 },
},
],
},
],
};
const pkgInglewood: ComparisonPackage = {
id: 'inglewood-everyday',
name: 'Everyday Funeral Package',
price: 7200,
provider: {
name: 'Inglewood Chapel',
location: 'Inglewood',
logoUrl: DEMO_LOGO,
rating: 4.2,
reviewCount: 45,
verified: false,
},
sections: [
{
heading: 'Essentials',
items: [
{ name: 'Allowance for Coffin', value: { type: 'unknown' } },
{ name: 'Cremation Certificate/Permit', value: { type: 'unknown' } },
{ name: 'Crematorium: Mackay Family Crematorium', value: { type: 'unknown' } },
{ name: 'Death Registration Certificate', value: { type: 'unknown' } },
{ name: 'Dressing Fee', value: { type: 'unknown' } },
{ name: 'NSW Government Levy — Cremation', value: { type: 'unknown' } },
{ name: 'Professional Mortuary Care', value: { type: 'unknown' } },
{ name: 'Professional Service Fee', value: { type: 'unknown' } },
{ name: 'Transportation Service Fee', value: { type: 'unknown' } },
],
},
{
heading: 'Optionals',
items: [
{ name: 'Digital Recording of the Funeral Service', value: { type: 'unknown' } },
{ name: 'Flowers', value: { type: 'unknown' } },
{ name: 'Online Notice', value: { type: 'unknown' } },
{ name: 'Viewing Fee', value: { type: 'unknown' } },
],
},
{
heading: 'Extras',
items: [
{ name: 'Allowance for Celebrant', value: { type: 'unknown' } },
{ name: 'Catering', value: { type: 'unknown' } },
{ name: 'Newspaper Notice', value: { type: 'unknown' } },
{ name: 'Saturday Service Fee', value: { type: 'unknown' } },
],
},
],
};
const pkgRecommended: ComparisonPackage = {
...pkgWollongong,
id: 'recommended-premium',
name: 'Premium Cremation Service',
price: 8450,
isRecommended: true,
provider: {
name: 'H. Parsons Funeral Directors',
location: 'Wentworth',
logoUrl: DEMO_LOGO,
rating: 4.9,
reviewCount: 203,
verified: true,
},
};
const pkgNoItemised: ComparisonPackage = {
id: 'no-data',
name: 'Basic Cremation',
price: 4500,
provider: {
name: 'Smith & Sons',
location: 'Bankstown',
verified: false,
},
sections: [],
itemizedAvailable: false,
};
// ─── Meta ───────────────────────────────────────────────────────────────────
const meta: Meta<typeof ComparisonTable> = {
title: 'Organisms/ComparisonTable',
component: ComparisonTable,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
decorators: [
(Story) => (
<Box sx={{ p: { xs: 2, md: 4 }, maxWidth: 1200, mx: 'auto' }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof ComparisonTable>;
// --- Default (3 packages) ----------------------------------------------------
/** Three packages from different providers — full comparison */
export const Default: Story = {
args: {
packages: [pkgWollongong, pkgMackay, pkgInglewood],
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
},
};
// --- Two Packages ------------------------------------------------------------
/** Minimal two-column comparison */
export const TwoPackages: Story = {
args: {
packages: [pkgWollongong, pkgMackay],
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
},
};
// --- With Recommended --------------------------------------------------------
/** 3 user + 1 recommended = 4 columns — recommended has warm bg + badge */
export const WithRecommended: Story = {
args: {
packages: [pkgWollongong, pkgMackay, pkgInglewood, pkgRecommended],
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
},
};
// --- Mixed Verified/Unverified -----------------------------------------------
/** Mix of verified (Make Arrangement) and unverified (Make Enquiry) providers */
export const MixedVerified: Story = {
args: {
packages: [pkgWollongong, pkgInglewood],
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
},
};
// --- Missing Itemised Data ---------------------------------------------------
/** One provider has no itemised breakdown — cells show "—" */
export const MissingData: Story = {
args: {
packages: [pkgWollongong, pkgNoItemised, pkgMackay],
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
},
};

View File

@@ -1,377 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Card } from '../../atoms/Card';
import { ComparisonColumnCard } from '../../molecules/ComparisonColumnCard';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Cell value types for the comparison table */
export type ComparisonCellValue =
| { type: 'price'; amount: number }
| { type: 'allowance'; amount: number }
| { type: 'complimentary' }
| { type: 'included' }
| { type: 'poa' }
| { type: 'unknown' }
| { type: 'unavailable' };
export interface ComparisonLineItem {
name: string;
info?: string;
value: ComparisonCellValue;
}
export interface ComparisonSection {
heading: string;
items: ComparisonLineItem[];
}
export interface ComparisonProvider {
name: string;
location: string;
logoUrl?: string;
rating?: number;
reviewCount?: number;
verified: boolean;
}
export interface ComparisonPackage {
id: string;
name: string;
price: number;
provider: ComparisonProvider;
sections: ComparisonSection[];
isRecommended?: boolean;
itemizedAvailable?: boolean;
}
export interface ComparisonTableProps {
packages: ComparisonPackage[];
onArrange: (packageId: string) => void;
onRemove: (packageId: string) => void;
sx?: SxProps<Theme>;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function formatPrice(amount: number): string {
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
}
function CellValue({ value }: { value: ComparisonCellValue }) {
switch (value.type) {
case 'price':
return (
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
{formatPrice(value.amount)}
</Typography>
);
case 'allowance':
return (
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
{formatPrice(value.amount)}*
</Typography>
);
case 'complimentary':
return (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
<CheckCircleOutlineIcon
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
aria-hidden
/>
<Typography
variant="body2"
sx={{ color: 'var(--fa-color-feedback-success)', fontWeight: 500 }}
>
Complimentary
</Typography>
</Box>
);
case 'included':
return (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
<CheckCircleOutlineIcon
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
aria-hidden
/>
<Typography
variant="body2"
sx={{ color: 'var(--fa-color-feedback-success)', fontWeight: 500 }}
>
Included
</Typography>
</Box>
);
case 'poa':
return (
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
Price On Application
</Typography>
);
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 }}
>
Unknown
</Typography>
<InfoOutlinedIcon
sx={{ fontSize: 14, color: 'var(--fa-color-neutral-500)' }}
aria-hidden
/>
</Box>
);
case 'unavailable':
return (
<Typography variant="body2" sx={{ color: 'var(--fa-color-neutral-400)' }}>
</Typography>
);
}
}
function buildMergedSections(
packages: ComparisonPackage[],
): { heading: string; items: { name: string; info?: string }[] }[] {
const sectionMap = new Map<string, { name: string; info?: string }[]>();
const sectionOrder: string[] = [];
for (const pkg of packages) {
if (pkg.itemizedAvailable === false) continue;
for (const section of pkg.sections) {
if (!sectionMap.has(section.heading)) {
sectionMap.set(section.heading, []);
sectionOrder.push(section.heading);
}
const existing = sectionMap.get(section.heading)!;
for (const item of section.items) {
if (!existing.some((e) => e.name === item.name)) {
existing.push({ name: item.name, info: item.info });
}
}
}
}
return sectionOrder.map((heading) => ({
heading,
items: sectionMap.get(heading) ?? [],
}));
}
function lookupValue(
pkg: ComparisonPackage,
sectionHeading: string,
itemName: string,
): ComparisonCellValue {
if (pkg.itemizedAvailable === false) return { type: 'unavailable' };
const section = pkg.sections.find((s) => s.heading === sectionHeading);
if (!section) return { type: 'unavailable' };
const item = section.items.find((i) => i.name === itemName);
if (!item) return { type: 'unavailable' };
return item.value;
}
/** Section heading with left accent border */
function SectionHeading({ children }: { children: React.ReactNode }) {
return (
<Box
sx={{
bgcolor: 'var(--fa-color-surface-subtle)',
px: 3,
py: 2.5,
borderLeft: '3px solid',
borderLeftColor: 'var(--fa-color-brand-500)',
}}
>
<Typography variant="h6" component="h3">
{children}
</Typography>
</Box>
);
}
/** Reusable bordered table wrapper */
const tableSx = {
display: 'grid',
border: '1px solid',
borderColor: 'divider',
borderRadius: 'var(--fa-card-border-radius-default)',
overflow: 'hidden',
bgcolor: 'background.paper',
};
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Side-by-side package comparison table for the FA design system.
*
* Info card in top-left column, floating verified badges above cards,
* section tables with left accent borders, no reviews table (rating in cards).
*
* Desktop only — ComparisonPage handles the mobile card view.
*/
export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableProps>(
({ packages, onArrange, onRemove, sx }, ref) => {
const colCount = packages.length + 1;
const mergedSections = buildMergedSections(packages);
const gridCols = `minmax(220px, 280px) repeat(${packages.length}, minmax(200px, 1fr))`;
const minW = packages.length > 3 ? 960 : packages.length > 2 ? 800 : 600;
return (
<Box
ref={ref}
role="table"
aria-label="Package comparison"
sx={[
{
display: { xs: 'none', md: 'block' },
overflowX: 'auto',
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
<Box sx={{ minWidth: minW }}>
{/* ── Package header cards ── */}
<Box
role="row"
sx={{
display: 'grid',
gridTemplateColumns: gridCols,
gap: 2,
mb: 4,
alignItems: 'stretch',
pt: 3, // Room for floating verified badges
}}
>
{/* Info card — stretches to match package card height, text at top */}
<Card
role="columnheader"
variant="elevated"
padding="default"
sx={{
bgcolor: 'var(--fa-color-surface-subtle)',
alignSelf: 'stretch',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-start',
border: 'none',
boxShadow: 'none',
}}
>
<Typography variant="label" sx={{ fontWeight: 700, display: 'block', mb: 1 }}>
Package Comparison
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ lineHeight: 1.5, display: 'block' }}
>
Review and compare features side-by-side to find the right fit.
</Typography>
</Card>
{/* Package column header cards */}
{packages.map((pkg) => (
<ComparisonColumnCard
key={pkg.id}
pkg={pkg}
onArrange={onArrange}
onRemove={onRemove}
/>
))}
</Box>
{/* ── Section tables (each separate with left accent headings) ── */}
{mergedSections.map((section) => (
<Box key={section.heading} sx={{ ...tableSx, gridTemplateColumns: gridCols, mb: 3 }}>
<Box role="row" sx={{ gridColumn: `1 / ${colCount + 1}` }}>
<SectionHeading>{section.heading}</SectionHeading>
</Box>
{section.items.map((item) => (
<Box
key={item.name}
role="row"
sx={{
gridColumn: `1 / ${colCount + 1}`,
display: 'grid',
gridTemplateColumns: 'subgrid',
transition: 'background-color 0.15s ease',
'&:hover': { bgcolor: 'var(--fa-color-brand-50)' },
}}
>
<Box
role="cell"
sx={{
px: 3,
py: 2,
borderTop: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="body2" color="text.secondary" component="span">
{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}`}
sx={{
fontSize: 14,
color: 'var(--fa-color-neutral-400)',
cursor: 'help',
verticalAlign: 'middle',
}}
/>
</Tooltip>
</Box>
)}
</Box>
{packages.map((pkg) => (
<Box
key={pkg.id}
role="cell"
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
px: 2,
py: 2,
borderTop: '1px solid',
borderColor: 'divider',
borderLeft: '1px solid',
borderLeftColor: 'divider',
}}
>
<CellValue value={lookupValue(pkg, section.heading, item.name)} />
</Box>
))}
</Box>
))}
</Box>
))}
{packages.some((p) => p.itemizedAvailable === false) && mergedSections.length > 0 && (
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
* Some providers have not provided an itemised pricing breakdown. Their items are
shown as "—" above.
</Typography>
)}
</Box>
</Box>
);
},
);
ComparisonTable.displayName = 'ComparisonTable';
export default ComparisonTable;

View File

@@ -1,9 +0,0 @@
export { ComparisonTable, default } from './ComparisonTable';
export type {
ComparisonTableProps,
ComparisonPackage,
ComparisonProvider,
ComparisonSection,
ComparisonLineItem,
ComparisonCellValue,
} from './ComparisonTable';

View File

@@ -41,6 +41,10 @@ export interface FuneralFinderV3Props {
onSearch?: (params: FuneralFinderV3SearchParams) => void; onSearch?: (params: FuneralFinderV3SearchParams) => void;
/** Shows loading state on the CTA */ /** Shows loading state on the CTA */
loading?: boolean; loading?: boolean;
/** Optional heading override */
heading?: string;
/** Optional subheading override */
subheading?: string;
/** MUI sx override for the root container */ /** MUI sx override for the root container */
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
} }
@@ -247,7 +251,13 @@ const selectMenuProps = {
*/ */
export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3Props>( export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3Props>(
(props, ref) => { (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 ────────────────────────────── // ─── IDs for aria-labelledby ──────────────────────────────
const id = React.useId(); const id = React.useId();
@@ -382,6 +392,29 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
...(Array.isArray(sx) ? sx : [sx]), ...(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 ─────────────────────────────────── */} {/* ── How can we help ─────────────────────────────────── */}
<Box ref={statusSectionRef}> <Box ref={statusSectionRef}>
<SectionLabel id={statusLabelId}>How Can We Help</SectionLabel> <SectionLabel id={statusLabelId}>How Can We Help</SectionLabel>
@@ -528,7 +561,7 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
placeholder="Enter suburb or postcode" placeholder="Enter suburb or postcode"
inputRef={locationInputRef} inputRef={locationInputRef}
startAdornment={ startAdornment={
<InputAdornment position="start" sx={{ ml: 0.25, mr: -0.5 }}> <InputAdornment position="start" sx={{ ml: 0.5 }}>
<LocationOnOutlinedIcon <LocationOnOutlinedIcon
sx={{ sx={{
fontSize: 20, fontSize: 20,
@@ -544,7 +577,6 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
...fieldBaseSx, ...fieldBaseSx,
'& .MuiOutlinedInput-input': { '& .MuiOutlinedInput-input': {
...fieldInputStyles, ...fieldInputStyles,
pl: 0.75,
'&::placeholder': { '&::placeholder': {
color: 'var(--fa-color-text-disabled)', color: 'var(--fa-color-text-disabled)',
opacity: 1, opacity: 1,
@@ -585,12 +617,12 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
loading={loading} loading={loading}
endIcon={!loading ? <ArrowForwardIcon /> : undefined} endIcon={!loading ? <ArrowForwardIcon /> : undefined}
onClick={handleSubmit} onClick={handleSubmit}
sx={{ minHeight: { xs: 40, sm: 52 }, fontSize: { xs: '0.875rem', sm: undefined } }} sx={{ minHeight: 52 }}
> >
Search Search Local Providers
</Button> </Button>
<Typography <Typography
variant="caption" variant="captionSm"
color="text.secondary" color="text.secondary"
sx={{ textAlign: 'center', display: 'block', mt: 1.5 }} sx={{ textAlign: 'center', display: 'block', mt: 1.5 }}
> >

View File

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

View File

@@ -6,14 +6,9 @@ import Drawer from '@mui/material/Drawer';
import List from '@mui/material/List'; import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton'; import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText'; 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 useMediaQuery from '@mui/material/useMediaQuery';
import MenuIcon from '@mui/icons-material/Menu'; import MenuIcon from '@mui/icons-material/Menu';
import CloseIcon from '@mui/icons-material/Close'; 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 type { SxProps, Theme } from '@mui/material/styles';
import { IconButton } from '../../atoms/IconButton'; import { IconButton } from '../../atoms/IconButton';
import { Link } from '../../atoms/Link'; import { Link } from '../../atoms/Link';
@@ -23,16 +18,14 @@ import { Divider } from '../../atoms/Divider';
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
/** A navigation link item. May have children to render as a dropdown. */ /** A navigation link item */
export interface NavItem { export interface NavItem {
/** Display label */ /** Display label */
label: string; label: string;
/** URL to navigate to (ignored when `children` is provided) */ /** URL to navigate to */
href?: string; href: string;
/** Click handler (alternative to href for SPA navigation) */ /** Click handler (alternative to href for SPA navigation) */
onClick?: () => void; onClick?: () => void;
/** Sub-items rendered as a dropdown (desktop) or collapsible (mobile) */
children?: NavItem[];
} }
/** Props for the FA Navigation organism */ /** Props for the FA Navigation organism */
@@ -51,163 +44,6 @@ export interface NavigationProps {
sx?: SxProps<Theme>; 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 ─────────────────────────────────────────────────────────────── // ─── Component ───────────────────────────────────────────────────────────────
/** /**
@@ -215,13 +51,26 @@ const MobileCollapsible: React.FC<MobileCollapsibleProps> = ({ item, onItemClick
* *
* Responsive header with logo, navigation links, and optional CTA. * Responsive header with logo, navigation links, and optional CTA.
* Desktop shows links inline; mobile collapses to hamburger + drawer. * 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" * Maps to Figma "Main Nav" (14:108) desktop and "Mobile Header"
* (2391:41508) mobile patterns. * (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>( export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
({ logo, onLogoClick, items = [], ctaLabel, onCtaClick, sx }, ref) => { ({ 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 isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down('md'));
const handleDrawerToggle = () => setDrawerOpen((prev) => !prev); const handleDrawerToggle = () => setDrawerOpen((prev) => !prev);
const closeDrawer = () => setDrawerOpen(false);
return ( return (
<> <>
@@ -299,28 +147,24 @@ export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
aria-label="Main navigation" aria-label="Main navigation"
sx={{ display: 'flex', alignItems: 'center', gap: 3.5 }} sx={{ display: 'flex', alignItems: 'center', gap: 3.5 }}
> >
{items.map((item) => {items.map((item) => (
item.children && item.children.length > 0 ? ( <Link
<DesktopDropdown key={item.label} item={item} /> key={item.label}
) : ( href={item.href}
<Link onClick={item.onClick}
key={item.label} underline="hover"
href={item.href} sx={{
onClick={item.onClick} color: 'var(--fa-color-brand-900)',
underline="hover" fontWeight: 600,
sx={{ fontSize: '1rem',
color: 'var(--fa-color-brand-900)', '&:hover': {
fontWeight: 600, color: 'primary.main',
fontSize: '1rem', },
'&:hover': { }}
color: 'primary.main', >
}, {item.label}
}} </Link>
> ))}
{item.label}
</Link>
),
)}
{ctaLabel && ( {ctaLabel && (
<Button variant="contained" size="medium" onClick={onCtaClick}> <Button variant="contained" size="medium" onClick={onCtaClick}>
@@ -366,40 +210,36 @@ export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
{/* Nav items */} {/* Nav items */}
<List component="nav" aria-label="Main navigation"> <List component="nav" aria-label="Main navigation">
{items.map((item) => {items.map((item) => (
item.children && item.children.length > 0 ? ( <ListItemButton
<MobileCollapsible key={item.label} item={item} onItemClick={closeDrawer} /> key={item.label}
) : ( component="a"
<ListItemButton href={item.href}
key={item.label} onClick={(e: React.MouseEvent) => {
component="a" if (item.onClick) {
href={item.href} e.preventDefault();
onClick={(e: React.MouseEvent) => { item.onClick();
if (item.onClick) { }
e.preventDefault(); setDrawerOpen(false);
item.onClick(); }}
} sx={{
closeDrawer(); py: 1.5,
px: 3,
minHeight: 44,
'&:hover': {
bgcolor: 'var(--fa-color-brand-100)',
},
}}
>
<ListItemText
primary={item.label}
primaryTypographyProps={{
fontWeight: 500,
fontSize: '1rem',
}} }}
sx={{ />
py: 1.5, </ListItemButton>
px: 3, ))}
minHeight: 44,
'&:hover': {
bgcolor: 'var(--fa-color-brand-100)',
},
}}
>
<ListItemText
primary={item.label}
primaryTypographyProps={{
fontWeight: 500,
fontSize: '1rem',
}}
/>
</ListItemButton>
),
)}
</List> </List>
{ctaLabel && ( {ctaLabel && (
@@ -410,7 +250,7 @@ export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
fullWidth fullWidth
onClick={() => { onClick={() => {
if (onCtaClick) onCtaClick(); if (onCtaClick) onCtaClick();
closeDrawer(); setDrawerOpen(false);
}} }}
> >
{ctaLabel} {ctaLabel}

View File

@@ -15,101 +15,97 @@ const DEMO_IMAGE =
const essentials = [ const essentials = [
{ {
name: 'Allowance for Coffin', name: 'Accommodation',
price: 1750, price: 1500,
isAllowance: true, info: 'Refrigerated holding of the deceased prior to the funeral service.',
info: 'Allowance amount — upgrade options available during arrangement.',
},
{
name: 'Cremation Certificate/Permit',
price: 350,
info: 'Statutory medical referee fee required for all cremations in NSW.',
},
{
name: 'Crematorium: Mackay Family Crematorium',
price: 660,
info: 'Cremation facility fees at the selected crematorium.',
}, },
{ {
name: 'Death Registration Certificate', name: 'Death Registration Certificate',
price: 70, price: 1500,
info: 'Lodgement of death registration with NSW Registry of Births, Deaths & Marriages.', info: 'Lodgement of death registration with NSW Registry of Births, Deaths & Marriages.',
}, },
{ {
name: 'Dressing Fee', name: 'Doctor Fee for Cremation',
price: 0, price: 1500,
priceLabel: 'Complimentary', info: 'Statutory medical referee fee required for all cremations in NSW.',
info: 'Dressing and preparation of the deceased — included at no charge.',
}, },
{ {
name: 'NSW Government Levy — Cremation', name: 'NSW Government Levy — Cremation',
price: 45.1, price: 1500,
info: 'NSW Government cremation levy as set by the Department of Health.', info: 'NSW Government cremation levy as set by the Department of Health.',
}, },
{ {
name: 'Professional Mortuary Care', name: 'Professional Mortuary Care',
price: 440, price: 1500,
info: 'Preparation and care of the deceased.', info: 'Preparation and care of the deceased.',
}, },
{ {
name: 'Professional Service Fee', name: 'Professional Service Fee',
price: 3650.9, price: 1500,
info: 'Coordination of all funeral arrangements and services.', info: 'Coordination of all funeral arrangements and services.',
}, },
{ {
name: 'Transportation Service Fee', name: 'Allowance for Coffin',
price: 0, price: 1500,
priceLabel: 'Complimentary', isAllowance: true,
info: 'Transfer of the deceased to the funeral home — included in this package.', info: 'Allowance amount — upgrade options available during arrangement.',
},
{
name: 'Allowance for Crematorium',
price: 1500,
isAllowance: true,
info: 'Allowance for crematorium fees — varies by location.',
},
{
name: 'Allowance for Hearse',
price: 1500,
isAllowance: true,
info: 'Allowance for hearse transfer — distance surcharges may apply.',
}, },
]; ];
const optionals = [ const complimentary = [
{ {
name: 'Digital Recording of the Funeral Service', name: 'Dressing Fee',
priceLabel: 'Complimentary', info: 'Dressing and preparation of the deceased — included at no charge.',
info: 'Professional video recording of the funeral service.',
},
{
name: 'Online Notice',
priceLabel: 'Complimentary',
info: 'Online death notice published on the funeral home website.',
},
{
name: 'Viewing Fee',
priceLabel: 'Complimentary',
info: 'One private family viewing — included at no charge.',
},
{
name: 'Webstreaming of the Funeral Service',
priceLabel: 'Complimentary',
info: 'Live webstream of the funeral service for remote attendees.',
}, },
{ name: 'Viewing Fee', info: 'One private family viewing — included at no charge.' },
]; ];
const extras = { const extras = {
heading: 'Extras', heading: 'Extras',
items: [ items: [
{ {
name: 'Allowance for Celebrant', name: 'Allowance for Flowers',
price: 550, price: 1500,
isAllowance: true,
info: 'Seasonal floral arrangements for the service.',
},
{
name: 'Allowance for Master of Ceremonies',
price: 1500,
isAllowance: true, isAllowance: true,
info: 'Professional celebrant or MC for the funeral service.', info: 'Professional celebrant or MC for the funeral service.',
}, },
{ {
name: 'Catering', name: 'After Business Hours Service Surcharge',
priceLabel: 'Price On Application', price: 1500,
info: 'Catering for the wake or post-service gathering.', info: 'Additional fee for services held outside standard business hours.',
}, },
{ {
name: 'Newspaper Notice', name: 'After Hours Prayers',
priceLabel: 'Price On Application', price: 1500,
info: 'Published death notice in local or national newspaper.', info: 'Evening prayer service at the funeral home.',
}, },
{ {
name: 'Saturday Service Fee', name: 'Coffin Bearing by Funeral Directors',
price: 880, price: 1500,
info: 'Additional fee for services held on a Saturday.', info: 'Professional pallbearing by funeral directors.',
},
{
name: 'Digital Recording',
price: 1500,
info: 'Professional video recording of the funeral service.',
}, },
], ],
}; };
@@ -173,16 +169,16 @@ type Story = StoryObj<typeof PackageDetail>;
// --- Default ----------------------------------------------------------------- // --- Default -----------------------------------------------------------------
/** Full package detail panel — Essentials, Optionals, Total, then Extras */ /** Full package detail panel — Essentials, Complimentary, Total, then Extras */
export const Default: Story = { export const Default: Story = {
args: { args: {
name: 'Traditional Family Cremation Service', name: 'Everyday Funeral Package',
price: 6966, price: 900,
sections: [ sections: [
{ heading: 'Essentials', items: essentials }, { heading: 'Essentials', items: essentials },
{ heading: 'Optionals', items: optionals }, { heading: 'Complimentary Items', items: complimentary },
], ],
total: 6966, total: 2700,
extras, extras,
terms: termsText, terms: termsText,
onArrange: () => alert('Make Arrangement clicked'), onArrange: () => alert('Make Arrangement clicked'),
@@ -195,10 +191,10 @@ export const Default: Story = {
/** Compare button in loading state — adding to comparison cart */ /** Compare button in loading state — adding to comparison cart */
export const CompareLoading: Story = { export const CompareLoading: Story = {
args: { args: {
name: 'Traditional Family Cremation Service', name: 'Everyday Funeral Package',
price: 6966, price: 900,
sections: [{ heading: 'Essentials', items: essentials.slice(0, 4) }], sections: [{ heading: 'Essentials', items: essentials.slice(0, 4) }],
total: 6966, total: 6000,
onArrange: () => alert('Make Arrangement'), onArrange: () => alert('Make Arrangement'),
onCompare: () => {}, onCompare: () => {},
compareLoading: true, compareLoading: true,
@@ -207,16 +203,16 @@ export const CompareLoading: Story = {
// --- Without Extras ---------------------------------------------------------- // --- Without Extras ----------------------------------------------------------
/** Simpler package with essentials and optionals only — no extras */ /** Simpler package with essentials and complimentary only */
export const WithoutExtras: Story = { export const WithoutExtras: Story = {
args: { args: {
name: 'Essential Cremation Package', name: 'Essential Funeral Package',
price: 4850, price: 600,
sections: [ sections: [
{ heading: 'Essentials', items: essentials.slice(0, 6) }, { heading: 'Essentials', items: essentials.slice(0, 6) },
{ heading: 'Optionals', items: optionals.slice(0, 2) }, { heading: 'Complimentary Items', items: complimentary },
], ],
total: 4850, total: 9000,
terms: termsText, terms: termsText,
onArrange: () => alert('Make Arrangement'), onArrange: () => alert('Make Arrangement'),
onCompare: () => alert('Compare'), onCompare: () => alert('Compare'),
@@ -336,9 +332,9 @@ export const PackageSelectPage: Story = {
price={packages.find((p) => p.id === selectedPkg)?.price ?? 0} price={packages.find((p) => p.id === selectedPkg)?.price ?? 0}
sections={[ sections={[
{ heading: 'Essentials', items: essentials }, { heading: 'Essentials', items: essentials },
{ heading: 'Optionals', items: optionals }, { heading: 'Complimentary Items', items: complimentary },
]} ]}
total={6966} total={2700}
extras={extras} extras={extras}
terms={termsText} terms={termsText}
onArrange={() => alert(`Making arrangement for: ${selectedPkg}`)} onArrange={() => alert(`Making arrangement for: ${selectedPkg}`)}

View File

@@ -19,8 +19,6 @@ export interface PackageLineItem {
price?: number; price?: number;
/** Whether this is an allowance (shows asterisk) */ /** Whether this is an allowance (shows asterisk) */
isAllowance?: boolean; isAllowance?: boolean;
/** Custom price display — overrides formatted price (e.g. "Complimentary", "Price On Application") */
priceLabel?: string;
} }
/** A section of items within a package (e.g. "Essentials", "Complimentary Items") */ /** A section of items within a package (e.g. "Essentials", "Complimentary Items") */
@@ -85,7 +83,6 @@ function SectionBlock({ section, subtext }: { section: PackageSection; subtext?:
info={item.info} info={item.info}
price={item.price} price={item.price}
isAllowance={item.isAllowance} isAllowance={item.isAllowance}
priceLabel={item.priceLabel}
/> />
))} ))}
</Box> </Box>

View File

@@ -109,7 +109,6 @@ export const ServiceSelector = React.forwardRef<HTMLDivElement, ServiceSelectorP
<Box <Box
role="radiogroup" role="radiogroup"
aria-label={heading} aria-label={heading}
aria-required={continueLabel ? true : undefined}
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}
> >
{items.map((item) => ( {items.map((item) => (

View File

@@ -1,457 +0,0 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { ComparisonPage } from './ComparisonPage';
import type { ComparisonPackage } from '../../organisms/ComparisonTable';
import { Navigation } from '../../organisms/Navigation';
const DEMO_LOGO = 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=72&h=72&fit=crop';
const FALogoNav = () => (
<Box component="img" src="/brandlogo/logo-full.svg" alt="Funeral Arranger" sx={{ height: 28 }} />
);
// ─── Mock data ──────────────────────────────────────────────────────────────
const pkgWollongong: ComparisonPackage = {
id: 'wollongong-everyday',
name: 'Everyday Funeral Package',
price: 6966,
provider: {
name: 'Wollongong City Funerals',
location: 'Wollongong',
logoUrl: DEMO_LOGO,
rating: 4.8,
reviewCount: 122,
verified: true,
},
sections: [
{
heading: 'Essentials',
items: [
{
name: 'Allowance for Coffin',
info: 'Allowance amount — upgrade options available.',
value: { type: 'allowance', amount: 1750 },
},
{
name: 'Cremation Certificate/Permit',
info: 'Statutory medical referee fee.',
value: { type: 'price', amount: 350 },
},
{
name: 'Crematorium',
info: 'Cremation facility fees.',
value: { type: 'price', amount: 660 },
},
{
name: 'Death Registration Certificate',
info: 'Lodgement with NSW Registry.',
value: { type: 'price', amount: 70 },
},
{
name: 'Dressing Fee',
info: 'Dressing and preparation.',
value: { type: 'complimentary' },
},
{
name: 'NSW Government Levy — Cremation',
info: 'NSW Government cremation levy.',
value: { type: 'price', amount: 45.1 },
},
{
name: 'Professional Mortuary Care',
info: 'Preparation and care.',
value: { type: 'price', amount: 440 },
},
{
name: 'Professional Service Fee',
info: 'Coordination of arrangements.',
value: { type: 'price', amount: 3650.9 },
},
{
name: 'Transportation Service Fee',
info: 'Transfer of the deceased.',
value: { type: 'complimentary' },
},
],
},
{
heading: 'Optionals',
items: [
{
name: 'Digital Recording',
info: 'Professional video recording.',
value: { type: 'complimentary' },
},
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'complimentary' } },
{
name: 'Viewing Fee',
info: 'One private family viewing.',
value: { type: 'complimentary' },
},
{
name: 'Flowers',
info: 'Seasonal floral arrangements.',
value: { type: 'complimentary' },
},
],
},
{
heading: 'Extras',
items: [
{
name: 'Allowance for Celebrant',
info: 'Professional celebrant or MC.',
value: { type: 'allowance', amount: 550 },
},
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
{
name: 'Saturday Service Fee',
info: 'Additional fee for Saturday services.',
value: { type: 'price', amount: 880 },
},
],
},
],
};
const pkgMackay: ComparisonPackage = {
id: 'mackay-everyday',
name: 'Everyday Funeral Package',
price: 5495.45,
provider: {
name: 'Mackay Family Funerals',
location: 'Inglewood',
logoUrl: DEMO_LOGO,
rating: 4.6,
reviewCount: 87,
verified: true,
},
sections: [
{
heading: 'Essentials',
items: [
{
name: 'Allowance for Coffin',
info: 'Allowance amount.',
value: { type: 'allowance', amount: 1500 },
},
{
name: 'Cremation Certificate/Permit',
info: 'Medical referee fee.',
value: { type: 'price', amount: 350 },
},
{
name: 'Crematorium',
info: 'Cremation facility fees.',
value: { type: 'price', amount: 660 },
},
{
name: 'Death Registration Certificate',
info: 'NSW Registry.',
value: { type: 'price', amount: 70 },
},
{ name: 'Dressing Fee', info: 'Dressing and preparation.', value: { type: 'included' } },
{
name: 'NSW Government Levy — Cremation',
info: 'Government levy.',
value: { type: 'price', amount: 45.1 },
},
{
name: 'Professional Mortuary Care',
info: 'Preparation and care.',
value: { type: 'price', amount: 440 },
},
{
name: 'Professional Service Fee',
info: 'Coordination.',
value: { type: 'price', amount: 2430.35 },
},
{ name: 'Transportation Service Fee', info: 'Transfer.', value: { type: 'included' } },
],
},
{
heading: 'Optionals',
items: [
{ name: 'Digital Recording', info: 'Video recording.', value: { type: 'unknown' } },
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'included' } },
{ name: 'Viewing Fee', info: 'Private family viewing.', value: { type: 'included' } },
{ name: 'Flowers', info: 'Floral arrangements.', value: { type: 'included' } },
],
},
{
heading: 'Extras',
items: [
{
name: 'Allowance for Celebrant',
info: 'Celebrant or MC.',
value: { type: 'allowance', amount: 450 },
},
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
{
name: 'Saturday Service Fee',
info: 'Saturday surcharge.',
value: { type: 'price', amount: 750 },
},
],
},
],
};
const pkgInglewood: ComparisonPackage = {
id: 'inglewood-everyday',
name: 'Everyday Funeral Package',
price: 7200,
provider: {
name: 'Inglewood Chapel',
location: 'Inglewood',
logoUrl: DEMO_LOGO,
rating: 4.2,
reviewCount: 45,
verified: false,
},
sections: [
{
heading: 'Essentials',
items: [
{ name: 'Allowance for Coffin', value: { type: 'unknown' } },
{ name: 'Cremation Certificate/Permit', value: { type: 'unknown' } },
{ name: 'Crematorium', value: { type: 'unknown' } },
{ name: 'Death Registration Certificate', value: { type: 'unknown' } },
{ name: 'Dressing Fee', value: { type: 'unknown' } },
{ name: 'NSW Government Levy — Cremation', value: { type: 'unknown' } },
{ name: 'Professional Mortuary Care', value: { type: 'unknown' } },
{ name: 'Professional Service Fee', value: { type: 'unknown' } },
{ name: 'Transportation Service Fee', value: { type: 'unknown' } },
],
},
{
heading: 'Optionals',
items: [
{ name: 'Digital Recording', value: { type: 'unknown' } },
{ name: 'Flowers', value: { type: 'unknown' } },
{ name: 'Online Notice', value: { type: 'unknown' } },
{ name: 'Viewing Fee', value: { type: 'unknown' } },
],
},
{
heading: 'Extras',
items: [
{ name: 'Allowance for Celebrant', value: { type: 'unknown' } },
{ name: 'Catering', value: { type: 'unknown' } },
{ name: 'Newspaper Notice', value: { type: 'unknown' } },
{ name: 'Saturday Service Fee', value: { type: 'unknown' } },
],
},
],
};
const pkgRecommended: ComparisonPackage = {
id: 'recommended-premium',
name: 'Premium Cremation Service',
price: 8450,
provider: {
name: 'H. Parsons Funeral Directors',
location: 'Wentworth',
logoUrl: DEMO_LOGO,
rating: 4.9,
reviewCount: 203,
verified: true,
},
sections: [
{
heading: 'Essentials',
items: [
{
name: 'Allowance for Coffin',
info: 'Premium coffin allowance.',
value: { type: 'allowance', amount: 2500 },
},
{
name: 'Cremation Certificate/Permit',
info: 'Medical referee fee.',
value: { type: 'price', amount: 350 },
},
{
name: 'Crematorium',
info: 'Premium crematorium.',
value: { type: 'price', amount: 850 },
},
{
name: 'Death Registration Certificate',
info: 'NSW Registry.',
value: { type: 'price', amount: 70 },
},
{
name: 'Dressing Fee',
info: 'Dressing and preparation.',
value: { type: 'complimentary' },
},
{
name: 'NSW Government Levy — Cremation',
info: 'Government levy.',
value: { type: 'price', amount: 45.1 },
},
{
name: 'Professional Mortuary Care',
info: 'Full preparation and care.',
value: { type: 'price', amount: 580 },
},
{
name: 'Professional Service Fee',
info: 'Full coordination.',
value: { type: 'price', amount: 4054.9 },
},
{
name: 'Transportation Service Fee',
info: 'Premium transfer.',
value: { type: 'complimentary' },
},
],
},
{
heading: 'Optionals',
items: [
{
name: 'Digital Recording',
info: 'HD video recording.',
value: { type: 'complimentary' },
},
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'complimentary' } },
{ name: 'Viewing Fee', info: 'Private family viewing.', value: { type: 'complimentary' } },
{ name: 'Flowers', info: 'Premium floral arrangements.', value: { type: 'complimentary' } },
{ name: 'Webstreaming', info: 'HD live webstream.', value: { type: 'complimentary' } },
],
},
{
heading: 'Extras',
items: [
{
name: 'Allowance for Celebrant',
info: 'Premium celebrant.',
value: { type: 'allowance', amount: 700 },
},
{
name: 'Catering',
info: 'Full catering included.',
value: { type: 'price', amount: 1200 },
},
{
name: 'Newspaper Notice',
info: 'Published death notice.',
value: { type: 'price', amount: 350 },
},
{
name: 'Saturday Service Fee',
info: 'No Saturday surcharge.',
value: { type: 'complimentary' },
},
],
},
],
};
// ─── Meta ───────────────────────────────────────────────────────────────────
const defaultNav = (
<Navigation
logo={<FALogoNav />}
items={[
{ label: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' },
{ label: 'Log in', href: '/login' },
]}
/>
);
const meta: Meta<typeof ComparisonPage> = {
title: 'Pages/ComparisonPage',
component: ComparisonPage,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
args: {
navigation: defaultNav,
onShare: () => alert('Share'),
onPrint: () => window.print(),
},
};
export default meta;
type Story = StoryObj<typeof ComparisonPage>;
// --- Default (3 packages, desktop) -------------------------------------------
/** Three packages from different providers */
export const Default: Story = {
args: {
packages: [pkgWollongong, pkgMackay, pkgInglewood],
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
onBack: () => alert('Back'),
},
};
// --- Two Packages ------------------------------------------------------------
/** Minimal two-package comparison */
export const TwoPackages: Story = {
args: {
packages: [pkgWollongong, pkgMackay],
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
onBack: () => alert('Back'),
},
};
// --- With Recommended --------------------------------------------------------
/** 3 user packages + 1 recommended — recommended shown as additional column/tab */
export const WithRecommended: Story = {
args: {
packages: [pkgWollongong, pkgMackay, pkgInglewood],
recommendedPackage: pkgRecommended,
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
onBack: () => alert('Back'),
},
};
// --- Mobile View -------------------------------------------------------------
/** Mobile viewport — shows tabbed card view */
export const MobileView: Story = {
parameters: {
viewport: { defaultViewport: 'mobile1' },
},
args: {
packages: [pkgWollongong, pkgMackay, pkgInglewood],
recommendedPackage: pkgRecommended,
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
onBack: () => alert('Back'),
},
};
// --- Interactive (with remove) -----------------------------------------------
/** Interactive — remove packages from comparison */
export const Interactive: Story = {
render: (args) => {
const [pkgs, setPkgs] = useState([pkgWollongong, pkgMackay, pkgInglewood]);
return (
<ComparisonPage
{...args}
packages={pkgs}
recommendedPackage={pkgRecommended}
onArrange={(id) => alert(`Make arrangement for: ${id}`)}
onRemove={(id) => setPkgs(pkgs.filter((p) => p.id !== id))}
onBack={() => alert('Back to packages')}
/>
);
},
};

View File

@@ -1,237 +0,0 @@
import React, { useId, useState, useRef, useCallback } from 'react';
import Box from '@mui/material/Box';
import useMediaQuery from '@mui/material/useMediaQuery';
import { useTheme } from '@mui/material/styles';
import 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 { WizardLayout } from '../../templates/WizardLayout';
import { ComparisonTable, type ComparisonPackage } from '../../organisms/ComparisonTable';
import { ComparisonPackageCard } from '../../molecules/ComparisonPackageCard';
import { ComparisonTabCard } from '../../molecules/ComparisonTabCard';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the ComparisonPage */
export interface ComparisonPageProps {
/** User-selected packages to compare (max 3) */
packages: ComparisonPackage[];
/** System-recommended package — always shown as an additional column */
recommendedPackage?: ComparisonPackage;
/** Called when user clicks CTA on a package */
onArrange: (packageId: string) => void;
/** Called when user removes a package from comparison */
onRemove: (packageId: string) => void;
/** Called when user clicks Back */
onBack: () => void;
/** Called when user clicks Share */
onShare?: () => void;
/** Called when user clicks Print */
onPrint?: () => void;
/** Navigation bar slot */
navigation?: React.ReactNode;
/** MUI sx prop */
sx?: SxProps<Theme>;
}
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Package comparison page for the FA design system (V2 — production).
*
* Desktop: Full ComparisonTable with info card, floating verified badges,
* section tables with left accent borders. **Recommended package appears as
* the first (leftmost) column.**
* Mobile: Tabbed card view with horizontal tab rail. **Recommended package is
* the first tab in the rail, but the first user-selected package is the
* initially active tab** — the recommended tab is a suggestion, not the
* default view.
*
* Share + Print utility actions in the page header.
*
* See `ComparisonPageV1.tsx` for the archived V1 (recommended-last) layout.
*/
export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPageProps>(
(
{ packages, recommendedPackage, onArrange, onRemove, onBack, onShare, onPrint, navigation, sx },
ref,
) => {
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[] = [];
if (recommendedPackage) {
result.push({ ...recommendedPackage, isRecommended: true });
}
result.push(...packages);
return result;
}, [packages, recommendedPackage]);
// On mobile, default the active tab to the first user-selected package
// (not the recommended). Recommended is first in the rail as a suggestion.
const defaultTabIdx = recommendedPackage ? 1 : 0;
const [activeTabIdx, setActiveTabIdx] = useState(defaultTabIdx);
const activePackage = allPackages[activeTabIdx] ?? allPackages[0];
const providerCount = new Set(allPackages.map((p) => p.provider.name)).size;
const subtitle =
providerCount > 1
? `Comparing ${packages.length} package${packages.length !== 1 ? 's' : ''} from different providers`
: `Comparing ${packages.length} package${packages.length !== 1 ? 's' : ''}`;
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
variant="wide-form"
navigation={navigation}
showBackLink
backLabel="Back"
onBack={onBack}
>
{/* Page header with Share/Print actions */}
<Box sx={{ mb: { xs: 3, md: 5 } }}>
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 2,
flexWrap: 'wrap',
}}
>
<Box>
<Typography variant="h2" component="h1" sx={{ mb: 1 }}>
Compare packages
</Typography>
<Typography variant="body1" color="text.secondary" aria-live="polite">
{subtitle}
</Typography>
</Box>
{/* Share + Print */}
{(onShare || onPrint) && (
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
{onShare && (
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<ShareOutlinedIcon />}
onClick={onShare}
>
Share
</Button>
)}
{onPrint && (
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<PrintOutlinedIcon />}
onClick={onPrint}
>
Print
</Button>
)}
</Box>
)}
</Box>
</Box>
{/* Desktop: ComparisonTable */}
{!isMobile && (
<ComparisonTable packages={allPackages} onArrange={onArrange} onRemove={onRemove} />
)}
{/* Mobile: Tab rail + card view */}
{isMobile && allPackages.length > 0 && (
<>
{/* Tab rail — mini cards showing provider + package + price */}
<Box
ref={railRef}
role="tablist"
id={tablistId}
aria-label="Packages to compare"
sx={{
display: 'flex',
gap: 1.5,
overflowX: 'auto',
py: 2,
px: 2,
mx: -2,
mt: 1,
mb: 3,
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
WebkitOverflowScrolling: 'touch',
}}
>
{allPackages.map((pkg, idx) => (
<ComparisonTabCard
key={pkg.id}
ref={(el: HTMLDivElement | null) => {
tabRefs.current[idx] = el;
}}
pkg={pkg}
isActive={idx === activeTabIdx}
hasRecommended={hasRecommended}
tabId={`comparison-tab-${idx}`}
tabPanelId={`comparison-tabpanel-${idx}`}
onClick={() => handleTabClick(idx)}
/>
))}
</Box>
{activePackage && (
<Box
role="tabpanel"
id={`comparison-tabpanel-${activeTabIdx}`}
aria-labelledby={`comparison-tab-${activeTabIdx}`}
>
<ComparisonPackageCard pkg={activePackage} onArrange={onArrange} />
</Box>
)}
</>
)}
</WizardLayout>
</Box>
);
},
);
ComparisonPage.displayName = 'ComparisonPage';
export default ComparisonPage;

View File

@@ -1,457 +0,0 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { ComparisonPageV1 } from './ComparisonPageV1';
import type { ComparisonPackage } from '../../organisms/ComparisonTable';
import { Navigation } from '../../organisms/Navigation';
const DEMO_LOGO = 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=72&h=72&fit=crop';
const FALogoNav = () => (
<Box component="img" src="/brandlogo/logo-full.svg" alt="Funeral Arranger" sx={{ height: 28 }} />
);
// ─── Mock data ──────────────────────────────────────────────────────────────
const pkgWollongong: ComparisonPackage = {
id: 'wollongong-everyday',
name: 'Everyday Funeral Package',
price: 6966,
provider: {
name: 'Wollongong City Funerals',
location: 'Wollongong',
logoUrl: DEMO_LOGO,
rating: 4.8,
reviewCount: 122,
verified: true,
},
sections: [
{
heading: 'Essentials',
items: [
{
name: 'Allowance for Coffin',
info: 'Allowance amount — upgrade options available.',
value: { type: 'allowance', amount: 1750 },
},
{
name: 'Cremation Certificate/Permit',
info: 'Statutory medical referee fee.',
value: { type: 'price', amount: 350 },
},
{
name: 'Crematorium',
info: 'Cremation facility fees.',
value: { type: 'price', amount: 660 },
},
{
name: 'Death Registration Certificate',
info: 'Lodgement with NSW Registry.',
value: { type: 'price', amount: 70 },
},
{
name: 'Dressing Fee',
info: 'Dressing and preparation.',
value: { type: 'complimentary' },
},
{
name: 'NSW Government Levy — Cremation',
info: 'NSW Government cremation levy.',
value: { type: 'price', amount: 45.1 },
},
{
name: 'Professional Mortuary Care',
info: 'Preparation and care.',
value: { type: 'price', amount: 440 },
},
{
name: 'Professional Service Fee',
info: 'Coordination of arrangements.',
value: { type: 'price', amount: 3650.9 },
},
{
name: 'Transportation Service Fee',
info: 'Transfer of the deceased.',
value: { type: 'complimentary' },
},
],
},
{
heading: 'Optionals',
items: [
{
name: 'Digital Recording',
info: 'Professional video recording.',
value: { type: 'complimentary' },
},
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'complimentary' } },
{
name: 'Viewing Fee',
info: 'One private family viewing.',
value: { type: 'complimentary' },
},
{
name: 'Flowers',
info: 'Seasonal floral arrangements.',
value: { type: 'complimentary' },
},
],
},
{
heading: 'Extras',
items: [
{
name: 'Allowance for Celebrant',
info: 'Professional celebrant or MC.',
value: { type: 'allowance', amount: 550 },
},
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
{
name: 'Saturday Service Fee',
info: 'Additional fee for Saturday services.',
value: { type: 'price', amount: 880 },
},
],
},
],
};
const pkgMackay: ComparisonPackage = {
id: 'mackay-everyday',
name: 'Everyday Funeral Package',
price: 5495.45,
provider: {
name: 'Mackay Family Funerals',
location: 'Inglewood',
logoUrl: DEMO_LOGO,
rating: 4.6,
reviewCount: 87,
verified: true,
},
sections: [
{
heading: 'Essentials',
items: [
{
name: 'Allowance for Coffin',
info: 'Allowance amount.',
value: { type: 'allowance', amount: 1500 },
},
{
name: 'Cremation Certificate/Permit',
info: 'Medical referee fee.',
value: { type: 'price', amount: 350 },
},
{
name: 'Crematorium',
info: 'Cremation facility fees.',
value: { type: 'price', amount: 660 },
},
{
name: 'Death Registration Certificate',
info: 'NSW Registry.',
value: { type: 'price', amount: 70 },
},
{ name: 'Dressing Fee', info: 'Dressing and preparation.', value: { type: 'included' } },
{
name: 'NSW Government Levy — Cremation',
info: 'Government levy.',
value: { type: 'price', amount: 45.1 },
},
{
name: 'Professional Mortuary Care',
info: 'Preparation and care.',
value: { type: 'price', amount: 440 },
},
{
name: 'Professional Service Fee',
info: 'Coordination.',
value: { type: 'price', amount: 2430.35 },
},
{ name: 'Transportation Service Fee', info: 'Transfer.', value: { type: 'included' } },
],
},
{
heading: 'Optionals',
items: [
{ name: 'Digital Recording', info: 'Video recording.', value: { type: 'unknown' } },
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'included' } },
{ name: 'Viewing Fee', info: 'Private family viewing.', value: { type: 'included' } },
{ name: 'Flowers', info: 'Floral arrangements.', value: { type: 'included' } },
],
},
{
heading: 'Extras',
items: [
{
name: 'Allowance for Celebrant',
info: 'Celebrant or MC.',
value: { type: 'allowance', amount: 450 },
},
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
{
name: 'Saturday Service Fee',
info: 'Saturday surcharge.',
value: { type: 'price', amount: 750 },
},
],
},
],
};
const pkgInglewood: ComparisonPackage = {
id: 'inglewood-everyday',
name: 'Everyday Funeral Package',
price: 7200,
provider: {
name: 'Inglewood Chapel',
location: 'Inglewood',
logoUrl: DEMO_LOGO,
rating: 4.2,
reviewCount: 45,
verified: false,
},
sections: [
{
heading: 'Essentials',
items: [
{ name: 'Allowance for Coffin', value: { type: 'unknown' } },
{ name: 'Cremation Certificate/Permit', value: { type: 'unknown' } },
{ name: 'Crematorium', value: { type: 'unknown' } },
{ name: 'Death Registration Certificate', value: { type: 'unknown' } },
{ name: 'Dressing Fee', value: { type: 'unknown' } },
{ name: 'NSW Government Levy — Cremation', value: { type: 'unknown' } },
{ name: 'Professional Mortuary Care', value: { type: 'unknown' } },
{ name: 'Professional Service Fee', value: { type: 'unknown' } },
{ name: 'Transportation Service Fee', value: { type: 'unknown' } },
],
},
{
heading: 'Optionals',
items: [
{ name: 'Digital Recording', value: { type: 'unknown' } },
{ name: 'Flowers', value: { type: 'unknown' } },
{ name: 'Online Notice', value: { type: 'unknown' } },
{ name: 'Viewing Fee', value: { type: 'unknown' } },
],
},
{
heading: 'Extras',
items: [
{ name: 'Allowance for Celebrant', value: { type: 'unknown' } },
{ name: 'Catering', value: { type: 'unknown' } },
{ name: 'Newspaper Notice', value: { type: 'unknown' } },
{ name: 'Saturday Service Fee', value: { type: 'unknown' } },
],
},
],
};
const pkgRecommended: ComparisonPackage = {
id: 'recommended-premium',
name: 'Premium Cremation Service',
price: 8450,
provider: {
name: 'H. Parsons Funeral Directors',
location: 'Wentworth',
logoUrl: DEMO_LOGO,
rating: 4.9,
reviewCount: 203,
verified: true,
},
sections: [
{
heading: 'Essentials',
items: [
{
name: 'Allowance for Coffin',
info: 'Premium coffin allowance.',
value: { type: 'allowance', amount: 2500 },
},
{
name: 'Cremation Certificate/Permit',
info: 'Medical referee fee.',
value: { type: 'price', amount: 350 },
},
{
name: 'Crematorium',
info: 'Premium crematorium.',
value: { type: 'price', amount: 850 },
},
{
name: 'Death Registration Certificate',
info: 'NSW Registry.',
value: { type: 'price', amount: 70 },
},
{
name: 'Dressing Fee',
info: 'Dressing and preparation.',
value: { type: 'complimentary' },
},
{
name: 'NSW Government Levy — Cremation',
info: 'Government levy.',
value: { type: 'price', amount: 45.1 },
},
{
name: 'Professional Mortuary Care',
info: 'Full preparation and care.',
value: { type: 'price', amount: 580 },
},
{
name: 'Professional Service Fee',
info: 'Full coordination.',
value: { type: 'price', amount: 4054.9 },
},
{
name: 'Transportation Service Fee',
info: 'Premium transfer.',
value: { type: 'complimentary' },
},
],
},
{
heading: 'Optionals',
items: [
{
name: 'Digital Recording',
info: 'HD video recording.',
value: { type: 'complimentary' },
},
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'complimentary' } },
{ name: 'Viewing Fee', info: 'Private family viewing.', value: { type: 'complimentary' } },
{ name: 'Flowers', info: 'Premium floral arrangements.', value: { type: 'complimentary' } },
{ name: 'Webstreaming', info: 'HD live webstream.', value: { type: 'complimentary' } },
],
},
{
heading: 'Extras',
items: [
{
name: 'Allowance for Celebrant',
info: 'Premium celebrant.',
value: { type: 'allowance', amount: 700 },
},
{
name: 'Catering',
info: 'Full catering included.',
value: { type: 'price', amount: 1200 },
},
{
name: 'Newspaper Notice',
info: 'Published death notice.',
value: { type: 'price', amount: 350 },
},
{
name: 'Saturday Service Fee',
info: 'No Saturday surcharge.',
value: { type: 'complimentary' },
},
],
},
],
};
// ─── Meta ───────────────────────────────────────────────────────────────────
const defaultNav = (
<Navigation
logo={<FALogoNav />}
items={[
{ label: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' },
{ label: 'Log in', href: '/login' },
]}
/>
);
const meta: Meta<typeof ComparisonPageV1> = {
title: 'Archive/ComparisonPage V1',
component: ComparisonPageV1,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
args: {
navigation: defaultNav,
onShare: () => alert('Share'),
onPrint: () => window.print(),
},
};
export default meta;
type Story = StoryObj<typeof ComparisonPageV1>;
// --- Default (3 packages, desktop) -------------------------------------------
/** Three packages from different providers */
export const Default: Story = {
args: {
packages: [pkgWollongong, pkgMackay, pkgInglewood],
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
onBack: () => alert('Back'),
},
};
// --- Two Packages ------------------------------------------------------------
/** Minimal two-package comparison */
export const TwoPackages: Story = {
args: {
packages: [pkgWollongong, pkgMackay],
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
onBack: () => alert('Back'),
},
};
// --- With Recommended --------------------------------------------------------
/** 3 user packages + 1 recommended — recommended shown as additional column/tab */
export const WithRecommended: Story = {
args: {
packages: [pkgWollongong, pkgMackay, pkgInglewood],
recommendedPackage: pkgRecommended,
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
onBack: () => alert('Back'),
},
};
// --- Mobile View -------------------------------------------------------------
/** Mobile viewport — shows tabbed card view */
export const MobileView: Story = {
parameters: {
viewport: { defaultViewport: 'mobile1' },
},
args: {
packages: [pkgWollongong, pkgMackay, pkgInglewood],
recommendedPackage: pkgRecommended,
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
onBack: () => alert('Back'),
},
};
// --- Interactive (with remove) -----------------------------------------------
/** Interactive — remove packages from comparison */
export const Interactive: Story = {
render: (args) => {
const [pkgs, setPkgs] = useState([pkgWollongong, pkgMackay, pkgInglewood]);
return (
<ComparisonPageV1
{...args}
packages={pkgs}
recommendedPackage={pkgRecommended}
onArrange={(id) => alert(`Make arrangement for: ${id}`)}
onRemove={(id) => setPkgs(pkgs.filter((p) => p.id !== id))}
onBack={() => alert('Back to packages')}
/>
);
},
};

View File

@@ -1,230 +0,0 @@
import React, { useId, useState, useRef, useCallback } from 'react';
import Box from '@mui/material/Box';
import useMediaQuery from '@mui/material/useMediaQuery';
import { useTheme } from '@mui/material/styles';
import 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 { WizardLayout } from '../../templates/WizardLayout';
import { ComparisonTable, type ComparisonPackage } from '../../organisms/ComparisonTable';
import { ComparisonPackageCard } from '../../molecules/ComparisonPackageCard';
import { ComparisonTabCard } from '../../molecules/ComparisonTabCard';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the ComparisonPageV1 */
export interface ComparisonPageV1Props {
/** User-selected packages to compare (max 3) */
packages: ComparisonPackage[];
/** System-recommended package — always shown as an additional column */
recommendedPackage?: ComparisonPackage;
/** Called when user clicks CTA on a package */
onArrange: (packageId: string) => void;
/** Called when user removes a package from comparison */
onRemove: (packageId: string) => void;
/** Called when user clicks Back */
onBack: () => void;
/** Called when user clicks Share */
onShare?: () => void;
/** Called when user clicks Print */
onPrint?: () => void;
/** Navigation bar slot */
navigation?: React.ReactNode;
/** MUI sx prop */
sx?: SxProps<Theme>;
}
// ─── Component ──────────────────────────────────────────────────────────────
/**
* **Archived — V1.** See `ComparisonPage.tsx` (V2) for the production version.
*
* Package comparison page for the FA design system.
*
* Desktop: Full ComparisonTable with info card, floating verified badges,
* section tables with left accent borders. Recommended package appears as the
* **last** column.
* Mobile: Tabbed card view with horizontal chip rail. Recommended package is
* the last tab.
*
* Share + Print utility actions in the page header.
*/
export const ComparisonPageV1 = React.forwardRef<HTMLDivElement, ComparisonPageV1Props>(
(
{ packages, recommendedPackage, onArrange, onRemove, onBack, onShare, onPrint, navigation, sx },
ref,
) => {
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];
if (recommendedPackage) {
result.push({ ...recommendedPackage, isRecommended: true });
}
return result;
}, [packages, recommendedPackage]);
const [activeTabIdx, setActiveTabIdx] = useState(0);
const activePackage = allPackages[activeTabIdx] ?? allPackages[0];
const providerCount = new Set(allPackages.map((p) => p.provider.name)).size;
const subtitle =
providerCount > 1
? `Comparing ${packages.length} package${packages.length !== 1 ? 's' : ''} from different providers`
: `Comparing ${packages.length} package${packages.length !== 1 ? 's' : ''}`;
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
variant="wide-form"
navigation={navigation}
showBackLink
backLabel="Back"
onBack={onBack}
>
{/* Page header with Share/Print actions */}
<Box sx={{ mb: { xs: 3, md: 5 } }}>
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 2,
flexWrap: 'wrap',
}}
>
<Box>
<Typography variant="h2" component="h1" sx={{ mb: 1 }}>
Compare packages
</Typography>
<Typography variant="body1" color="text.secondary" aria-live="polite">
{subtitle}
</Typography>
</Box>
{/* Share + Print */}
{(onShare || onPrint) && (
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
{onShare && (
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<ShareOutlinedIcon />}
onClick={onShare}
>
Share
</Button>
)}
{onPrint && (
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<PrintOutlinedIcon />}
onClick={onPrint}
>
Print
</Button>
)}
</Box>
)}
</Box>
</Box>
{/* Desktop: ComparisonTable */}
{!isMobile && (
<ComparisonTable packages={allPackages} onArrange={onArrange} onRemove={onRemove} />
)}
{/* Mobile: Tab rail + card view */}
{isMobile && allPackages.length > 0 && (
<>
{/* Tab rail — mini cards showing provider + package + price */}
<Box
ref={railRef}
role="tablist"
id={tablistId}
aria-label="Packages to compare"
sx={{
display: 'flex',
gap: 1.5,
overflowX: 'auto',
py: 2,
px: 2,
mx: -2,
mt: 1,
mb: 3,
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
WebkitOverflowScrolling: 'touch',
}}
>
{allPackages.map((pkg, idx) => (
<ComparisonTabCard
key={pkg.id}
ref={(el: HTMLDivElement | null) => {
tabRefs.current[idx] = el;
}}
pkg={pkg}
isActive={idx === activeTabIdx}
hasRecommended={hasRecommended}
tabId={`comparison-tab-${idx}`}
tabPanelId={`comparison-tabpanel-${idx}`}
onClick={() => handleTabClick(idx)}
/>
))}
</Box>
{activePackage && (
<Box
role="tabpanel"
id={`comparison-tabpanel-${activeTabIdx}`}
aria-labelledby={`comparison-tab-${activeTabIdx}`}
>
<ComparisonPackageCard pkg={activePackage} onArrange={onArrange} />
</Box>
)}
</>
)}
</WizardLayout>
</Box>
);
},
);
ComparisonPageV1.displayName = 'ComparisonPageV1';
export default ComparisonPageV1;

View File

@@ -1,4 +0,0 @@
export { ComparisonPage, default } from './ComparisonPage';
export type { ComparisonPageProps } from './ComparisonPage';
export { ComparisonPageV1 } from './ComparisonPageV1';
export type { ComparisonPageV1Props } from './ComparisonPageV1';

View File

@@ -40,16 +40,6 @@ const nav = (
<Navigation <Navigation
logo={<FALogo />} logo={<FALogo />}
items={[ 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: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' }, { label: 'Contact Us', href: '/contact' },
{ label: 'Log in', href: '/login' }, { label: 'Log in', href: '/login' },

View File

@@ -13,7 +13,6 @@ import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography'; import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button'; import { Button } from '../../atoms/Button';
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact'; import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
import { assetUrl } from '../../../utils/assetUrl';
import { Divider } from '../../atoms/Divider'; import { Divider } from '../../atoms/Divider';
import { FuneralFinderV3, type FuneralFinderV3SearchParams } from '../../organisms/FuneralFinder'; import { FuneralFinderV3, type FuneralFinderV3SearchParams } from '../../organisms/FuneralFinder';
@@ -186,8 +185,8 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
discoverMapSlot, discoverMapSlot,
onSelectFeaturedProvider, onSelectFeaturedProvider,
features = [], features = [],
featuresHeading = '4 Reasons to use Funeral Arranger', featuresHeading = 'How it works',
featuresBody, featuresBody = 'Search local funeral directors, compare transparent pricing, and personalise a plan — all in your own time. No pressure, no hidden costs.',
googleRating, googleRating,
googleReviewCount, googleReviewCount,
testimonials = [], testimonials = [],
@@ -241,32 +240,21 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
}} }}
> >
<Container <Container
maxWidth={false} maxWidth="md"
sx={{ sx={{
position: 'relative', position: 'relative',
zIndex: 1, zIndex: 1,
textAlign: 'center', textAlign: 'center',
maxWidth: 990, pt: { xs: 8, md: 11 },
pt: { xs: 10, md: 14 }, pb: 4,
pb: { xs: 3, md: 4 },
}} }}
> >
<Typography <Typography
variant="body1" variant="display3"
sx={{
color: 'rgba(255,255,255,0.85)',
fontStyle: 'italic',
mb: 2,
}}
>
Trusted by thousands of families across Australia
</Typography>
<Typography
variant="display2"
component="h1" component="h1"
id="hero-heading" id="hero-heading"
tabIndex={-1} tabIndex={-1}
sx={{ mb: 5, color: 'var(--fa-color-white)' }} sx={{ mb: 3, color: 'var(--fa-color-white)' }}
> >
{heroHeading} {heroHeading}
</Typography> </Typography>
@@ -284,14 +272,20 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
position: 'relative', position: 'relative',
zIndex: 2, zIndex: 2,
width: '100%', width: '100%',
px: { xs: 3, md: 2 }, px: 2,
pt: 6, pt: 2,
pb: 0, pb: 0,
mb: { xs: -14, md: -18 }, mb: { xs: -14, md: -18 },
}} }}
> >
<Box sx={{ width: '100%', maxWidth: finderSlot ? 500 : 520, mx: 'auto' }}> <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> </Box>
</Box> </Box>
@@ -321,7 +315,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
}} }}
> >
<Typography <Typography
variant="display2" variant="display3"
component="h1" component="h1"
id="hero-heading" id="hero-heading"
tabIndex={-1} tabIndex={-1}
@@ -374,115 +368,28 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
}} }}
> >
<Box sx={{ maxWidth: 620, mx: 'auto' }}> <Box sx={{ maxWidth: 620, mx: 'auto' }}>
{finderSlot || <FuneralFinderV3 onSearch={onSearch} loading={searchLoading} />} {finderSlot || (
<FuneralFinderV3
heading="Find your local providers"
onSearch={onSearch}
loading={searchLoading}
/>
)}
</Box> </Box>
</Box> </Box>
)} )}
{/* ═══════════════════════════════════════════════════════════════════ {/* ═══════════════════════════════════════════════════════════════════
Section 2b: Partner Logos Carousel Section 2c: Discover — Map + Featured Providers (V2)
═══════════════════════════════════════════════════════════════════ */}
{partnerLogos.length > 0 && (
<Box
component="section"
aria-labelledby="partners-heading"
sx={{
bgcolor: 'var(--fa-color-surface-default)',
borderBottom: '1px solid #ebe0d4',
pt: { xs: 22, md: 28 },
pb: { xs: 10, md: 14 },
}}
>
<Container maxWidth="lg">
<Typography
variant="overline"
component="h2"
id="partners-heading"
sx={{
textAlign: 'center',
color: 'var(--fa-color-brand-600)',
mb: { xs: 6, md: 10 },
}}
>
{partnerTrustLine}
</Typography>
</Container>
{/* Carousel track */}
<Box
role="presentation"
sx={{
overflow: 'hidden',
position: 'relative',
'&::before, &::after': {
content: '""',
position: 'absolute',
top: 0,
bottom: 0,
width: 80,
zIndex: 1,
pointerEvents: 'none',
},
'&::before': {
left: 0,
background: 'linear-gradient(to right, #fff, transparent)',
},
'&::after': {
right: 0,
background: 'linear-gradient(to left, #fff, transparent)',
},
}}
>
<Box
aria-label="Partner funeral directors"
sx={{
display: 'flex',
gap: { xs: 8, md: 12 },
alignItems: 'center',
width: 'max-content',
animation: 'logoScroll 35s linear infinite',
'@keyframes logoScroll': {
'0%': { transform: 'translateX(0)' },
'100%': { transform: 'translateX(-50%)' },
},
'&:hover': { animationPlayState: 'paused' },
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
}}
>
{[...partnerLogos, ...partnerLogos].map((logo, i) => (
<Box
key={`${logo.alt}-${i}`}
component="img"
src={logo.src}
alt={i < partnerLogos.length ? logo.alt : ''}
aria-hidden={i >= partnerLogos.length ? true : undefined}
sx={{
height: { xs: 46, md: 55 },
maxWidth: { xs: 140, md: 184 },
width: 'auto',
objectFit: 'contain',
filter: 'grayscale(100%) brightness(1.2)',
opacity: 0.4,
flexShrink: 0,
}}
/>
))}
</Box>
</Box>
</Box>
)}
{/* ═══════════════════════════════════════════════════════════════════
Section 2c: Discover — Map + Featured Providers
═══════════════════════════════════════════════════════════════════ */} ═══════════════════════════════════════════════════════════════════ */}
{featuredProviders.length > 0 && ( {featuredProviders.length > 0 && (
<Box <Box
component="section" component="section"
aria-labelledby="discover-heading" aria-labelledby="discover-heading"
sx={{ sx={{
bgcolor: '#fdfbf9', bgcolor: 'var(--fa-color-surface-subtle)',
pt: { xs: 10, md: 14 }, pt: { xs: 22, md: 28 },
pb: { xs: 10, md: 14 }, pb: { xs: 8, md: 12 },
}} }}
> >
<Container maxWidth="lg"> <Container maxWidth="lg">
@@ -498,7 +405,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
<Typography <Typography
variant="body1" variant="body1"
color="text.secondary" 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 From trusted local providers to personalised options, find the right care near
you. you.
@@ -571,7 +478,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
{/* CTA */} {/* CTA */}
<Box sx={{ textAlign: 'center', mt: 4 }}> <Box sx={{ textAlign: 'center', mt: 4 }}>
<Button variant="text" size="medium" onClick={onCtaClick}> <Button variant="text" size="medium" onClick={onCtaClick}>
Start exploring Start exploring &rarr;
</Button> </Button>
</Box> </Box>
</Container> </Container>
@@ -579,212 +486,93 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
)} )}
{/* ═══════════════════════════════════════════════════════════════════ {/* ═══════════════════════════════════════════════════════════════════
Section 3b: Why Use FA — Text + Image Section 3: Partner Logos Carousel
═══════════════════════════════════════════════════════════════════ */} ═══════════════════════════════════════════════════════════════════ */}
<Box {partnerLogos.length > 0 && (
component="section" <Box
aria-labelledby="why-fa-heading" component="section"
sx={{ aria-label="Trusted partners"
bgcolor: 'var(--fa-color-surface-default)', sx={{
borderTop: '1px solid #f3efea', bgcolor: 'var(--fa-color-surface-cool)',
borderBottom: '1px solid #f3efea', pt: { xs: 10, md: 13 },
py: { xs: 10, md: 14 }, pb: { xs: 8, md: 10 },
}} }}
> >
<Container maxWidth="lg"> <Container maxWidth="lg">
<Typography
variant="body1"
color="text.secondary"
sx={{ textAlign: 'center', mb: { xs: 4, md: 6 } }}
>
{partnerTrustLine}
</Typography>
</Container>
{/* Carousel track */}
<Box <Box
role="presentation"
sx={{ sx={{
display: 'grid', overflow: 'hidden',
gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, position: 'relative',
gap: { xs: 4, md: 8 }, '&::before, &::after': {
alignItems: 'center', 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)',
},
}} }}
> >
{/* Text */}
<Box sx={{ textAlign: { xs: 'center', md: 'left' } }}>
<Typography
variant="overline"
component="div"
sx={{ color: 'var(--fa-color-brand-600)', mb: 1.5 }}
>
Why Use Funeral Arranger
</Typography>
<Typography
variant="display3"
component="h2"
id="why-fa-heading"
sx={{ mb: 2.5, color: 'text.primary' }}
>
Making an impossible time a little easier
</Typography>
<Typography
variant="body1"
color="text.secondary"
sx={{ fontSize: { xs: '0.875rem', md: '1rem' } }}
>
Funeral planning doesn&rsquo;t have to be overwhelming. Whether a loved one has
just passed, is imminent, or you&rsquo;re pre-planning the future for yourself.
Compare transparent pricing from local funeral directors. Explore the service
options, coffins and more to personalise a funeral plan in clear, easy steps.
</Typography>
</Box>
{/* Image */}
<Box <Box
aria-label="Partner funeral directors"
sx={{ sx={{
borderRadius: 'var(--fa-border-radius-lg, 12px)', display: 'flex',
overflow: 'hidden', gap: { xs: 8, md: 12 },
'& img': { alignItems: 'center',
width: '100%', width: 'max-content',
height: 'auto', animation: 'logoScroll 35s linear infinite',
display: 'block', '@keyframes logoScroll': {
'0%': { transform: 'translateX(0)' },
'100%': { transform: 'translateX(-50%)' },
}, },
'&:hover': { animationPlayState: 'paused' },
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
}} }}
> >
<img {[...partnerLogos, ...partnerLogos].map((logo, i) => (
src={assetUrl('/images/Homepage/people.png')} <Box
alt="Family planning together with care and confidence" key={`${logo.alt}-${i}`}
/> component="img"
src={logo.src}
alt={i < partnerLogos.length ? logo.alt : ''}
aria-hidden={i >= partnerLogos.length ? true : undefined}
sx={{
height: { xs: 46, md: 55 },
maxWidth: { xs: 140, md: 184 },
width: 'auto',
objectFit: 'contain',
filter: 'grayscale(100%) brightness(1.2)',
opacity: 0.4,
flexShrink: 0,
}}
/>
))}
</Box> </Box>
</Box> </Box>
</Container> </Box>
</Box> )}
{/* ═══════════════════════════════════════════════════════════════════
Section 3c: What You Can Do Here — Three Feature Cards
═══════════════════════════════════════════════════════════════════ */}
<Box
component="section"
aria-labelledby="what-you-can-do-heading"
sx={{
bgcolor: '#f8f5f1',
py: { xs: 10, md: 14 },
}}
>
<Container maxWidth="lg">
<Box sx={{ textAlign: 'center', mb: { xs: 5, md: 8 } }}>
<Typography
variant="overline"
component="div"
sx={{ color: 'var(--fa-color-brand-600)', mb: 1.5 }}
>
What You Can Do Here
</Typography>
<Typography
variant="display3"
component="h2"
id="what-you-can-do-heading"
sx={{ color: 'text.primary' }}
>
Three ways we can help you today
</Typography>
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: 'repeat(3, 1fr)' },
gap: { xs: 3, md: 4 },
}}
>
{/* Card 1: Compare pricing */}
<Box
sx={{
bgcolor: 'var(--fa-color-surface-default)',
borderRadius: 'var(--fa-card-border-radius-default, 8px)',
boxShadow: 'var(--fa-shadow-md)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<Box
sx={{
height: 200,
background:
'linear-gradient(135deg, var(--fa-color-brand-100) 0%, var(--fa-color-brand-200) 100%)',
}}
/>
<Box sx={{ p: 3, flex: 1, display: 'flex', flexDirection: 'column' }}>
<Typography variant="h5" component="h3" sx={{ mb: 1.5, color: 'text.primary' }}>
Compare pricing
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3, flex: 1 }}>
See verified, itemised prices from multiple funeral directors in your area
side by side.
</Typography>
<Button variant="outlined" size="medium" fullWidth>
Compare prices in my area
</Button>
</Box>
</Box>
{/* Card 2: Find a funeral director */}
<Box
sx={{
bgcolor: 'var(--fa-color-surface-default)',
borderRadius: 'var(--fa-card-border-radius-default, 8px)',
boxShadow: 'var(--fa-shadow-md)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<Box
sx={{
height: 200,
background:
'linear-gradient(135deg, var(--fa-color-sage-100, #E8EDEF) 0%, var(--fa-color-sage-200, #D0D8DD) 100%)',
}}
/>
<Box sx={{ p: 3, flex: 1, display: 'flex', flexDirection: 'column' }}>
<Typography variant="h5" component="h3" sx={{ mb: 1.5, color: 'text.primary' }}>
Find a funeral director
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3, flex: 1 }}>
Browse rated, reviewed directors near you with profiles, photos, and contact
details.
</Typography>
<Button variant="outlined" size="medium" fullWidth>
Search near me
</Button>
</Box>
</Box>
{/* Card 3: Arrange a funeral */}
<Box
sx={{
bgcolor: 'var(--fa-color-surface-default)',
borderRadius: 'var(--fa-card-border-radius-default, 8px)',
boxShadow: 'var(--fa-shadow-md)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<Box
sx={{
height: 200,
background:
'linear-gradient(135deg, var(--fa-color-neutral-100) 0%, var(--fa-color-neutral-200) 100%)',
}}
/>
<Box sx={{ p: 3, flex: 1, display: 'flex', flexDirection: 'column' }}>
<Typography variant="h5" component="h3" sx={{ mb: 1.5, color: 'text.primary' }}>
Arrange a funeral
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3, flex: 1 }}>
Build a fully customised quote &mdash; choose coffin, flowers, transport,
venue, and more.
</Typography>
<Button variant="outlined" size="medium" fullWidth>
Start building your quote
</Button>
</Box>
</Box>
</Box>
</Container>
</Box>
{/* ═══════════════════════════════════════════════════════════════════ {/* ═══════════════════════════════════════════════════════════════════
Section 4: Why Use Funeral Arranger (Features) Section 4: Why Use Funeral Arranger (Features)
@@ -795,35 +583,26 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
aria-labelledby="features-heading" aria-labelledby="features-heading"
sx={{ sx={{
bgcolor: 'var(--fa-color-surface-default)', bgcolor: 'var(--fa-color-surface-default)',
py: { xs: 10, md: 14 }, py: { xs: 8, md: 12 },
}} }}
> >
<Container maxWidth="lg"> <Container maxWidth="lg">
<Box sx={{ textAlign: 'center', mb: { xs: 5, md: 8 } }}> <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 <Typography
variant="display3" variant="display3"
component="h2" component="h2"
id="features-heading" id="features-heading"
sx={{ mb: featuresBody ? 2.5 : 0, color: 'text.primary' }} sx={{ mb: 2.5, color: 'text.primary' }}
> >
{featuresHeading} {featuresHeading}
</Typography> </Typography>
{featuresBody && ( <Typography
<Typography variant="body1"
variant="body1" color="text.secondary"
color="text.secondary" sx={{ maxWidth: 560, mx: 'auto' }}
sx={{ maxWidth: 560, mx: 'auto', fontSize: { xs: '0.875rem', md: '1rem' } }} >
> {featuresBody}
{featuresBody} </Typography>
</Typography>
)}
</Box> </Box>
<Box <Box
@@ -869,22 +648,11 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
component="section" component="section"
aria-labelledby="reviews-heading" aria-labelledby="reviews-heading"
sx={{ sx={{
py: { xs: 10, md: 14 }, py: { xs: 8, md: 12 },
bgcolor: '#f8f5f1', bgcolor: 'var(--fa-color-surface-subtle)',
}} }}
> >
<Container maxWidth="md"> <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 <Typography
variant="display3" variant="display3"
component="h2" component="h2"
@@ -915,29 +683,26 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
</Box> </Box>
)} )}
{/* Editorial testimonials — left-aligned with dividers */} {/* Editorial testimonials — alternating alignment with dividers */}
<Box <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
sx={{
display: 'flex',
flexDirection: 'column',
gap: 0,
maxWidth: 560,
mx: 'auto',
}}
>
{testimonials.map((t, i) => { {testimonials.map((t, i) => {
const isRight = i % 2 === 1;
return ( return (
<React.Fragment key={`${t.name}-${i}`}> <React.Fragment key={`${t.name}-${i}`}>
{i > 0 && <Divider sx={{ my: 4 }} />} {i > 0 && <Divider sx={{ my: 4 }} />}
<Box <Box
sx={{ sx={{
textAlign: 'left', textAlign: isRight ? 'right' : 'left',
maxWidth: '85%',
ml: isRight ? 'auto' : 0,
mr: isRight ? 0 : 'auto',
}} }}
> >
<FormatQuoteIcon <FormatQuoteIcon
sx={{ sx={{
fontSize: 32, fontSize: 32,
color: 'var(--fa-color-brand-300)', color: 'var(--fa-color-brand-300)',
transform: isRight ? 'scaleX(-1)' : 'none',
mb: 1, mb: 1,
}} }}
/> />
@@ -985,7 +750,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
sx={{ sx={{
background: background:
'linear-gradient(180deg, var(--fa-color-brand-100, #F5EDE4) 0%, var(--fa-color-surface-warm, #FEF9F5) 100%)', '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' }}> <Container maxWidth="md" sx={{ textAlign: 'center' }}>
@@ -997,7 +762,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
> >
{ctaHeading} {ctaHeading}
</Typography> </Typography>
<Button variant="contained" size="medium" onClick={onCtaClick}> <Button variant="text" size="large" onClick={onCtaClick}>
{ctaButtonLabel} {ctaButtonLabel}
</Button> </Button>
</Container> </Container>
@@ -1012,17 +777,17 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
aria-labelledby="faq-heading" aria-labelledby="faq-heading"
sx={{ sx={{
bgcolor: 'var(--fa-color-surface-default)', bgcolor: 'var(--fa-color-surface-default)',
py: { xs: 10, md: 14 }, py: { xs: 8, md: 12 },
}} }}
> >
<Container maxWidth="lg"> <Container maxWidth="lg">
<Typography <Typography
variant="display3" variant="h2"
component="h2" component="h2"
id="faq-heading" id="faq-heading"
sx={{ textAlign: 'center', mb: { xs: 5, md: 8 }, color: 'text.primary' }} sx={{ textAlign: 'center', mb: { xs: 5, md: 8 }, color: 'text.primary' }}
> >
Frequently Asked Questions FAQ
</Typography> </Typography>
<Box sx={{ maxWidth: 700, mx: 'auto' }}> <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 }}> <AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0, py: 1.5 }}>
<Typography <Typography variant="body1" sx={{ fontWeight: 500 }}>
variant="body1"
sx={{
fontWeight: 500,
fontSize: { xs: '0.875rem', md: '1rem' },
}}
>
{item.question} {item.question}
</Typography> </Typography>
</AccordionSummary> </AccordionSummary>
@@ -1064,11 +823,6 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
</AccordionDetails> </AccordionDetails>
</Accordion> </Accordion>
))} ))}
<Box sx={{ textAlign: 'center', mt: 4 }}>
<Button variant="text" size="medium">
See more
</Button>
</Box>
</Box> </Box>
</Container> </Container>
</Box> </Box>

View File

@@ -8,7 +8,6 @@ import { HomePage } from './HomePage';
import type { FeaturedProvider, TrustStat } from './HomePage'; import type { FeaturedProvider, TrustStat } from './HomePage';
import { Navigation } from '../../organisms/Navigation'; import { Navigation } from '../../organisms/Navigation';
import { Footer } from '../../organisms/Footer'; import { Footer } from '../../organisms/Footer';
import { assetUrl } from '../../../utils/assetUrl';
// ─── Shared helpers ────────────────────────────────────────────────────────── // ─── Shared helpers ──────────────────────────────────────────────────────────
@@ -42,16 +41,6 @@ const nav = (
<Navigation <Navigation
logo={<FALogo />} logo={<FALogo />}
items={[ 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: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' }, { label: 'Contact Us', href: '/contact' },
{ label: 'Log in', href: '/login' }, { label: 'Log in', href: '/login' },
@@ -242,7 +231,7 @@ export const Default: Story = {
args: { args: {
navigation: nav, navigation: nav,
footer, footer,
heroImageUrl: assetUrl('/images/heroes/parsonshero.png'), heroImageUrl: '/brandassets/images/heroes/parsonshero.png',
stats: trustStats, stats: trustStats,
featuredProviders, featuredProviders,
onSelectFeaturedProvider: (id) => console.log('Featured provider:', id), onSelectFeaturedProvider: (id) => console.log('Featured provider:', id),

View File

@@ -9,7 +9,6 @@ import type { FeaturedProvider, TrustStat, PartnerLogo } from './HomePage';
import React from 'react'; import React from 'react';
import { Navigation } from '../../organisms/Navigation'; import { Navigation } from '../../organisms/Navigation';
import { Footer } from '../../organisms/Footer'; import { Footer } from '../../organisms/Footer';
import { assetUrl } from '../../../utils/assetUrl';
// ─── Shared helpers ────────────────────────────────────────────────────────── // ─── Shared helpers ──────────────────────────────────────────────────────────
@@ -38,16 +37,6 @@ const nav = (
<Navigation <Navigation
logo={<FALogo />} logo={<FALogo />}
items={[ 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: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' }, { label: 'Contact Us', href: '/contact' },
{ label: 'Log in', href: '/login' }, { label: 'Log in', href: '/login' },
@@ -188,8 +177,8 @@ const featuredProviders: FeaturedProvider[] = [
name: 'H.Parsons Funeral Directors', name: 'H.Parsons Funeral Directors',
location: 'Wollongong, NSW', location: 'Wollongong, NSW',
verified: true, verified: true,
imageUrl: assetUrl('/images/venues/hparsons-funeral-home-kiama/01.jpg'), imageUrl: '/brandassets/images/venues/hparsons-funeral-home-kiama/01.jpg',
logoUrl: assetUrl('/images/providers/hparsons-funeral-directors/logo.png'), logoUrl: '/brandassets/images/providers/hparsons-funeral-directors/logo.png',
rating: 4.6, rating: 4.6,
reviewCount: 7, reviewCount: 7,
startingPrice: 900, startingPrice: 900,
@@ -199,8 +188,8 @@ const featuredProviders: FeaturedProvider[] = [
name: 'Rankins Funerals', name: 'Rankins Funerals',
location: 'Wollongong, NSW', location: 'Wollongong, NSW',
verified: true, verified: true,
imageUrl: assetUrl('/images/venues/rankins-funeral-home-warrawong/01.jpg'), imageUrl: '/brandassets/images/venues/rankins-funeral-home-warrawong/01.jpg',
logoUrl: assetUrl('/images/providers/rankins-funerals/logo.png'), logoUrl: '/brandassets/images/providers/rankins-funerals/logo.png',
rating: 4.8, rating: 4.8,
reviewCount: 23, reviewCount: 23,
startingPrice: 1200, startingPrice: 1200,
@@ -210,8 +199,8 @@ const featuredProviders: FeaturedProvider[] = [
name: 'Easy Funerals', name: 'Easy Funerals',
location: 'Sydney, NSW', location: 'Sydney, NSW',
verified: true, verified: true,
imageUrl: assetUrl('/images/venues/lakeside-memorial-park-chapel/01.jpg'), imageUrl: '/brandassets/images/venues/lakeside-memorial-park-chapel/01.jpg',
logoUrl: assetUrl('/images/providers/easy-funerals/logo.png'), logoUrl: '/brandassets/images/providers/easy-funerals/logo.png',
rating: 4.5, rating: 4.5,
reviewCount: 42, reviewCount: 42,
startingPrice: 850, startingPrice: 850,
@@ -220,30 +209,30 @@ const featuredProviders: FeaturedProvider[] = [
const partnerLogos: PartnerLogo[] = [ 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', alt: 'H.Parsons Funeral Directors',
}, },
{ src: assetUrl('/images/providers/rankins-funerals/logo.png'), alt: 'Rankins Funerals' }, { src: '/brandassets/images/providers/rankins-funerals/logo.png', alt: 'Rankins Funerals' },
{ src: assetUrl('/images/providers/easy-funerals/logo.png'), alt: 'Easy Funerals' }, { src: '/brandassets/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/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', alt: 'Killick Family Funerals',
}, },
{ {
src: assetUrl('/images/providers/kenneallys-funerals/logo.png'), src: '/brandassets/images/providers/kenneallys-funerals/logo.png',
alt: "Kenneally's Funerals", 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', 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', 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', alt: 'Mackay Family Funerals',
}, },
]; ];
@@ -251,7 +240,7 @@ const partnerLogos: PartnerLogo[] = [
// ─── Meta ──────────────────────────────────────────────────────────────────── // ─── Meta ────────────────────────────────────────────────────────────────────
const meta: Meta<typeof HomePage> = { const meta: Meta<typeof HomePage> = {
title: 'Pages/HomePage', title: 'Archive/HomePage V3',
component: HomePage, component: HomePage,
parameters: { parameters: {
layout: 'fullscreen', layout: 'fullscreen',
@@ -268,19 +257,19 @@ export const Default: Story = {
args: { args: {
navigation: nav, navigation: nav,
footer, footer,
heroImageUrl: assetUrl('/images/heroes/hero-couple.jpg'), heroImageUrl: '/brandassets/images/heroes/hero-3.png',
heroHeading: 'Compare funeral director pricing near you and arrange with confidence', heroHeading: 'Compare funeral directors pricing near you and arrange with confidence',
heroSubheading: 'Transparent pricing \u00B7 No hidden fees \u00B7 Arrange 24/7', heroSubheading: 'Transparent pricing \u00B7 No hidden fees \u00B7 Arrange 24/7',
stats: trustStats, stats: trustStats,
featuredProviders, featuredProviders,
discoverMapSlot: React.createElement('img', { discoverMapSlot: React.createElement('img', {
src: assetUrl('/images/placeholder/map.png'), src: '/brandassets/images/placeholder/map.png',
alt: 'Map showing provider locations', alt: 'Map showing provider locations',
style: { width: '100%', height: '100%', objectFit: 'cover' }, style: { width: '100%', height: '100%', objectFit: 'cover' },
}), }),
onSelectFeaturedProvider: (id) => console.log('Featured provider:', id), onSelectFeaturedProvider: (id) => console.log('Featured provider:', id),
partnerLogos, partnerLogos,
partnerTrustLine: 'Verified funeral directors on Funeral Arranger', partnerTrustLine: 'Trusted by hundreds of verified funeral directors across Australia',
features, features,
googleRating: 4.9, googleRating: 4.9,
googleReviewCount: 2340, googleReviewCount: 2340,

View File

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

View File

@@ -26,11 +26,6 @@
--fa-input-height-sm: 40px; /** Small — compact forms, admin layouts, matches Button medium height */ --fa-input-height-sm: 40px; /** Small — compact forms, admin layouts, matches Button medium height */
--fa-input-height-md: 48px; /** Medium (default) — standard forms, matches Button large for alignment */ --fa-input-height-md: 48px; /** Medium (default) — standard forms, matches Button large for alignment */
--fa-input-icon-size-default: 20px; /** 20px — icon size inside input field, matches Figma trailing icon */ --fa-input-icon-size-default: 20px; /** 20px — icon size inside input field, matches Figma trailing icon */
--fa-map-pin-height: 28px; /** Pill height — compact for map density */
--fa-map-pin-font-size: 12px; /** Small but legible price text */
--fa-map-pin-dot-size: 12px; /** Small circle marker */
--fa-map-pin-nub-size: 6px; /** Nub triangle size */
--fa-mini-card-image-height: 120px; /** Shorter image than full listing cards (180px) for compact grids */
--fa-provider-card-image-height: 180px; /** Fixed image height for consistent card sizing in list layouts */ --fa-provider-card-image-height: 180px; /** Fixed image height for consistent card sizing in list layouts */
--fa-provider-card-logo-size: 64px; /** Logo width/height — rounded rectangle, overlapping image bottom into content row */ --fa-provider-card-logo-size: 64px; /** Logo width/height — rounded rectangle, overlapping image bottom into content row */
--fa-radio-size-default: 20px; /** Default radio size — matches Figma 16px + padding for 44px touch target area */ --fa-radio-size-default: 20px; /** Default radio size — matches Figma 16px + padding for 44px touch target area */
@@ -273,10 +268,6 @@
--fa-input-font-size-default: var(--fa-font-size-base); /** 16px — prevents iOS auto-zoom on focus, matches Figma */ --fa-input-font-size-default: var(--fa-font-size-base); /** 16px — prevents iOS auto-zoom on focus, matches Figma */
--fa-input-border-radius-default: var(--fa-border-radius-sm); /** 4px — subtle rounding, consistent with Figma design */ --fa-input-border-radius-default: var(--fa-border-radius-sm); /** 4px — subtle rounding, consistent with Figma design */
--fa-input-gap-default: var(--fa-spacing-2); /** 8px — vertical rhythm between label/input/helper, slightly more generous than Figma's 6px for readability */ --fa-input-gap-default: var(--fa-spacing-2); /** 8px — vertical rhythm between label/input/helper, slightly more generous than Figma's 6px for readability */
--fa-map-pin-padding-x: var(--fa-spacing-3); /** 12px horizontal padding inside pill */
--fa-map-pin-border-radius: var(--fa-border-radius-full); /** Fully rounded pill shape */
--fa-mini-card-content-padding: var(--fa-spacing-3); /** 12px — matches ProviderCard/VenueCard content padding */
--fa-mini-card-content-gap: var(--fa-spacing-1); /** 4px vertical gap between content rows */
--fa-provider-card-logo-border-radius: var(--fa-border-radius-md); /** 8px rounded rectangle — softer than circle, matches card border radius */ --fa-provider-card-logo-border-radius: var(--fa-border-radius-md); /** 8px rounded rectangle — softer than circle, matches card border radius */
--fa-provider-card-content-padding: var(--fa-spacing-3); /** 12px content padding — tight to keep card compact in listing layout */ --fa-provider-card-content-padding: var(--fa-spacing-3); /** 12px content padding — tight to keep card compact in listing layout */
--fa-provider-card-content-gap: var(--fa-spacing-1); /** 4px vertical gap between content rows — tight for compact listing cards */ --fa-provider-card-content-gap: var(--fa-spacing-1); /** 4px vertical gap between content rows — tight for compact listing cards */

View File

@@ -71,15 +71,6 @@ export declare const InputFontSizeDefault: string;
export declare const InputBorderRadiusDefault: string; export declare const InputBorderRadiusDefault: string;
export declare const InputGapDefault: string; export declare const InputGapDefault: string;
export declare const InputIconSizeDefault: string; export declare const InputIconSizeDefault: string;
export declare const MapPinHeight: string;
export declare const MapPinPaddingX: string;
export declare const MapPinFontSize: string;
export declare const MapPinBorderRadius: string;
export declare const MapPinDotSize: string;
export declare const MapPinNubSize: string;
export declare const MiniCardImageHeight: string;
export declare const MiniCardContentPadding: string;
export declare const MiniCardContentGap: string;
export declare const ProviderCardImageHeight: string; export declare const ProviderCardImageHeight: string;
export declare const ProviderCardLogoSize: string; export declare const ProviderCardLogoSize: string;
export declare const ProviderCardLogoBorderRadius: string; export declare const ProviderCardLogoBorderRadius: string;

View File

@@ -72,15 +72,6 @@ export const InputFontSizeDefault = "1rem"; // 16px — prevents iOS auto-zoom o
export const InputBorderRadiusDefault = "4px"; // 4px — subtle rounding, consistent with Figma design export const InputBorderRadiusDefault = "4px"; // 4px — subtle rounding, consistent with Figma design
export const InputGapDefault = "8px"; // 8px — vertical rhythm between label/input/helper, slightly more generous than Figma's 6px for readability export const InputGapDefault = "8px"; // 8px — vertical rhythm between label/input/helper, slightly more generous than Figma's 6px for readability
export const InputIconSizeDefault = "20px"; // 20px — icon size inside input field, matches Figma trailing icon export const InputIconSizeDefault = "20px"; // 20px — icon size inside input field, matches Figma trailing icon
export const MapPinHeight = "28px"; // Pill height — compact for map density
export const MapPinPaddingX = "12px"; // 12px horizontal padding inside pill
export const MapPinFontSize = "12px"; // Small but legible price text
export const MapPinBorderRadius = "9999px"; // Fully rounded pill shape
export const MapPinDotSize = "12px"; // Small circle marker
export const MapPinNubSize = "6px"; // Nub triangle size
export const MiniCardImageHeight = "120px"; // Shorter image than full listing cards (180px) for compact grids
export const MiniCardContentPadding = "12px"; // 12px — matches ProviderCard/VenueCard content padding
export const MiniCardContentGap = "4px"; // 4px vertical gap between content rows
export const ProviderCardImageHeight = "180px"; // Fixed image height for consistent card sizing in list layouts export const ProviderCardImageHeight = "180px"; // Fixed image height for consistent card sizing in list layouts
export const ProviderCardLogoSize = "64px"; // Logo width/height — rounded rectangle, overlapping image bottom into content row export const ProviderCardLogoSize = "64px"; // Logo width/height — rounded rectangle, overlapping image bottom into content row
export const ProviderCardLogoBorderRadius = "8px"; // 8px rounded rectangle — softer than circle, matches card border radius export const ProviderCardLogoBorderRadius = "8px"; // 8px rounded rectangle — softer than circle, matches card border radius

View File

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

View File

@@ -1,17 +0,0 @@
{
"mapPin": {
"$description": "MapPin atom tokens — price-pill map markers for provider/venue map views. Verified (brand) vs unverified (neutral) visual distinction.",
"height": { "$type": "dimension", "$value": "28px", "$description": "Pill height — compact for map density" },
"paddingX": { "$type": "dimension", "$value": "{spacing.3}", "$description": "12px horizontal padding inside pill" },
"fontSize": { "$type": "dimension", "$value": "12px", "$description": "Small but legible price text" },
"borderRadius": { "$type": "dimension", "$value": "{borderRadius.full}", "$description": "Fully rounded pill shape" },
"dot": {
"$description": "Dot variant for pins without a price label.",
"size": { "$type": "dimension", "$value": "12px", "$description": "Small circle marker" }
},
"nub": {
"$description": "Downward-pointing nub anchoring the pill to the map location.",
"size": { "$type": "dimension", "$value": "6px", "$description": "Nub triangle size" }
}
}
}

View File

@@ -1,15 +0,0 @@
{
"miniCard": {
"$description": "MiniCard molecule tokens — compact vertical card for providers, venues, packages in grids, recommendations, and map popups.",
"image": {
"$type": "dimension",
"$description": "Hero image area dimensions.",
"height": { "$value": "120px", "$description": "Shorter image than full listing cards (180px) for compact grids" }
},
"content": {
"$description": "Content area spacing.",
"padding": { "$type": "dimension", "$value": "{spacing.3}", "$description": "12px — matches ProviderCard/VenueCard content padding" },
"gap": { "$type": "dimension", "$value": "{spacing.1}", "$description": "4px vertical gap between content rows" }
}
}
}