Compare commits

..

1 Commits

Author SHA1 Message Date
ed78fc6cc2 Strip AI tooling for fa-dev push
Remove .claude/ from tracking before pushing to fa-dev so the shared
remote doesn't carry Claude Code agent definitions and skills.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:08:24 +10:00
89 changed files with 3785 additions and 8830 deletions

7
.gitignore vendored
View File

@@ -1,6 +1,5 @@
node_modules/
dist/
dist-demo/
storybook-static/
tokens/export/
*.local
@@ -29,9 +28,6 @@ docs/reference/how-to-work-with-both-tools.md
docs/reference/mcp-setup.md
docs/reference/retroactive-review-plan.md
# Deploy scripts (contain credentials)
scripts/
# Build logs
build-storybook.log
@@ -43,6 +39,3 @@ temp-db/
# Root-level screenshots
/*.png
# IDE-specific
*.code-workspace

View File

@@ -1,4 +1,3 @@
<script src="https://mcp.figma.com/mcp/html-to-design/capture.js" async></script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

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,81 +0,0 @@
# Parsons demo host — drop into swag's /config/nginx/site-confs/ directory.
#
# Serves static demo slices at parsons.tensordesign.com.au/<slice>/ behind
# basic auth. One server block, one cert (Let's Encrypt via swag), one
# htpasswd covering all slices.
#
# Document root layout (host filesystem):
# <host_path>/parsons-demos/
# index.html ← optional landing page listing slices
# arrangement/
# index.html
# assets/...
# <other-slices>/
#
# Bind-mount that directory into swag at /config/www/parsons-demos/ — the
# `root` directive below assumes that path. Adjust if you mount elsewhere.
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name parsons.*;
# swag manages the cert chain via SUBDOMAINS — make sure `parsons` is in
# the SUBDOMAINS env var of the swag container so this resolves.
include /config/nginx/ssl.conf;
root /config/www/parsons-demos;
index index.html;
# One credential file covering every slice. Create with:
# docker exec -it swag htpasswd -c /config/nginx/.htpasswd-parsons client
auth_basic "Parsons demos";
auth_basic_user_file /config/nginx/.htpasswd-parsons;
# Optional: don't auth the root listing if you want it publicly visible.
# (Currently auth covers it too — change to `auth_basic off;` to expose.)
# Root path serves the optional landing index.html if present, else 404.
location = / {
try_files /index.html =404;
}
# Long cache for fingerprinted assets — Vite produces hashed filenames so
# this is safe. HTML is short-cache so updates land on next refresh.
# NOTE: asset + html regex locations must come BEFORE the slice fallback
# below, because nginx uses the first matching regex location.
location ~* \.(?:js|css|woff2?|ttf|otf|eot|png|jpg|jpeg|gif|svg|webp|ico)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-cache, must-revalidate";
}
# SPA fallback per slice. /<slice>/<react-route> resolves to that
# slice's index.html so React Router handles the rest. Static assets
# (.js/.css/.png/etc.) are handled by the regex blocks above.
location ~ ^/(?<slice>[^/]+)/ {
try_files $uri $uri/ /$slice/index.html;
}
# Hide hidden files (e.g. .htpasswd if it ever ends up in webroot)
location ~ /\. {
deny all;
}
}
# HTTP → HTTPS redirect — swag's default server already covers this for
# wildcard subdomains, but include explicitly here in case the default is
# customised.
server {
listen 80;
listen [::]:80;
server_name parsons.*;
return 301 https://$host$request_uri;
}

173
package-lock.json generated
View File

@@ -10,15 +10,11 @@
"dependencies": {
"@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0",
"@googlemaps/markerclusterer": "^2.6.2",
"@mui/icons-material": "^5.16.0",
"@mui/material": "^5.16.0",
"@mui/system": "^5.16.0",
"@vis.gl/react-google-maps": "^1.8.3",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-router-dom": "^7.14.1",
"zustand": "^5.0.12"
"react-dom": "^18.3.0"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
@@ -1462,26 +1458,6 @@
"react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@googlemaps/js-api-loader": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-2.0.2.tgz",
"integrity": "sha512-bKVuTqatS8Jven5aFqVB7rCHF1VFEzpzyi0ruzO0GUR+A7m9oMqMgtnmpANj7kMYEvvhty8Fk7TnJ1MKjWHu+Q==",
"license": "Apache-2.0",
"dependencies": {
"@types/google.maps": "^3.53.1"
}
},
"node_modules/@googlemaps/markerclusterer": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@googlemaps/markerclusterer/-/markerclusterer-2.6.2.tgz",
"integrity": "sha512-U6uVhq8iWhiIckA89sgRu8OK35mjd6/3CuoZKWakKEf0QmRRWpatlsPb3kqXkoWSmbcZkopRiI4dnW6DQSd7bQ==",
"license": "Apache-2.0",
"dependencies": {
"@types/supercluster": "^7.1.3",
"fast-equals": "^5.2.2",
"supercluster": "^8.0.1"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -4130,18 +4106,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/google.maps": {
"version": "3.64.0",
"resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.64.0.tgz",
"integrity": "sha512-dN0H6tB4lgLQLovcbPXFYYOEV41TpyyJghzb5jrzjB96FZmjeOghevVdC+BMGd6YqyCqXaggyEtqRXLRjzCBZA==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -4205,15 +4169,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/supercluster": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -4523,21 +4478,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@vis.gl/react-google-maps": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/@vis.gl/react-google-maps/-/react-google-maps-1.8.3.tgz",
"integrity": "sha512-DW7nEuvOJ299DmdBnvGiUARrgS/+sTEO1iJgG9J8YaErZqLoq7S4TJ22f3EjJvR4dti4L4gft43JEK77nnKXDw==",
"license": "MIT",
"dependencies": {
"@googlemaps/js-api-loader": "^2.0.2",
"@types/google.maps": "^3.54.10",
"fast-deep-equal": "^3.1.3"
},
"peerDependencies": {
"react": ">=16.8.0 || ^19.0 || ^19.0.0-rc",
"react-dom": ">=16.8.0 || ^19.0 || ^19.0.0-rc"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -5447,19 +5387,6 @@
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cosmiconfig": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
@@ -6531,17 +6458,9 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -7931,12 +7850,6 @@
"node": ">=4.0"
}
},
"node_modules/kdbush": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
"license": "ISC"
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -9617,44 +9530,6 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.1.tgz",
"integrity": "sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.1.tgz",
"integrity": "sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==",
"license": "MIT",
"dependencies": {
"react-router": "7.14.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
@@ -10026,12 +9901,6 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -10635,15 +10504,6 @@
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
"license": "MIT"
},
"node_modules/supercluster": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.0.2"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -12239,35 +12099,6 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/zustand": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}

View File

@@ -19,23 +19,16 @@
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"chromatic": "chromatic --exit-zero-on-changes --build-script-name=build:storybook",
"demo:dev": "vite -c vite.demo.config.ts --mode arrangement",
"demo:build": "vite build -c vite.demo.config.ts",
"demo:publish": "npm run demo:build -- --mode arrangement && ./scripts/deploy-demo.sh arrangement",
"prepare": "husky"
},
"dependencies": {
"@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0",
"@googlemaps/markerclusterer": "^2.6.2",
"@mui/icons-material": "^5.16.0",
"@mui/material": "^5.16.0",
"@mui/system": "^5.16.0",
"@vis.gl/react-google-maps": "^1.8.3",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-router-dom": "^7.14.1",
"zustand": "^5.0.12"
"react-dom": "^18.3.0"
},
"devDependencies": {
"@eslint/js": "^9.39.4",

View File

@@ -1,77 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { ClusterMarker } from './ClusterMarker';
const meta: Meta<typeof ClusterMarker> = {
title: 'Atoms/ClusterMarker',
component: ClusterMarker,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: {
default: 'map',
values: [{ name: 'map', value: '#E5E3DF' }],
},
},
argTypes: {
onClick: { action: 'clicked' },
},
};
export default meta;
type Story = StoryObj<typeof ClusterMarker>;
/** Cluster containing at least one verified provider — promoted palette */
export const MixedOrVerified: Story = {
args: {
count: 5,
hasVerified: true,
},
};
/** Cluster of all-unverified providers — neutral palette */
export const AllUnverified: Story = {
args: {
count: 3,
hasVerified: false,
},
};
/** Small cluster — pair of providers */
export const Pair: Story = {
args: {
count: 2,
hasVerified: true,
},
};
/** Large cluster — double-digit count */
export const LargeCluster: Story = {
args: {
count: 27,
hasVerified: true,
},
};
/** Side-by-side comparison — verified vs unverified at various counts */
export const PaletteGrid: Story = {
render: () => (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: 6,
p: 4,
}}
>
<ClusterMarker count={2} hasVerified />
<ClusterMarker count={5} hasVerified />
<ClusterMarker count={12} hasVerified />
<ClusterMarker count={99} hasVerified />
<ClusterMarker count={2} />
<ClusterMarker count={5} />
<ClusterMarker count={12} />
<ClusterMarker count={99} />
</Box>
),
};

View File

@@ -1,161 +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 ClusterMarker atom */
export interface ClusterMarkerProps {
/** Number of providers in this cluster */
count: number;
/** True if any provider in the cluster is verified — drives the promoted palette */
hasVerified?: boolean;
/** Click handler — opens the cluster popup */
onClick?: (e: React.MouseEvent) => void;
/** MUI sx prop for the root element */
sx?: SxProps<Theme>;
}
// ─── Constants ──────────────────────────────────────────────────────────────
const NUB_SIZE = 'var(--fa-map-pin-nub-size)';
const BADGE_SIZE = 36;
// ─── Colour sets — matches MapPin ───────────────────────────────────────────
const colours = {
verified: {
bg: 'var(--fa-color-brand-700)',
text: 'var(--fa-color-white)',
border: 'var(--fa-color-brand-700)',
nub: 'var(--fa-color-brand-700)',
},
unverified: {
bg: 'var(--fa-color-neutral-100)',
text: 'var(--fa-color-neutral-800)',
border: 'var(--fa-color-neutral-300)',
nub: 'var(--fa-color-neutral-100)',
},
} as const;
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Cluster map marker for the FA design system.
*
* Circular pill with a count, representing N provider pins grouped at the
* same screen location. Sibling to `MapPin` — same palette language (verified
* promoted, unverified neutral), same nub treatment, same shadow.
*
* `hasVerified` drives the palette: if *any* provider in the cluster is
* verified, the cluster adopts the promoted (brand-700) palette. All-unverified
* clusters use the neutral palette.
*
* Designed for use as the `render`-ed output of `@googlemaps/markerclusterer`.
* Pure CSS + SVG — no canvas. role="button" + keyboard + focus ring.
*
* Usage:
* ```tsx
* <ClusterMarker count={5} hasVerified onClick={...} />
* <ClusterMarker count={12} />
* ```
*/
export const ClusterMarker = React.forwardRef<HTMLDivElement, ClusterMarkerProps>(
({ count, hasVerified = false, onClick, sx }, ref) => {
const palette = hasVerified ? colours.verified : colours.unverified;
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.key === 'Enter' || e.key === ' ') && onClick) {
e.preventDefault();
onClick(e as unknown as React.MouseEvent);
}
};
const label = `${count} providers in this area`;
return (
<Box
ref={ref}
role="button"
tabIndex={0}
aria-label={label}
onClick={onClick}
onKeyDown={handleKeyDown}
sx={[
{
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
cursor: 'pointer',
transition: 'transform 150ms ease-in-out',
// Fade in on mount — matches MapPin and popups for a consistent
// entry timing across the map.
'@keyframes clusterMarkerIn': {
from: { opacity: 0 },
to: { opacity: 1 },
},
animation: 'clusterMarkerIn 180ms ease-out',
'&:hover': { transform: 'scale(1.08)' },
'&:focus-visible': {
outline: 'none',
'& > .ClusterMarker-badge': {
outline: '2px solid var(--fa-color-interactive-focus)',
outlineOffset: '2px',
},
},
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Circular badge */}
<Box
className="ClusterMarker-badge"
sx={{
width: BADGE_SIZE,
height: BADGE_SIZE,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: palette.bg,
border: '1px solid',
borderColor: palette.border,
boxShadow: 'var(--fa-shadow-sm)',
color: palette.text,
fontFamily: 'var(--fa-font-family-body)',
fontSize: 14,
fontWeight: 700,
lineHeight: 1,
}}
>
{count}
</Box>
{/* Nub — same SVG pattern as MapPin for visual continuity */}
<svg
aria-hidden
viewBox="0 0 16 8"
style={{
display: 'block',
width: `calc(2 * ${NUB_SIZE})`,
height: NUB_SIZE,
marginTop: '-1px',
overflow: 'visible',
}}
>
<path d="M 0 -3 L 16 -3 L 16 0 L 8 8 L 0 0 Z" fill={palette.bg} />
<path
d="M 0 0 L 8 8 L 16 0"
fill="none"
stroke={palette.border}
strokeWidth={1}
strokeLinejoin="round"
/>
</svg>
</Box>
);
},
);
ClusterMarker.displayName = 'ClusterMarker';
export default ClusterMarker;

View File

@@ -1 +0,0 @@
export { ClusterMarker, type ClusterMarkerProps } from './ClusterMarker';

View File

@@ -21,8 +21,8 @@ const meta: Meta<typeof MapPin> = {
export default meta;
type Story = StoryObj<typeof MapPin>;
/** Verified provider — promoted brand palette (dark copper bg, white text) */
export const Verified: Story = {
/** Verified provider with name and price — warm brand label */
export const VerifiedWithPrice: Story = {
args: {
name: 'H.Parsons Funeral Directors',
price: 900,
@@ -31,7 +31,7 @@ export const Verified: Story = {
};
/** Unverified provider — neutral grey label */
export const Unverified: Story = {
export const UnverifiedWithPrice: Story = {
args: {
name: 'Smith & Sons Funerals',
price: 1200,
@@ -39,7 +39,66 @@ export const Unverified: Story = {
},
};
/** Custom price label (e.g. "POA" for providers without a fixed starting price) */
/** 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',
@@ -82,7 +141,7 @@ export const MapSimulation: Story = {
<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 onClick={() => {}} />
<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={() => {}} />
@@ -93,7 +152,12 @@ export const MapSimulation: Story = {
<MapPin name="Smith & Sons" price={1100} onClick={() => {}} />
</Box>
<Box sx={{ position: 'absolute', top: 300, left: 400 }}>
<MapPin name="Local Provider" price={1600} onClick={() => {}} />
<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

@@ -6,14 +6,16 @@ import type { SxProps, Theme } from '@mui/material/styles';
/** Props for the FA MapPin atom */
export interface MapPinProps {
/** Provider or venue name (required — shown as line 1) */
name: string;
/** Starting package price in dollars — shown as "From $X" on line 2 */
/** 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 palette vs neutral palette) */
/** 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 */
@@ -25,24 +27,34 @@ export interface MapPinProps {
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 = 210;
const MAX_WIDTH = 180;
// ─── Colour sets ────────────────────────────────────────────────────────────
const colours = {
verified: {
bg: 'var(--fa-color-brand-700)',
name: 'var(--fa-color-white)',
price: 'var(--fa-color-brand-200)',
nub: 'var(--fa-color-brand-700)',
border: 'var(--fa-color-brand-700)',
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;
@@ -56,25 +68,26 @@ const colours = {
* the exact map location.
*
* - **Line 1**: Provider name (bold, truncated)
* - **Line 2**: "From $X" (smaller, secondary colour)
* - **Line 2**: "From $X" (smaller, secondary colour) — optional
*
* Visual distinction:
* - **Verified** providers: warm brand palette (dark copper bg, white text)
* - **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 Google Maps. Pure CSS — no
* canvas, no SVG dependency. Selection/popup behaviour is handled at the
* organism level (ProviderMap swaps pin → popup on click).
* 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" price={1200} />
* <MapPin name="Botanical" priceLabel="POA" verified />
* <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, onClick, sx }, ref) => {
({ name, price, priceLabel, verified = false, active = false, onClick, sx }, ref) => {
const palette = verified ? colours.verified : colours.unverified;
const hasPrice = price != null || priceLabel != null;
@@ -93,7 +106,7 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
ref={ref}
role="button"
tabIndex={0}
aria-label={`${name}${hasPrice ? `, packages from ${priceLabel ?? `$${price?.toLocaleString('en-AU')}`}` : ''}${verified ? ', verified' : ''}`}
aria-label={`${name ?? (verified ? 'Verified' : 'Unverified') + ' provider'}${hasPrice ? `, packages from $${price?.toLocaleString('en-AU') ?? priceLabel}` : ''}${verified ? ', verified' : ''}${active ? ' (selected)' : ''}`}
onClick={onClick}
onKeyDown={handleKeyDown}
sx={[
@@ -103,13 +116,7 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
alignItems: 'center',
cursor: 'pointer',
transition: 'transform 150ms ease-in-out',
// Fade in on mount — matches the popup's exit timing so the pin
// reappears smoothly when a popup closes.
'@keyframes mapPinIn': {
from: { opacity: 0 },
to: { opacity: 1 },
},
animation: 'mapPinIn 180ms ease-out',
transform: active ? 'scale(1.08)' : 'scale(1)',
'&:hover': {
transform: 'scale(1.08)',
},
@@ -135,65 +142,53 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
py: 0.5,
px: PIN_PX,
borderRadius: PIN_RADIUS,
backgroundColor: palette.bg,
backgroundColor: active ? palette.activeBg : palette.bg,
border: '1px solid',
borderColor: palette.border,
boxShadow: 'var(--fa-shadow-sm)',
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 row — verified icon (left) + name */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
maxWidth: '100%',
}}
>
{verified && (
// Inline SVG of Material's Verified (outlined) icon. Kept as
// inline SVG because MapPin is mounted via createRoot outside
// the MUI ThemeProvider, so @mui/icons-material wouldn't pick
// up theme defaults.
<svg
aria-hidden
width="12"
height="12"
viewBox="0 0 24 24"
style={{ flexShrink: 0, fill: palette.name }}
>
<path d="M23 11.99l-2.44-2.79.34-3.69-3.61-.82-1.89-3.2L12 2.96 8.6 1.49 6.71 4.69 3.1 5.5l.34 3.7L1 11.99l2.44 2.79-.34 3.7 3.61.82 1.89 3.2L12 21.03l3.4 1.47 1.89-3.2 3.61-.82-.34-3.69L23 11.99zm-12.91 4.72l-3.8-3.81 1.48-1.48 2.32 2.33 5.85-5.87 1.48 1.48-7.33 7.35z" />
</svg>
)}
{/* Name */}
{name && (
<Box
component="span"
sx={{
fontSize: 12,
fontWeight: 700,
fontFamily: 'var(--fa-font-family-body)',
fontFamily: (t: Theme) => t.typography.fontFamily,
lineHeight: 1.3,
color: palette.name,
color: active ? palette.activeName : palette.name,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: 0,
maxWidth: '100%',
transition: 'color 150ms ease-in-out',
}}
>
{name}
</Box>
</Box>
)}
{/* Price line */}
{hasPrice && (
<Box
component="span"
sx={{
fontSize: 11,
fontWeight: 600,
fontFamily: 'var(--fa-font-family-body)',
fontSize: !name ? 12 : 11,
fontWeight: !name ? 700 : 600,
fontFamily: (t: Theme) => t.typography.fontFamily,
lineHeight: 1.2,
color: palette.price,
color: !name
? active
? palette.activeName
: palette.name
: active
? palette.activePrice
: palette.price,
whiteSpace: 'nowrap',
transition: 'color 150ms ease-in-out',
}}
>
{priceText}
@@ -201,33 +196,19 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
)}
</Box>
{/* Nub — downward pointer. Two SVG paths:
• fill is an extended pentagon that overhangs 3 units *into* the
pill's bg so sub-pixel scaling artifacts (hover transform) can't
expose the pill's bottom border through the seam;
• stroke is a separate open path on the two slanted sides only,
so the nub outline is continuous with the pill's border.
overflow: visible lets the fill render above the viewBox. */}
<svg
{/* Nub — downward pointer */}
<Box
aria-hidden
viewBox="0 0 16 8"
style={{
display: 'block',
width: `calc(2 * ${NUB_SIZE})`,
height: NUB_SIZE,
marginTop: '-1px',
overflow: 'visible',
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',
}}
>
<path d="M 0 -3 L 16 -3 L 16 0 L 8 8 L 0 0 Z" fill={palette.bg} />
<path
d="M 0 0 L 8 8 L 16 0"
fill="none"
stroke={palette.border}
strokeWidth={1}
strokeLinejoin="round"
/>
</svg>
</Box>
);
},

View File

@@ -1,114 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { ClusterPopup } from './ClusterPopup';
const meta: Meta<typeof ClusterPopup> = {
title: 'Molecules/ClusterPopup',
component: ClusterPopup,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: {
default: 'map',
values: [{ name: 'map', value: '#E5E3DF' }],
},
},
decorators: [
(Story) => (
<Box sx={{ p: 4 }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof ClusterPopup>;
// Fixture data — mirrors the shape used in the demo
const mixedCluster = [
{
id: 'parsons',
name: 'H.Parsons Funeral Directors',
location: 'Wentworth, NSW',
verified: true,
rating: 4.6,
startingPrice: 1800,
},
{
id: 'rankins',
name: 'Rankins Funeral Services',
location: 'Warrawong, NSW',
verified: true,
rating: 4.8,
startingPrice: 2450,
},
{
id: 'wollongong-city',
name: 'Wollongong City Funerals',
location: 'Wollongong, NSW',
verified: false,
rating: 4.2,
startingPrice: 3400,
},
{
id: 'botanical',
name: 'Botanical Funerals',
location: 'Newtown, NSW',
verified: false,
rating: 4.9,
startingPrice: 5200,
},
];
/** Mixed-tier cluster — verified providers sorted to top */
export const Mixed: Story = {
args: {
providers: mixedCluster,
onSelectProvider: (id) => {
alert(`Drill into ${id}`);
},
onClose: () => {
alert('Close cluster');
},
},
};
/** Small pair — two providers at the same location */
export const Pair: Story = {
args: {
providers: mixedCluster.slice(0, 2),
onSelectProvider: () => {},
onClose: () => {},
},
};
/** All verified — every provider in the cluster is a partner */
export const AllVerified: Story = {
args: {
providers: mixedCluster.filter((p) => p.verified),
onSelectProvider: () => {},
onClose: () => {},
},
};
/** All unverified — no partners in this cluster */
export const AllUnverified: Story = {
args: {
providers: mixedCluster.filter((p) => !p.verified),
onSelectProvider: () => {},
onClose: () => {},
},
};
/** Tall cluster — scrolls when providers exceed visible area */
export const TallCluster: Story = {
args: {
providers: [
...mixedCluster,
...mixedCluster.map((p) => ({ ...p, id: `${p.id}-2`, name: `${p.name} (Branch 2)` })),
],
onSelectProvider: () => {},
onClose: () => {},
},
};

View File

@@ -1,360 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import IconButton from '@mui/material/IconButton';
import ButtonBase from '@mui/material/ButtonBase';
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
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';
// ─── Types ──────────────────────────────────────────────────────────────────
/** A provider summary used in the cluster list */
export interface ClusterPopupProvider {
/** Unique provider ID */
id: string;
/** Provider display name */
name: string;
/** Location text (suburb, city) */
location: string;
/** Whether this is a verified/partner provider — drives sort order + colour accents */
verified?: boolean;
/** Average rating */
rating?: number;
/** Starting package price in dollars — shown as "From $X" on the right */
startingPrice?: number;
/** Custom price label (e.g. "POA") — overrides the formatted price */
priceLabel?: string;
}
/** Props for the FA ClusterPopup molecule */
export interface ClusterPopupProps {
/** Providers in this cluster */
providers: ClusterPopupProvider[];
/** Click handler — fires when a provider row is clicked */
onSelectProvider: (id: string) => void;
/** Close handler — fires when the close button is clicked */
onClose?: () => void;
/** When true, animates the popup out (opacity + scale) without unmounting.
* Callers should unmount after the transition completes (180ms). */
exiting?: boolean;
/** MUI sx prop for the root element */
sx?: SxProps<Theme>;
}
// ─── Constants ──────────────────────────────────────────────────────────────
const POPUP_WIDTH = 320;
const MAX_CONTENT_HEIGHT = 360;
const NUB_SIZE = 8;
/** Fixed width reserved for the verified-icon slot so all row titles share
* the same x-origin regardless of whether the row is verified. */
const VERIFIED_SLOT_WIDTH = 18;
// ─── Row sub-component ──────────────────────────────────────────────────────
interface ProviderRowProps {
provider: ClusterPopupProvider;
onClick: () => void;
}
/**
* Single provider row inside the cluster list. Image-free layout:
* verified-icon slot (fixed width so titles align across rows) + name +
* location/rating meta. Full-width clickable surface. Clicking triggers
* `onClick` — in `ProviderMap` that pans+zooms the map to the provider's
* location and opens their single-provider popup.
*/
const ProviderRow: React.FC<ProviderRowProps> = ({ provider, onClick }) => {
const hasPrice = provider.startingPrice != null || provider.priceLabel != null;
const priceText =
provider.priceLabel ??
(provider.startingPrice != null ? `$${provider.startingPrice.toLocaleString('en-AU')}` : null);
return (
<ButtonBase
onClick={(e) => {
// stopPropagation so the DOM click doesn't bubble to Map.onClick
// (which would clear state the same frame we're trying to drill in).
e.stopPropagation();
onClick();
}}
sx={{
width: '100%',
display: 'flex',
// flex-start so the verified-icon slot aligns with the name's top line,
// not the vertical centre of the row.
alignItems: 'flex-start',
gap: 1,
p: 1.25,
borderRadius: 1,
textAlign: 'left',
transition: 'background-color 120ms ease-in-out',
'&:hover': {
bgcolor: provider.verified
? 'var(--fa-color-brand-50)'
: 'var(--fa-color-surface-subtle)',
},
'&:focus-visible': {
outline: '2px solid var(--fa-color-interactive-focus)',
outlineOffset: 2,
},
}}
>
{/* Verified-icon slot — reserved width + fixed line-height so the icon
sits vertically on the name's line-box regardless of whether the
row has location/rating/price content below. */}
<Box
sx={{
width: VERIFIED_SLOT_WIDTH,
flexShrink: 0,
height: '1.25em',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{provider.verified && (
<VerifiedOutlinedIcon
sx={{ fontSize: 16, color: 'var(--fa-color-brand-600)' }}
aria-label="Verified provider"
/>
)}
</Box>
{/* Text column — name + location/rating meta */}
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 0.25 }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
color: provider.verified ? 'var(--fa-color-brand-700)' : 'text.primary',
minWidth: 0,
lineHeight: 1.25,
}}
maxLines={1}
>
{provider.name}
</Typography>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
color: 'text.secondary',
flexWrap: 'wrap',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<LocationOnOutlinedIcon sx={{ fontSize: 12 }} aria-hidden />
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
{provider.location}
</Typography>
</Box>
{provider.rating != null && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<StarRoundedIcon sx={{ fontSize: 12, color: 'warning.main' }} aria-hidden />
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
{provider.rating}
</Typography>
</Box>
)}
</Box>
</Box>
{/* Price column — right-aligned, matches MapPopup's "From $X" typography.
Verified providers get the brand-600 copper price; unverified get
text.primary. "From" label uses caption/secondary for hierarchy. */}
{hasPrice && (
<Box
sx={{
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
pt: '1px',
}}
>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 10 }}>
From
</Typography>
<Typography
variant="body2"
sx={{
fontWeight: 700,
fontSize: 13,
color: provider.verified ? 'var(--fa-color-brand-600)' : 'text.primary',
lineHeight: 1.2,
}}
>
{priceText}
</Typography>
</Box>
)}
</ButtonBase>
);
};
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Cluster popup card for the FA design system.
*
* Appears when a cluster marker is clicked. Shows the providers grouped at
* that map location as a scrollable stack of image-free rows — each row: a
* fixed-width verified-icon slot (so titles align across mixed-tier lists) +
* provider name (copper for verified, neutral for unverified) + location and
* rating meta. Clicking a row calls `onSelectProvider(id)`. In the
* ProviderMap flow, that pans and zooms the map to the provider's location
* before opening their single-provider popup — restoring spatial context
* that a list-only popup otherwise loses.
*
* Verified providers are sorted to the top of the list (business outcome:
* promote partner providers in any crowded cluster).
*
* Sibling to `MapPopup` — same card + nub treatment, same drop-shadow, same
* 320px width, same `surface-subtle` header bar convention. Designed to
* render inside a Google Maps `AdvancedMarker`.
*
* Composes: Paper + Typography + IconButton + ButtonBase + icons.
*
* Usage:
* ```tsx
* <ClusterPopup
* providers={[
* { id: 'p1', name: 'H.Parsons', location: 'Wentworth', verified: true, rating: 4.6 },
* { id: 'p2', name: 'Smith & Sons', location: 'Cronulla', verified: false, rating: 4.2 },
* ]}
* onSelectProvider={(id) => drillIntoProvider(id)}
* onClose={() => closePopup()}
* />
* ```
*/
export const ClusterPopup = React.forwardRef<HTMLDivElement, ClusterPopupProps>(
({ providers, onSelectProvider, onClose, exiting = false, sx }, ref) => {
// Verified-first sort (stable within each tier)
const sorted = React.useMemo(
() =>
[...providers].sort((a, b) => Number(b.verified ?? false) - Number(a.verified ?? false)),
[providers],
);
return (
<Box
ref={ref}
// Swallow clicks on any empty space inside the popup (header, scroll
// gutter, etc.) so they don't bubble to Map.onClick and close us.
onClick={(e) => e.stopPropagation()}
sx={[
{
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.15))',
transformOrigin: 'bottom center',
transition: 'opacity 180ms ease-out, transform 180ms ease-out',
opacity: exiting ? 0 : 1,
transform: exiting ? 'scale(0.9)' : 'scale(1)',
'@keyframes clusterPopupIn': {
from: { opacity: 0, transform: 'scale(0.9)' },
to: { opacity: 1, transform: 'scale(1)' },
},
animation: exiting ? undefined : 'clusterPopupIn 180ms ease-out',
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
<Paper
elevation={0}
sx={{
width: POPUP_WIDTH,
borderRadius: 'var(--fa-card-border-radius-default)',
overflow: 'hidden',
bgcolor: 'background.paper',
display: 'flex',
flexDirection: 'column',
maxHeight: MAX_CONTENT_HEIGHT,
}}
>
{/* Header bar */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 2,
py: 1.25,
bgcolor: 'var(--fa-color-surface-subtle)',
borderBottom: '1px solid',
borderColor: 'divider',
flexShrink: 0,
}}
>
<MapOutlinedIcon sx={{ fontSize: 16, color: 'text.secondary' }} aria-hidden />
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary', flex: 1 }}>
{providers.length} providers in this area
</Typography>
{onClose && (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
aria-label="Close cluster popup"
sx={{ mr: -0.5 }}
>
<CloseRoundedIcon sx={{ fontSize: 18 }} />
</IconButton>
)}
</Box>
{/* Provider list — scrollable */}
<Box
sx={{
overflowY: 'auto',
p: 1,
display: 'flex',
flexDirection: 'column',
gap: 1,
// Thin scrollbar styling
scrollbarWidth: 'thin',
'&::-webkit-scrollbar': { width: 6 },
'&::-webkit-scrollbar-thumb': {
background: 'rgba(0,0,0,0.2)',
borderRadius: 3,
},
}}
>
{sorted.map((p) => (
<ProviderRow key={p.id} provider={p} onClick={() => onSelectProvider(p.id)} />
))}
</Box>
</Paper>
{/* Nub — matches MapPopup (fill-only, soft shadow carries the depth) */}
<svg
aria-hidden
width={NUB_SIZE * 2}
height={NUB_SIZE}
viewBox={`0 0 ${NUB_SIZE * 2} ${NUB_SIZE}`}
style={{ display: 'block', marginTop: '-1px', overflow: 'visible' }}
>
<path
d={`M 0 0 L ${NUB_SIZE} ${NUB_SIZE} L ${NUB_SIZE * 2} 0`}
fill="var(--fa-color-white)"
/>
</svg>
</Box>
);
},
);
ClusterPopup.displayName = 'ClusterPopup';
export default ClusterPopup;

View File

@@ -1 +0,0 @@
export { ClusterPopup, type ClusterPopupProps, type ClusterPopupProvider } from './ClusterPopup';

View File

@@ -85,36 +85,6 @@ export const Empty: Story = {
},
};
// --- Mobile ------------------------------------------------------------------
/** Mobile viewport — expanded by default, with a grey-filled right-chevron
* on the right of the pill. Tap the chevron to retract the pill to the
* right corner (the middle content animates to width:0, so the pill
* visually shrinks as one unit rather than swapping into a separate mini
* pill). Tap the left-chevron on the collapsed pill to expand. On add
* while collapsed, the full bar auto-peeks for 3s, then re-collapses. */
export const Mobile: Story = {
args: {
packages: samplePackages.slice(0, 2),
onCompare: () => alert('Compare clicked'),
},
parameters: {
viewport: { defaultViewport: 'mobile1' },
},
};
/** Mobile — single package state. Same behaviour as `Mobile`, Compare
* CTA disabled ("Add another to compare"). */
export const MobileSingle: Story = {
args: {
packages: samplePackages.slice(0, 1),
onCompare: () => alert('Compare clicked'),
},
parameters: {
viewport: { defaultViewport: 'mobile1' },
},
};
// --- Interactive Demo --------------------------------------------------------
/** Interactive demo — add packages and see the bar update */

View File

@@ -1,12 +1,8 @@
import React from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import Slide from '@mui/material/Slide';
import useMediaQuery from '@mui/material/useMediaQuery';
import IconButton from '@mui/material/IconButton';
import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded';
import ChevronLeftRoundedIcon from '@mui/icons-material/ChevronLeftRounded';
import { useTheme, type SxProps, type Theme } from '@mui/material/styles';
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';
@@ -35,14 +31,6 @@ export interface CompareBarProps {
sx?: SxProps<Theme>;
}
// ─── Constants ──────────────────────────────────────────────────────────────
/** How long the bar stays expanded after a new package is added while
* collapsed. Long enough to read, short enough not to obstruct. */
const PEEK_DURATION_MS = 3000;
/** Middle-content expand/collapse duration (width + opacity). */
const COLLAPSE_MS = 300;
// ─── Component ───────────────────────────────────────────────────────────────
/**
@@ -51,54 +39,16 @@ const COLLAPSE_MS = 300;
* Shows a fraction badge (1/3, 2/3, 3/3), contextual copy, and a Compare CTA.
* Present on both ProvidersStep and PackagesStep.
*
* **Mobile collapse** (xs only): users can tap a right-chevron to retract
* the pill to the right edge — the middle content (status text + Compare
* button) animates to width:0 while the pill stays anchored at the same
* right offset, so the whole thing appears to shrink into the corner as
* one unit rather than two separate elements. Tap again to expand. When
* a new package is added while collapsed, the bar auto-peeks for
* `PEEK_DURATION_MS` so the user sees the tally update, then re-collapses.
*
* Desktop (md+) stays expanded — there's plenty of space, and the
* collapse chevron is not rendered.
*
* Composes Badge + Button + Typography + IconButton.
* Composes Badge + Button + Typography.
*/
export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
({ packages, onCompare, error, sx }, ref) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const count = packages.length;
const visible = count > 0;
const canCompare = count >= 2;
const statusText = count === 1 ? 'Add another to compare' : 'Ready to compare';
// Collapse state — mobile only. Starts expanded; when the basket empties
// we reset so the next fresh fill starts visible.
const [collapsed, setCollapsed] = React.useState(false);
const [peeking, setPeeking] = React.useState(false);
const lastCountRef = React.useRef(count);
React.useEffect(() => {
if (!visible) setCollapsed(false);
}, [visible]);
// Auto-peek when a package is added while collapsed.
React.useEffect(() => {
const prev = lastCountRef.current;
lastCountRef.current = count;
if (collapsed && count > prev) {
setPeeking(true);
const t = window.setTimeout(() => setPeeking(false), PEEK_DURATION_MS);
return () => window.clearTimeout(t);
}
}, [count, collapsed]);
/** Effective "is the middle content hidden?" — only on mobile, when the
* user has collapsed and we're not currently peeking. */
const mobileCollapsed = isMobile && collapsed && !peeking;
return (
<Slide direction="up" in={visible} mountOnEnter unmountOnExit>
<Paper
@@ -108,123 +58,52 @@ export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
aria-live="polite"
aria-label={`${count} of 3 packages selected for comparison`}
sx={[
(t: Theme) => ({
(theme: Theme) => ({
position: 'fixed',
// Clear the sticky HelpBar (~40px) + breathing room. FA theme
// uses a 4px spacing base, so spacing(16) = 64px.
bottom: t.spacing(16),
// z-index sits below the mobile map-view drawer (modal: 1300)
// but above app chrome (appBar: 1100). snackbar (1400) was too
// aggressive — the drawer visually covers this bar on mobile.
zIndex: t.zIndex.drawer,
// Mobile: right-anchored so when the middle collapses the pill
// appears to retract to the right corner. Desktop: centered.
...(isMobile
? { right: t.spacing(4), left: 'auto' }
: { left: 0, right: 0, mx: 'auto' }),
width: 'fit-content',
bottom: theme.spacing(3),
left: '50%',
transform: 'translateX(-50%)',
zIndex: theme.zIndex.snackbar,
borderRadius: '9999px',
display: 'flex',
alignItems: 'center',
gap: { xs: 1.25, md: 2 },
px: { xs: 1.5, md: 3 },
py: { xs: 0.75, md: 1.5 },
maxWidth: { xs: 'calc(100vw - 32px)', md: 460 },
overflow: 'hidden',
transition: `padding ${COLLAPSE_MS}ms ease-out`,
gap: 1.5,
px: 2.5,
py: 1.25,
maxWidth: { xs: 'calc(100vw - 32px)', md: 420 },
}),
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Fraction badge — shows "N/3" when expanded, just "N" when
collapsed on mobile (reads as a circle at mini size). */}
<Badge
color="brand"
variant="soft"
size={isMobile ? 'medium' : 'large'}
sx={{
flexShrink: 0,
// When collapsed, force the badge toward a circle by
// equalising min-width and min-height at the medium-badge
// height (26px).
...(mobileCollapsed && {
minWidth: 'var(--fa-badge-height-md)',
justifyContent: 'center',
px: 0,
}),
}}
>
{mobileCollapsed ? count : `${count}/3`}
{/* Fraction badge — 1/3, 2/3, 3/3 */}
<Badge color="brand" variant="soft" size="small" sx={{ flexShrink: 0 }}>
{count}/3
</Badge>
{/* Middle content (status + Compare CTA) — animates to zero
max-width when collapsed, letting the pill shrink as one unit
with the right edge staying fixed. */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: { xs: 1.25, md: 2 },
maxWidth: mobileCollapsed ? 0 : 600,
opacity: mobileCollapsed ? 0 : 1,
overflow: 'hidden',
transition: `max-width ${COLLAPSE_MS}ms ease-out, opacity ${Math.round(
COLLAPSE_MS * 0.6,
)}ms ease-out`,
}}
>
{/* Status text */}
<Typography
variant={isMobile ? 'body2' : 'body1'}
variant="body2"
role={error ? 'alert' : undefined}
sx={{
fontWeight: 500,
whiteSpace: 'nowrap',
color: error ? 'var(--fa-color-text-brand)' : 'text.primary',
flexShrink: 0,
}}
>
{error || statusText}
</Typography>
{/* Compare CTA */}
<Button
variant="contained"
size={isMobile ? 'small' : 'medium'}
size="small"
startIcon={<CompareArrowsIcon />}
onClick={onCompare}
disabled={!canCompare}
tabIndex={mobileCollapsed ? -1 : 0}
sx={{ flexShrink: 0, borderRadius: '9999px' }}
>
Compare
</Button>
</Box>
{/* Mobile-only collapse/expand chevron — grey-filled circle that
swaps icon direction based on state. Rendered at all times so
the IconButton container stays in the layout and the icon swap
happens in place without mount/unmount. */}
{isMobile && (
<IconButton
aria-label={mobileCollapsed ? 'Show comparison basket' : 'Hide comparison basket'}
aria-expanded={!mobileCollapsed}
onClick={() => setCollapsed((c) => !c)}
size="small"
sx={{
flexShrink: 0,
width: 32,
height: 32,
borderRadius: '50%',
bgcolor: 'var(--fa-color-neutral-200)',
color: 'text.secondary',
'&:hover': { bgcolor: 'var(--fa-color-neutral-300)' },
}}
>
{mobileCollapsed ? (
<ChevronLeftRoundedIcon fontSize="small" />
) : (
<ChevronRightRoundedIcon fontSize="small" />
)}
</IconButton>
)}
</Paper>
</Slide>
);

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,255 +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/recommended badge, name, location, rating),
* package name, total price, CTA button, and optional Remove link. The badge
* floats above the card's top edge — "Recommended" (primary fill) replaces
* "Verified" (soft) when the package is recommended. Recommended packages
* also get a warm selected card state with a brand-600 border.
*
* 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 badge — Recommended (primary fill) takes priority over Verified (soft) */}
{(pkg.isRecommended || pkg.provider.verified) && (
<Badge
color="brand"
variant={pkg.isRecommended ? 'filled' : 'soft'}
size="medium"
icon={
pkg.isRecommended ? (
<StarRoundedIcon sx={{ fontSize: 16 }} />
) : (
<VerifiedOutlinedIcon sx={{ fontSize: 16 }} />
)
}
sx={{
position: 'absolute',
top: -13,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1,
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
}}
>
{pkg.isRecommended ? 'Recommended' : 'Verified'}
</Badge>
)}
<Card
variant="outlined"
selected={pkg.isRecommended}
padding="none"
sx={{
overflow: 'hidden',
flex: 1,
display: 'flex',
flexDirection: 'column',
...(pkg.isRecommended && {
borderColor: 'var(--fa-color-brand-600)',
}),
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
px: 2.5,
pt: 5,
pb: 3,
gap: 1,
flex: 1,
}}
>
{/* Provider name — always reserves space for 2 lines (via minHeight),
content bottom-aligned so single-line names sit flush with the
next item below rather than floating high in the slot. */}
<Box
sx={{
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'center',
gap: 0.75,
maxWidth: '100%',
minHeight: 36, // 2 × (14px label × 1.286 line-height)
}}
>
{pkg.isRecommended && (
<VerifiedOutlinedIcon
sx={{
fontSize: 16,
color: 'var(--fa-color-brand-600)',
flexShrink: 0,
mb: '2px',
}}
aria-label="Verified provider"
/>
)}
<Tooltip
title={pkg.provider.name}
arrow
placement="top"
disableHoverListener={pkg.provider.name.length < 50}
>
<Typography
variant="label"
sx={{
fontWeight: 600,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: 0,
}}
>
{pkg.provider.name}
</Typography>
</Tooltip>
</Box>
{/* Location */}
<Typography variant="caption" color="text.secondary">
{pkg.provider.location}
</Typography>
{/* Rating (or dash placeholder to keep card heights consistent) */}
{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>
) : (
<Typography variant="body2" color="text.secondary" aria-label="No reviews yet">
</Typography>
)}
<Divider sx={{ width: '100%', my: 1.5 }} />
<Typography variant="h6" component="p">
{pkg.name}
</Typography>
{/* Price subgroup — tighter internal spacing than the outer gap
so the label sits close to the amount it describes. */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 0.25,
}}
>
<Typography variant="caption" color="text.secondary">
Total package price
</Typography>
<Typography variant="h5" sx={{ color: 'primary.main', fontWeight: 700 }}>
{formatPrice(pkg.price)}
</Typography>
</Box>
{/* 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={{ px: 4 }}
>
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
</Button>
{/* Always render the same Link element; hide when no Remove action
applies (recommended or no handler). Keeps the footer row
identical across all cards so CTAs align. */}
{(() => {
const canRemove = !pkg.isRecommended && !!onRemove;
return (
<Link
component="button"
variant="caption"
color="text.secondary"
underline="hover"
onClick={canRemove ? () => onRemove!(pkg.id) : undefined}
tabIndex={canRemove ? 0 : -1}
aria-hidden={!canRemove}
sx={{
...(!canRemove && { visibility: 'hidden', pointerEvents: 'none' }),
}}
>
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,323 +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 { 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"
padding="none"
sx={[
{
overflow: 'hidden',
boxShadow: 'var(--fa-shadow-sm)',
// Body defaults to white; only the header carries the warm/subtle
// tint so the tint signals "provider" rather than washing the
// whole card.
bgcolor: 'background.paper',
// Match the desktop ComparisonColumnCard recommended treatment:
// explicit 2px brand-600 border (same as Card's selected state,
// but without the warm background wash that `selected` applies).
...(pkg.isRecommended && {
border: '2px solid var(--fa-color-brand-600)',
}),
},
...(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: 3,
pt: 3,
pb: 4,
}}
>
{/* Provider name with optional inline verified icon (matches desktop
ComparisonColumnCard treatment) */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
mb: 1.25,
}}
>
{pkg.provider.verified && (
<VerifiedOutlinedIcon
sx={{
fontSize: 16,
color: 'var(--fa-color-brand-600)',
flexShrink: 0,
}}
aria-label="Verified provider"
/>
)}
<Typography variant="label" sx={{ fontWeight: 600 }}>
{pkg.provider.name}
</Typography>
</Box>
{/* Location + Rating */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<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={{ my: 3 }} />
{/* Package info group — name, label, price stacked with small internal gap */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
<Typography variant="h5" component="p">
{pkg.name}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
<Typography variant="caption" color="text.secondary">
Total package price
</Typography>
<Typography variant="h3" sx={{ color: 'primary.main', fontWeight: 700 }}>
{formatPrice(pkg.price)}
</Typography>
</Box>
</Box>
<Button
variant={pkg.provider.verified ? 'contained' : 'soft'}
color={pkg.provider.verified ? 'primary' : 'secondary'}
size="medium"
fullWidth
onClick={() => onArrange(pkg.id)}
sx={{ mt: 3 }}
>
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
</Button>
</Box>
{/* Sections — with left accent borders on headings */}
<Box sx={{ px: 2.5, pt: 3.5, pb: 3 }}>
{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 ? 5 : 0 }}>
{/* Section heading with left accent */}
<Box
sx={{
borderLeft: '3px solid',
borderLeftColor: 'var(--fa-color-brand-500)',
pl: 1.5,
mb: 2.5,
}}
>
<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: 2,
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,155 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
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.
Matches the desktop ComparisonColumnCard styling (filled brand +
star icon) for consistency between surfaces. */}
{pkg.isRecommended ? (
<Badge
color="brand"
variant="filled"
size="small"
icon={<StarRoundedIcon sx={{ fontSize: 14 }} />}
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: 235,
cursor: 'pointer',
boxShadow: 'var(--fa-shadow-sm)',
...(pkg.isRecommended && {
borderColor: 'var(--fa-color-brand-600)',
}),
...(isActive && {
boxShadow: 'var(--fa-shadow-md)',
}),
}}
>
<Box sx={{ px: 2, pt: 3.5, 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

@@ -77,14 +77,8 @@ export const FilterPanel = React.forwardRef<HTMLDivElement, FilterPanelProps>(
title={label}
footer={
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{onClear ? (
<Button
variant="text"
size="small"
color="secondary"
onClick={() => onClear()}
disabled={activeCount === 0}
>
{onClear && activeCount > 0 ? (
<Button variant="text" size="small" color="secondary" onClick={() => onClear()}>
Reset filters
</Button>
) : (

View File

@@ -1,32 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { HelpBar } from './HelpBar';
const meta: Meta<typeof HelpBar> = {
title: 'Molecules/HelpBar',
component: HelpBar,
tags: ['autodocs'],
parameters: { layout: 'fullscreen' },
decorators: [
(Story) => (
// Fake page content so the sticky footer has something to sit under.
<Box sx={{ minHeight: 400, display: 'flex', flexDirection: 'column' }}>
<Box sx={{ flex: 1, p: 4, bgcolor: 'background.default' }}>
Page content scrolls above the help bar.
</Box>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof HelpBar>;
/** Default — uses FA's standard support number. */
export const Default: Story = {};
/** Custom number — spaces preserved in the label, stripped in the tel link. */
export const CustomNumber: Story = {
args: { phone: '1300 000 000' },
};

View File

@@ -1,64 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import PhoneIcon from '@mui/icons-material/Phone';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Link } from '../../atoms/Link';
// ─── Types ──────────────────────────────────────────────────────────────────
/** Props for the FA HelpBar molecule */
export interface HelpBarProps {
/** Phone number shown in the bar. Spaces preserved in the label,
* stripped in the `tel:` href. Defaults to FA's support number. */
phone?: string;
/** MUI sx prop — merged onto the default footer chrome. */
sx?: SxProps<Theme>;
}
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Sticky help footer used at the bottom of every wizard page. Shows a
* phone-icon prefix + "Need help? Call us on" + the support number as a
* tel-link. White fill, top border, sticky to the viewport bottom.
*
* Used by `WizardLayout` (for all variants that don't set `hideHelpBar`)
* and by pages that bypass WizardLayout's chrome (e.g. the mobile-map-first
* layout on `ProvidersStep`). Promoted from a WizardLayout-internal
* component so both sources render an identical footer — preventing drift
* if the phone number or styling ever changes.
*/
export const HelpBar = React.forwardRef<HTMLDivElement, HelpBarProps>(
({ phone = '1800 987 888', sx }, ref) => (
<Box
ref={ref}
component="footer"
sx={[
{
position: 'sticky',
bottom: 0,
zIndex: 10,
bgcolor: 'background.paper',
borderTop: '1px solid',
borderColor: 'divider',
py: 1.5,
px: { xs: 2, md: 4 },
textAlign: 'center',
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
<Typography variant="body2" color="text.secondary" component="span">
<PhoneIcon sx={{ fontSize: 16, verticalAlign: 'text-bottom', mr: 0.5 }} />
Need help? Call us on{' '}
<Link href={`tel:${phone.replace(/\s/g, '')}`} sx={{ fontWeight: 600 }}>
{phone}
</Link>
</Typography>
</Box>
),
);
HelpBar.displayName = 'HelpBar';
export default HelpBar;

View File

@@ -1 +0,0 @@
export { HelpBar, type HelpBarProps } from './HelpBar';

View File

@@ -1,92 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import Box from '@mui/material/Box';
import { LocationSearchInput } from './LocationSearchInput';
const meta: Meta<typeof LocationSearchInput> = {
title: 'Molecules/LocationSearchInput',
component: LocationSearchInput,
tags: ['autodocs'],
parameters: { layout: 'centered' },
decorators: [
(Story) => (
<Box sx={{ width: 360, p: 2, bgcolor: 'background.default' }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof LocationSearchInput>;
// Caller-provided chrome mirroring the ProvidersStep chip strip — useful
// for visualising the molecule in its real context. Users of the molecule
// on other surfaces would pass their own (or none).
const providerChromeSx = {
'& .MuiOutlinedInput-root': {
bgcolor: 'background.paper',
boxShadow: 'var(--fa-shadow-sm)',
borderRadius: 'var(--fa-button-border-radius-default)',
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'var(--fa-color-neutral-300)',
borderWidth: 1,
},
'& .MuiOutlinedInput-root.Mui-focused': {
boxShadow: 'var(--fa-shadow-sm)',
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'var(--fa-color-neutral-300)',
borderWidth: 1,
},
},
} as const;
// ─── Stories ────────────────────────────────────────────────────────────────
/** Empty state — no committed value, no draft. The primary magnifying-glass
* stays anchored to the right edge. */
export const Empty: Story = {
render: (args) => {
const [value, setValue] = useState('');
return <LocationSearchInput {...args} value={value} onChange={setValue} />;
},
args: { sx: providerChromeSx },
};
/** Committed-chip state — the value renders as a chip with an X to clear. */
export const WithCommittedValue: Story = {
render: (args) => {
const [value, setValue] = useState('Wollongong, 2500');
return <LocationSearchInput {...args} value={value} onChange={setValue} />;
},
args: { sx: providerChromeSx },
};
/** Unstyled — no caller chrome. Shows the raw molecule output (just the
* correctness CSS kicks in; the rest is MUI defaults). */
export const Unstyled: Story = {
render: (args) => {
const [value, setValue] = useState('');
return <LocationSearchInput {...args} value={value} onChange={setValue} />;
},
};
/** With onCommit side-effect — logs when the user explicitly commits
* (separate from the always-fired onChange). */
export const WithOnCommit: Story = {
render: (args) => {
const [value, setValue] = useState('');
return (
<LocationSearchInput
{...args}
value={value}
onChange={setValue}
onCommit={(v) => {
console.log('committed:', v);
}}
/>
);
},
args: { sx: providerChromeSx, placeholder: 'Type a suburb and press Enter' },
};

View File

@@ -1,199 +0,0 @@
import React from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
import InputAdornment from '@mui/material/InputAdornment';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import SearchIcon from '@mui/icons-material/Search';
import type { SxProps, Theme } from '@mui/material/styles';
import { Chip } from '../../atoms/Chip';
import { IconButton } from '../../atoms/IconButton';
// ─── Types ──────────────────────────────────────────────────────────────────
/** Props for the FA LocationSearchInput molecule */
export interface LocationSearchInputProps {
/** Committed location value. When non-empty, rendered as a chip inside
* the input; when empty, placeholder shows and the input accepts typing. */
value: string;
/** Fires whenever the committed value changes — on explicit commit (Enter
* or search button) with the new value, or on chip delete with ''. */
onChange: (value: string) => void;
/** Optional extra callback fired *only* on explicit commit (not on chip
* delete). Useful for triggering search side-effects beyond the value
* update (analytics, external fetch, etc.). */
onCommit?: (value: string) => void;
/** Placeholder text shown when no value is committed and no draft typed. */
placeholder?: string;
/** Accessible label for the input. */
'aria-label'?: string;
/** MUI sx prop — merged after the molecule's internal correctness CSS.
* Use this to style the outlined input's chrome (bgcolor, shadow, border,
* radius). Internal CSS targets `.MuiAutocomplete-inputRoot` whereas most
* chrome sx uses `.MuiOutlinedInput-root`, so collisions are avoided. */
sx?: SxProps<Theme>;
}
// ─── Internal correctness CSS ───────────────────────────────────────────────
/**
* Absolute-anchors the commit button (end adornment) to the right edge of
* the input — stock MUI Autocomplete does this on `.MuiAutocomplete-endAdornment`,
* but overriding `InputProps.endAdornment` puts our button inside a
* `.MuiInputAdornment-positionEnd` that defaults to `position: static` and
* would slide left as chips / draft text fill the input.
*
* `pr: 5` on the input root reserves the right-edge lane so input content
* can't run under the button. Selectors use `.MuiAutocomplete-inputRoot`
* (not `.MuiOutlinedInput-root`) so caller sx for chrome can sit alongside
* these rules without colliding on the same key.
*/
const INTERNAL_SX = {
'& .MuiAutocomplete-inputRoot': {
position: 'relative',
pr: 5,
},
'& .MuiAutocomplete-inputRoot .MuiInputAdornment-positionEnd': {
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
height: 'auto',
maxHeight: 'none',
m: 0,
},
} as const;
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Location search input with committed-chip semantics.
*
* - **Typing produces a draft** (local state, not propagated).
* - **Pressing Enter or the primary-filled magnifying-glass button commits**
* the draft: fires `onChange(draft)` and `onCommit?.(draft)`, clears the
* draft, renders the committed value as a chip inside the input.
* - **Tapping the chip's X** clears the committed value (`onChange('')`).
*
* Capped to one chip at a time — if the user commits a new value while a
* chip exists, the new value replaces it. This matches the product intent
* (one active location per search) and keeps the UX obvious.
*
* The molecule owns the endAdornment absolute-anchoring + right-side
* padding so the commit button never drifts as chips / draft fill the input.
* Chrome (bgcolor, shadow, border, radius) is caller-controlled via `sx`.
*
* Originally extracted from ProvidersStep (D046) where the same pattern
* lived inline in both the mobile-map floating strip and the desktop/mobile
* sticky search bar.
*/
export const LocationSearchInput = React.forwardRef<HTMLDivElement, LocationSearchInputProps>(
(
{
value,
onChange,
onCommit,
placeholder = 'Search a town or suburb...',
'aria-label': ariaLabel = 'Search location',
sx,
},
ref,
) => {
const [draft, setDraft] = React.useState('');
const commit = (next: string) => {
const trimmed = next.trim();
if (!trimmed) return;
onChange(trimmed);
onCommit?.(trimmed);
setDraft('');
};
return (
<Autocomplete
ref={ref}
multiple
freeSolo
options={[]}
forcePopupIcon={false}
clearIcon={null}
value={value.trim() ? [value.trim()] : []}
inputValue={draft}
onInputChange={(_, newDraft, reason) => {
// Autocomplete fires a 'reset' input-change after a commit that
// would echo the committed value back into our draft — ignore it.
if (reason === 'reset') return;
setDraft(newDraft);
}}
onChange={(_, newValue) => {
if (newValue.length === 0) {
// Chip deleted
onChange('');
return;
}
// Cap at 1: take the most-recent entry as the new committed value.
const last = newValue[newValue.length - 1];
if (typeof last === 'string') commit(last);
}}
renderTags={(val, getTagProps) =>
val.map((option, index) => {
const { key, ...chipProps } = getTagProps({ index });
return (
<Chip
key={key}
label={option}
size="small"
aria-label={`Current location: ${option}. Press delete to clear.`}
{...chipProps}
/>
);
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={value.trim() ? '' : placeholder}
size="small"
inputProps={{
...params.inputProps,
'aria-label': ariaLabel,
}}
InputProps={{
...params.InputProps,
startAdornment: (
<>
<InputAdornment position="start" sx={{ ml: 0.5, mr: 0.5 }}>
<LocationOnOutlinedIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
</InputAdornment>
{params.InputProps.startAdornment}
</>
),
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="Search"
onClick={() => commit(draft)}
sx={{
width: 28,
height: 28,
borderRadius: '50%',
bgcolor: 'primary.main',
color: 'primary.contrastText',
'&:hover': { bgcolor: 'primary.dark' },
'&:focus-visible': { outline: 'none' },
}}
>
<SearchIcon sx={{ fontSize: 16 }} />
</IconButton>
</InputAdornment>
),
}}
/>
)}
sx={[INTERNAL_SX, ...(Array.isArray(sx) ? sx : [sx])]}
/>
);
},
);
LocationSearchInput.displayName = 'LocationSearchInput';
export default LocationSearchInput;

View File

@@ -1 +0,0 @@
export { LocationSearchInput, type LocationSearchInputProps } from './LocationSearchInput';

View File

@@ -132,7 +132,7 @@ export const WithPin: Story = {
verified
onClick={() => {}}
/>
<MapPin name="H.Parsons" price={900} verified />
<MapPin name="H.Parsons" price={900} verified active />
</>
),
};

View File

@@ -31,9 +31,6 @@ export interface MapPopupProps {
verified?: boolean;
/** Click handler — entire card is clickable */
onClick?: () => void;
/** When true, animates the popup out (opacity + scale) without unmounting.
* Callers should unmount after the transition completes (180ms). */
exiting?: boolean;
/** MUI sx prop for the root element */
sx?: SxProps<Theme>;
}
@@ -88,7 +85,6 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
capacity,
verified = false,
onClick,
exiting = false,
sx,
},
ref,
@@ -107,21 +103,12 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
}
}, [name]);
// Swallow clicks on the popup so they don't bubble to an enclosing
// Map.onClick (which would close the popup mid-click). Always applied,
// even when onClick is unset, because callers consistently render this
// molecule inside a map context where ambient clicks should not escape.
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
return (
<Box
ref={ref}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
onClick={handleClick}
onClick={onClick}
onKeyDown={
onClick
? (e: React.KeyboardEvent) => {
@@ -140,17 +127,8 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
alignItems: 'center',
filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.15))',
cursor: onClick ? 'pointer' : 'default',
transformOrigin: 'bottom center',
transition: 'opacity 180ms ease-out, transform 180ms ease-out',
opacity: exiting ? 0 : 1,
transform: exiting ? 'scale(0.9)' : 'scale(1)',
'@keyframes mapPopupIn': {
from: { opacity: 0, transform: 'scale(0.9)' },
to: { opacity: 1, transform: 'scale(1)' },
},
animation: exiting ? undefined : 'mapPopupIn 180ms ease-out',
'&:hover':
onClick && !exiting
transition: 'transform 150ms ease-in-out',
'&:hover': onClick
? {
transform: 'scale(1.02)',
}
@@ -171,7 +149,6 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
borderRadius: 'var(--fa-card-border-radius-default)',
overflow: 'hidden',
bgcolor: 'background.paper',
position: 'relative',
}}
>
{/* ── Image ── */}
@@ -302,20 +279,19 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
</Box>
</Paper>
{/* Nub — downward pointer. SVG (fill-only; MapPopup uses a drop-shadow
for depth instead of a hard border, so no stroke needed) */}
<svg
{/* Nub — downward pointer connecting to pin */}
<Box
aria-hidden
width={NUB_SIZE * 2}
height={NUB_SIZE}
viewBox={`0 0 ${NUB_SIZE * 2} ${NUB_SIZE}`}
style={{ display: 'block', marginTop: '-1px', overflow: 'visible' }}
>
<path
d={`M 0 0 L ${NUB_SIZE} ${NUB_SIZE} L ${NUB_SIZE * 2} 0`}
fill="var(--fa-color-white)"
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',
}}
/>
</svg>
</Box>
);
},

View File

@@ -1,146 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { MapProviderDrawer } from './MapProviderDrawer';
const meta: Meta<typeof MapProviderDrawer> = {
title: 'Molecules/MapProviderDrawer',
component: MapProviderDrawer,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
viewport: { defaultViewport: 'mobile1' },
},
decorators: [
// Simulate the mobile map-view container: fixed-size, relatively-positioned,
// with a faux map background behind the drawer.
(Story) => (
<Box
sx={{
position: 'relative',
width: 390,
height: 700,
mx: 'auto',
overflow: 'hidden',
// Very rough map-tile fill so the drawer has contrast behind it.
background: 'linear-gradient(135deg, #C9DFC4 0%, #B5D4F0 50%, #C9DFC4 100%)',
}}
>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof MapProviderDrawer>;
// ─── Fixtures ───────────────────────────────────────────────────────────────
const parsons = {
id: 'parsons',
name: 'H.Parsons Funeral Directors',
location: 'Wentworth, NSW',
verified: true,
imageUrl: '/images/funeral-homes/parsons-chapel.jpg',
logoUrl: '/images/providers/parsons-logo.png',
rating: 4.6,
reviewCount: 7,
startingPrice: 1800,
};
const clusterProviders = [
parsons,
{
id: 'rankins',
name: 'Rankins Funeral Services',
location: 'Warrawong, NSW',
verified: true,
rating: 4.8,
startingPrice: 2450,
},
{
id: 'killick',
name: 'Killick Family Funerals',
location: 'Kingaroy, QLD',
verified: true,
rating: 4.9,
startingPrice: 3100,
},
{
id: 'wollongong-city',
name: 'Wollongong City Funerals',
location: 'Wollongong, NSW',
verified: false,
rating: 4.2,
startingPrice: 3400,
},
];
const log =
(label: string) =>
(arg?: string): void => {
console.log(label, arg ?? '');
};
// ─── Stories ────────────────────────────────────────────────────────────────
/** Single-provider drawer — the whole ProviderCard is clickable and fires
* `onSelectProvider` (in production, this navigates to the packages page). */
export const SingleProvider: Story = {
args: {
active: {
provider: parsons,
cluster: null,
exiting: false,
},
onClose: log('close'),
onSelectProvider: log('select'),
onDrillIntoProvider: log('drillInto'),
},
};
/** Cluster drawer — verified-first list of rows. Tapping a row fires
* `onDrillIntoProvider`; in production this pans + zooms the map and
* swaps the drawer's `active` to a single-provider state. */
export const Cluster: Story = {
args: {
active: {
provider: null,
cluster: {
providers: clusterProviders,
position: { lat: -34.42, lng: 150.89 },
},
exiting: false,
},
onClose: log('close'),
onSelectProvider: log('select'),
onDrillIntoProvider: log('drillInto'),
},
};
/** Closed state — the drawer is in the DOM but translated off-screen. */
export const Closed: Story = {
args: {
active: null,
onClose: log('close'),
onSelectProvider: log('select'),
onDrillIntoProvider: log('drillInto'),
},
};
/** Small cluster of two — verified pair. */
export const ClusterPair: Story = {
args: {
active: {
provider: null,
cluster: {
providers: clusterProviders.slice(0, 2),
position: { lat: -34.42, lng: 150.89 },
},
exiting: false,
},
onClose: log('close'),
onSelectProvider: log('select'),
onDrillIntoProvider: log('drillInto'),
},
};

View File

@@ -1,267 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import ButtonBase from '@mui/material/ButtonBase';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { Button } from '../../atoms/Button';
import { IconButton } from '../../atoms/IconButton';
import { Typography } from '../../atoms/Typography';
import { ProviderCard } from '../ProviderCard';
import type { ProviderData } from '../../pages/ProvidersStep';
import type { ProviderMapActiveState } from '../../organisms/ProviderMap';
// ─── Types ──────────────────────────────────────────────────────────────────
/** Props for the FA MapProviderDrawer molecule */
export interface MapProviderDrawerProps {
/** Current active state from `ProviderMap` (wire via `onActiveChange`).
* `null` = no active pin/cluster; drawer is hidden. */
active: ProviderMapActiveState | null;
/** Fires when the close X is tapped. Typically wired to the map's
* imperative `clearActive()`. */
onClose: () => void;
/** Fires when the single-provider card is tapped (entire card clickable).
* Typically navigates to that provider's packages. */
onSelectProvider: (id: string) => void;
/** Fires when a cluster row is tapped. Typically wired to the map's
* imperative `drillIntoProvider()` which pans + zooms + swaps the
* drawer's content to a single-provider card. */
onDrillIntoProvider: (id: string) => void;
/** MUI sx prop for the root Paper — merged onto the default positioning. */
sx?: SxProps<Theme>;
}
// ─── Cluster row ────────────────────────────────────────────────────────────
const ClusterRow: React.FC<{
provider: ProviderData;
onClick: () => void;
}> = ({ provider: p, onClick }) => (
<ButtonBase
onClick={onClick}
sx={{
width: '100%',
justifyContent: 'flex-start',
textAlign: 'left',
px: 2,
py: 1.25,
gap: 1,
// Start-align so the verified icon sits on the name's baseline —
// matches the desktop ClusterPopup row treatment.
alignItems: 'flex-start',
borderBottom: '1px solid',
borderColor: 'divider',
'&:last-of-type': { borderBottom: 'none' },
'&:hover': { bgcolor: 'var(--fa-color-surface-subtle)' },
}}
>
{/* Verified-icon slot — reserved width + fixed line-height so the icon
sits on the name's line-box regardless of location/rating meta
below. Mirrors desktop ClusterPopup's treatment (D043 refinement). */}
<Box
sx={{
width: 18,
height: '1.25em',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
{p.verified && <VerifiedOutlinedIcon sx={{ fontSize: 16, color: 'primary.main' }} />}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
color: p.verified ? 'primary.main' : 'text.primary',
lineHeight: 1.25,
mb: 0.25,
}}
>
{p.name}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'text.secondary' }}>
<Typography variant="caption">{p.location}</Typography>
{p.rating != null && (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.25 }}>
<StarRoundedIcon sx={{ fontSize: 14, color: 'warning.main' }} />
<Typography variant="caption">{p.rating.toFixed(1)}</Typography>
</Box>
)}
</Box>
</Box>
{p.startingPrice != null && (
<Box sx={{ flexShrink: 0, textAlign: 'right', pl: 1 }}>
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
From
</Typography>
<Typography
variant="body2"
sx={{ fontWeight: 600, color: p.verified ? 'primary.main' : 'text.primary' }}
>
${p.startingPrice.toLocaleString('en-AU')}
</Typography>
</Box>
)}
</ButtonBase>
);
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Bottom drawer that surfaces `ProviderMap`'s popup content outside the
* map itself. Used by the mobile map-first layout (see D045): the map
* runs full-bleed, and when a pin or cluster is tapped the drawer slides
* up from the bottom with the appropriate content.
*
* **Two content states, driven by `active`:**
* - `active.provider` → renders a `ProviderCard` edge-to-edge, entire card
* clickable (fires `onSelectProvider`)
* - `active.cluster` → renders a verified-first list of rows (verified icon
* slot + name + location + rating + "From $X"); tapping a row fires
* `onDrillIntoProvider` which is wired to the map's imperative
* `drillIntoProvider()` (pans + zooms, then swaps `active` to that
* provider — the drawer content flips to the single-provider card).
*
* **Animation:** slides up via `transform: translateY()` + 220ms transition.
* When `active.exiting` is true, the drawer slides down immediately (the
* map organism is in the middle of its 180ms exit fade on the hidden pin
* beneath). `visibility: hidden` kicks in only after the slide completes,
* so the drawer stays in the DOM for the exit animation.
*
* **Positioning:** uses `position: absolute; bottom: 0; left: 0; right: 0`
* by default — the consumer MUST render this inside a relatively-positioned
* container (typically the map-view `<main>`). Override via `sx` if needed.
*
* Related: row layout mirrors `ClusterPopup` (the anchored on-map variant);
* future consolidation possible if both container contracts converge.
*/
export const MapProviderDrawer = React.forwardRef<HTMLDivElement, MapProviderDrawerProps>(
({ active, onClose, onSelectProvider, onDrillIntoProvider, sx }, ref) => {
const provider = active?.provider ?? null;
const cluster = active?.cluster ?? null;
const isOpen = !!(active && !active.exiting && (provider || cluster));
const isExiting = !!active?.exiting;
const ariaLabel = provider
? `${provider.name} details`
: cluster
? `${cluster.providers.length} providers in this area`
: undefined;
return (
<Paper
ref={ref}
elevation={0}
role="dialog"
aria-label={ariaLabel}
aria-hidden={!isOpen}
sx={[
(t) => ({
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
// Sit above the floating CompareBar (which uses zIndex.drawer)
// so that when a pin or cluster is active the drawer visually
// covers the bar, not vice versa.
zIndex: t.zIndex.modal,
maxHeight: '60vh',
overflow: 'auto',
borderRadius: 0,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
boxShadow: 'var(--fa-shadow-lg)',
transform: isOpen ? 'translateY(0)' : 'translateY(100%)',
transition: 'transform 220ms ease-out',
pointerEvents: isOpen ? 'auto' : 'none',
visibility: isOpen || isExiting ? 'visible' : 'hidden',
}),
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Header strip — holds the close X (and the cluster count when
applicable) so neither sits over the card image below.
Horizontal padding matches the cluster rows (px: 2) so the
heading aligns with the row content beneath. */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
minHeight: 40,
px: 2,
py: 0.5,
gap: 1,
bgcolor: 'var(--fa-color-surface-subtle)',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
>
{cluster && !provider && (
<Typography variant="labelLg" sx={{ color: 'text.secondary', display: 'block' }}>
{cluster.providers.length} providers in this area
</Typography>
)}
<IconButton
aria-label="Close"
onClick={onClose}
size="small"
sx={{
ml: 'auto',
width: 32,
height: 32,
color: 'text.secondary',
'&:hover': { bgcolor: 'var(--fa-color-surface-subtle)' },
}}
>
<CloseRoundedIcon sx={{ fontSize: 20 }} />
</IconButton>
</Box>
{/* Single-provider content — card is display-only; a CTA button
below handles navigation to the provider's packages. */}
{provider && (
<Box>
<ProviderCard
name={provider.name}
location={provider.location}
verified={provider.verified}
imageUrl={provider.imageUrl}
logoUrl={provider.logoUrl}
rating={provider.rating}
reviewCount={provider.reviewCount}
startingPrice={provider.startingPrice}
sx={{ borderRadius: 0, boxShadow: 'none', border: 'none' }}
/>
<Box sx={{ px: 2, pb: 2, pt: 1 }}>
<Button variant="contained" fullWidth onClick={() => onSelectProvider(provider.id)}>
View Packages
</Button>
</Box>
</Box>
)}
{/* Cluster list content — tap a row to drill in */}
{cluster && !provider && (
<Box sx={{ pb: 1 }}>
{[...cluster.providers]
.sort((a, b) => Number(!!b.verified) - Number(!!a.verified))
.map((p) => (
<ClusterRow key={p.id} provider={p} onClick={() => onDrillIntoProvider(p.id)} />
))}
</Box>
)}
</Paper>
);
},
);
MapProviderDrawer.displayName = 'MapProviderDrawer';
export default MapProviderDrawer;

View File

@@ -1 +0,0 @@
export { MapProviderDrawer, type MapProviderDrawerProps } from './MapProviderDrawer';

View File

@@ -172,10 +172,7 @@ export const ProviderCard = React.forwardRef<HTMLDivElement, ProviderCardProps>(
width: LOGO_SIZE,
height: LOGO_SIZE,
borderRadius: LOGO_BORDER_RADIUS,
// 'contain' so wide/tall logos scale proportionally inside
// the square slot rather than cropping. Background fills any
// letterboxed space so it still reads as a tile.
objectFit: 'contain',
objectFit: 'cover',
backgroundColor: 'background.paper',
boxShadow: 'var(--fa-shadow-sm)',
border: '2px solid var(--fa-color-white)',

View File

@@ -1,104 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import Box from '@mui/material/Box';
import { SortMenu } from './SortMenu';
const meta: Meta<typeof SortMenu> = {
title: 'Molecules/SortMenu',
component: SortMenu,
tags: ['autodocs'],
parameters: { layout: 'centered' },
decorators: [
(Story) => (
<Box sx={{ p: 4 }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof SortMenu>;
const providerSortOptions = [
{ value: 'recommended', label: 'Recommended' },
{ value: 'nearest', label: 'Nearest' },
{ value: 'price_low', label: 'Price low to high' },
{ value: 'price_high', label: 'Price high to low' },
];
// Caller-provided chrome mirroring ProvidersStep's chip strip.
const controlChromeSx = {
height: 32,
bgcolor: 'background.paper',
borderColor: 'var(--fa-color-neutral-300)',
borderRadius: 'var(--fa-button-border-radius-default)',
boxShadow: 'var(--fa-shadow-sm)',
textTransform: 'none',
'&:hover': {
bgcolor: 'background.paper',
borderColor: 'var(--fa-color-neutral-300)',
},
'&:focus-visible': { outline: 'none' },
} as const;
// ─── Stories ────────────────────────────────────────────────────────────────
/** Compact variant — the trigger reads "Sort by" regardless of current
* value. Current value surfaces in the menu's selected state. Best for
* narrow layouts (mobile). */
export const Compact: Story = {
render: (args) => {
const [value, setValue] = useState('recommended');
return <SortMenu {...args} value={value} onChange={setValue} />;
},
args: {
options: providerSortOptions,
variant: 'compact',
sx: controlChromeSx,
},
};
/** Verbose variant — trigger reads "Sort: <current label>" with a
* swap-vertical icon. Best for desktop where horizontal space is cheap. */
export const Verbose: Story = {
render: (args) => {
const [value, setValue] = useState('price_low');
return <SortMenu {...args} value={value} onChange={setValue} />;
},
args: {
options: providerSortOptions,
variant: 'verbose',
sx: controlChromeSx,
},
};
/** No chrome — raw output. Useful for checking the molecule's default
* Button atom appearance before any caller sx. */
export const Bare: Story = {
render: (args) => {
const [value, setValue] = useState('recommended');
return <SortMenu {...args} value={value} onChange={setValue} />;
},
args: {
options: providerSortOptions,
variant: 'compact',
},
};
/** Smaller option set — demonstrating that the component adapts to any
* options array, not just the provider-sort defaults. */
export const TwoOptions: Story = {
render: (args) => {
const [value, setValue] = useState('newest');
return <SortMenu {...args} value={value} onChange={setValue} />;
},
args: {
options: [
{ value: 'newest', label: 'Newest first' },
{ value: 'oldest', label: 'Oldest first' },
],
variant: 'verbose',
sx: controlChromeSx,
},
};

View File

@@ -1,118 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import SwapVertIcon from '@mui/icons-material/SwapVert';
import type { SxProps, Theme } from '@mui/material/styles';
import { Button } from '../../atoms/Button';
// ─── Types ──────────────────────────────────────────────────────────────────
/** A sort option shown in the menu */
export interface SortOption {
/** Machine-readable value (e.g. 'price_low'). Passed back via `onChange`. */
value: string;
/** Human-readable label (e.g. 'Price low to high'). Shown in the menu and,
* in the `verbose` variant, on the trigger button. */
label: string;
}
/** Props for the FA SortMenu molecule */
export interface SortMenuProps {
/** Current sort value (controlled). Must match one of the options' values. */
value: string;
/** Fires when the user picks a different sort option. */
onChange: (value: string) => void;
/** Sort options to surface in the menu, in display order. */
options: SortOption[];
/** Trigger label variant:
* - `compact` (default): button reads just "Sort by"; current value
* surfaces only in the menu's selected item and in the aria-label.
* Best for narrow surfaces (mobile, chip-strip floating controls).
* - `verbose`: button reads "Sort: <current label>" with a leading
* swap-vertical icon. Best for desktop where horizontal space is
* cheap and the current value is worth surfacing inline. */
variant?: 'compact' | 'verbose';
/** MUI sx prop — applied to the trigger Button. Callers pass chrome
* (bgcolor, border, shadow, radius, height) here. */
sx?: SxProps<Theme>;
}
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Dropdown sort control — a trigger Button + anchored Menu.
*
* Tap the button → menu opens anchored to the button's bottom-right; pick
* an option → menu closes and `onChange` fires with the new value. The
* currently-selected option is visually marked in the menu (MUI's
* `selected` state on MenuItem).
*
* **Accessibility:** trigger button has `aria-haspopup="listbox"` and an
* `aria-label` that spells out the current sort ("Sort by Recommended"),
* so screen-reader users get the state regardless of which label variant
* is rendered. Selected MenuItem has `aria-selected="true"` via MUI.
*
* Originally extracted from ProvidersStep (which had the same Button +
* Menu pattern inline in two places with a minor "Sort by" vs
* "Sort: <label>" difference). Intended for reuse on VenueStep,
* CoffinsStep, or anywhere a sort menu is needed.
*/
export const SortMenu = React.forwardRef<HTMLButtonElement, SortMenuProps>(
({ value, onChange, options, variant = 'compact', sx }, ref) => {
const [anchor, setAnchor] = React.useState<null | HTMLElement>(null);
const current = options.find((o) => o.value === value);
const ariaLabel = `Sort by ${current?.label ?? 'default'}`;
return (
<>
<Button
ref={ref}
variant="outlined"
color="secondary"
size="small"
startIcon={variant === 'verbose' ? <SwapVertIcon sx={{ fontSize: 16 }} /> : undefined}
onClick={(e) => setAnchor(e.currentTarget)}
aria-haspopup="listbox"
aria-label={ariaLabel}
sx={sx}
>
{variant === 'compact' ? (
'Sort by'
) : (
<>
<Box component="span" sx={{ color: 'text.secondary', fontWeight: 400, mr: 0.5 }}>
Sort:
</Box>
{current?.label ?? ''}
</>
)}
</Button>
<Menu
anchorEl={anchor}
open={Boolean(anchor)}
onClose={() => setAnchor(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
{options.map((opt) => (
<MenuItem
key={opt.value}
selected={opt.value === value}
onClick={() => {
onChange(opt.value);
setAnchor(null);
}}
sx={{ fontSize: '0.813rem' }}
>
{opt.label}
</MenuItem>
))}
</Menu>
</>
);
},
);
SortMenu.displayName = 'SortMenu';
export default SortMenu;

View File

@@ -1 +0,0 @@
export { SortMenu, type SortMenuProps, type SortOption } from './SortMenu';

View File

@@ -218,33 +218,50 @@ const pkgInglewood: ComparisonPackage = {
{
heading: 'Essentials',
items: [
{ name: 'Allowance for Coffin', value: { type: 'unknown' } },
{ name: 'Cremation Certificate/Permit', value: { type: 'unknown' } },
{ name: 'Crematorium: Mackay Family Crematorium', value: { type: 'unknown' } },
{ name: 'Death Registration Certificate', value: { type: 'unknown' } },
{ name: 'Dressing Fee', value: { type: 'unknown' } },
{ name: 'NSW Government Levy — Cremation', value: { type: 'unknown' } },
{ name: 'Professional Mortuary Care', value: { type: 'unknown' } },
{ name: 'Professional Service Fee', value: { type: 'unknown' } },
{ name: 'Transportation Service Fee', value: { type: 'unknown' } },
{
name: 'Allowance for Coffin',
info: 'Allowance amount — upgrade options available.',
value: { type: 'allowance', amount: 1800 },
},
{
name: 'Cremation Certificate/Permit',
info: 'Statutory medical referee fee.',
value: { type: 'price', amount: 350 },
},
{
name: 'Death Registration Certificate',
info: 'Lodgement with NSW Registry.',
value: { type: 'price', amount: 70 },
},
{
name: 'Professional Service Fee',
info: 'Coordination of arrangements.',
value: { type: 'price', amount: 3980 },
},
{
name: 'Transportation Service Fee',
info: 'Transfer of the deceased.',
value: { type: 'price', amount: 500 },
},
],
},
{
heading: 'Optionals',
items: [
{ name: 'Digital Recording of the Funeral Service', value: { type: 'unknown' } },
{ name: 'Flowers', value: { type: 'unknown' } },
{ name: 'Online Notice', value: { type: 'unknown' } },
{ name: 'Viewing Fee', value: { type: 'unknown' } },
{ name: 'Viewing Fee', info: 'One private family viewing.', value: { type: 'included' } },
{ name: 'Flowers', info: 'Seasonal floral arrangements.', value: { type: 'poa' } },
{
name: 'Digital Recording of the Funeral Service',
info: 'Professional video recording.',
value: { type: 'price', amount: 250 },
},
],
},
{
heading: 'Extras',
items: [
{ name: 'Allowance for Celebrant', value: { type: 'unknown' } },
{ name: 'Catering', value: { type: 'unknown' } },
{ name: 'Newspaper Notice', value: { type: 'unknown' } },
{ name: 'Saturday Service Fee', value: { type: 'unknown' } },
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
],
},
],
@@ -346,7 +363,7 @@ export const MixedVerified: Story = {
// --- Missing Itemised Data ---------------------------------------------------
/** One provider has no itemised breakdown — unverified cells show "Unknown" */
/** One provider has no itemised breakdown — cells show "" */
export const MissingData: Story = {
args: {
packages: [pkgWollongong, pkgNoItemised, pkgMackay],

View File

@@ -3,10 +3,15 @@ import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Badge } from '../../atoms/Badge';
import { Card } from '../../atoms/Card';
import { ComparisonColumnCard } from '../../molecules/ComparisonColumnCard';
import { Link } from '../../atoms/Link';
import { Divider } from '../../atoms/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -63,55 +68,7 @@ function formatPrice(amount: number): string {
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
}
/**
* Inline icon + label wrapper with optically aligned centres.
*
* body2's line-height adds vertical padding above/below the glyphs. Flex
* centring then aligns geometric centres, which puts the icon slightly
* above the text's visual centre. Setting `lineHeight: 1` on the row
* collapses the text line-box to the font size so geometric and visual
* centres match.
*/
function CellIconText({
icon,
iconPosition = 'leading',
color,
children,
}: {
icon: React.ReactNode;
iconPosition?: 'leading' | 'trailing';
color: string;
children: React.ReactNode;
}) {
return (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
lineHeight: 1,
}}
>
{iconPosition === 'leading' && icon}
<Typography variant="body2" sx={{ color, fontWeight: 500, lineHeight: 1 }} component="span">
{children}
</Typography>
{iconPosition === 'trailing' && icon}
</Box>
);
}
/** Sections where a missing item is better expressed as "Not Included"
* than a bare em-dash — these are opt-in items, so absence is meaningful. */
const OPTIONAL_SECTION_HEADINGS = new Set(['Optionals', 'Extras']);
function CellValue({
value,
sectionHeading,
}: {
value: ComparisonCellValue;
sectionHeading: string;
}) {
function CellValue({ value }: { value: ComparisonCellValue }) {
switch (value.type) {
case 'price':
return (
@@ -127,31 +84,33 @@ function CellValue({
);
case 'complimentary':
return (
<CellIconText
color="var(--fa-color-feedback-success)"
icon={
<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
</CellIconText>
</Typography>
</Box>
);
case 'included':
return (
<CellIconText
color="var(--fa-color-feedback-success)"
icon={
<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
</CellIconText>
</Typography>
</Box>
);
case 'poa':
return (
@@ -161,30 +120,11 @@ function CellValue({
);
case 'unknown':
return (
<CellIconText
color="var(--fa-color-neutral-500)"
iconPosition="trailing"
icon={
<InfoOutlinedIcon
sx={{ fontSize: 14, color: 'var(--fa-color-neutral-500)' }}
aria-hidden
/>
}
>
<Badge color="default" variant="soft" size="small">
Unknown
</CellIconText>
</Badge>
);
case 'unavailable':
if (OPTIONAL_SECTION_HEADINGS.has(sectionHeading)) {
return (
<Typography
variant="body2"
sx={{ color: 'var(--fa-color-neutral-500)', fontWeight: 500 }}
>
Not Included
</Typography>
);
}
return (
<Typography variant="body2" sx={{ color: 'var(--fa-color-neutral-400)' }}>
@@ -226,20 +166,11 @@ function lookupValue(
sectionHeading: string,
itemName: string,
): ComparisonCellValue {
// For unverified providers, absence means "we don't know" — data is
// scraped/estimated. For verified providers, absence means the package
// explicitly doesn't include this item (→ "Not Included" in Optionals/
// Extras; em-dash in Essentials as a safety net — canonical-essentials
// rule says every verified package has all 9, so this path shouldn't
// fire in practice).
const missing: ComparisonCellValue = pkg.provider.verified
? { type: 'unavailable' }
: { type: 'unknown' };
if (pkg.itemizedAvailable === false) return missing;
if (pkg.itemizedAvailable === false) return { type: 'unavailable' };
const section = pkg.sections.find((s) => s.heading === sectionHeading);
if (!section) return missing;
if (!section) return { type: 'unavailable' };
const item = section.items.find((i) => i.name === itemName);
if (!item) return missing;
if (!item) return { type: 'unavailable' };
return item.value;
}
@@ -272,18 +203,6 @@ const tableSx = {
bgcolor: 'background.paper',
};
/**
* Fixed column width for both the row-label column and each package column.
* Natural table width = COMPARISON_TABLE_COL_WIDTH × (packages.length + 1).
* Exposed so ComparisonPage can size its width-matching page header container
* to align left edges with the table on horizontal overflow.
*/
export const COMPARISON_TABLE_COL_WIDTH = 300;
/** z-index scale for sticky layers inside the table. */
const Z_STICKY_LEFT = 20;
const Z_STICKY_LEFT_SECTION = 25; // section heading left cell above body cells
// ─── Component ──────────────────────────────────────────────────────────────
/**
@@ -296,10 +215,10 @@ const Z_STICKY_LEFT_SECTION = 25; // section heading left cell above body cells
*/
export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableProps>(
({ packages, onArrange, onRemove, sx }, ref) => {
const mergedSections = buildMergedSections(packages);
const colCount = packages.length + 1;
const gridCols = `${COMPARISON_TABLE_COL_WIDTH}px repeat(${packages.length}, ${COMPARISON_TABLE_COL_WIDTH}px)`;
const recommendedColIdx = packages.findIndex((p) => p.isRecommended);
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
@@ -309,34 +228,32 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
sx={[
{
display: { xs: 'none', md: 'block' },
width: COMPARISON_TABLE_COL_WIDTH * colCount,
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 — scrolls with the package columns. Previously
sticky-left to mirror the row-label column, but that pinned
it over the leftmost (recommended) package on horizontal
scroll. The row labels below stay sticky on their own. */}
<Box sx={{ px: 2 }}>
{/* 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)',
height: '100%',
alignSelf: 'stretch',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-start',
@@ -355,16 +272,157 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
Review and compare features side-by-side to find the right fit.
</Typography>
</Card>
</Box>
{/* Package cards */}
{packages.map((pkg) => (
<Box key={pkg.id} sx={{ px: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<ComparisonColumnCard
pkg={pkg}
onArrange={onArrange}
onRemove={onRemove}
sx={{ flex: 1 }}
<Box
key={pkg.id}
role="columnheader"
aria-label={pkg.isRecommended ? `${pkg.name} (Recommended)` : pkg.name}
sx={{
position: 'relative',
overflow: 'visible',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Floating verified badge — overlaps card top edge */}
{pkg.provider.verified && (
<Badge
color="brand"
variant="soft"
size="small"
icon={<VerifiedOutlinedIcon sx={{ fontSize: 14 }} />}
sx={{
position: 'absolute',
top: -12,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1,
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
}}
>
Verified
</Badge>
)}
<Card
variant="outlined"
selected={pkg.isRecommended}
padding="none"
sx={{ overflow: 'hidden', flex: 1, display: 'flex', flexDirection: 'column' }}
>
{pkg.isRecommended && (
<Box
sx={{ bgcolor: 'var(--fa-color-brand-600)', py: 0.75, textAlign: 'center' }}
>
<Typography
variant="labelSm"
sx={{
color: 'var(--fa-color-white)',
fontWeight: 600,
letterSpacing: '0.05em',
textTransform: 'uppercase',
}}
>
Recommended
</Typography>
</Box>
)}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
px: 2.5,
py: 2.5,
pt: pkg.provider.verified ? 3 : 2.5,
gap: 0.5,
flex: 1,
}}
>
{/* Provider name (truncated with tooltip) */}
<Tooltip
title={pkg.provider.name}
arrow
placement="top"
disableHoverListener={pkg.provider.name.length < 24}
>
<Typography
variant="label"
sx={{
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}}
>
{pkg.provider.name}
</Typography>
</Tooltip>
{/* Location */}
<Typography variant="caption" color="text.secondary">
{pkg.provider.location}
</Typography>
{/* Rating */}
{pkg.provider.rating != null && (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
<StarRoundedIcon
sx={{ fontSize: 16, color: 'var(--fa-color-brand-500)' }}
aria-hidden
/>
<Typography variant="body2" color="text.secondary">
{pkg.provider.rating}
{pkg.provider.reviewCount != null && ` (${pkg.provider.reviewCount})`}
</Typography>
</Box>
)}
<Divider sx={{ width: '100%', my: 1 }} />
<Typography variant="h6" component="p">
{pkg.name}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
Total package price
</Typography>
<Typography variant="h5" sx={{ color: 'primary.main', fontWeight: 700 }}>
{formatPrice(pkg.price)}
</Typography>
{/* Spacer pushes CTA to bottom across all cards */}
<Box sx={{ flex: 1 }} />
<Button
variant={pkg.provider.verified ? 'contained' : 'soft'}
color={pkg.provider.verified ? 'primary' : 'secondary'}
size="medium"
onClick={() => onArrange(pkg.id)}
sx={{ mt: 1.5, px: 4 }}
>
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
</Button>
{!pkg.isRecommended && (
<Link
component="button"
variant="body2"
color="text.secondary"
underline="hover"
onClick={() => onRemove(pkg.id)}
sx={{ mt: 0.5 }}
>
Remove
</Link>
)}
</Box>
</Card>
</Box>
))}
</Box>
@@ -372,35 +430,9 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
{/* ── Section tables (each separate with left accent headings) ── */}
{mergedSections.map((section) => (
<Box key={section.heading} sx={{ ...tableSx, gridTemplateColumns: gridCols, mb: 3 }}>
{/* Section heading row — left cell sticky so label stays visible on horizontal scroll */}
<Box
role="row"
sx={{
gridColumn: `1 / ${colCount + 1}`,
display: 'grid',
gridTemplateColumns: 'subgrid',
}}
>
<Box
sx={{
position: 'sticky',
left: 0,
zIndex: Z_STICKY_LEFT_SECTION,
gridColumn: '1 / 2',
}}
>
<Box role="row" sx={{ gridColumn: `1 / ${colCount + 1}` }}>
<SectionHeading>{section.heading}</SectionHeading>
</Box>
{/* Background continuation for the remaining columns so they
share the heading's surface-subtle wash. */}
<Box
aria-hidden
sx={{
gridColumn: `2 / ${colCount + 1}`,
bgcolor: 'var(--fa-color-surface-subtle)',
}}
/>
</Box>
{section.items.map((item) => (
<Box
@@ -410,38 +442,26 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
gridColumn: `1 / ${colCount + 1}`,
display: 'grid',
gridTemplateColumns: 'subgrid',
// Tiered hover: base cells go to surface-subtle, recommended
// column cells inherit a warmer surface-warm tint on row hover.
'&:hover .comparison-cell': {
bgcolor: 'var(--fa-color-surface-subtle)',
},
'&:hover .comparison-cell--recommended': {
bgcolor: 'var(--fa-color-surface-warm)',
},
transition: 'background-color 0.15s ease',
'&:hover': { bgcolor: 'var(--fa-color-brand-50)' },
}}
>
{/* Row-label cell — sticky-left */}
<Box
role="cell"
className="comparison-cell comparison-cell--label"
sx={{
position: 'sticky',
left: 0,
zIndex: Z_STICKY_LEFT,
bgcolor: 'background.paper',
display: 'flex',
alignItems: 'center',
gap: 0.5,
px: 3,
py: 2,
borderTop: '1px solid',
borderColor: 'divider',
transition: 'background-color 0.15s ease',
}}
>
<Typography variant="body2" color="text.secondary" component="span">
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 0 }}>
{item.name}
</Typography>
{item.info && (
<Box component="span" sx={{ whiteSpace: 'nowrap' }}>
{'\u00A0'}
<Tooltip title={item.info} arrow placement="top">
<InfoOutlinedIcon
aria-label={`More information about ${item.name}`}
@@ -449,23 +469,17 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
fontSize: 14,
color: 'var(--fa-color-neutral-400)',
cursor: 'help',
verticalAlign: 'middle',
flexShrink: 0,
}}
/>
</Tooltip>
</Box>
)}
</Box>
{packages.map((pkg, idx) => {
const isRecommended = idx === recommendedColIdx;
return (
{packages.map((pkg) => (
<Box
key={pkg.id}
role="cell"
className={
'comparison-cell' + (isRecommended ? ' comparison-cell--recommended' : '')
}
sx={{
display: 'flex',
alignItems: 'center',
@@ -476,27 +490,24 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
borderColor: 'divider',
borderLeft: '1px solid',
borderLeftColor: 'divider',
transition: 'background-color 0.15s ease',
// Resting tint for the recommended column so it reads
// as the default column even without hover.
...(isRecommended && {
bgcolor:
'color-mix(in srgb, var(--fa-color-surface-warm) 50%, transparent)',
}),
}}
>
<CellValue
value={lookupValue(pkg, section.heading, item.name)}
sectionHeading={section.heading}
/>
</Box>
);
})}
<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>
);
},
);

View File

@@ -1,4 +1,4 @@
export { ComparisonTable, COMPARISON_TABLE_COL_WIDTH, default } from './ComparisonTable';
export { ComparisonTable, default } from './ComparisonTable';
export type {
ComparisonTableProps,
ComparisonPackage,

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,17 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { PackageDetail } from './PackageDetail';
import { ServiceOption } from '../../molecules/ServiceOption';
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
import { Chip } from '../../atoms/Chip';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Navigation } from '../Navigation';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
const DEMO_IMAGE =
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400&h=300&fit=crop';
const essentials = [
{
@@ -106,6 +117,41 @@ const extras = {
const termsText =
'* This package includes a funeral service at a chapel or a church with a funeral procession following to the crematorium. It includes many of the most commonly selected funeral options preselected for you. Many people choose this package for the extended funeral rituals — of course, you can tailor the funeral service to meet your needs and budget as you go through the selections.';
const packages = [
{
id: 'everyday',
name: 'Everyday Funeral Package',
price: 900,
description:
'Our most popular package with all essential services included. Suitable for a traditional chapel or church service.',
},
{
id: 'deluxe',
name: 'Deluxe Funeral Package',
price: 1200,
description: 'An enhanced package with premium coffin and additional floral arrangements.',
},
{
id: 'essential',
name: 'Essential Funeral Package',
price: 600,
description: 'A simple, dignified service covering all necessary arrangements.',
},
{
id: 'catholic',
name: 'Catholic Service',
price: 950,
description:
'A service tailored for Catholic traditions including prayers and church ceremony.',
},
];
const funeralTypes = ['All', 'Cremation', 'Burial', 'Memorial', 'Catholic', 'Direct Cremation'];
const FALogoNav = () => (
<Box component="img" src="/brandlogo/logo-full.svg" alt="Funeral Arranger" sx={{ height: 28 }} />
);
const meta: Meta<typeof PackageDetail> = {
title: 'Organisms/PackageDetail',
component: PackageDetail,
@@ -159,24 +205,6 @@ export const CompareLoading: Story = {
},
};
/** "Added to comparison" state — package is already in the basket.
* The Compare button keeps its default soft/secondary chrome + "Compare"
* label, and gains a trailing check icon. Click is a toggle — the
* caller wires `onCompare` to add-or-remove based on the `inCart` prop
* it's passing in (e.g. via `basket.toggle(key)`). aria-pressed and the
* aria-label spell out the state for SR users. */
export const InCart: Story = {
args: {
name: 'Traditional Family Cremation Service',
price: 6966,
sections: [{ heading: 'Essentials', items: essentials.slice(0, 4) }],
total: 6966,
onArrange: () => alert('Make Arrangement'),
onCompare: () => {},
inCart: true,
},
};
// --- Without Extras ----------------------------------------------------------
/** Simpler package with essentials and optionals only — no extras */
@@ -194,3 +222,132 @@ export const WithoutExtras: Story = {
onCompare: () => alert('Compare'),
},
};
// --- Package Select Page Layout ----------------------------------------------
/** Full page layout — left: package list, right: detail panel */
export const PackageSelectPage: Story = {
decorators: [
(Story) => (
<Box sx={{ maxWidth: 'none', width: '100%' }}>
<Story />
</Box>
),
],
render: () => {
const [selectedPkg, setSelectedPkg] = useState('everyday');
const [activeFilter, setActiveFilter] = useState('Cremation');
const [comparing, setComparing] = useState(false);
const handleCompare = () => {
setComparing(true);
setTimeout(() => setComparing(false), 1500);
};
return (
<Box>
<Navigation
logo={<FALogoNav />}
items={[
{ label: 'Provider Portal', href: '/provider-portal' },
{ label: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' },
{ label: 'Log in', href: '/login' },
]}
/>
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' },
gap: { xs: 3, md: 4 },
maxWidth: 'lg',
mx: 'auto',
px: { xs: 2, md: 4 },
py: { xs: 2, md: 4 },
alignItems: 'start',
}}
>
{/* Left column */}
<Box>
<Button
variant="text"
color="secondary"
startIcon={<ArrowBackIcon />}
sx={{ mb: 2, ml: -1 }}
>
Back
</Button>
<Typography variant="h2" sx={{ mb: 3 }}>
Select a package
</Typography>
<ProviderCardCompact
name="H.Parsons"
location="Wentworth"
imageUrl={DEMO_IMAGE}
rating={4.5}
reviewCount={11}
sx={{ mb: 3 }}
/>
{/* Funeral type filter */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 3 }}>
{funeralTypes.map((type) => (
<Chip
key={type}
label={type}
variant={activeFilter === type ? 'filled' : 'outlined'}
selected={activeFilter === type}
onClick={() => setActiveFilter(type)}
size="small"
/>
))}
</Box>
<Typography variant="h4" sx={{ mb: 2 }}>
Packages
</Typography>
<Box
role="radiogroup"
aria-label="Available packages"
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}
>
{packages.map((pkg) => (
<ServiceOption
key={pkg.id}
name={pkg.name}
price={pkg.price}
description={pkg.description}
selected={selectedPkg === pkg.id}
onClick={() => setSelectedPkg(pkg.id)}
maxDescriptionLines={2}
/>
))}
</Box>
</Box>
{/* Right column: package detail */}
<Box sx={{ position: { md: 'sticky' }, top: { md: 96 } }}>
<PackageDetail
name={packages.find((p) => p.id === selectedPkg)?.name ?? ''}
price={packages.find((p) => p.id === selectedPkg)?.price ?? 0}
sections={[
{ heading: 'Essentials', items: essentials },
{ heading: 'Optionals', items: optionals },
]}
total={6966}
extras={extras}
terms={termsText}
onArrange={() => alert(`Making arrangement for: ${selectedPkg}`)}
onCompare={handleCompare}
compareLoading={comparing}
/>
</Box>
</Box>
</Box>
);
},
};

View File

@@ -1,9 +1,6 @@
import React from 'react';
import Box from '@mui/material/Box';
import useMediaQuery from '@mui/material/useMediaQuery';
import { useTheme } from '@mui/material/styles';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
@@ -56,11 +53,6 @@ export interface PackageDetailProps {
arrangeDisabled?: boolean;
/** Whether the compare button is in loading state */
compareLoading?: boolean;
/** Whether this package is already in the comparison basket. When true,
* the Compare button swaps its label to "Added" and adds a leading check
* icon. The button remains clickable — the caller is expected to treat
* `onCompare` as a toggle (add when not in cart, remove when in cart). */
inCart?: boolean;
/** Custom label for the arrange CTA button (default: "Make Arrangement") */
arrangeLabel?: string;
/** Disclaimer shown below the price (e.g. for unverified/estimated pricing) */
@@ -132,7 +124,6 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
terms,
onArrange,
onCompare,
inCart = false,
arrangeDisabled = false,
compareLoading = false,
arrangeLabel = 'Make Arrangement',
@@ -142,11 +133,6 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
},
ref,
) => {
// CTA buttons stay side-by-side on all viewports; size down on xs so
// "Make Arrangement" + "Compare" fit a ~360px mobile column without wrap.
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const ctaSize = isMobile ? 'medium' : 'large';
return (
<Box
ref={ref}
@@ -155,7 +141,6 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
border: '1px solid',
borderColor: 'divider',
borderRadius: 'var(--fa-card-border-radius-default)',
boxShadow: 'var(--fa-card-shadow-default)',
overflow: 'hidden',
},
...(Array.isArray(sx) ? sx : [sx]),
@@ -164,7 +149,7 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
{/* Header band — warm bg to separate from content */}
<Box
sx={{
bgcolor: 'background.paper',
bgcolor: 'var(--fa-color-surface-warm)',
px: { xs: 2, sm: 3 },
pt: 3,
pb: 2.5,
@@ -193,10 +178,10 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 1.25,
gap: 1,
mt: 1.5,
px: 2,
py: 1.5,
px: 1.5,
py: 1,
bgcolor: 'var(--fa-color-surface-cool, #F5F7FA)',
borderRadius: 'var(--fa-border-radius-sm, 6px)',
border: '1px solid',
@@ -204,20 +189,22 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
}}
>
<InfoOutlinedIcon
sx={{ fontSize: 16, color: 'text.secondary', mt: '3px', flexShrink: 0 }}
sx={{ fontSize: 16, color: 'text.secondary', mt: '1px', flexShrink: 0 }}
aria-hidden
/>
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.5 }}>
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.4 }}>
{priceDisclaimer}
</Typography>
</Box>
)}
{/* CTA buttons — always side-by-side */}
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 1.5, mt: 2.5 }}>
{/* CTA buttons */}
<Box
sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, gap: 1.5, mt: 2.5 }}
>
<Button
variant="contained"
size={ctaSize}
size="large"
fullWidth
disabled={arrangeDisabled}
onClick={onArrange}
@@ -225,19 +212,12 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
{arrangeLabel}
</Button>
{onCompare && (
// Same soft/secondary chrome + "Compare" label in both states;
// when the package is in the basket a trailing check icon
// appears. Click is a toggle — caller decides to add or remove
// based on the `inCart` it's passing in.
<Button
variant="soft"
color="secondary"
size={ctaSize}
size="large"
loading={compareLoading}
endIcon={inCart ? <CheckRoundedIcon /> : undefined}
onClick={onCompare}
aria-pressed={inCart}
aria-label={inCart ? 'Remove from comparison' : 'Add to comparison'}
sx={{ flexShrink: 0 }}
>
Compare

View File

@@ -1,110 +0,0 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { ProviderMap } from './ProviderMap';
import { providers as demoProviders } from '../../../demo/shared/fixtures/providers';
import type { ProviderData } from '../../pages/ProvidersStep';
const meta: Meta<typeof ProviderMap> = {
title: 'Organisms/ProviderMap',
component: ProviderMap,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component:
'Google Map showing provider pins with click-to-open popup. Uses the MapPin atom for markers and the MapPopup molecule for the popup card. Auto-fits the viewport to all providers with coords. Clicking a popup triggers `onSelectProvider`.',
},
},
},
decorators: [
(Story) => (
<Box sx={{ width: '100vw', height: '100vh', display: 'flex' }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof ProviderMap>;
// Cast: DemoProvider adds `tier` over ProviderData, structural subset for the map
const providers = demoProviders as ProviderData[];
// ────────────────────────────────────────────────────────────────────────────
/** All 7 demo providers with real NSW/QLD coordinates. Map fits bounds across them. */
export const Default: Story = {
args: {
providers,
onSelectProvider: (id) => {
alert(`Navigate to provider ${id}`);
},
},
};
/** One provider pre-selected — its pin renders in the active (inverted) state. */
export const WithSelectedProvider: Story = {
args: {
providers,
selectedProviderId: 'parsons',
onSelectProvider: (id) => {
alert(`Navigate to provider ${id}`);
},
},
};
/** Interactive demo — clicking a popup clears/re-selects as if navigating. */
export const InteractiveSelection: Story = {
render: (args) => {
const StoryWrapper = () => {
const [selected, setSelected] = useState<string | null>(null);
return (
<ProviderMap
{...args}
selectedProviderId={selected}
onSelectProvider={(id) => setSelected((prev) => (prev === id ? null : id))}
/>
);
};
return <StoryWrapper />;
},
args: {
providers,
onSelectProvider: () => {},
},
};
/** Providers without coords — falls back to the "Map unavailable" empty state. */
export const NoCoords: Story = {
args: {
providers: providers.map(({ coords: _omit, ...p }) => p),
onSelectProvider: () => {},
},
};
/** No API key supplied — renders the empty state without attempting to load Google Maps. */
export const NoApiKey: Story = {
args: {
providers,
apiKey: '',
onSelectProvider: () => {},
},
};
/** Single provider — map centres on that coord with zoom 13. */
export const SingleProvider: Story = {
args: {
providers: [providers[0]],
onSelectProvider: () => {},
},
};
/** Mixed — some providers with coords, some without. Only those with coords render. */
export const PartialCoords: Story = {
args: {
providers: providers.map((p, i) => (i % 2 === 0 ? p : { ...p, coords: undefined })),
onSelectProvider: () => {},
},
};

View File

@@ -1,589 +0,0 @@
import React from 'react';
import { createRoot, type Root } from 'react-dom/client';
import Box from '@mui/material/Box';
import type { SxProps, Theme } from '@mui/material/styles';
import { ThemeProvider, useTheme } from '@mui/material/styles';
import {
APIProvider,
Map as GoogleMap,
AdvancedMarker,
useMap,
useMapsLibrary,
} from '@vis.gl/react-google-maps';
import { MarkerClusterer, GridAlgorithm } from '@googlemaps/markerclusterer';
import { MapPin } from '../../atoms/MapPin';
import { ClusterMarker } from '../../atoms/ClusterMarker';
import { MapPopup } from '../../molecules/MapPopup';
import { ClusterPopup } from '../../molecules/ClusterPopup';
import { Typography } from '../../atoms/Typography';
import type { ProviderData } from '../../pages/ProvidersStep';
// ─── Constants ──────────────────────────────────────────────────────────────
/** Sydney — fallback centre when no providers have coords and no default supplied */
const FALLBACK_CENTER = { lat: -33.8688, lng: 151.2093 };
const FALLBACK_ZOOM = 5;
/** Google Maps requires a mapId for AdvancedMarker support */
const MAP_ID = 'fa-provider-map';
/** fitBounds padding (applied as google.maps.Padding) */
const BOUNDS_PADDING = { top: 64, right: 48, bottom: 64, left: 48 };
/** Screen-pixel radius at which nearby pins collapse into a cluster */
const CLUSTER_GRID_SIZE = 70;
/** Zoom level above which clustering is disabled (pins show individually) */
const CLUSTER_MAX_ZOOM = 13;
/** Zoom level the map animates to on cluster drill-in (street-level, past
* CLUSTER_MAX_ZOOM so nearby cluster members break apart into their own pins) */
const DRILL_IN_ZOOM = 15;
/** Exit-animation duration for popups on close — keep in sync with the
* transition values set on MapPopup/ClusterPopup. */
const POPUP_EXIT_MS = 180;
// ─── Types ──────────────────────────────────────────────────────────────────
/** Shape of the currently-active provider or cluster selection, emitted to
* callers that opt into external popup rendering (see `externalisePopups`). */
export interface ProviderMapActiveState {
/** Active single provider, if a pin was tapped (or a cluster row drilled into) */
provider: ProviderData | null;
/** Active cluster, if a cluster marker was tapped and no row has been drilled into */
cluster: { providers: ProviderData[]; position: { lat: number; lng: number } } | null;
/** True while the exit animation is running — callers may want to mirror it */
exiting: boolean;
}
/** Imperative handle exposed via ref. Used when rendering popups externally. */
export interface ProviderMapHandle {
/** Close the currently-active popup (animated). No-op if nothing is open. */
clearActive: () => void;
/** Pan + zoom the map to a provider's coords and set them as the active
* single-provider selection. Equivalent to a cluster-row tap. */
drillIntoProvider: (id: string) => void;
}
/** Props for the FA ProviderMap organism */
export interface ProviderMapProps {
/** Providers to render as pins. Providers without coords are filtered out silently. */
providers: ProviderData[];
/** ID of the provider whose popup should open (external selection, e.g. list hover) */
selectedProviderId?: string | null;
/** Called when the user clicks through a popup — usually triggers navigation */
onSelectProvider: (id: string) => void;
/** Initial map centre — used only when no providers have coords */
defaultCenter?: { lat: number; lng: number };
/** Initial zoom — used only when no providers have coords */
defaultZoom?: number;
/** Google Maps API key. Defaults to `import.meta.env.VITE_GOOGLE_MAPS_API_KEY`. */
apiKey?: string;
/** When true, suppress the organism's own MapPopup + ClusterPopup rendering.
* The active state is still tracked internally (pins still hide when active)
* and emitted via `onActiveChange` so callers can render a drawer, sheet,
* or other external container. Used by the mobile map-first layout. */
externalisePopups?: boolean;
/** Fires whenever the active provider/cluster state changes. Paired with
* `externalisePopups` — the caller uses this to drive external UI. */
onActiveChange?: (state: ProviderMapActiveState) => void;
/** MUI sx prop for the root element */
sx?: SxProps<Theme>;
}
interface ActiveCluster {
providers: ProviderData[];
position: google.maps.LatLngLiteral;
}
// ─── Internal components ────────────────────────────────────────────────────
/**
* Fits the map to the bounds of all providers with coords. Runs whenever the
* provider list changes. Sited inside APIProvider so `useMap()` resolves.
*/
const FitBounds: React.FC<{ providers: ProviderData[] }> = ({ providers }) => {
const map = useMap();
React.useEffect(() => {
if (!map) return;
const withCoords = providers.filter((p) => p.coords);
if (withCoords.length === 0) return;
if (withCoords.length === 1) {
map.setCenter(withCoords[0].coords!);
map.setZoom(13);
return;
}
const bounds = new window.google.maps.LatLngBounds();
withCoords.forEach((p) => bounds.extend(p.coords!));
map.fitBounds(bounds, BOUNDS_PADDING);
}, [map, providers]);
return null;
};
/**
* Captures the Google Map instance into a parent ref so imperative
* actions (panTo, setZoom) can be triggered from outside the Map context.
*/
const MapRefCapture: React.FC<{
mapRef: React.MutableRefObject<google.maps.Map | null>;
}> = ({ mapRef }) => {
const map = useMap();
React.useEffect(() => {
mapRef.current = map;
}, [map, mapRef]);
return null;
};
/**
* Imperative marker layer — builds AdvancedMarker instances with React
* content, groups them via MarkerClusterer, and rebuilds whenever the
* visible provider set changes.
*
* Providers listed in `hiddenIds` are excluded from the map (their popup is
* currently showing instead).
*/
const MarkerLayer: React.FC<{
providers: ProviderData[];
hiddenIds: Set<string>;
theme: Theme;
externalisePopups: boolean;
onPinClick: (id: string) => void;
onSelectProvider: (id: string) => void;
onClusterClick: (providers: ProviderData[], position: google.maps.LatLngLiteral) => void;
}> = ({
providers,
hiddenIds,
theme,
externalisePopups,
onPinClick,
onSelectProvider,
onClusterClick,
}) => {
const map = useMap();
const markerLibrary = useMapsLibrary('marker');
// Stash callbacks in a ref so the effect below doesn't re-run (and rebuild
// every marker) when the parent passes fresh arrow-function references.
const onPinClickRef = React.useRef(onPinClick);
const onSelectProviderRef = React.useRef(onSelectProvider);
const onClusterClickRef = React.useRef(onClusterClick);
React.useEffect(() => {
onPinClickRef.current = onPinClick;
onSelectProviderRef.current = onSelectProvider;
onClusterClickRef.current = onClusterClick;
}, [onPinClick, onSelectProvider, onClusterClick]);
React.useEffect(() => {
if (!map || !markerLibrary) return;
const roots: Root[] = [];
const markerToProvider = new Map<google.maps.marker.AdvancedMarkerElement, ProviderData>();
const markers = providers
.filter((p) => p.coords && !hiddenIds.has(p.id))
.map((p) => {
const el = document.createElement('div');
const root = createRoot(el);
if (p.verified) {
root.render(
<ThemeProvider theme={theme}>
<MapPopup
name={p.name}
imageUrl={p.imageUrl}
price={p.startingPrice}
location={p.location}
rating={p.rating}
verified
onClick={() =>
externalisePopups
? onPinClickRef.current(p.id)
: onSelectProviderRef.current(p.id)
}
/>
</ThemeProvider>,
);
} else {
root.render(
<MapPin
name={p.name}
price={p.startingPrice}
verified={p.verified}
onClick={(e) => {
e.stopPropagation();
onPinClickRef.current(p.id);
}}
/>,
);
}
roots.push(root);
const marker = new markerLibrary.AdvancedMarkerElement({
position: p.coords,
content: el,
gmpClickable: true,
});
if (!p.verified) {
marker.addListener('click', (event: google.maps.MapMouseEvent) => {
event.stop();
onPinClickRef.current(p.id);
});
}
markerToProvider.set(marker, p);
return marker;
});
const clusterer = new MarkerClusterer({
map,
markers,
algorithm: new GridAlgorithm({
maxZoom: CLUSTER_MAX_ZOOM,
gridSize: CLUSTER_GRID_SIZE,
}),
// Override the library's default "zoom to fit cluster" on click —
// we open the cluster popup instead. The event shape the library
// passes varies: sometimes a google.maps.MapMouseEvent (has .stop),
// sometimes a plain DOM MouseEvent. Stop whichever we got so the
// click doesn't also fire Map.onClick and clear our state.
onClusterClick: (event, cluster) => {
const anyEvent = event as unknown as {
stop?: () => void;
stopPropagation?: () => void;
domEvent?: { stopPropagation?: () => void };
};
anyEvent.stop?.();
anyEvent.stopPropagation?.();
anyEvent.domEvent?.stopPropagation?.();
const providersInCluster = cluster.markers
.map((m) => markerToProvider.get(m as google.maps.marker.AdvancedMarkerElement))
.filter((p): p is ProviderData => !!p);
const clusterPosition =
cluster.position instanceof window.google.maps.LatLng
? cluster.position.toJSON()
: (cluster.position as google.maps.LatLngLiteral);
onClusterClickRef.current(providersInCluster, clusterPosition);
},
renderer: {
render: ({ count, position, markers: clusterMarkers }) => {
const providersInCluster = clusterMarkers
.map((m) => markerToProvider.get(m as google.maps.marker.AdvancedMarkerElement))
.filter((p): p is ProviderData => !!p);
const hasVerified = providersInCluster.some((p) => p.verified);
const el = document.createElement('div');
const root = createRoot(el);
// Visual only — click is handled at the MarkerClusterer level above.
root.render(<ClusterMarker count={count} hasVerified={hasVerified} />);
roots.push(root);
return new markerLibrary.AdvancedMarkerElement({
position,
content: el,
gmpClickable: true,
});
},
},
});
return () => {
clusterer.clearMarkers();
// Defer unmount so React doesn't warn about unmounting during render.
setTimeout(() => {
roots.forEach((r) => r.unmount());
}, 0);
};
}, [map, markerLibrary, providers, hiddenIds]);
return null;
};
/** Empty-state shown when no API key is configured or no providers have coords. */
const MapEmptyState: React.FC<{ reason: 'no-key' | 'no-coords' }> = ({ reason }) => (
<Box sx={{ m: 'auto', textAlign: 'center', px: 3 }}>
<Typography variant="body1" color="text.secondary" sx={{ mb: 0.5 }}>
Map unavailable
</Typography>
<Typography variant="caption" color="text.secondary">
{reason === 'no-key'
? 'Google Maps API key not configured.'
: 'No provider locations to display.'}
</Typography>
</Box>
);
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Google Map showing provider pins with clustering + click-to-open popups.
*
* **Interaction model:**
* - Clicking an individual pin **morphs** it into a `MapPopup` at the same
* coord. Clicking the map background reverts.
* - Pins within `CLUSTER_GRID_SIZE` (70px) of each other collapse into a
* `ClusterMarker` — but only while zoomed out at level `CLUSTER_MAX_ZOOM`
* (13) or below. Zoom in past that and every pin shows individually.
* - Clicking a cluster opens a `ClusterPopup` listing its providers
* (verified-first). Clicking a row **pans and zooms the map to that
* provider's location** (zoom 15 = past the clustering ceiling, so the
* other cluster members separate into their own pins around the selected
* one) and opens that provider's `MapPopup`. The cluster state is cleared
* — there's no back-to-list; the user's path forward is clear rather than
* hierarchical.
*
* **Viewport:** auto-fits to include every provider with coords on load and
* when the list changes. Single-provider maps centre with zoom 13.
*
* **Empty states:** if no API key is set or no providers have coords, a
* subtle empty state renders in place (no throw).
*
* Composes `MapPin` + `ClusterMarker` (atoms) + `MapPopup` + `ClusterPopup`
* (molecules). Clustering via `@googlemaps/markerclusterer`.
*/
export const ProviderMap = React.forwardRef<ProviderMapHandle, ProviderMapProps>(
(
{
providers,
selectedProviderId,
onSelectProvider,
defaultCenter = FALLBACK_CENTER,
defaultZoom = FALLBACK_ZOOM,
apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY,
externalisePopups = false,
onActiveChange,
sx,
},
ref,
) => {
const muiTheme = useTheme();
const [activeProviderId, setActiveProviderId] = React.useState<string | null>(null);
const [activeCluster, setActiveCluster] = React.useState<ActiveCluster | null>(null);
const [exiting, setExiting] = React.useState(false);
const mapRef = React.useRef<google.maps.Map | null>(null);
const exitTimerRef = React.useRef<number | null>(null);
// Helper: cancel any pending exit timer so rapid clicks don't clobber
// newly-opened popups with a leftover clear from a previous close.
const cancelExit = React.useCallback(() => {
if (exitTimerRef.current) {
window.clearTimeout(exitTimerRef.current);
exitTimerRef.current = null;
}
setExiting(false);
}, []);
React.useEffect(
() => () => {
if (exitTimerRef.current) window.clearTimeout(exitTimerRef.current);
},
[],
);
const withCoords = React.useMemo(() => providers.filter((p) => p.coords), [providers]);
// External selection (e.g. list hover) force-opens a popup. Internal click wins.
const effectiveProviderId = activeProviderId ?? selectedProviderId ?? null;
const activeProvider = React.useMemo(
() =>
effectiveProviderId ? (withCoords.find((p) => p.id === effectiveProviderId) ?? null) : null,
[withCoords, effectiveProviderId],
);
// Pins hidden from the map (because their popup is showing instead).
// Verified providers are excluded — their marker IS the MapPopup.
const hiddenIds = React.useMemo(() => {
const s = new Set<string>();
if (effectiveProviderId) {
const p = withCoords.find((prov) => prov.id === effectiveProviderId);
if (p && !p.verified) s.add(effectiveProviderId);
}
if (activeCluster) {
activeCluster.providers.forEach((p) => s.add(p.id));
}
return s;
}, [effectiveProviderId, activeCluster, withCoords]);
const handlePinClick = React.useCallback(
(id: string) => {
cancelExit();
setActiveProviderId(id);
setActiveCluster(null);
},
[cancelExit],
);
const handleClusterClick = React.useCallback(
(clusterProviders: ProviderData[], position: google.maps.LatLngLiteral) => {
cancelExit();
setActiveProviderId(null);
setActiveCluster({ providers: clusterProviders, position });
},
[cancelExit],
);
/** Shared close path — animate the popup out (exiting=true triggers the
* CSS transition in MapPopup / ClusterPopup), then actually clear state
* after the transition completes so the pin can fade back in. */
const closeWithExit = React.useCallback(() => {
if (!activeProviderId && !activeCluster) return;
if (exitTimerRef.current) window.clearTimeout(exitTimerRef.current);
setExiting(true);
exitTimerRef.current = window.setTimeout(() => {
setActiveProviderId(null);
setActiveCluster(null);
setExiting(false);
exitTimerRef.current = null;
}, POPUP_EXIT_MS);
}, [activeProviderId, activeCluster]);
const handleMapClick = closeWithExit;
const handleCloseCluster = closeWithExit;
// Emit active-state changes when the caller is rendering popups externally.
const onActiveChangeRef = React.useRef(onActiveChange);
React.useEffect(() => {
onActiveChangeRef.current = onActiveChange;
}, [onActiveChange]);
React.useEffect(() => {
onActiveChangeRef.current?.({
provider: activeProvider,
cluster: activeCluster
? {
providers: activeCluster.providers,
position: {
lat: activeCluster.position.lat,
lng: activeCluster.position.lng,
},
}
: null,
exiting,
});
}, [activeProvider, activeCluster, exiting]);
/** Cluster list → single-provider drill-in.
* Pans + zooms the map to the provider's coords (zoom 15 = past
* CLUSTER_MAX_ZOOM so nearby cluster members separate into individual
* pins around the selected one), then clears the cluster state and
* opens the single-provider popup. */
const handleDrillIntoProvider = React.useCallback(
(id: string) => {
cancelExit();
const provider = withCoords.find((p) => p.id === id);
if (provider?.coords && mapRef.current) {
mapRef.current.panTo(provider.coords);
mapRef.current.setZoom(DRILL_IN_ZOOM);
}
setActiveProviderId(id);
setActiveCluster(null);
},
[withCoords, cancelExit],
);
// Imperative handle for external callers (drawer close, cluster-row tap).
React.useImperativeHandle(
ref,
() => ({
clearActive: closeWithExit,
drillIntoProvider: handleDrillIntoProvider,
}),
[closeWithExit, handleDrillIntoProvider],
);
const rootSx = [
{
position: 'relative' as const,
display: 'flex',
flex: 1,
minHeight: 300,
width: '100%',
overflow: 'hidden',
bgcolor: 'var(--fa-color-surface-cool)',
},
...(Array.isArray(sx) ? sx : [sx]),
];
// Empty states
if (!apiKey) {
return (
<Box role="application" aria-label="Provider map" sx={rootSx}>
<MapEmptyState reason="no-key" />
</Box>
);
}
if (withCoords.length === 0) {
return (
<Box role="application" aria-label="Provider map" sx={rootSx}>
<MapEmptyState reason="no-coords" />
</Box>
);
}
return (
<Box ref={ref} role="application" aria-label="Provider map" sx={rootSx}>
<APIProvider apiKey={apiKey}>
<GoogleMap
defaultCenter={defaultCenter}
defaultZoom={defaultZoom}
mapId={MAP_ID}
disableDefaultUI
zoomControl
gestureHandling="greedy"
onClick={handleMapClick}
style={{ width: '100%', height: '100%' }}
>
<FitBounds providers={withCoords} />
<MapRefCapture mapRef={mapRef} />
<MarkerLayer
providers={withCoords}
hiddenIds={hiddenIds}
theme={muiTheme}
externalisePopups={externalisePopups}
onPinClick={handlePinClick}
onSelectProvider={onSelectProvider}
onClusterClick={handleClusterClick}
/>
{/* Click-to-reveal popup for unverified providers. Verified
providers are always rendered as MapPopup inside MarkerLayer,
so they don't need this path. */}
{!externalisePopups && activeProvider && !activeProvider.verified && (
<AdvancedMarker position={activeProvider.coords!} zIndex={1000}>
<MapPopup
name={activeProvider.name}
imageUrl={activeProvider.imageUrl}
price={activeProvider.startingPrice}
location={activeProvider.location}
rating={activeProvider.rating}
verified={activeProvider.verified}
exiting={exiting}
onClick={() => onSelectProvider(activeProvider.id)}
/>
</AdvancedMarker>
)}
{/* Cluster list popup — shown while a cluster is active and no
provider has been drilled into. Drilling clears activeCluster,
which swaps this for the single-provider popup above. */}
{!externalisePopups && activeCluster && !activeProviderId && (
<AdvancedMarker position={activeCluster.position} zIndex={1000}>
<ClusterPopup
providers={activeCluster.providers.map((p) => ({
id: p.id,
name: p.name,
location: p.location,
verified: p.verified,
rating: p.rating,
startingPrice: p.startingPrice,
}))}
exiting={exiting}
onSelectProvider={handleDrillIntoProvider}
onClose={handleCloseCluster}
/>
</AdvancedMarker>
)}
</GoogleMap>
</APIProvider>
</Box>
);
},
);
ProviderMap.displayName = 'ProviderMap';
export default ProviderMap;

View File

@@ -1,6 +0,0 @@
export {
ProviderMap,
type ProviderMapProps,
type ProviderMapHandle,
type ProviderMapActiveState,
} from './ProviderMap';

View File

@@ -122,7 +122,7 @@ const pkgMackay: ComparisonPackage = {
name: 'Everyday Funeral Package',
price: 5495.45,
provider: {
name: 'Mackay Family Funeral Directors & Cremation Services',
name: 'Mackay Family Funerals',
location: 'Inglewood',
logoUrl: DEMO_LOGO,
rating: 4.6,
@@ -216,33 +216,50 @@ const pkgInglewood: ComparisonPackage = {
{
heading: 'Essentials',
items: [
{ name: 'Allowance for Coffin', value: { type: 'unknown' } },
{ name: 'Cremation Certificate/Permit', value: { type: 'unknown' } },
{ name: 'Crematorium', value: { type: 'unknown' } },
{ name: 'Death Registration Certificate', value: { type: 'unknown' } },
{ name: 'Dressing Fee', value: { type: 'unknown' } },
{ name: 'NSW Government Levy — Cremation', value: { type: 'unknown' } },
{ name: 'Professional Mortuary Care', value: { type: 'unknown' } },
{ name: 'Professional Service Fee', value: { type: 'unknown' } },
{ name: 'Transportation Service Fee', value: { type: 'unknown' } },
{
name: 'Allowance for Coffin',
info: 'Allowance amount.',
value: { type: 'allowance', amount: 1800 },
},
{
name: 'Cremation Certificate/Permit',
info: 'Medical referee fee.',
value: { type: 'price', amount: 350 },
},
{
name: 'Death Registration Certificate',
info: 'NSW Registry.',
value: { type: 'price', amount: 70 },
},
{
name: 'Professional Service Fee',
info: 'Coordination.',
value: { type: 'price', amount: 3980 },
},
{
name: 'Transportation Service Fee',
info: 'Transfer.',
value: { type: 'price', amount: 500 },
},
],
},
{
heading: 'Optionals',
items: [
{ name: 'Digital Recording', value: { type: 'unknown' } },
{ name: 'Flowers', value: { type: 'unknown' } },
{ name: 'Online Notice', value: { type: 'unknown' } },
{ name: 'Viewing Fee', value: { type: 'unknown' } },
{ name: 'Viewing Fee', info: 'Private family viewing.', value: { type: 'included' } },
{ name: 'Flowers', info: 'Floral arrangements.', value: { type: 'poa' } },
{
name: 'Digital Recording',
info: 'Video recording.',
value: { type: 'price', amount: 250 },
},
],
},
{
heading: 'Extras',
items: [
{ name: 'Allowance for Celebrant', value: { type: 'unknown' } },
{ name: 'Catering', value: { type: 'unknown' } },
{ name: 'Newspaper Notice', value: { type: 'unknown' } },
{ name: 'Saturday Service Fee', value: { type: 'unknown' } },
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
],
},
],

View File

@@ -1,23 +1,27 @@
import React, { useId, useState, useRef, useCallback } from 'react';
import React, { useId, useState } from 'react';
import Box from '@mui/material/Box';
import Divider from '@mui/material/Divider';
import useMediaQuery from '@mui/material/useMediaQuery';
import { useTheme } from '@mui/material/styles';
import Tooltip from '@mui/material/Tooltip';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import ShareOutlinedIcon from '@mui/icons-material/ShareOutlined';
import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Link } from '../../atoms/Link';
import { Badge } from '../../atoms/Badge';
import { Divider } from '../../atoms/Divider';
import { Card } from '../../atoms/Card';
import { WizardLayout } from '../../templates/WizardLayout';
import {
ComparisonTable,
COMPARISON_TABLE_COL_WIDTH,
type ComparisonPackage,
type ComparisonCellValue,
} from '../../organisms/ComparisonTable';
import { ComparisonPackageCard } from '../../molecules/ComparisonPackageCard';
import { ComparisonTabCard } from '../../molecules/ComparisonTabCard';
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -43,6 +47,259 @@ export interface ComparisonPageProps {
sx?: SxProps<Theme>;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function formatPrice(amount: number): string {
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
}
function MobileCellValue({ value }: { value: ComparisonCellValue }) {
switch (value.type) {
case 'price':
return (
<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 (
<Badge color="default" variant="soft" size="small">
Unknown
</Badge>
);
case 'unavailable':
return (
<Typography
variant="body2"
sx={{ color: 'var(--fa-color-neutral-400)', textAlign: 'right' }}
>
</Typography>
);
}
}
// ─── Mobile card view ───────────────────────────────────────────────────────
function MobilePackageCard({
pkg,
onArrange,
}: {
pkg: ComparisonPackage;
onArrange: (id: string) => void;
}) {
return (
<Card
variant="outlined"
selected={pkg.isRecommended}
padding="none"
sx={{ overflow: 'hidden' }}
>
{/* 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="large"
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>
<MobileCellValue value={item.value} />
</Box>
))}
</Box>
</Box>
))
)}
</Box>
</Card>
);
}
// ─── Component ──────────────────────────────────────────────────────────────
/**
@@ -68,8 +325,6 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const tablistId = useId();
const railRef = useRef<HTMLDivElement>(null);
const tabRefs = useRef<(HTMLDivElement | null)[]>([]);
const allPackages = React.useMemo(() => {
const result: ComparisonPackage[] = [];
@@ -92,84 +347,17 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
? `Comparing ${packages.length} package${packages.length !== 1 ? 's' : ''} from different providers`
: `Comparing ${packages.length} package${packages.length !== 1 ? 's' : ''}`;
const hasRecommended = allPackages.some((p) => p.isRecommended);
const scrollToCenter = useCallback((idx: number) => {
const tab = tabRefs.current[idx];
if (tab && railRef.current) {
const rail = railRef.current;
const tabCenter = tab.offsetLeft + tab.offsetWidth / 2;
const railCenter = rail.offsetWidth / 2;
rail.scrollTo({ left: tabCenter - railCenter, behavior: 'smooth' });
}
}, []);
const handleTabClick = useCallback(
(idx: number) => {
setActiveTabIdx(idx);
scrollToCenter(idx);
},
[scrollToCenter],
);
// Center the default tab on mount
React.useEffect(() => {
// Small delay to allow layout to settle
const timer = setTimeout(() => scrollToCenter(defaultTabIdx), 50);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Natural table width = (row-label col) + (pkg col × n), matches page header maxWidth.
// Page header container reaches this same width so the table's left edge aligns
// with the page header's left edge when the table overflows horizontally.
const tableNaturalWidth = COMPARISON_TABLE_COL_WIDTH * (allPackages.length + 1);
const pageMaxWidth = COMPARISON_TABLE_COL_WIDTH * 4; // fits 3-package case flush
// Matching horizontal padding between the page header container and the
// table-zone spacers keeps inner-content left edges aligned on all viewports.
const edgePadding = { xs: 16, md: 24 };
return (
<Box ref={ref} sx={sx}>
<WizardLayout
variant={isMobile ? 'wide-form' : 'bleed'}
variant="wide-form"
navigation={navigation}
showBackLink={isMobile}
showBackLink
backLabel="Back"
onBack={onBack}
>
{!isMobile && (
<>
{/* Page header zone — centred, bounded to the table's natural width */}
<Box sx={{ display: 'flex', justifyContent: 'center', width: '100%' }}>
<Box
sx={{
width: '100%',
maxWidth: pageMaxWidth,
px: { xs: `${edgePadding.xs}px`, md: `${edgePadding.md}px` },
pt: { xs: 2, md: 3 },
pb: { xs: 3, md: 5 },
}}
>
<Link
component="button"
onClick={onBack}
underline="hover"
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
color: 'text.secondary',
fontSize: '0.875rem',
fontWeight: 500,
mb: 2,
'&:hover': { color: 'text.primary' },
}}
>
<ArrowBackIcon sx={{ fontSize: 18 }} />
Back
</Link>
{/* Page header with Share/Print actions */}
<Box sx={{ mb: { xs: 3, md: 5 } }}>
<Box
sx={{
display: 'flex',
@@ -188,6 +376,7 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
</Typography>
</Box>
{/* Share + Print */}
{(onShare || onPrint) && (
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
{onShare && (
@@ -216,147 +405,102 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
)}
</Box>
</Box>
</Box>
<Divider />
{/* Table zone — width-matching spacers centre the table when room
allows, collapse to the minimum when table is wider than
viewport so overflow extends rightward from the page's
content column. */}
<Box
sx={{
display: 'flex',
width: 'max-content',
minWidth: '100%',
py: { xs: 3, md: 5 },
}}
>
<Box
aria-hidden
sx={{
flex: 1,
minWidth: { xs: `${edgePadding.xs}px`, md: `${edgePadding.md}px` },
}}
/>
<Box sx={{ flexShrink: 0, width: tableNaturalWidth }}>
<ComparisonTable
packages={allPackages}
onArrange={onArrange}
onRemove={onRemove}
/>
</Box>
<Box
aria-hidden
sx={{
flex: 1,
minWidth: { xs: `${edgePadding.xs}px`, md: `${edgePadding.md}px` },
}}
/>
</Box>
</>
{/* Desktop: ComparisonTable */}
{!isMobile && (
<ComparisonTable packages={allPackages} onArrange={onArrange} onRemove={onRemove} />
)}
{/* Mobile: Tab rail + card view */}
{isMobile && allPackages.length > 0 && (
<>
<Box sx={{ mb: 3 }}>
<Typography variant="h2" component="h1" sx={{ mb: 1 }}>
Compare packages
</Typography>
<Typography variant="body1" color="text.secondary" aria-live="polite">
{subtitle}
</Typography>
</Box>
<Divider sx={{ mb: 3 }} />
<Typography
id="comparison-rail-heading"
variant="label"
component="h2"
sx={{ fontWeight: 600, display: 'block', mb: 1.5 }}
>
Choose a package to view
</Typography>
{/* Tab rail — mini cards showing provider + package name */}
<Box
ref={railRef}
role="tablist"
id={tablistId}
aria-labelledby="comparison-rail-heading"
aria-label="Packages to compare"
sx={{
display: 'flex',
gap: 1.5,
overflowX: 'auto',
py: 2,
px: 2,
mx: -2,
mb: 1.5,
pb: 1,
mb: 2.5,
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>
{/* Dot indicator — position + count. Purely visual supplement;
the tab rail above is the accessible navigation, so dots
are aria-hidden and skipped by keyboard tab-order. */}
<Box
aria-hidden="true"
sx={{
display: 'flex',
justifyContent: 'center',
gap: 0.5,
mb: 3,
}}
>
{allPackages.map((_, idx) => {
{allPackages.map((pkg, idx) => {
const isActive = idx === activeTabIdx;
return (
<Box
key={idx}
component="button"
type="button"
tabIndex={-1}
onClick={() => handleTabClick(idx)}
<Card
key={pkg.id}
role="tab"
aria-selected={isActive}
aria-controls={`comparison-tabpanel-${idx}`}
id={`comparison-tab-${idx}`}
variant="outlined"
selected={isActive}
padding="none"
onClick={() => setActiveTabIdx(idx)}
interactive
sx={{
appearance: 'none',
border: 0,
background: 'transparent',
flexShrink: 0,
minWidth: 150,
maxWidth: 200,
cursor: 'pointer',
p: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'& > span': {
display: 'block',
width: isActive ? 24 : 8,
height: 8,
borderRadius: 4,
bgcolor: isActive
? 'var(--fa-color-brand-600)'
: 'var(--fa-color-neutral-300)',
transition: 'width 0.2s ease, background-color 0.2s ease',
},
...(pkg.isRecommended &&
!isActive && {
borderColor: 'var(--fa-color-brand-500)',
}),
}}
>
<span />
<Box sx={{ px: 2, py: 1.5 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
mb: 0.25,
}}
>
{pkg.isRecommended && (
<StarRoundedIcon
aria-label="Recommended"
sx={{
fontSize: 16,
color: 'var(--fa-color-brand-600)',
flexShrink: 0,
}}
/>
)}
<Typography
variant="labelSm"
sx={{
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
}}
>
{pkg.provider.name}
</Typography>
</Box>
<Typography
variant="caption"
color="text.secondary"
sx={{
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{pkg.name}
</Typography>
</Box>
</Card>
);
})}
</Box>
@@ -367,7 +511,7 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
id={`comparison-tabpanel-${activeTabIdx}`}
aria-labelledby={`comparison-tab-${activeTabIdx}`}
>
<ComparisonPackageCard pkg={activePackage} onArrange={onArrange} />
<MobilePackageCard pkg={activePackage} onArrange={onArrange} />
</Box>
)}
</>

View File

@@ -216,33 +216,50 @@ const pkgInglewood: ComparisonPackage = {
{
heading: 'Essentials',
items: [
{ name: 'Allowance for Coffin', value: { type: 'unknown' } },
{ name: 'Cremation Certificate/Permit', value: { type: 'unknown' } },
{ name: 'Crematorium', value: { type: 'unknown' } },
{ name: 'Death Registration Certificate', value: { type: 'unknown' } },
{ name: 'Dressing Fee', value: { type: 'unknown' } },
{ name: 'NSW Government Levy — Cremation', value: { type: 'unknown' } },
{ name: 'Professional Mortuary Care', value: { type: 'unknown' } },
{ name: 'Professional Service Fee', value: { type: 'unknown' } },
{ name: 'Transportation Service Fee', value: { type: 'unknown' } },
{
name: 'Allowance for Coffin',
info: 'Allowance amount.',
value: { type: 'allowance', amount: 1800 },
},
{
name: 'Cremation Certificate/Permit',
info: 'Medical referee fee.',
value: { type: 'price', amount: 350 },
},
{
name: 'Death Registration Certificate',
info: 'NSW Registry.',
value: { type: 'price', amount: 70 },
},
{
name: 'Professional Service Fee',
info: 'Coordination.',
value: { type: 'price', amount: 3980 },
},
{
name: 'Transportation Service Fee',
info: 'Transfer.',
value: { type: 'price', amount: 500 },
},
],
},
{
heading: 'Optionals',
items: [
{ name: 'Digital Recording', value: { type: 'unknown' } },
{ name: 'Flowers', value: { type: 'unknown' } },
{ name: 'Online Notice', value: { type: 'unknown' } },
{ name: 'Viewing Fee', value: { type: 'unknown' } },
{ name: 'Viewing Fee', info: 'Private family viewing.', value: { type: 'included' } },
{ name: 'Flowers', info: 'Floral arrangements.', value: { type: 'poa' } },
{
name: 'Digital Recording',
info: 'Video recording.',
value: { type: 'price', amount: 250 },
},
],
},
{
heading: 'Extras',
items: [
{ name: 'Allowance for Celebrant', value: { type: 'unknown' } },
{ name: 'Catering', value: { type: 'unknown' } },
{ name: 'Newspaper Notice', value: { type: 'unknown' } },
{ name: 'Saturday Service Fee', value: { type: 'unknown' } },
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
],
},
],

View File

@@ -1,16 +1,27 @@
import React, { useId, useState, useRef, useCallback } from 'react';
import React, { useId, useState } from 'react';
import Box from '@mui/material/Box';
import useMediaQuery from '@mui/material/useMediaQuery';
import { useTheme } from '@mui/material/styles';
import Tooltip from '@mui/material/Tooltip';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import ShareOutlinedIcon from '@mui/icons-material/ShareOutlined';
import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Badge } from '../../atoms/Badge';
import { Divider } from '../../atoms/Divider';
import { Card } from '../../atoms/Card';
import { WizardLayout } from '../../templates/WizardLayout';
import { ComparisonTable, type ComparisonPackage } from '../../organisms/ComparisonTable';
import { ComparisonPackageCard } from '../../molecules/ComparisonPackageCard';
import { ComparisonTabCard } from '../../molecules/ComparisonTabCard';
import {
ComparisonTable,
type ComparisonPackage,
type ComparisonCellValue,
} from '../../organisms/ComparisonTable';
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -36,6 +47,259 @@ export interface ComparisonPageV1Props {
sx?: SxProps<Theme>;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function formatPrice(amount: number): string {
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
}
function MobileCellValue({ value }: { value: ComparisonCellValue }) {
switch (value.type) {
case 'price':
return (
<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 (
<Badge color="default" variant="soft" size="small">
Unknown
</Badge>
);
case 'unavailable':
return (
<Typography
variant="body2"
sx={{ color: 'var(--fa-color-neutral-400)', textAlign: 'right' }}
>
</Typography>
);
}
}
// ─── Mobile card view ───────────────────────────────────────────────────────
function MobilePackageCard({
pkg,
onArrange,
}: {
pkg: ComparisonPackage;
onArrange: (id: string) => void;
}) {
return (
<Card
variant="outlined"
selected={pkg.isRecommended}
padding="none"
sx={{ overflow: 'hidden' }}
>
{/* 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="large"
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>
<MobileCellValue value={item.value} />
</Box>
))}
</Box>
</Box>
))
)}
</Box>
</Card>
);
}
// ─── Component ──────────────────────────────────────────────────────────────
/**
@@ -59,8 +323,6 @@ export const ComparisonPageV1 = React.forwardRef<HTMLDivElement, ComparisonPageV
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const tablistId = useId();
const railRef = useRef<HTMLDivElement>(null);
const tabRefs = useRef<(HTMLDivElement | null)[]>([]);
const allPackages = React.useMemo(() => {
const result = [...packages];
@@ -79,33 +341,6 @@ export const ComparisonPageV1 = React.forwardRef<HTMLDivElement, ComparisonPageV
? `Comparing ${packages.length} package${packages.length !== 1 ? 's' : ''} from different providers`
: `Comparing ${packages.length} package${packages.length !== 1 ? 's' : ''}`;
const hasRecommended = allPackages.some((p) => p.isRecommended);
const scrollToCenter = useCallback((idx: number) => {
const tab = tabRefs.current[idx];
if (tab && railRef.current) {
const rail = railRef.current;
const tabCenter = tab.offsetLeft + tab.offsetWidth / 2;
const railCenter = rail.offsetWidth / 2;
rail.scrollTo({ left: tabCenter - railCenter, behavior: 'smooth' });
}
}, []);
const handleTabClick = useCallback(
(idx: number) => {
setActiveTabIdx(idx);
scrollToCenter(idx);
},
[scrollToCenter],
);
// Center the default tab on mount
React.useEffect(() => {
const timer = setTimeout(() => scrollToCenter(0), 50);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Box ref={ref} sx={sx}>
<WizardLayout
@@ -173,9 +408,8 @@ export const ComparisonPageV1 = React.forwardRef<HTMLDivElement, ComparisonPageV
{/* Mobile: Tab rail + card view */}
{isMobile && allPackages.length > 0 && (
<>
{/* Tab rail — mini cards showing provider + package + price */}
{/* Tab rail — mini cards showing provider + package name */}
<Box
ref={railRef}
role="tablist"
id={tablistId}
aria-label="Packages to compare"
@@ -183,30 +417,86 @@ export const ComparisonPageV1 = React.forwardRef<HTMLDivElement, ComparisonPageV
display: 'flex',
gap: 1.5,
overflowX: 'auto',
py: 2,
px: 2,
mx: -2,
mt: 1,
mb: 3,
pb: 1,
mb: 2.5,
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
WebkitOverflowScrolling: 'touch',
}}
>
{allPackages.map((pkg, idx) => (
<ComparisonTabCard
{allPackages.map((pkg, idx) => {
const isActive = idx === activeTabIdx;
return (
<Card
key={pkg.id}
ref={(el: HTMLDivElement | null) => {
tabRefs.current[idx] = el;
role="tab"
aria-selected={isActive}
aria-controls={`comparison-tabpanel-${idx}`}
id={`comparison-tab-${idx}`}
variant="outlined"
selected={isActive}
padding="none"
onClick={() => setActiveTabIdx(idx)}
interactive
sx={{
flexShrink: 0,
minWidth: 150,
maxWidth: 200,
cursor: 'pointer',
...(pkg.isRecommended &&
!isActive && {
borderColor: 'var(--fa-color-brand-500)',
}),
}}
>
<Box sx={{ px: 2, py: 1.5 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
mb: 0.25,
}}
>
{pkg.isRecommended && (
<StarRoundedIcon
aria-label="Recommended"
sx={{
fontSize: 16,
color: 'var(--fa-color-brand-600)',
flexShrink: 0,
}}
pkg={pkg}
isActive={idx === activeTabIdx}
hasRecommended={hasRecommended}
tabId={`comparison-tab-${idx}`}
tabPanelId={`comparison-tabpanel-${idx}`}
onClick={() => handleTabClick(idx)}
/>
))}
)}
<Typography
variant="labelSm"
sx={{
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
}}
>
{pkg.provider.name}
</Typography>
</Box>
<Typography
variant="caption"
color="text.secondary"
sx={{
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{pkg.name}
</Typography>
</Box>
</Card>
);
})}
</Box>
{activePackage && (
@@ -215,7 +505,7 @@ export const ComparisonPageV1 = React.forwardRef<HTMLDivElement, ComparisonPageV
id={`comparison-tabpanel-${activeTabIdx}`}
aria-labelledby={`comparison-tab-${activeTabIdx}`}
>
<ComparisonPackageCard pkg={activePackage} onArrange={onArrange} />
<MobilePackageCard pkg={activePackage} onArrange={onArrange} />
</Box>
)}
</>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { PackagesStep } from './PackagesStep';
import type { NearbyVerifiedProvider, PackageData, PackagesStepProvider } from './PackagesStep';
import type { PackageData, PackagesStepProvider } from './PackagesStep';
import { Navigation } from '../../organisms/Navigation';
import Box from '@mui/material/Box';
// ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -35,19 +35,10 @@ const nav = (
/>
);
// ─── Mock data ───────────────────────────────────────────────────────────────
const verifiedProvider: PackagesStepProvider = {
name: 'H.Parsons Funeral Directors',
location: 'Wentworth, NSW',
imageUrl: '/images/placeholder/hparsonsvenue.jpg',
rating: 4.6,
reviewCount: 7,
};
const unverifiedProvider: PackagesStepProvider = {
const mockProvider: PackagesStepProvider = {
name: 'H.Parsons Funeral Directors',
location: 'Wentworth, NSW',
imageUrl: 'https://placehold.co/120x80/E8E0D6/8B6F47?text=H.Parsons',
rating: 4.6,
reviewCount: 7,
};
@@ -156,119 +147,6 @@ const otherPackages: PackageData[] = [
},
];
const manyOtherPackages: PackageData[] = [
...otherPackages,
{
id: 'memorial',
name: 'Memorial Service',
price: 2400,
description: 'A celebration-of-life service without burial or cremation on the same day.',
sections: [
{
heading: 'Essentials',
items: [
{ name: 'Professional Service Fee', price: 1200 },
{ name: 'Venue coordination', price: 600 },
{ name: 'Memorial book', price: 100 },
],
},
],
total: 2400,
},
{
id: 'graveside',
name: 'Graveside Service',
price: 2900,
description: 'A simple graveside committal, ideal for smaller family gatherings.',
sections: [
{
heading: 'Essentials',
items: [
{ name: 'Professional Mortuary Care', price: 1000 },
{ name: 'Professional Service Fee', price: 1100 },
{ name: 'Cemetery coordination', price: 400 },
],
},
],
total: 2900,
},
{
id: 'prepaid-basic',
name: 'Prepaid Basic Plan',
price: 3600,
description: 'Lock in todays price for a basic cremation package, paid over 12 months.',
sections: [
{
heading: 'Essentials',
items: [
{ name: 'Locked-in pricing', price: 0, priceLabel: 'Complimentary' },
{ name: 'Professional Service Fee', price: 1200 },
{ name: 'Professional Mortuary Care', price: 1000 },
],
},
],
total: 3600,
},
];
const nearbyVerifiedProviders: NearbyVerifiedProvider[] = [
{
id: 'rankins',
name: 'Rankins Funerals',
imageUrl: '/images/placeholder/hparsonsvenue.jpg',
location: 'Warrawong, NSW',
startingPrice: 2450,
rating: 4.8,
reviewCount: 23,
},
{
id: 'mannings',
name: 'Mannings Funerals',
imageUrl: '/images/placeholder/hparsonsvenue.jpg',
location: 'Bega, NSW',
startingPrice: 1950,
rating: 4.7,
reviewCount: 42,
},
{
id: 'killick',
name: 'Killick Family Funerals',
imageUrl: '/images/placeholder/hparsonsvenue.jpg',
location: 'Kingaroy, QLD',
startingPrice: 3100,
rating: 4.9,
reviewCount: 15,
},
{
id: 'mackay',
name: 'Mackay Family Funerals',
imageUrl: '/images/placeholder/hparsonsvenue.jpg',
location: 'Ourimbah, NSW',
startingPrice: 2780,
rating: 4.6,
reviewCount: 19,
},
];
const tier2Packages: PackageData[] = [
{
id: 't2-standard',
name: 'Standard Funeral Service',
price: 5200,
description:
'A full-service package based on publicly available information. Breakdown not available — make an enquiry to confirm what is included.',
sections: [],
},
{
id: 't2-basic',
name: 'Basic Cremation',
price: 3400,
description:
'An entry-level package based on publicly available information. Pricing is indicative only.',
sections: [],
},
];
// ─── Meta ────────────────────────────────────────────────────────────────────
const meta: Meta<typeof PackagesStep> = {
@@ -283,152 +161,22 @@ const meta: Meta<typeof PackagesStep> = {
export default meta;
type Story = StoryObj<typeof PackagesStep>;
// ─── Verified ────────────────────────────────────────────────────────────────
// ─── Interactive (default) ──────────────────────────────────────────────────
/** Verified provider — matching packages + up to 3 other packages from the same provider */
export const Verified: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>('everyday');
return (
<PackagesStep
provider={verifiedProvider}
providerTier="verified"
packages={matchedPackages}
secondaryList={{ kind: 'same-provider-more', packages: otherPackages }}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onCompare={() => alert('Open compare view')}
onProviderClick={() => alert('Open provider profile (future)')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── Verified — with "See all" link ─────────────────────────────────────────
/** Verified provider with 5+ other packages — shows first 3 + "See all N packages" link */
export const VerifiedWithManyOtherPackages: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>('everyday');
return (
<PackagesStep
provider={verifiedProvider}
providerTier="verified"
packages={matchedPackages}
secondaryList={{ kind: 'same-provider-more', packages: manyOtherPackages }}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onSeeAllPackages={() => alert('Route to showAllFromProvider variant')}
onProviderClick={() => alert('Open provider profile (future)')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── "Show all from provider" variant ───────────────────────────────────────
/** Flat "All packages from [Provider]" view — no grouping, selected package preserved */
export const AllFromProvider: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>('everyday');
const allPackages = [...matchedPackages, ...manyOtherPackages];
return (
<PackagesStep
provider={verifiedProvider}
providerTier="verified"
packages={allPackages}
showAllFromProvider
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onCompare={() => alert('Open compare view')}
onProviderClick={() => alert('Open provider profile (future)')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── Tier 3 (itemised breakdown) ────────────────────────────────────────────
/** Tier 3 unverified — itemised breakdown + "Make an enquiry" + nearby verified alternatives */
export const Tier3: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>('everyday');
return (
<PackagesStep
provider={unverifiedProvider}
providerTier="tier3"
packages={matchedPackages}
secondaryList={{ kind: 'nearby-verified', providers: nearbyVerifiedProviders }}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Make an enquiry')}
onCompare={() => alert('Open compare view')}
onNearbyProviderClick={(id) => alert(`Route to verified provider: ${id}`)}
onProviderClick={() => alert('Open provider profile (future)')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── Tier 2 (price only, no breakdown) ──────────────────────────────────────
/** Tier 2 unverified — price only, detail panel shows "Itemised Pricing Unavailable" */
export const Tier2: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>('t2-standard');
return (
<PackagesStep
provider={unverifiedProvider}
providerTier="tier2"
packages={tier2Packages}
secondaryList={{ kind: 'nearby-verified', providers: nearbyVerifiedProviders }}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Make an enquiry')}
onCompare={() => alert('Open compare view')}
onNearbyProviderClick={(id) => alert(`Route to verified provider: ${id}`)}
onProviderClick={() => alert('Open provider profile (future)')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── Edge cases ──────────────────────────────────────────────────────────────
/** No selection yet — empty detail panel */
export const NoSelection: Story = {
/** Matched + other packages — select a package, see detail, click Make Arrangement */
export const Default: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<PackagesStep
provider={verifiedProvider}
providerTier="verified"
provider={mockProvider}
packages={matchedPackages}
secondaryList={{ kind: 'same-provider-more', packages: otherPackages }}
otherPackages={otherPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onCompare={() => alert('Open compare view')}
onProviderClick={() => alert('Open provider profile (future)')}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
navigation={nav}
/>
@@ -436,21 +184,44 @@ export const NoSelection: Story = {
},
};
/** Verified provider with no "other packages" — primary list only */
export const VerifiedNoSecondary: Story = {
// ─── With selection ─────────────────────────────────────────────────────────
/** Package already selected — detail panel visible */
export const WithSelection: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>('everyday');
return (
<PackagesStep
provider={mockProvider}
packages={matchedPackages}
otherPackages={otherPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── No other packages (all match) ─────────────────────────────────────────
/** All packages match filters — no "Other packages" section */
export const AllMatching: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<PackagesStep
provider={verifiedProvider}
providerTier="verified"
packages={matchedPackages}
provider={mockProvider}
packages={[...matchedPackages, ...otherPackages]}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onCompare={() => alert('Open compare view')}
onProviderClick={() => alert('Open provider profile (future)')}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
navigation={nav}
/>
@@ -458,6 +229,8 @@ export const VerifiedNoSecondary: Story = {
},
};
// ─── Pre-planning ───────────────────────────────────────────────────────────
/** Pre-planning flow — softer copy */
export const PrePlanning: Story = {
render: () => {
@@ -465,15 +238,13 @@ export const PrePlanning: Story = {
return (
<PackagesStep
provider={verifiedProvider}
providerTier="verified"
provider={mockProvider}
packages={matchedPackages}
secondaryList={{ kind: 'same-provider-more', packages: otherPackages }}
otherPackages={otherPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onCompare={() => alert('Open compare view')}
onProviderClick={() => alert('Open provider profile (future)')}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
navigation={nav}
isPrePlanning
@@ -482,15 +253,16 @@ export const PrePlanning: Story = {
},
};
/** Validation error */
// ─── Validation error ───────────────────────────────────────────────────────
/** Error shown when no package selected */
export const WithError: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<PackagesStep
provider={verifiedProvider}
providerTier="verified"
provider={mockProvider}
packages={matchedPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}

View File

@@ -1,125 +1,68 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import Box from '@mui/material/Box';
import { useTheme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import type { SxProps, Theme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout';
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
import { ServiceOption } from '../../molecules/ServiceOption';
import { MiniCard } from '../../molecules/MiniCard';
import { PackageDetail } from '../../organisms/PackageDetail';
import type { PackageSection } from '../../organisms/PackageDetail';
import { Typography } from '../../atoms/Typography';
import { Divider } from '../../atoms/Divider';
import { Link } from '../../atoms/Link';
import type { PackageData, PackagesStepProvider, ProviderTier, SecondaryList } from './types';
export type {
PackageData,
PackagesStepProvider,
NearbyVerifiedProvider,
ProviderTier,
SecondaryList,
} from './types';
// ─── Types ───────────────────────────────────────────────────────────────────
// ─── Tier copy map ───────────────────────────────────────────────────────────
interface TierCopy {
heading: string;
subheading: (isPrePlanning: boolean) => string;
arrangeLabel: string;
priceDisclaimer?: string;
itemizedUnavailable: boolean;
emptyDetailMessage: string;
/** Provider summary for the compact card */
export interface PackagesStepProvider {
/** Provider name */
name: string;
/** Location */
location: string;
/** Image URL */
imageUrl?: string;
/** Rating */
rating?: number;
/** Review count */
reviewCount?: number;
}
const TIER_COPY: Record<ProviderTier, TierCopy> = {
verified: {
heading: 'Choose a funeral package',
subheading: (isPrePlanning) =>
isPrePlanning
? 'Compare packages to find what suits your wishes. Nothing is committed until you confirm.'
: 'Each package includes a set of services. You can customise your selections in the next steps.',
arrangeLabel: 'Make Arrangement',
itemizedUnavailable: false,
emptyDetailMessage: "Select a package to see what's included.",
},
tier3: {
heading: 'Explore available packages',
subheading: (isPrePlanning) =>
isPrePlanning
? 'Browse estimated packages to get a sense of what this provider offers. Nothing is committed.'
: 'These packages are based on publicly available information. Make an enquiry to confirm details and pricing.',
arrangeLabel: 'Make an enquiry',
priceDisclaimer:
"Prices are estimates based on publicly available information and may not reflect the provider's current pricing.",
itemizedUnavailable: false,
emptyDetailMessage: "Select a package to see what's included.",
},
tier2: {
heading: 'Explore available packages',
subheading: (isPrePlanning) =>
isPrePlanning
? 'Browse estimated packages to get a sense of what this provider offers. Nothing is committed.'
: 'These packages are based on publicly available information. Make an enquiry to confirm details and pricing.',
arrangeLabel: 'Make an enquiry',
priceDisclaimer:
"Prices are estimates based on publicly available information and may not reflect the provider's current pricing.",
itemizedUnavailable: true,
emptyDetailMessage: 'Select a package to see more details.',
},
};
// Show at most this many "other packages from this provider" inline before
// switching to "top N + See all →" behaviour.
const SAME_PROVIDER_INLINE_LIMIT = 3;
// Max number of verified provider MiniCards in the "Similar packages from
// verified providers" grid on unverified pages.
const NEARBY_VERIFIED_LIMIT = 4;
// ─── Props ───────────────────────────────────────────────────────────────────
/** Package data for the selection list */
export interface PackageData {
/** Unique package ID */
id: string;
/** Package display name */
name: string;
/** Package price in dollars */
price: number;
/** Short description */
description?: string;
/** Line item sections for the detail panel */
sections: PackageSection[];
/** Total price (may differ from base price with extras) */
total?: number;
/** Extra items section (after total) */
extras?: PackageSection;
/** Terms and conditions */
terms?: string;
}
/** Props for the PackagesStep page component */
export interface PackagesStepProps {
/** Provider shown at the top of the list panel */
/** Provider summary shown at top of the list panel */
provider: PackagesStepProvider;
/** Provider tier — drives copy, CTA label, disclaimer, itemised-unavailable state */
providerTier: ProviderTier;
/** Packages in the primary list (filtered by user preferences, or all when `showAllFromProvider`) */
/** Packages matching the user's filters from the previous step */
packages: PackageData[];
/** Secondary list below the primary one — same-provider-more or nearby-verified. Suppressed when `showAllFromProvider` is true. */
secondaryList?: SecondaryList;
/** Other packages from this provider that didn't match filters (shown in secondary group) */
otherPackages?: PackageData[];
/** Currently selected package ID */
selectedPackageId: string | null;
/** Callback when a primary-list package is selected (or cleared via mobile back) */
onSelectPackage: (id: string | null) => void;
/** Callback when "Make Arrangement" / "Make an enquiry" is clicked */
/** Callback when a package is selected */
onSelectPackage: (id: string) => void;
/** Callback when "Make Arrangement" is clicked (opens ArrangementDialog) */
onArrange: () => void;
/** Callback when the "Compare" button on the PackageDetail panel is clicked */
onCompare?: () => void;
/** Whether the currently-selected package is already in the comparison
* basket. When true, PackageDetail swaps its Compare button into the
* "In comparison" selected-state (inert; removal via CompareBar). */
isSelectedPackageInCart?: boolean;
/** Callback when a nearby-verified provider card is clicked (route change to that provider's PackagesStep) */
onNearbyProviderClick?: (id: string) => void;
/**
* Callback when "See all N packages from [Provider]" is clicked.
* Expected to route to the same PackagesStep with `showAllFromProvider` set.
* Only used when secondaryList.kind === 'same-provider-more' and list length > 3.
*/
onSeeAllPackages?: () => void;
/** Callback when the provider card is clicked (future: opens provider profile) */
/** Callback when the provider card is clicked (opens provider profile popup) */
onProviderClick?: () => void;
/** Callback for the Back button */
onBack: () => void;
/**
* When true, renders the "All packages from [Provider]" variant:
* flat list, no grouping, no secondary list, no "Matching your preferences" heading.
* Caller passes the full package list in `packages`.
*/
showAllFromProvider?: boolean;
/** Validation error */
error?: string;
/** Whether the arrange action is loading */
@@ -132,61 +75,23 @@ export interface PackagesStepProps {
sx?: SxProps<Theme>;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/** Accent bar + label — used for both "Matching your preferences" and "Other packages from [X]". */
function GroupHeading({
label,
emphasis = 'primary',
}: {
label: string;
emphasis?: 'primary' | 'secondary';
}) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2 }}>
<Box
sx={{
width: 3,
height: 20,
borderRadius: 1,
bgcolor: emphasis === 'primary' ? 'primary.main' : 'text.secondary',
flexShrink: 0,
}}
/>
<Typography
variant="body2"
sx={{
fontWeight: 600,
color: emphasis === 'primary' ? 'text.primary' : 'text.secondary',
}}
>
{label}
</Typography>
</Box>
);
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Package selection step — tier-aware, unified page component.
* Step 3 — Package selection page for the FA arrangement wizard.
*
* Handles all three provider tiers (verified, tier3, tier2) via the
* `providerTier` prop. Header copy, CTA label, price disclaimer, and
* itemised-unavailable state are derived from tier.
* List + Detail split layout. Left panel shows the selected provider
* (compact) and selectable package cards. Right panel shows the full
* detail breakdown of the selected package with "Make Arrangement" CTA.
*
* Left column layout varies by `secondaryList`:
* - `same-provider-more` (verified): primary "Matching your preferences"
* list + "Other packages from [Provider]" list. If >3 other packages,
* shows top 3 + "See all N packages from [Provider] →" link that routes
* to the same page with `showAllFromProvider`.
* - `nearby-verified` (unverified tiers): primary list + "Similar packages
* from verified providers" 2-column MiniCard grid, capped at 4. Every
* card is verified by definition.
* Packages are split into two groups:
* - **Matching your preferences**: packages that matched the user's filters
* from the providers step
* - **Other packages from [Provider]**: remaining packages outside those
* filters, shown below a divider for passive discovery
*
* When `showAllFromProvider` is true, renders a flat "All packages from
* [Provider]" list with no grouping and no secondary list. The caller
* preserves `selectedPackageId` across this navigation.
* Selecting a package reveals its detail. Clicking "Make Arrangement"
* on the detail panel triggers the ArrangementDialog (D-E).
*
* Pure presentation component — props in, callbacks out.
*
@@ -194,95 +99,37 @@ function GroupHeading({
*/
export const PackagesStep: React.FC<PackagesStepProps> = ({
provider,
providerTier,
packages,
secondaryList,
otherPackages = [],
selectedPackageId,
onSelectPackage,
onArrange,
onCompare,
isSelectedPackageInCart = false,
onNearbyProviderClick,
onSeeAllPackages,
onProviderClick,
onBack,
showAllFromProvider = false,
error,
loading = false,
navigation,
isPrePlanning = false,
sx,
}) => {
const copy = TIER_COPY[providerTier];
// Look up the selected package across BOTH the primary list and the
// same-provider-more secondary list — tapping "Premium Funeral Service"
// in the "Other packages from X" section should surface its detail too.
const selectedPackage =
packages.find((p) => p.id === selectedPackageId) ??
(secondaryList?.kind === 'same-provider-more'
? secondaryList.packages.find((p) => p.id === selectedPackageId)
: undefined);
const allPackages = [...packages, ...otherPackages];
const selectedPackage = allPackages.find((p) => p.id === selectedPackageId);
const hasOtherPackages = otherPackages.length > 0;
// Mobile drill-in: on mobile, the list is the default view — only when the
// user explicitly taps a package do we swap in the detail panel. This
// distinguishes "parent pre-selected first package for desktop auto-display"
// (which should NOT jump to detail on mobile) from "user tapped a package".
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [hasDrilledIn, setHasDrilledIn] = useState(false);
const mobileShowDetail = isMobile && hasDrilledIn && selectedPackageId != null;
const handleSelectPackage = (id: string | null) => {
setHasDrilledIn(id != null);
onSelectPackage(id);
};
useEffect(() => {
if (mobileShowDetail) window.scrollTo({ top: 0, behavior: 'auto' });
}, [mobileShowDetail]);
const handleLayoutBack = mobileShowDetail ? () => handleSelectPackage(null) : onBack;
const layoutBackLabel = mobileShowDetail ? 'Back to packages' : 'Back';
// Secondary list suppressed in "show all" mode.
const activeSecondaryList = showAllFromProvider ? undefined : secondaryList;
const hasSecondary = Boolean(activeSecondaryList);
// For same-provider-more, show top N inline; surface "See all" when over limit.
const sameProviderPackages =
activeSecondaryList?.kind === 'same-provider-more' ? activeSecondaryList.packages : [];
const sameProviderOverflow = sameProviderPackages.length > SAME_PROVIDER_INLINE_LIMIT;
const sameProviderVisible = sameProviderOverflow
? sameProviderPackages.slice(0, SAME_PROVIDER_INLINE_LIMIT)
: sameProviderPackages;
const heading = showAllFromProvider ? `All packages from ${provider.name}` : copy.heading;
const subheading = showAllFromProvider
? `Every package ${provider.name} offers, including those outside your preferences.`
: copy.subheading(isPrePlanning);
const primaryListAriaLabel = showAllFromProvider
? `All packages from ${provider.name}`
: 'Funeral packages';
const subheading = isPrePlanning
? 'Compare packages to find what suits your wishes. Nothing is committed until you confirm.'
: 'Each package includes a set of services. You can customise your selections in the next steps.';
return (
<WizardLayout
variant="list-detail"
navigation={navigation}
showBackLink
backLabel={layoutBackLabel}
onBack={handleLayoutBack}
backLabel="Back"
onBack={onBack}
sx={sx}
secondaryPanel={
<Box
sx={{
display: {
xs: mobileShowDetail ? 'block' : 'none',
md: 'block',
},
}}
>
{selectedPackage ? (
selectedPackage ? (
<PackageDetail
name={selectedPackage.name}
price={selectedPackage.price}
@@ -291,12 +138,7 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
extras={selectedPackage.extras}
terms={selectedPackage.terms}
onArrange={onArrange}
onCompare={onCompare}
inCart={isSelectedPackageInCart}
arrangeDisabled={loading}
arrangeLabel={copy.arrangeLabel}
priceDisclaimer={copy.priceDisclaimer}
itemizedUnavailable={copy.itemizedUnavailable}
/>
) : (
<Box
@@ -312,24 +154,14 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
}}
>
<Typography variant="body1" color="text.secondary" sx={{ textAlign: 'center' }}>
{copy.emptyDetailMessage}
Select a package to see what&apos;s included.
</Typography>
</Box>
)}
</Box>
)
}
>
{/* List column — hidden on mobile when a package is selected (drill-in) */}
<Box
sx={{
display: {
xs: mobileShowDetail ? 'none' : 'block',
md: 'block',
},
}}
>
{/* Provider compact card */}
<Box sx={{ mb: 6 }}>
{/* Provider compact card — clickable to open provider profile */}
<Box sx={{ mb: 3 }}>
<ProviderCardCompact
name={provider.name}
location={provider.location}
@@ -340,15 +172,15 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
/>
</Box>
{/* Heading + subheading */}
{/* Heading */}
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
{heading}
Choose a funeral package
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 6 }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{subheading}
</Typography>
{/* Error */}
{/* Error message */}
{error && (
<Typography
variant="body2"
@@ -359,14 +191,35 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
</Typography>
)}
{/* ─── Primary packages ─── */}
{/* Show "Matching your preferences" heading only when a secondary list follows */}
{hasSecondary && !showAllFromProvider && <GroupHeading label="Matching your preferences" />}
{/* ─── Matching packages ─── */}
{hasOtherPackages && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
mb: 2,
}}
>
<Box
sx={{
width: 3,
height: 20,
borderRadius: 1,
bgcolor: 'primary.main',
flexShrink: 0,
}}
/>
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
Matching your preferences
</Typography>
</Box>
)}
<Box
role="radiogroup"
aria-label={primaryListAriaLabel}
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 4 }}
aria-label="Funeral packages"
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
>
{packages.map((pkg) => (
<ServiceOption
@@ -375,7 +228,7 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
description={pkg.description}
price={pkg.price}
selected={selectedPackageId === pkg.id}
onClick={() => handleSelectPackage(pkg.id)}
onClick={() => onSelectPackage(pkg.id)}
/>
))}
@@ -388,96 +241,49 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
)}
</Box>
{/* ─── Secondary: same-provider-more ─── */}
{activeSecondaryList?.kind === 'same-provider-more' && sameProviderPackages.length > 0 && (
{/* ─── Other packages (passive discovery) ─── */}
{hasOtherPackages && (
<>
<Divider sx={{ my: 8 }} />
<GroupHeading label={`Other packages from ${provider.name}`} emphasis="secondary" />
<Divider sx={{ mb: 2 }} />
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
mb: 2,
}}
>
<Box
sx={{
width: 3,
height: 20,
borderRadius: 1,
bgcolor: 'text.secondary',
flexShrink: 0,
}}
/>
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.secondary' }}>
Other packages from {provider.name}
</Typography>
</Box>
<Box
role="radiogroup"
aria-label={`Other packages from ${provider.name}`}
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
mb: sameProviderOverflow ? 2 : 3,
opacity: 0.85,
}}
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3, opacity: 0.85 }}
>
{sameProviderVisible.map((pkg) => (
{otherPackages.map((pkg) => (
<ServiceOption
key={pkg.id}
name={pkg.name}
description={pkg.description}
price={pkg.price}
selected={selectedPackageId === pkg.id}
onClick={() => handleSelectPackage(pkg.id)}
/>
))}
</Box>
{sameProviderOverflow && onSeeAllPackages && (
<Box sx={{ mb: 3 }}>
<Link
component="button"
type="button"
onClick={onSeeAllPackages}
underline="hover"
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
fontWeight: 600,
}}
>
See {sameProviderPackages.length - SAME_PROVIDER_INLINE_LIMIT} more packages from
this provider
<ArrowForwardIcon sx={{ fontSize: 16 }} aria-hidden />
</Link>
</Box>
)}
</>
)}
{/* ─── Secondary: nearby-verified ─── */}
{activeSecondaryList?.kind === 'nearby-verified' &&
activeSecondaryList.providers.length > 0 && (
<>
<Divider sx={{ my: 8 }} />
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 2 }}>
<VerifiedOutlinedIcon
sx={{ fontSize: 16, color: 'primary.main', mt: '3px' }}
aria-hidden
/>
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
Similar packages from verified providers
</Typography>
</Box>
<Box
aria-label="Similar packages from verified providers"
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)' },
gap: 2,
mb: 3,
}}
>
{activeSecondaryList.providers.slice(0, NEARBY_VERIFIED_LIMIT).map((p) => (
<MiniCard
key={p.id}
title={p.name}
imageUrl={p.imageUrl}
verified
price={p.startingPrice}
location={p.location}
rating={p.rating}
onClick={onNearbyProviderClick ? () => onNearbyProviderClick(p.id) : undefined}
onClick={() => onSelectPackage(pkg.id)}
/>
))}
</Box>
</>
)}
</Box>
</WizardLayout>
);
};

View File

@@ -1,105 +0,0 @@
import type { PackageSection } from '../../organisms/PackageDetail';
// ─── Tier ────────────────────────────────────────────────────────────────────
/**
* Provider tier — drives header copy, CTA label, disclaimer text, and
* whether the PackageDetail panel shows an itemised breakdown.
*
* - `verified`: Paid-listing provider. Full data, "Make Arrangement" CTA.
* - `tier3`: Unverified provider with itemised breakdown scraped from public info.
* - `tier2`: Unverified provider with total price only (no itemised breakdown).
*/
export type ProviderTier = 'verified' | 'tier3' | 'tier2';
// ─── Provider ────────────────────────────────────────────────────────────────
export interface PackagesStepProvider {
/** Provider name */
name: string;
/** Location */
location: string;
/** Hero image — typically only supplied for verified providers */
imageUrl?: string;
/** Rating */
rating?: number;
/** Review count */
reviewCount?: number;
}
// ─── Package data ────────────────────────────────────────────────────────────
/**
* Package data for the selection list.
*
* For `tier2` providers, callers should pass `sections: []` (and optionally
* omit `total`); the detail panel switches to "Itemised Pricing Unavailable"
* automatically based on the `providerTier` prop.
*/
export interface PackageData {
/** Unique package ID */
id: string;
/** Package display name */
name: string;
/** Package price in dollars */
price: number;
/** Short description shown on the option card */
description?: string;
/** Line-item sections for the detail panel (empty for tier2) */
sections: PackageSection[];
/** Total price shown between main sections and extras */
total?: number;
/** Extra-cost items shown after the total */
extras?: PackageSection;
/** Terms and conditions */
terms?: string;
}
/**
* A verified provider surfaced on an unverified provider's PackagesStep.
*
* By definition every entry in this list is verified — the section is a
* curated "here are the real partners near you" promotion — so there is no
* `verified` flag on the data shape. Components that render this list pass
* a hard-coded `verified={true}` to their card.
*/
export interface NearbyVerifiedProvider {
/** Provider ID — routes to `/providers/:id/packages` */
id: string;
/** Provider name */
name: string;
/** Hero image URL (verified providers always have one) */
imageUrl: string;
/** Location (suburb, state) */
location: string;
/** Starting price — formatted as "From $X" on the card */
startingPrice: number;
/** Average rating */
rating?: number;
/** Number of reviews */
reviewCount?: number;
}
// ─── Secondary list ──────────────────────────────────────────────────────────
/**
* Discriminated union for the second list below the primary packages.
*
* - `same-provider-more`: Other packages from the same (verified) provider.
* Rendered as a ServiceOption list. If more than 3, the list shows the
* first 3 + a "See all N packages from [Provider]" link that navigates
* to the same PackagesStep with preference filters off.
* - `nearby-verified`: Verified providers promoted on unverified-tier pages
* under the heading "Similar packages from verified providers". Rendered
* as a 2-col MiniCard grid capped at 4. Clicking a card routes to that
* provider's PackagesStep.
*/
export type SecondaryList =
| {
kind: 'same-provider-more';
packages: PackageData[];
}
| {
kind: 'nearby-verified';
providers: NearbyVerifiedProvider[];
};

View File

@@ -5,25 +5,19 @@ import InputAdornment from '@mui/material/InputAdornment';
import Autocomplete from '@mui/material/Autocomplete';
import FormControlLabel from '@mui/material/FormControlLabel';
import Slider from '@mui/material/Slider';
import MenuItem from '@mui/material/MenuItem';
import Menu from '@mui/material/Menu';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import ToggleButton from '@mui/material/ToggleButton';
import useMediaQuery from '@mui/material/useMediaQuery';
import SwapVertIcon from '@mui/icons-material/SwapVert';
import ViewListOutlinedIcon from '@mui/icons-material/ViewListOutlined';
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { useTheme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout';
import { ProviderCard } from '../../molecules/ProviderCard';
import { FilterPanel } from '../../molecules/FilterPanel';
import { MapProviderDrawer } from '../../molecules/MapProviderDrawer';
import { LocationSearchInput } from '../../molecules/LocationSearchInput';
import { HelpBar } from '../../molecules/HelpBar';
import { SortMenu } from '../../molecules/SortMenu';
import {
ProviderMap,
type ProviderMapActiveState,
type ProviderMapHandle,
} from '../../organisms/ProviderMap';
import { Button } from '../../atoms/Button';
import { Chip } from '../../atoms/Chip';
import { Switch } from '../../atoms/Switch';
import { Typography } from '../../atoms/Typography';
@@ -55,8 +49,6 @@ export interface ProviderData {
distanceKm?: number;
/** Brief description */
description?: string;
/** Geographic coordinates for map display */
coords?: { lat: number; lng: number };
}
/** A funeral type option for the filter */
@@ -173,8 +165,8 @@ const DEFAULT_FUNERAL_TYPES: FuneralTypeOption[] = [
const SORT_OPTIONS: { value: ProviderSortBy; label: string }[] = [
{ value: 'recommended', label: 'Recommended' },
{ value: 'nearest', label: 'Nearest' },
{ value: 'price_low', label: 'Price low to high' },
{ value: 'price_high', label: 'Price high to low' },
{ value: 'price_low', label: 'Price: Low to High' },
{ value: 'price_high', label: 'Price: High to Low' },
];
export const EMPTY_FILTER_VALUES: ProviderFilterValues = {
@@ -202,98 +194,6 @@ const chipWrapSx = {
gap: 1,
} as const;
/**
* Shared visual tokens for the ProvidersStep control chips. Search, Filters,
* Sort by, and the List/Map toggle all reference these so their outline /
* radius / fill / shadow / height read as one coherent set. Kept on the page
* (not promoted to a design-system-wide primitive) because this is a
* page-local "control cluster" pattern — Button and Input already own their
* own radii in the theme.
*/
const CONTROL_CHROME = {
height: 32,
borderColor: 'var(--fa-color-neutral-300)',
borderRadius: 'var(--fa-button-border-radius-default)',
bgcolor: 'background.paper',
boxShadow: 'var(--fa-shadow-sm)',
} as const;
/** sx for an outlined Button carrying CONTROL_CHROME (used for Sort by). */
const controlButtonSx = {
height: CONTROL_CHROME.height,
bgcolor: CONTROL_CHROME.bgcolor,
borderColor: CONTROL_CHROME.borderColor,
borderRadius: CONTROL_CHROME.borderRadius,
boxShadow: CONTROL_CHROME.boxShadow,
textTransform: 'none',
'&:hover': { bgcolor: CONTROL_CHROME.bgcolor, borderColor: CONTROL_CHROME.borderColor },
'&:focus-visible': { outline: 'none' },
} as const;
/** sx for the FilterPanel wrapper — targets its internal trigger Button. */
const filterTriggerSx = {
'& .MuiButton-root': controlButtonSx,
} as const;
/** sx for a ToggleButtonGroup carrying CONTROL_CHROME (used for List/Map). */
const controlToggleSx = {
borderRadius: CONTROL_CHROME.borderRadius,
boxShadow: CONTROL_CHROME.boxShadow,
'& .MuiToggleButton-root': {
height: CONTROL_CHROME.height,
px: 1.5,
py: 0,
textTransform: 'none',
fontSize: 'var(--fa-button-font-size-sm)',
fontWeight: 600,
borderColor: CONTROL_CHROME.borderColor,
bgcolor: CONTROL_CHROME.bgcolor,
'&:hover': { bgcolor: CONTROL_CHROME.bgcolor },
'&.Mui-selected': {
bgcolor: 'var(--fa-color-brand-100)',
color: 'primary.main',
'&:hover': { bgcolor: 'var(--fa-color-brand-200)' },
},
},
} as const;
/** sx for the Autocomplete/TextField search input carrying CONTROL_CHROME.
* Absolute-anchors the end adornment (commit button) to the right edge —
* MUI's stock Autocomplete does this on `.MuiAutocomplete-endAdornment`,
* but overriding `InputProps.endAdornment` puts the content in a
* `.MuiInputAdornment-positionEnd` (which is static by default), so the
* button slides left as chips/draft fill the input. `paddingRight` on the
* OutlinedInput reserves the lane so input content can't run under it. */
const controlInputSx = {
'& .MuiOutlinedInput-root': {
bgcolor: CONTROL_CHROME.bgcolor,
boxShadow: CONTROL_CHROME.boxShadow,
borderRadius: CONTROL_CHROME.borderRadius,
pr: 5,
position: 'relative',
},
'& .MuiOutlinedInput-root .MuiInputAdornment-positionEnd': {
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
height: 'auto',
maxHeight: 'none',
m: 0,
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: CONTROL_CHROME.borderColor,
borderWidth: 1,
},
'& .MuiOutlinedInput-root.Mui-focused': {
boxShadow: CONTROL_CHROME.boxShadow,
'& .MuiOutlinedInput-notchedOutline': {
borderColor: CONTROL_CHROME.borderColor,
borderWidth: 1,
},
},
} as const;
// ─── Component ───────────────────────────────────────────────────────────────
/**
@@ -342,12 +242,8 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
? 'Take your time exploring providers. You can always come back and choose a different one.'
: 'These providers are near your location. Each has their own packages and pricing.';
// ─── Mobile map-first plumbing ───
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const mapRef = React.useRef<ProviderMapHandle>(null);
const [mapActive, setMapActive] = React.useState<ProviderMapActiveState | null>(null);
const showMobileMapLayout = isMobile && viewMode === 'map';
// ─── Local state ───
const [sortAnchor, setSortAnchor] = React.useState<null | HTMLElement>(null);
// ─── Price input local state (commits on blur / Enter) ───
const [priceMinInput, setPriceMinInput] = React.useState(String(filterValues.priceRange[0]));
@@ -398,12 +294,180 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
onFilterChange({ ...filterValues, funeralTypes: next });
};
// ─── Shared JSX fragments (used by desktop + mobile-map layouts) ───────────
return (
<WizardLayout
variant="list-map"
navigation={navigation}
progressStepper={progressStepper}
runningTotal={runningTotal}
showBackLink
backLabel="Back"
onBack={onBack}
sx={sx}
secondaryPanel={
<Box sx={{ position: 'relative', flex: 1, display: 'flex' }}>
{/* Floating view toggle */}
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={(_, val) => val && onViewModeChange?.(val as ListViewMode)}
size="small"
aria-label="View mode"
sx={{
position: 'absolute',
top: 12,
left: 12,
zIndex: 1,
bgcolor: 'background.paper',
boxShadow: 'var(--fa-shadow-md)',
borderRadius: 1,
'& .MuiToggleButton-root': {
px: 1.5,
py: 0.5,
fontSize: '0.75rem',
fontWeight: 500,
gap: 0.5,
border: '1px solid',
borderColor: 'divider',
textTransform: 'none',
'&.Mui-selected': {
bgcolor: 'var(--fa-color-brand-100)',
color: 'primary.main',
borderColor: 'primary.main',
'&:hover': { bgcolor: 'var(--fa-color-brand-200)' },
},
},
}}
>
<ToggleButton value="list" aria-label="List view">
<ViewListOutlinedIcon sx={{ fontSize: 16 }} />
List
</ToggleButton>
<ToggleButton value="map" aria-label="Map view">
<MapOutlinedIcon sx={{ fontSize: 16 }} />
Map
</ToggleButton>
</ToggleButtonGroup>
/** The full filter-dialog content — used by both desktop's sticky FilterPanel
* and the mobile-map floating FilterPanel. */
const filterDialogChildren = (
{/* Map content */}
{mapPanel || (
<Box
sx={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'var(--fa-color-surface-cool)',
borderLeft: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="body1" color="text.secondary">
Map coming soon
</Typography>
</Box>
)}
</Box>
}
>
{/* Heading — scrolls with listings */}
<Typography variant="h4" component="h1" sx={{ mb: 0.5, pt: 2 }} tabIndex={-1}>
Find a funeral director
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{subheading}
</Typography>
{/* Sticky controls — search + filters pinned while listings scroll */}
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: 1,
bgcolor: 'background.default',
pt: 3,
pb: 1.5,
mx: { xs: -2, md: -3 },
px: { xs: 2, md: 3 },
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
{/* Location search */}
<TextField
placeholder="Search a town or suburb..."
aria-label="Search providers by town or suburb"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && onSearch) {
e.preventDefault();
onSearch(searchQuery);
}
}}
fullWidth
size="small"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<LocationOnOutlinedIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
</InputAdornment>
),
}}
sx={{ mb: 1.5 }}
/>
{/* Control bar — filters + sort */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
{/* Filters */}
<FilterPanel activeCount={activeCount} onClear={handleClear}>
{/* ── Location ── */}
<Box>
<Typography variant="labelLg" sx={sectionHeadingSx}>
Location
</Typography>
<Autocomplete
multiple
freeSolo
value={searchQuery.trim() ? [searchQuery.trim()] : []}
onChange={(_, newValue) => {
// Take the last entered value as the active search
const last = newValue[newValue.length - 1] ?? '';
onSearchChange(typeof last === 'string' ? last : '');
}}
options={[]}
renderInput={(params) => (
<TextField
{...params}
placeholder={searchQuery.trim() ? '' : 'Search a town or suburb...'}
size="small"
InputProps={{
...params.InputProps,
startAdornment: (
<>
<InputAdornment position="start" sx={{ ml: 0.5 }}>
<LocationOnOutlinedIcon
sx={{ color: 'text.secondary', fontSize: 18 }}
/>
</InputAdornment>
{params.InputProps.startAdornment}
</>
),
}}
/>
)}
size="small"
/>
</Box>
<Divider />
{/* ── Service tradition ── */}
<Box>
<Typography variant="labelLg" sx={sectionHeadingSx}>
@@ -436,7 +500,7 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
selected={filterValues.funeralTypes.includes(option.value)}
onClick={() => handleFuneralTypeToggle(option.value)}
variant="outlined"
size="medium"
size="small"
/>
))}
</Box>
@@ -444,22 +508,19 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
<Divider />
{/* ── Provider features ── Switch aligned to the first text line so
wrapped labels read cleanly on narrow screens */}
{/* ── Provider features ── */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<FormControlLabel
control={
<Switch
checked={filterValues.verifiedOnly}
onChange={(_, checked) => onFilterChange({ ...filterValues, verifiedOnly: checked })}
onChange={(_, checked) =>
onFilterChange({ ...filterValues, verifiedOnly: checked })
}
/>
}
label="Verified providers only"
sx={{
mx: 0,
alignItems: 'flex-start',
'& .MuiFormControlLabel-label': { pt: 0.75 },
}}
sx={{ mx: 0 }}
/>
<FormControlLabel
control={
@@ -471,11 +532,7 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
/>
}
label="Online arrangements available"
sx={{
mx: 0,
alignItems: 'flex-start',
'& .MuiFormControlLabel-label': { pt: 0.75 },
}}
sx={{ mx: 0 }}
/>
</Box>
@@ -541,249 +598,43 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
/>
</Box>
</Box>
</>
);
// ─── Mobile map-first layout ───────────────────────────────────────────────
if (showMobileMapLayout) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
overflow: 'hidden',
bgcolor: 'background.default',
}}
>
{navigation}
<Box component="main" sx={{ position: 'relative', flex: 1, minHeight: 0 }}>
{/* Full-bleed map */}
<Box sx={{ position: 'absolute', inset: 0, display: 'flex' }}>
<ProviderMap
ref={mapRef}
providers={providers}
onSelectProvider={onSelectProvider}
externalisePopups
onActiveChange={setMapActive}
/>
</Box>
{/* Floating control strip — no container chrome; each control has
its own fill/border so it reads cleanly over any map tile */}
<Box
sx={{
position: 'absolute',
top: 12,
left: 12,
right: 12,
zIndex: 2,
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
{/* Search input — committed-chip pattern, chrome via controlInputSx */}
<LocationSearchInput
value={searchQuery}
onChange={onSearchChange}
onCommit={onSearch}
aria-label="Search providers by town or suburb"
sx={controlInputSx}
/>
{/* Control row: Filters, Sort by, view toggle.
Each control reads as part of one chip set — shared outline,
radius, fill, and shadow via CONTROL_CHROME. */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FilterPanel activeCount={activeCount} onClear={handleClear} sx={filterTriggerSx}>
{filterDialogChildren}
</FilterPanel>
{/* Sort — compact trigger on the mobile floating strip */}
<SortMenu
value={sortBy}
onChange={(v) => onSortChange?.(v as ProviderSortBy)}
options={SORT_OPTIONS}
variant="compact"
sx={controlButtonSx}
/>
{/* View toggle — right-aligned; same outline/radius/fill/shadow
as Filters + Sort, with brand fill on the selected side. */}
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={(_, val) => val && onViewModeChange?.(val as ListViewMode)}
{/* Sort — compact menu button, pushed right */}
<Box sx={{ ml: 'auto' }}>
<Button
variant="outlined"
color="secondary"
size="small"
aria-label="View mode"
sx={[{ ml: 'auto', flexShrink: 0 }, controlToggleSx]}
startIcon={<SwapVertIcon sx={{ fontSize: 16 }} />}
onClick={(e) => setSortAnchor(e.currentTarget)}
aria-haspopup="listbox"
sx={{ textTransform: 'none' }}
>
<ToggleButton value="list" aria-label="List view">
List
</ToggleButton>
<ToggleButton value="map" aria-label="Map view">
Map
</ToggleButton>
</ToggleButtonGroup>
</Box>
</Box>
{/* Bottom drawer — slides up when a pin/cluster is active */}
<MapProviderDrawer
active={mapActive}
onClose={() => mapRef.current?.clearActive()}
onSelectProvider={onSelectProvider}
onDrillIntoProvider={(id) => mapRef.current?.drillIntoProvider(id)}
/>
</Box>
{/* Sticky help bar — shared HelpBar molecule so this footer stays
identical to WizardLayout's (which we bypass in this branch). */}
<HelpBar />
</Box>
);
}
// ─── Desktop + mobile-list layout ──────────────────────────────────────────
return (
<WizardLayout
variant="list-map"
navigation={navigation}
progressStepper={progressStepper}
runningTotal={runningTotal}
showBackLink
backLabel="Back"
onBack={onBack}
sx={sx}
secondaryPanel={
<Box sx={{ position: 'relative', flex: 1, display: 'flex' }}>
{/* Floating view toggle — same chrome as the sticky-bar controls,
anchored to the map panel's top-left. */}
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={(_, val) => val && onViewModeChange?.(val as ListViewMode)}
size="small"
aria-label="View mode"
sx={[
{ position: 'absolute', top: 12, left: 12, zIndex: 1 },
controlToggleSx,
{ '& .MuiToggleButton-root': { gap: 0.75 } },
]}
{SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? 'Sort'}
</Button>
<Menu
anchorEl={sortAnchor}
open={Boolean(sortAnchor)}
onClose={() => setSortAnchor(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<ToggleButton value="list" aria-label="List view">
<ViewListOutlinedIcon sx={{ fontSize: 16 }} />
List
</ToggleButton>
<ToggleButton value="map" aria-label="Map view">
<MapOutlinedIcon sx={{ fontSize: 16 }} />
Map
</ToggleButton>
</ToggleButtonGroup>
{/* Map content */}
{mapPanel || (
<Box
sx={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'var(--fa-color-surface-cool)',
borderLeft: '1px solid',
borderColor: 'divider',
{SORT_OPTIONS.map((opt) => (
<MenuItem
key={opt.value}
selected={opt.value === sortBy}
onClick={() => {
onSortChange?.(opt.value);
setSortAnchor(null);
}}
sx={{ fontSize: '0.813rem' }}
>
<Typography variant="body1" color="text.secondary">
Map coming soon
</Typography>
{opt.label}
</MenuItem>
))}
</Menu>
</Box>
)}
</Box>
}
>
{/* Heading — scrolls with listings */}
<Typography variant="h4" component="h1" sx={{ mb: 0.5, pt: 2 }} tabIndex={-1}>
Find a funeral director
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{subheading}
</Typography>
{/* Sticky controls — search + filters pinned while listings scroll */}
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: 1,
bgcolor: 'background.default',
pt: 3,
pb: 1.5,
mx: { xs: -2, md: -3 },
px: { xs: 2, md: 3 },
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
{/* Location search — committed location renders as a chip inside
the input. Shared with the mobile-map floating strip via the
LocationSearchInput molecule. */}
<LocationSearchInput
value={searchQuery}
onChange={onSearchChange}
onCommit={onSearch}
aria-label="Search providers by town or suburb"
sx={[controlInputSx, { mb: 1.5 }]}
/>
{/* Control bar — filters + sort */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<FilterPanel activeCount={activeCount} onClear={handleClear} sx={filterTriggerSx}>
{filterDialogChildren}
</FilterPanel>
{/* Sort — compact "Sort by" on mobile (grouped left next to
Filters); verbose "Sort: <label>" on desktop (pushed right). */}
<Box sx={{ ml: { xs: 0, md: 'auto' } }}>
<SortMenu
value={sortBy}
onChange={(v) => onSortChange?.(v as ProviderSortBy)}
options={SORT_OPTIONS}
variant={isMobile ? 'compact' : 'verbose'}
sx={controlButtonSx}
/>
</Box>
{/* Mobile-only view toggle — pinned to the right via ml: auto on xs.
Shares the same CONTROL_CHROME as Filters + Sort. */}
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={(_, val) => val && onViewModeChange?.(val as ListViewMode)}
size="small"
aria-label="View mode"
sx={[
{ display: { xs: 'inline-flex', md: 'none' }, ml: 'auto', flexShrink: 0 },
controlToggleSx,
]}
>
<ToggleButton value="list" aria-label="List view">
List
</ToggleButton>
<ToggleButton value="map" aria-label="Map view">
Map
</ToggleButton>
</ToggleButtonGroup>
</Box>
{/* Results count — below controls */}
@@ -793,10 +644,7 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
sx={{ mt: 3, display: 'block' }}
aria-live="polite"
>
<Box component="span" sx={{ fontWeight: 600, color: 'text.primary' }}>
{providers.length}
</Box>{' '}
provider{providers.length !== 1 ? 's' : ''} found
{providers.length} provider{providers.length !== 1 ? 's' : ''} found
</Typography>
</Box>
@@ -807,7 +655,7 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
sx={{
display: 'flex',
flexDirection: 'column',
gap: 4,
gap: 2,
pb: 3,
pt: 2,
px: { xs: 2, md: 3 },

View File

@@ -0,0 +1,206 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { UnverifiedPackageT2 } from './UnverifiedPackageT2';
import type {
UnverifiedPackageT2Data,
UnverifiedPackageT2Provider,
NearbyVerifiedPackage,
} from './UnverifiedPackageT2';
import { Navigation } from '../../organisms/Navigation';
import Box from '@mui/material/Box';
// ─── Helpers ─────────────────────────────────────────────────────────────────
const FALogo = () => (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box
component="img"
src="/brandlogo/logo-full.svg"
alt="Funeral Arranger"
sx={{ height: 28, display: { xs: 'none', md: 'block' } }}
/>
<Box
component="img"
src="/brandlogo/logo-short.svg"
alt="Funeral Arranger"
sx={{ height: 28, display: { xs: 'block', md: 'none' } }}
/>
</Box>
);
const nav = (
<Navigation
logo={<FALogo />}
items={[
{ label: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' },
{ label: 'Log in', href: '/login' },
]}
/>
);
const mockProvider: UnverifiedPackageT2Provider = {
name: 'H.Parsons Funeral Directors',
location: 'Wentworth, NSW',
rating: 4.6,
reviewCount: 7,
};
const mockPackages: UnverifiedPackageT2Data[] = [
{
id: 'everyday',
name: 'Everyday Funeral Package',
price: 2700,
description:
'A funeral service at a chapel or church with a funeral procession, including commonly selected options.',
},
{
id: 'deluxe',
name: 'Deluxe Funeral Package',
price: 4900,
description: 'A comprehensive package with premium inclusions and expanded service options.',
},
{
id: 'catholic',
name: 'Catholic Service',
price: 3200,
description:
'Tailored for Catholic funeral traditions including a Requiem Mass and graveside prayers.',
},
];
const nearbyVerifiedPackages: NearbyVerifiedPackage[] = [
{
id: 'rankins-standard',
packageName: 'Standard Cremation Package',
price: 2450,
providerName: 'Rankins Funerals',
location: 'Warrawong, NSW',
rating: 4.8,
reviewCount: 23,
},
{
id: 'easy-essential',
packageName: 'Essential Funeral Service',
price: 1950,
providerName: 'Easy Funerals',
location: 'Sydney, NSW',
rating: 4.5,
reviewCount: 42,
},
{
id: 'killick-classic',
packageName: 'Classic Farewell Package',
price: 3100,
providerName: 'Killick Family Funerals',
location: 'Shellharbour, NSW',
rating: 4.9,
reviewCount: 15,
},
];
// ─── Meta ────────────────────────────────────────────────────────────────────
const meta: Meta<typeof UnverifiedPackageT2> = {
title: 'Pages/UnverifiedPackageT2',
component: UnverifiedPackageT2,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
};
export default meta;
type Story = StoryObj<typeof UnverifiedPackageT2>;
// ─── Interactive (default) ──────────────────────────────────────────────────
/** Select a package to see the "Itemised Pricing Unavailable" detail panel */
export const Default: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<UnverifiedPackageT2
provider={mockProvider}
packages={mockPackages}
nearbyPackages={nearbyVerifiedPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Make an enquiry')}
onNearbyPackageClick={(id) => alert(`View nearby package: ${id}`)}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── With selection ─────────────────────────────────────────────────────────
/** Package selected — detail panel shows price + unavailable notice */
export const WithSelection: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>('everyday');
return (
<UnverifiedPackageT2
provider={mockProvider}
packages={mockPackages}
nearbyPackages={nearbyVerifiedPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Make an enquiry')}
onNearbyPackageClick={(id) => alert(`View nearby package: ${id}`)}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── No nearby packages ────────────────────────────────────────────────────
/** Only this provider's packages — no nearby verified section */
export const NoNearbyPackages: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<UnverifiedPackageT2
provider={mockProvider}
packages={mockPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Make an enquiry')}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── Validation error ───────────────────────────────────────────────────────
/** Error shown when no package selected */
export const WithError: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<UnverifiedPackageT2
provider={mockProvider}
packages={mockPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => {}}
onBack={() => alert('Back')}
error="Please choose a package to continue."
navigation={nav}
/>
);
},
};

View File

@@ -0,0 +1,318 @@
import React from 'react';
import Box from '@mui/material/Box';
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 { WizardLayout } from '../../templates/WizardLayout';
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
import { ServiceOption } from '../../molecules/ServiceOption';
import { PackageDetail } from '../../organisms/PackageDetail';
import { Typography } from '../../atoms/Typography';
import { Card } from '../../atoms/Card';
import { Divider } from '../../atoms/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Provider summary for the compact card */
export interface UnverifiedPackageT2Provider {
/** Provider name */
name: string;
/** Location */
location: string;
/** Image URL */
imageUrl?: string;
/** Rating */
rating?: number;
/** Review count */
reviewCount?: number;
}
/** Package data — price only, no itemised breakdown */
export interface UnverifiedPackageT2Data {
/** Unique package ID */
id: string;
/** Package display name */
name: string;
/** Package price in dollars */
price: number;
/** Short description */
description?: string;
}
/** A similar package from a nearby verified provider */
export interface NearbyVerifiedPackage {
/** Unique ID */
id: string;
/** Package name */
packageName: string;
/** Package price in dollars */
price: number;
/** Provider name */
providerName: string;
/** Provider location */
location: string;
/** Provider rating */
rating?: number;
/** Number of reviews */
reviewCount?: number;
}
/** Props for the UnverifiedPackageT2 page component */
export interface UnverifiedPackageT2Props {
/** Provider summary shown at top of the list panel (no image — unverified provider) */
provider: UnverifiedPackageT2Provider;
/** Packages with price only (no itemised breakdown) */
packages: UnverifiedPackageT2Data[];
/** Similar packages from nearby verified providers */
nearbyPackages?: NearbyVerifiedPackage[];
/** Currently selected package ID */
selectedPackageId: string | null;
/** Callback when a package is selected */
onSelectPackage: (id: string) => void;
/** Callback when "Make an enquiry" is clicked */
onArrange: () => void;
/** Callback when a nearby verified package is clicked */
onNearbyPackageClick?: (id: string) => void;
/** Callback when the provider card is clicked */
onProviderClick?: () => void;
/** Callback for the Back button */
onBack: () => void;
/** Validation error */
error?: string;
/** Whether the enquiry action is loading */
loading?: boolean;
/** Navigation bar */
navigation?: React.ReactNode;
/** Whether this is a pre-planning flow */
isPrePlanning?: boolean;
/** MUI sx prop */
sx?: SxProps<Theme>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* UnverifiedPackageT2 — Package selection page for Tier 2 unverified providers.
*
* Similar to T3 but the provider has only shared overall package prices,
* not itemised breakdowns. The detail panel shows an "Itemized Pricing
* Unavailable" notice instead of line items.
*
* Two sections:
* - **This provider's packages**: price-only, no breakdown available
* - **Similar packages from verified providers nearby**: promoted alternatives
*
* Pure presentation component — props in, callbacks out.
*/
export const UnverifiedPackageT2: React.FC<UnverifiedPackageT2Props> = ({
provider,
packages,
nearbyPackages = [],
selectedPackageId,
onSelectPackage,
onArrange,
onNearbyPackageClick,
onProviderClick,
onBack,
error,
loading = false,
navigation,
isPrePlanning = false,
sx,
}) => {
const selectedPackage = packages.find((p) => p.id === selectedPackageId);
const hasNearbyPackages = nearbyPackages.length > 0;
const subheading = isPrePlanning
? 'Browse estimated packages to get a sense of what this provider offers. Nothing is committed.'
: 'These packages are based on publicly available information. Make an enquiry to confirm details and pricing.';
return (
<WizardLayout
variant="list-detail"
navigation={navigation}
showBackLink
backLabel="Back"
onBack={onBack}
sx={sx}
secondaryPanel={
selectedPackage ? (
<PackageDetail
name={selectedPackage.name}
price={selectedPackage.price}
sections={[]}
onArrange={onArrange}
arrangeDisabled={loading}
arrangeLabel="Make an enquiry"
priceDisclaimer="Prices are estimates based on publicly available information and may not reflect the provider's current pricing."
itemizedUnavailable
/>
) : (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
minHeight: 300,
bgcolor: 'var(--fa-color-brand-50)',
borderRadius: 2,
p: 4,
}}
>
<Typography variant="body1" color="text.secondary" sx={{ textAlign: 'center' }}>
Select a package to see more details.
</Typography>
</Box>
)
}
>
{/* Provider compact card — no image for unverified */}
<Box sx={{ mb: 3 }}>
<ProviderCardCompact
name={provider.name}
location={provider.location}
rating={provider.rating}
reviewCount={provider.reviewCount}
onClick={onProviderClick}
/>
</Box>
{/* Heading */}
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
Explore available packages
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{subheading}
</Typography>
{/* Error message */}
{error && (
<Typography
variant="body2"
sx={{ mb: 2, color: 'var(--fa-color-text-brand)' }}
role="alert"
>
{error}
</Typography>
)}
{/* ─── Packages ─── */}
<Box
role="radiogroup"
aria-label="Funeral packages"
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
>
{packages.map((pkg) => (
<ServiceOption
key={pkg.id}
name={pkg.name}
description={pkg.description}
price={pkg.price}
selected={selectedPackageId === pkg.id}
onClick={() => onSelectPackage(pkg.id)}
/>
))}
{packages.length === 0 && (
<Box sx={{ py: 4, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
No packages match your current preferences.
</Typography>
</Box>
)}
</Box>
{/* ─── Similar packages from nearby verified providers ─── */}
{hasNearbyPackages && (
<>
<Divider sx={{ mb: 2.5 }} />
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
mb: 2,
}}
>
<VerifiedOutlinedIcon sx={{ fontSize: 16, color: 'primary.main' }} aria-hidden />
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
Similar packages from verified providers nearby
</Typography>
</Box>
<Box
aria-label="Similar packages from nearby verified providers"
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
>
{nearbyPackages.map((pkg) => (
<Card
key={pkg.id}
variant="outlined"
interactive={!!onNearbyPackageClick}
padding="none"
onClick={onNearbyPackageClick ? () => onNearbyPackageClick(pkg.id) : undefined}
sx={{ p: 'var(--fa-card-padding-compact)' }}
>
{/* Package name + price */}
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 2,
mb: 1,
}}
>
<Typography variant="h6" component="span">
{pkg.packageName}
</Typography>
<Typography
variant="labelLg"
component="span"
color="primary"
sx={{ whiteSpace: 'nowrap' }}
>
${pkg.price.toLocaleString('en-AU')}
</Typography>
</Box>
{/* Provider info */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Typography variant="body2" color="text.secondary">
{pkg.providerName}
</Typography>
{pkg.rating != null && (
<>
<Typography variant="body2" color="text.secondary">
&middot;
</Typography>
<StarRoundedIcon sx={{ fontSize: 14, color: 'warning.main' }} aria-hidden />
<Typography variant="caption" color="text.secondary">
{pkg.rating}
{pkg.reviewCount != null ? ` (${pkg.reviewCount})` : ''}
</Typography>
</>
)}
<Typography variant="body2" color="text.secondary">
&middot;
</Typography>
<LocationOnOutlinedIcon
sx={{ fontSize: 14, color: 'text.secondary' }}
aria-hidden
/>
<Typography variant="caption" color="text.secondary">
{pkg.location}
</Typography>
</Box>
</Card>
))}
</Box>
</>
)}
</WizardLayout>
);
};
UnverifiedPackageT2.displayName = 'UnverifiedPackageT2';
export default UnverifiedPackageT2;

View File

@@ -0,0 +1,2 @@
export { default } from './UnverifiedPackageT2';
export * from './UnverifiedPackageT2';

View File

@@ -0,0 +1,249 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { UnverifiedPackageT3 } from './UnverifiedPackageT3';
import type {
UnverifiedPackageT3Data,
UnverifiedPackageT3Provider,
NearbyVerifiedPackage,
} from './UnverifiedPackageT3';
import { Navigation } from '../../organisms/Navigation';
import Box from '@mui/material/Box';
// ─── Helpers ─────────────────────────────────────────────────────────────────
const FALogo = () => (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box
component="img"
src="/brandlogo/logo-full.svg"
alt="Funeral Arranger"
sx={{ height: 28, display: { xs: 'none', md: 'block' } }}
/>
<Box
component="img"
src="/brandlogo/logo-short.svg"
alt="Funeral Arranger"
sx={{ height: 28, display: { xs: 'block', md: 'none' } }}
/>
</Box>
);
const nav = (
<Navigation
logo={<FALogo />}
items={[
{ label: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' },
{ label: 'Log in', href: '/login' },
]}
/>
);
const mockProvider: UnverifiedPackageT3Provider = {
name: 'H.Parsons Funeral Directors',
location: 'Wentworth, NSW',
rating: 4.6,
reviewCount: 7,
};
const matchedPackages: UnverifiedPackageT3Data[] = [
{
id: 'everyday',
name: 'Everyday Funeral Package',
price: 2700,
description:
'This package includes a funeral service at a chapel or a church with a funeral procession. It includes many of the most commonly selected funeral options.',
sections: [
{
heading: 'Essentials',
items: [
{ name: 'Accommodation', price: 500 },
{ name: 'Death registration certificate', price: 150 },
{ name: 'Doctor fee for Cremation', price: 150 },
{ name: 'NSW Government Levy - Cremation', price: 83 },
{ name: 'Professional Mortuary Care', price: 1200 },
{ name: 'Professional Service Fee', price: 1120 },
],
},
{
heading: 'Complimentary Items',
items: [
{ name: 'Dressing Fee', price: 0 },
{ name: 'Viewing Fee', price: 0 },
],
},
],
total: 2700,
extras: {
heading: 'Extras',
items: [
{ name: 'Allowance for Flowers', price: 150, isAllowance: true },
{ name: 'Allowance for Master of Ceremonies', price: 500, isAllowance: true },
{ name: 'After Business Hours Service Surcharge', price: 150 },
{ name: 'After Hours Prayers', price: 1920 },
{ name: 'Coffin Bearing by Funeral Directors', price: 1500 },
{ name: 'Digital Recording', price: 500 },
],
},
terms:
'This package includes a funeral service at a chapel or a church with a funeral procession. Pricing may vary based on additional selections.',
},
];
const nearbyVerifiedPackages: NearbyVerifiedPackage[] = [
{
id: 'rankins-standard',
packageName: 'Standard Cremation Package',
price: 2450,
providerName: 'Rankins Funerals',
location: 'Warrawong, NSW',
rating: 4.8,
reviewCount: 23,
},
{
id: 'easy-essential',
packageName: 'Essential Funeral Service',
price: 1950,
providerName: 'Easy Funerals',
location: 'Sydney, NSW',
rating: 4.5,
reviewCount: 42,
},
{
id: 'killick-classic',
packageName: 'Classic Farewell Package',
price: 3100,
providerName: 'Killick Family Funerals',
location: 'Shellharbour, NSW',
rating: 4.9,
reviewCount: 15,
},
];
// ─── Meta ────────────────────────────────────────────────────────────────────
const meta: Meta<typeof UnverifiedPackageT3> = {
title: 'Pages/UnverifiedPackageT3',
component: UnverifiedPackageT3,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
};
export default meta;
type Story = StoryObj<typeof UnverifiedPackageT3>;
// ─── Interactive (default) ──────────────────────────────────────────────────
/** Matched + other packages — select a package, see detail, click Make Arrangement */
export const Default: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<UnverifiedPackageT3
provider={mockProvider}
packages={matchedPackages}
nearbyPackages={nearbyVerifiedPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── With selection ─────────────────────────────────────────────────────────
/** Package already selected — detail panel visible */
export const WithSelection: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>('everyday');
return (
<UnverifiedPackageT3
provider={mockProvider}
packages={matchedPackages}
nearbyPackages={nearbyVerifiedPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── No other packages (all match) ─────────────────────────────────────────
/** No nearby verified packages — only this provider's packages */
export const NoNearbyPackages: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<UnverifiedPackageT3
provider={mockProvider}
packages={matchedPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── Pre-planning ───────────────────────────────────────────────────────────
/** Pre-planning flow — softer copy */
export const PrePlanning: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<UnverifiedPackageT3
provider={mockProvider}
packages={matchedPackages}
nearbyPackages={nearbyVerifiedPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
navigation={nav}
isPrePlanning
/>
);
},
};
// ─── Validation error ───────────────────────────────────────────────────────
/** Error shown when no package selected */
export const WithError: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<UnverifiedPackageT3
provider={mockProvider}
packages={matchedPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => {}}
onBack={() => alert('Back')}
error="Please choose a package to continue."
navigation={nav}
/>
);
},
};

View File

@@ -0,0 +1,333 @@
import React from 'react';
import Box from '@mui/material/Box';
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 { WizardLayout } from '../../templates/WizardLayout';
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
import { ServiceOption } from '../../molecules/ServiceOption';
import { PackageDetail } from '../../organisms/PackageDetail';
import type { PackageSection } from '../../organisms/PackageDetail';
import { Typography } from '../../atoms/Typography';
import { Card } from '../../atoms/Card';
import { Divider } from '../../atoms/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Provider summary for the compact card */
export interface UnverifiedPackageT3Provider {
/** Provider name */
name: string;
/** Location */
location: string;
/** Image URL */
imageUrl?: string;
/** Rating */
rating?: number;
/** Review count */
reviewCount?: number;
}
/** Package data for the selection list */
export interface UnverifiedPackageT3Data {
/** Unique package ID */
id: string;
/** Package display name */
name: string;
/** Package price in dollars */
price: number;
/** Short description */
description?: string;
/** Line item sections for the detail panel */
sections: PackageSection[];
/** Total price (may differ from base price with extras) */
total?: number;
/** Extra items section (after total) */
extras?: PackageSection;
/** Terms and conditions */
terms?: string;
}
/** A similar package from a nearby verified provider */
export interface NearbyVerifiedPackage {
/** Unique ID */
id: string;
/** Package name */
packageName: string;
/** Package price in dollars */
price: number;
/** Provider name */
providerName: string;
/** Provider location */
location: string;
/** Provider rating */
rating?: number;
/** Number of reviews */
reviewCount?: number;
}
/** Props for the UnverifiedPackageT3 page component */
export interface UnverifiedPackageT3Props {
/** Provider summary shown at top of the list panel (no image — unverified provider) */
provider: UnverifiedPackageT3Provider;
/** Packages matching the user's filters from the previous step */
packages: UnverifiedPackageT3Data[];
/** Similar packages from nearby verified providers */
nearbyPackages?: NearbyVerifiedPackage[];
/** Currently selected package ID */
selectedPackageId: string | null;
/** Callback when a package is selected */
onSelectPackage: (id: string) => void;
/** Callback when "Make Arrangement" is clicked (opens ArrangementDialog) */
onArrange: () => void;
/** Callback when a nearby verified package is clicked */
onNearbyPackageClick?: (id: string) => void;
/** Callback when the provider card is clicked (opens provider profile popup) */
onProviderClick?: () => void;
/** Callback for the Back button */
onBack: () => void;
/** Validation error */
error?: string;
/** Whether the arrange action is loading */
loading?: boolean;
/** Navigation bar */
navigation?: React.ReactNode;
/** Whether this is a pre-planning flow */
isPrePlanning?: boolean;
/** MUI sx prop */
sx?: SxProps<Theme>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* UnverifiedPackageT3 — Package selection page for unverified (Tier 3) providers.
*
* List + Detail split layout. Left panel shows the selected provider
* (compact) and selectable package cards. Right panel shows the full
* detail breakdown of the selected package with "Make Arrangement" CTA.
*
* Two sections:
* - **This provider's packages**: estimated pricing from publicly available info
* - **Similar packages from verified providers nearby**: promoted alternatives
* with verified pricing, ratings, and location
*
* Selecting a package reveals its detail. Clicking "Make an enquiry"
* on the detail panel initiates contact with the unverified provider.
*
* Pure presentation component — props in, callbacks out.
*/
export const UnverifiedPackageT3: React.FC<UnverifiedPackageT3Props> = ({
provider,
packages,
nearbyPackages = [],
selectedPackageId,
onSelectPackage,
onArrange,
onNearbyPackageClick,
onProviderClick,
onBack,
error,
loading = false,
navigation,
isPrePlanning = false,
sx,
}) => {
const selectedPackage = packages.find((p) => p.id === selectedPackageId);
const hasNearbyPackages = nearbyPackages.length > 0;
const subheading = isPrePlanning
? 'Browse estimated packages to get a sense of what this provider offers. Nothing is committed.'
: 'These packages are based on publicly available information. Make an enquiry to confirm details and pricing.';
return (
<WizardLayout
variant="list-detail"
navigation={navigation}
showBackLink
backLabel="Back"
onBack={onBack}
sx={sx}
secondaryPanel={
selectedPackage ? (
<PackageDetail
name={selectedPackage.name}
price={selectedPackage.price}
sections={selectedPackage.sections}
total={selectedPackage.total}
extras={selectedPackage.extras}
terms={selectedPackage.terms}
onArrange={onArrange}
arrangeDisabled={loading}
arrangeLabel="Make an enquiry"
priceDisclaimer="Prices are estimates based on publicly available information and may not reflect the provider's current pricing."
/>
) : (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
minHeight: 300,
bgcolor: 'var(--fa-color-brand-50)',
borderRadius: 2,
p: 4,
}}
>
<Typography variant="body1" color="text.secondary" sx={{ textAlign: 'center' }}>
Select a package to see what&apos;s included.
</Typography>
</Box>
)
}
>
{/* Provider compact card — clickable to open provider profile */}
<Box sx={{ mb: 3 }}>
<ProviderCardCompact
name={provider.name}
location={provider.location}
rating={provider.rating}
reviewCount={provider.reviewCount}
onClick={onProviderClick}
/>
</Box>
{/* Heading */}
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
Explore available packages
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{subheading}
</Typography>
{/* Error message */}
{error && (
<Typography
variant="body2"
sx={{ mb: 2, color: 'var(--fa-color-text-brand)' }}
role="alert"
>
{error}
</Typography>
)}
{/* ─── Packages ─── */}
<Box
role="radiogroup"
aria-label="Funeral packages"
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
>
{packages.map((pkg) => (
<ServiceOption
key={pkg.id}
name={pkg.name}
description={pkg.description}
price={pkg.price}
selected={selectedPackageId === pkg.id}
onClick={() => onSelectPackage(pkg.id)}
/>
))}
{packages.length === 0 && (
<Box sx={{ py: 4, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
No packages match your current preferences.
</Typography>
</Box>
)}
</Box>
{/* ─── Similar packages from nearby verified providers ─── */}
{hasNearbyPackages && (
<>
<Divider sx={{ mb: 2.5 }} />
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
mb: 2,
}}
>
<VerifiedOutlinedIcon sx={{ fontSize: 16, color: 'primary.main' }} aria-hidden />
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
Similar packages from verified providers nearby
</Typography>
</Box>
<Box
aria-label="Similar packages from nearby verified providers"
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
>
{nearbyPackages.map((pkg) => (
<Card
key={pkg.id}
variant="outlined"
interactive={!!onNearbyPackageClick}
padding="none"
onClick={onNearbyPackageClick ? () => onNearbyPackageClick(pkg.id) : undefined}
sx={{ p: 'var(--fa-card-padding-compact)' }}
>
{/* Package name + price */}
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 2,
mb: 1,
}}
>
<Typography variant="h6" component="span">
{pkg.packageName}
</Typography>
<Typography
variant="labelLg"
component="span"
color="primary"
sx={{ whiteSpace: 'nowrap' }}
>
${pkg.price.toLocaleString('en-AU')}
</Typography>
</Box>
{/* Provider info */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Typography variant="body2" color="text.secondary">
{pkg.providerName}
</Typography>
{pkg.rating != null && (
<>
<Typography variant="body2" color="text.secondary">
&middot;
</Typography>
<StarRoundedIcon sx={{ fontSize: 14, color: 'warning.main' }} aria-hidden />
<Typography variant="caption" color="text.secondary">
{pkg.rating}
{pkg.reviewCount != null ? ` (${pkg.reviewCount})` : ''}
</Typography>
</>
)}
<Typography variant="body2" color="text.secondary">
&middot;
</Typography>
<LocationOnOutlinedIcon
sx={{ fontSize: 14, color: 'text.secondary' }}
aria-hidden
/>
<Typography variant="caption" color="text.secondary">
{pkg.location}
</Typography>
</Box>
</Card>
))}
</Box>
</>
)}
</WizardLayout>
);
};
UnverifiedPackageT3.displayName = 'UnverifiedPackageT3';
export default UnverifiedPackageT3;

View File

@@ -0,0 +1,2 @@
export { default } from './UnverifiedPackageT3';
export * from './UnverifiedPackageT3';

View File

@@ -2,9 +2,10 @@ import React from 'react';
import Box from '@mui/material/Box';
import Container from '@mui/material/Container';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import PhoneIcon from '@mui/icons-material/Phone';
import type { SxProps, Theme } from '@mui/material/styles';
import { Link } from '../../atoms/Link';
import { HelpBar } from '../../molecules/HelpBar';
import { Typography } from '../../atoms/Typography';
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -15,8 +16,7 @@ export type WizardLayoutVariant =
| 'list-map'
| 'list-detail'
| 'grid-sidebar'
| 'detail-toggles'
| 'bleed';
| 'detail-toggles';
/** Props for the WizardLayout template */
export interface WizardLayoutProps {
@@ -50,6 +50,33 @@ export interface WizardLayoutProps {
sx?: SxProps<Theme>;
}
// ─── Help bar ────────────────────────────────────────────────────────────────
const HelpBar: React.FC<{ phone: string }> = ({ phone }) => (
<Box
component="footer"
sx={{
position: 'sticky',
bottom: 0,
zIndex: 10,
bgcolor: 'background.paper',
borderTop: '1px solid',
borderColor: 'divider',
py: 1.5,
px: { xs: 2, md: 4 },
textAlign: 'center',
}}
>
<Typography variant="body2" color="text.secondary" component="span">
<PhoneIcon sx={{ fontSize: 16, verticalAlign: 'text-bottom', mr: 0.5 }} />
Need help? Call us on{' '}
<Link href={`tel:${phone.replace(/\s/g, '')}`} sx={{ fontWeight: 600 }}>
{phone}
</Link>
</Typography>
</Box>
);
// ─── Back link ───────────────────────────────────────────────────────────────
const BackLink: React.FC<{ label: string; onClick?: () => void }> = ({ label, onClick }) => (
@@ -335,30 +362,6 @@ const DetailTogglesLayout: React.FC<{
</Box>
);
/** Bleed: full-width scroll host. Main becomes the single scroll container
* (both axes). No inner Container — children are full-bleed. Back link is
* passed into children so it scrolls with the page content. Used by pages
* that own their own width + alignment logic (e.g. ComparisonPage). */
const BleedLayout: React.FC<{
children: React.ReactNode;
backLink?: React.ReactNode;
}> = ({ children, backLink }) => (
<Box
id="wizard-scroll"
data-wizard-scroll
sx={{
flex: 1,
minHeight: 0,
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
}}
>
{backLink}
{children}
</Box>
);
// ─── Variant map ─────────────────────────────────────────────────────────────
const LAYOUT_MAP: Record<
@@ -375,7 +378,6 @@ const LAYOUT_MAP: Record<
'list-detail': ListDetailLayout,
'grid-sidebar': GridSidebarLayout,
'detail-toggles': DetailTogglesLayout,
bleed: BleedLayout,
};
/* Stepper bar renders on any variant when progressStepper or runningTotal is provided */
@@ -385,15 +387,12 @@ const LAYOUT_MAP: Record<
/**
* Page-level layout template for the FA arrangement wizard.
*
* Provides 6 layout variants matching the wizard page templates:
* Provides 5 layout variants matching the wizard page templates:
* - **centered-form**: Single centered column for form steps (intro, auth, date/time, etc.)
* - **wide-form**: Wider single column for card grids (coffins, etc.)
* - **list-map**: Split view with scrollable card list and map panel (providers)
* - **list-detail**: Master-detail split for selection + detail (packages, preview)
* - **grid-sidebar**: Filter sidebar + card grid (coffins)
* - **detail-toggles**: Hero image + info column (venue, coffin details)
* - **bleed**: Viewport-locked, full-width scroll host with no inner container —
* the page owns its own alignment (comparison page)
*
* All variants share: navigation slot, optional back link, sticky help bar,
* and optional progress stepper + running total bar (shown when props provided).
@@ -427,8 +426,8 @@ export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
flexDirection: 'column',
minHeight: '100vh',
bgcolor: 'background.default',
// list-map + detail-toggles + bleed: lock to viewport so panels scroll independently
...((variant === 'list-map' || variant === 'detail-toggles' || variant === 'bleed') && {
// list-map + detail-toggles: lock to viewport so panels scroll independently
...((variant === 'list-map' || variant === 'detail-toggles') && {
height: '100vh',
overflow: 'hidden',
}),
@@ -446,12 +445,8 @@ export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
{/* Stepper + running total bar (grid-sidebar, detail-toggles only) */}
<StepperBar stepper={progressStepper} total={runningTotal} />
{/* Back link — inside children for list-map/detail-toggles/bleed (scrolls with content),
above content for other variants */}
{showBackLink &&
variant !== 'list-map' &&
variant !== 'detail-toggles' &&
variant !== 'bleed' && (
{/* Back link — inside left panel for list-map/detail-toggles, above content for others */}
{showBackLink && variant !== 'list-map' && variant !== 'detail-toggles' && (
<Container
maxWidth={variant === 'centered-form' ? 'sm' : 'lg'}
sx={{ pt: 2, px: { xs: 4, md: 3 } }}
@@ -468,8 +463,7 @@ export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
<LayoutComponent
secondaryPanel={secondaryPanel}
backLink={
showBackLink &&
(variant === 'list-map' || variant === 'detail-toggles' || variant === 'bleed') ? (
showBackLink && (variant === 'list-map' || variant === 'detail-toggles') ? (
<Box sx={{ pt: 1.5 }}>
<BackLink label={backLabel} onClick={onBack} />
</Box>

View File

@@ -1,22 +0,0 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useBasketUrlSync } from '../../shared/state/useBasketUrlSync';
import { ProvidersRoute } from './routes/Providers';
import { PackagesRoute } from './routes/Packages';
import { ComparisonRoute } from './routes/Comparison';
import { AppCompareBar } from './AppCompareBar';
export function App() {
useBasketUrlSync();
return (
<>
<Routes>
<Route path="/" element={<ProvidersRoute />} />
<Route path="/providers/:providerId/packages" element={<PackagesRoute />} />
<Route path="/comparison" element={<ComparisonRoute />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<AppCompareBar />
</>
);
}

View File

@@ -1,56 +0,0 @@
import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { CompareBar, type CompareBarPackage } from '../../../components/molecules/CompareBar';
import { useComparisonBasket } from '../../shared/state/useComparisonBasket';
import { resolveComparisonPackage, parseBasketKey } from '../../shared/fixtures/packages';
const ERROR_TIMEOUT_MS = 2500;
/**
* App-level CompareBar — hovers above every route except `/comparison`
* itself. Reads the basket store, resolves keys to display labels, and
* navigates to the comparison page when the user activates it.
*
* Surfaces transient error feedback (already-added / max-reached) by
* forwarding `lastError` to CompareBar and auto-clearing after a moment.
*/
export function AppCompareBar() {
const navigate = useNavigate();
const location = useLocation();
const packageKeys = useComparisonBasket((s) => s.packageKeys);
const lastError = useComparisonBasket((s) => s.lastError);
const clearError = useComparisonBasket((s) => s.clearError);
useEffect(() => {
if (!lastError) return;
const t = setTimeout(clearError, ERROR_TIMEOUT_MS);
return () => clearTimeout(t);
}, [lastError, clearError]);
if (location.pathname.startsWith('/comparison')) return null;
const packages: CompareBarPackage[] = packageKeys
.map((key) => {
const pkg = resolveComparisonPackage(key);
const parsed = parseBasketKey(key);
if (!pkg || !parsed) return null;
return {
id: key,
name: pkg.name,
providerName: pkg.provider.name,
};
})
.filter((p): p is CompareBarPackage => p !== null);
// CompareBar slides in only when packages.length > 0. To surface "already
// added" / "max reached" errors when the bar isn't yet visible (no items),
// we'd need a separate toast. For now: errors only appear once the bar is
// visible — fine for the common dupe case (basket has ≥1).
return (
<CompareBar
packages={packages}
onCompare={() => navigate('/comparison')}
error={lastError ?? undefined}
/>
);
}

View File

@@ -1,31 +0,0 @@
import Box from '@mui/material/Box';
import { Navigation } from '../../../components/organisms/Navigation';
import { assetUrl } from '../../shared/assets';
const FALogo = () => (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box
component="img"
src={assetUrl('/brandlogo/logo-full.svg')}
alt="Funeral Arranger"
sx={{ height: 28, display: { xs: 'none', md: 'block' } }}
/>
<Box
component="img"
src={assetUrl('/brandlogo/logo-short.svg')}
alt="Funeral Arranger"
sx={{ height: 28, display: { xs: 'block', md: 'none' } }}
/>
</Box>
);
export const demoNav = (
<Navigation
logo={<FALogo />}
items={[
{ label: 'FAQ', href: '#' },
{ label: 'Contact Us', href: '#' },
{ label: 'Log in', href: '#' },
]}
/>
);

View File

@@ -1,18 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Arrangement Demo — Funeral Arranger</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&family=Noto+Serif+SC:wght@400;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View File

@@ -1,23 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import { BrowserRouter } from 'react-router-dom';
import { theme } from '../../../theme';
import '../../../theme/generated/tokens.css';
import { App } from './App';
// Vite's `base` is `/arrangement/` in production. In dev the root is this app
// folder so base is `/`. import.meta.env.BASE_URL gives us the right value.
const basename = import.meta.env.BASE_URL.replace(/\/$/, '') || '/';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<BrowserRouter basename={basename}>
<App />
</BrowserRouter>
</ThemeProvider>
</React.StrictMode>,
);

View File

@@ -1,73 +0,0 @@
import { useNavigate } from 'react-router-dom';
import Box from '@mui/material/Box';
import { ComparisonPage } from '../../../../components/pages/ComparisonPage';
import { Typography } from '../../../../components/atoms/Typography';
import { Button } from '../../../../components/atoms/Button';
import { useComparisonBasket } from '../../../shared/state/useComparisonBasket';
import { resolveComparisonPackage, DEMO_RECOMMENDED_KEY } from '../../../shared/fixtures/packages';
import { demoNav } from '../DemoNav';
export function ComparisonRoute() {
const navigate = useNavigate();
const packageKeys = useComparisonBasket((s) => s.packageKeys);
const remove = useComparisonBasket((s) => s.remove);
// The system-recommended package is shown as an extra column on top of
// the user's basket. Dedupe against the basket so it never renders twice.
const recommendedPackage = resolveComparisonPackage(DEMO_RECOMMENDED_KEY) ?? undefined;
const packages = packageKeys
.filter((key) => key !== DEMO_RECOMMENDED_KEY)
.map((key) => {
const resolved = resolveComparisonPackage(key);
return resolved ? { key, pkg: resolved } : null;
})
.filter(
(x): x is { key: string; pkg: NonNullable<ReturnType<typeof resolveComparisonPackage>> } =>
x !== null,
);
// Empty state only when there's genuinely nothing to show — normally the
// recommended package will always resolve, so this branch is defensive.
if (packages.length === 0 && !recommendedPackage) {
return (
<Box sx={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
{demoNav}
<Box
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
p: 4,
textAlign: 'center',
}}
>
<Typography variant="h4">Nothing to compare yet</Typography>
<Typography variant="body1" color="text.secondary">
Pick a provider, choose a package, then tap Compare.
</Typography>
<Button onClick={() => navigate('/')}>Browse providers</Button>
</Box>
</Box>
);
}
return (
<ComparisonPage
packages={packages.map((p) => p.pkg)}
recommendedPackage={recommendedPackage}
onArrange={(id) => alert(`Arrange "${id}" — would route to next wizard step.`)}
onRemove={(id) => {
// ComparisonPackage.id is the bare package id; we need the basket's
// compound key. Find it back via the parallel array.
const entry = packages.find((p) => p.pkg.id === id);
if (entry) remove(entry.key);
}}
onBack={() => navigate(-1)}
navigation={demoNav}
/>
);
}

View File

@@ -1,76 +0,0 @@
import { useState } from 'react';
import { Navigate, useNavigate, useParams } from 'react-router-dom';
import { PackagesStep } from '../../../../components/pages/PackagesStep';
import { providersById, toPackagesStepProvider } from '../../../shared/fixtures/providers';
import {
packagesByProvider,
makeBasketKey,
nearbyVerifiedProviders,
} from '../../../shared/fixtures/packages';
import { useComparisonBasket } from '../../../shared/state/useComparisonBasket';
import { demoNav } from '../DemoNav';
export function PackagesRoute() {
const { providerId = '' } = useParams();
const navigate = useNavigate();
const provider = providersById[providerId];
const bundle = packagesByProvider[providerId];
const basket = useComparisonBasket();
const [selectedId, setSelectedId] = useState<string | null>(bundle?.matching[0]?.id ?? null);
if (!provider || !bundle) return <Navigate to="/" replace />;
// Compare CTA on the PackageDetail panel toggles the selection in the
// basket — adds when absent, removes when present. The button's visible
// state (Compare / Added + ✓) reflects `isSelectedInCart` below. The
// floating CompareBar (mounted in App.tsx) handles navigation once the
// user has 2+ packages selected.
const handleCompare = () => {
if (selectedId) basket.toggle(makeBasketKey(provider.id, selectedId));
};
// When the selected package is already in the basket, PackageDetail swaps
// the Compare button into its "In comparison" selected state.
const isSelectedInCart = selectedId ? basket.has(makeBasketKey(provider.id, selectedId)) : false;
// Tier-3 / tier-2 providers show verified-provider MiniCards instead of
// "more from this provider". Exclude the current provider from the
// "similar" list in case we ever add a verified id that collides.
const secondaryList =
provider.tier === 'verified'
? { kind: 'same-provider-more' as const, packages: bundle.other }
: {
kind: 'nearby-verified' as const,
providers: nearbyVerifiedProviders.filter((p) => p.id !== provider.id),
};
const secondaryHasItems =
secondaryList.kind === 'same-provider-more'
? secondaryList.packages.length > 0
: secondaryList.providers.length > 0;
return (
<PackagesStep
provider={toPackagesStepProvider(provider)}
providerTier={provider.tier}
packages={bundle.matching}
secondaryList={secondaryHasItems ? secondaryList : undefined}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() =>
alert(
provider.tier === 'verified'
? 'Make Arrangement — would route to next wizard step.'
: 'Make an enquiry — would open enquiry form.',
)
}
onCompare={handleCompare}
isSelectedPackageInCart={isSelectedInCart}
onNearbyProviderClick={(id) => navigate(`/providers/${id}/packages`)}
onProviderClick={() => alert('Provider profile — not built in this demo slice.')}
onBack={() => navigate('/')}
navigation={demoNav}
/>
);
}

View File

@@ -1,45 +0,0 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ProvidersStep,
EMPTY_FILTER_VALUES,
type ProviderFilterValues,
type ProviderSortBy,
type ListViewMode,
} from '../../../../components/pages/ProvidersStep';
import { ProviderMap } from '../../../../components/organisms/ProviderMap';
import { providers } from '../../../shared/fixtures/providers';
import { demoNav } from '../DemoNav';
export function ProvidersRoute() {
const navigate = useNavigate();
const [query, setQuery] = useState('');
const [filters, setFilters] = useState<ProviderFilterValues>(EMPTY_FILTER_VALUES);
const [sort, setSort] = useState<ProviderSortBy>('recommended');
const [view, setView] = useState<ListViewMode>('list');
const filtered = providers.filter((p) => p.location.toLowerCase().includes(query.toLowerCase()));
return (
<ProvidersStep
providers={filtered}
onSelectProvider={(id) => navigate(`/providers/${id}/packages`)}
searchQuery={query}
onSearchChange={setQuery}
filterValues={filters}
onFilterChange={setFilters}
sortBy={sort}
onSortChange={setSort}
viewMode={view}
onViewModeChange={setView}
onBack={() => window.history.back()}
navigation={demoNav}
mapPanel={
<ProviderMap
providers={filtered}
onSelectProvider={(id) => navigate(`/providers/${id}/packages`)}
/>
}
/>
);
}

View File

@@ -1,17 +0,0 @@
/**
* Resolve a public-asset path against Vite's base URL.
*
* In dev `import.meta.env.BASE_URL === '/'`, so `assetUrl('/images/foo.png')`
* returns `/images/foo.png` unchanged. In production the build sets base to
* `/arrangement/` (or whatever `--mode <slice>` was passed), and the same
* call returns `/arrangement/images/foo.png` so the bundled assets resolve
* correctly under the slice subpath.
*
* Always pass leading-slash paths — they're relative to the publicDir root.
*/
export const assetUrl = (path: string): string => {
const base = import.meta.env.BASE_URL;
const cleanBase = base.endsWith('/') ? base.slice(0, -1) : base;
const cleanPath = path.startsWith('/') ? path : `/${path}`;
return `${cleanBase}${cleanPath}`;
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,129 +0,0 @@
import type { ProviderData } from '../../../components/pages/ProvidersStep';
import type { PackagesStepProvider, ProviderTier } from '../../../components/pages/PackagesStep';
import { assetUrl } from '../assets';
export interface DemoProvider extends ProviderData {
id: string;
tier: ProviderTier;
}
export const providers: DemoProvider[] = [
{
id: 'parsons',
name: 'H.Parsons Funeral Directors',
location: 'Wentworth, NSW',
verified: true,
tier: 'verified',
imageUrl: assetUrl('/images/venues/hparsons-funeral-home-wollongong/01.jpg'),
logoUrl: assetUrl('/images/providers/hparsons-funeral-directors/logo.png'),
rating: 4.6,
reviewCount: 7,
startingPrice: 1800,
distanceKm: 2.3,
coords: { lat: -34.1074, lng: 141.9166 },
description:
'H.Parsons delivers premium funeral services with exceptional care and support, guiding families through every step with empathy and expertise.',
},
{
id: 'rankins',
name: 'Rankins Funeral Services',
location: 'Wollongong, NSW',
verified: true,
tier: 'verified',
imageUrl: assetUrl('/images/venues/rankins-funeral-home-warrawong/01.jpg'),
logoUrl: assetUrl('/images/providers/rankins-funerals/logo.png'),
rating: 4.8,
reviewCount: 23,
startingPrice: 2450,
distanceKm: 5.1,
coords: { lat: -34.487, lng: 150.897 },
},
{
id: 'wollongong-city',
name: 'Wollongong City Funerals',
location: 'Wollongong, NSW',
verified: false,
tier: 'tier3',
rating: 4.2,
reviewCount: 15,
startingPrice: 3400,
distanceKm: 6.8,
coords: { lat: -34.4278, lng: 150.8931 },
},
{
id: 'killick',
name: 'Killick Family Funerals',
location: 'Kingaroy, QLD',
verified: true,
tier: 'verified',
imageUrl: assetUrl('/images/venues/killick-family-funerals-chapel-kingaroy/01.jpg'),
logoUrl: assetUrl('/images/providers/killick-family-funerals/logo.png'),
rating: 4.9,
reviewCount: 15,
startingPrice: 3100,
distanceKm: 8.4,
coords: { lat: -26.5408, lng: 151.8388 },
},
{
id: 'mackay',
name: 'Mackay Family Funeral Directors',
location: 'Ourimbah, NSW',
verified: true,
tier: 'verified',
imageUrl: assetUrl('/images/venues/mackay-family-garden-estate/01.jpg'),
logoUrl: assetUrl('/images/providers/mackay-family-funerals/logo.webp'),
rating: 4.6,
reviewCount: 87,
startingPrice: 2800,
distanceKm: 18.2,
coords: { lat: -33.3644, lng: 151.3728 },
},
{
id: 'mannings',
name: 'Mannings Funerals',
location: 'Bega, NSW',
verified: true,
tier: 'verified',
imageUrl: assetUrl('/images/venues/mannings-chapel/01.jpg'),
logoUrl: assetUrl('/images/providers/mannings-funerals/logo.png'),
rating: 4.7,
reviewCount: 31,
startingPrice: 2600,
distanceKm: 22.0,
coords: { lat: -36.6742, lng: 149.8417 },
},
{
id: 'botanical',
name: 'Botanical Funerals',
location: 'Newtown, NSW',
verified: false,
tier: 'tier2',
rating: 4.9,
reviewCount: 8,
startingPrice: 5200,
distanceKm: 15.0,
coords: { lat: -33.8988, lng: 151.1794 },
},
];
export const providersById: Record<string, DemoProvider> = providers.reduce(
(acc, p) => {
acc[p.id] = p;
return acc;
},
{} as Record<string, DemoProvider>,
);
/**
* Strip demo-only fields so the value matches PackagesStepProvider exactly.
* (PackagesStepProvider is a structural subset of ProviderData — no `id`, no `tier`.)
*/
export function toPackagesStepProvider(p: DemoProvider): PackagesStepProvider {
return {
name: p.name,
location: p.location,
imageUrl: p.imageUrl,
rating: p.rating,
reviewCount: p.reviewCount,
};
}

View File

@@ -1,82 +0,0 @@
import { useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useComparisonBasket } from './useComparisonBasket';
const PARAM = 'compare';
const serialise = (keys: string[]): string => keys.join(',');
const deserialise = (raw: string | null): string[] =>
raw
? raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: [];
/**
* Two-way sync between the basket store and the `?compare=a:b,c:d` search param.
*
* Mount once near the router root. URL is the source of truth on initial load
* (so a shared link restores the basket); after that, store changes write
* through to the URL so the current basket is always shareable.
*
* In-app navigation from a page that carries `?compare=...` to one that
* doesn't (e.g. Back from PackagesStep to the providers map) would drop the
* param — to avoid wiping the store, we re-attach the store's keys to the
* new URL instead of treating the empty URL as a "clear" signal. External
* URL changes that DO carry params still push back into the store (shared
* links, manual edits, browser Back after a store write).
*/
export function useBasketUrlSync(): void {
const [searchParams, setSearchParams] = useSearchParams();
const initialised = useRef(false);
useEffect(() => {
const urlKeys = deserialise(searchParams.get(PARAM));
const storeKeys = useComparisonBasket.getState().packageKeys;
if (!initialised.current) {
initialised.current = true;
if (urlKeys.length > 0 && serialise(urlKeys) !== serialise(storeKeys)) {
useComparisonBasket.getState().setAll(urlKeys);
}
return;
}
if (serialise(urlKeys) === serialise(storeKeys)) return;
// URL empty + store has items → in-app navigation dropped the param.
// Re-attach the store's keys so the basket stays sticky across routes
// (and the current URL remains shareable).
if (urlKeys.length === 0 && storeKeys.length > 0) {
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
next.set(PARAM, serialise(storeKeys));
return next;
},
{ replace: true },
);
return;
}
// Otherwise URL is authoritative (shared link, manual edit, browser Back
// after a store write) — push it into the store.
useComparisonBasket.getState().setAll(urlKeys);
}, [searchParams, setSearchParams]);
useEffect(() => {
return useComparisonBasket.subscribe((state, prev) => {
if (serialise(state.packageKeys) === serialise(prev.packageKeys)) return;
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
if (state.packageKeys.length === 0) next.delete(PARAM);
else next.set(PARAM, serialise(state.packageKeys));
return next;
},
{ replace: true },
);
});
}, [setSearchParams]);
}

View File

@@ -1,49 +0,0 @@
import { create } from 'zustand';
import type { BasketKey } from '../fixtures/packages';
// ComparisonPage caps user-selected packages at 3 (recommended is shown as a
// separate column). Keep the basket aligned so we can't add a 4th and have it
// silently dropped at render time.
const MAX_BASKET = 3;
interface BasketState {
packageKeys: BasketKey[];
/** Transient feedback message — set when add() is rejected (dupe/full) */
lastError: string | null;
add: (key: BasketKey) => void;
remove: (key: BasketKey) => void;
toggle: (key: BasketKey) => void;
clear: () => void;
clearError: () => void;
setAll: (keys: BasketKey[]) => void;
has: (key: BasketKey) => boolean;
isFull: () => boolean;
}
export const useComparisonBasket = create<BasketState>((set, get) => ({
packageKeys: [],
lastError: null,
add: (key) =>
set((state) => {
if (state.packageKeys.includes(key)) {
return { ...state, lastError: 'Already added' };
}
if (state.packageKeys.length >= MAX_BASKET) {
return { ...state, lastError: `Maximum ${MAX_BASKET} packages` };
}
return { packageKeys: [...state.packageKeys, key], lastError: null };
}),
remove: (key) => set((state) => ({ packageKeys: state.packageKeys.filter((k) => k !== key) })),
toggle: (key) => {
const { has, add, remove } = get();
if (has(key)) remove(key);
else add(key);
},
clear: () => set({ packageKeys: [], lastError: null }),
clearError: () => set({ lastError: null }),
setAll: (keys) => set({ packageKeys: keys.slice(0, MAX_BASKET), lastError: null }),
has: (key) => get().packageKeys.includes(key),
isFull: () => get().packageKeys.length >= MAX_BASKET,
}));
export const BASKET_MAX = MAX_BASKET;

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,50 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
/**
* Per-slice demo build. Slice name comes from `--mode <name>` and selects
* the app folder, base path, and output directory.
*
* Dev: vite -c vite.demo.config.ts --mode arrangement
* Build: vite build -c vite.demo.config.ts --mode arrangement
* → dist-demo/arrangement/
*/
export default defineConfig(({ mode, command }) => {
const slice = mode;
const appRoot = path.resolve(__dirname, `src/demo/apps/${slice}`);
return {
root: appRoot,
// Load `.env` / `.env.local` from the repo root. Vite's default is to
// read env files from `root`, which here points into `src/demo/apps/...`
// where no env files live — so without this VITE_GOOGLE_MAPS_API_KEY
// never reaches the built bundle and ProviderMap silently falls back
// to its "no API key" empty state in production.
envDir: __dirname,
// Dev server uses absolute base so HMR/asset URLs work at the root;
// production build prefixes assets with /<slice>/ so the bundle is
// portable to any nginx location matching that path.
base: command === 'build' ? `/${slice}/` : '/',
// Mirror Storybook's staticDirs so /brandlogo/, /images/, etc. resolve.
publicDir: path.resolve(__dirname, 'brandassets'),
plugins: [react()],
resolve: {
alias: {
'@atoms': path.resolve(__dirname, 'src/components/atoms'),
'@molecules': path.resolve(__dirname, 'src/components/molecules'),
'@organisms': path.resolve(__dirname, 'src/components/organisms'),
'@theme': path.resolve(__dirname, 'src/theme'),
},
},
build: {
outDir: path.resolve(__dirname, `dist-demo/${slice}`),
emptyOutDir: true,
sourcemap: true,
},
server: {
port: 5180,
open: false,
},
};
});