Compare commits

..

1 Commits

Author SHA1 Message Date
af8956f130 Strip AI tooling and working docs for dev push 2026-04-13 15:49:49 +10:00
78 changed files with 2492 additions and 7276 deletions

4
.gitignore vendored
View File

@@ -1,6 +1,5 @@
node_modules/ node_modules/
dist/ dist/
dist-demo/
storybook-static/ storybook-static/
tokens/export/ tokens/export/
*.local *.local
@@ -43,6 +42,3 @@ temp-db/
# Root-level screenshots # Root-level screenshots
/*.png /*.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.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

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": { "dependencies": {
"@emotion/react": "^11.13.0", "@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0", "@emotion/styled": "^11.13.0",
"@googlemaps/markerclusterer": "^2.6.2",
"@mui/icons-material": "^5.16.0", "@mui/icons-material": "^5.16.0",
"@mui/material": "^5.16.0", "@mui/material": "^5.16.0",
"@mui/system": "^5.16.0", "@mui/system": "^5.16.0",
"@vis.gl/react-google-maps": "^1.8.3",
"react": "^18.3.0", "react": "^18.3.0",
"react-dom": "^18.3.0", "react-dom": "^18.3.0"
"react-router-dom": "^7.14.1",
"zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
@@ -1462,26 +1458,6 @@
"react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "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": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -4130,18 +4106,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -4205,15 +4169,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -4523,21 +4478,6 @@
"url": "https://opencollective.com/eslint" "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": { "node_modules/@vitejs/plugin-react": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -5447,19 +5387,6 @@
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"license": "MIT" "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": { "node_modules/cosmiconfig": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
@@ -6531,17 +6458,9 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT" "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": { "node_modules/fast-json-stable-stringify": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "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": ">=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": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -9617,44 +9530,6 @@
"node": ">=0.10.0" "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": { "node_modules/react-transition-group": {
"version": "4.4.5", "version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
@@ -10026,12 +9901,6 @@
"semver": "bin/semver.js" "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": { "node_modules/set-function-length": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -10635,15 +10504,6 @@
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
"license": "MIT" "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": { "node_modules/supports-color": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -12239,35 +12099,6 @@
"peerDependencies": { "peerDependencies": {
"zod": "^3.25.0 || ^4.0.0" "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": "vitest run --passWithNoTests",
"test:watch": "vitest", "test:watch": "vitest",
"chromatic": "chromatic --exit-zero-on-changes --build-script-name=build:storybook", "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" "prepare": "husky"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.13.0", "@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0", "@emotion/styled": "^11.13.0",
"@googlemaps/markerclusterer": "^2.6.2",
"@mui/icons-material": "^5.16.0", "@mui/icons-material": "^5.16.0",
"@mui/material": "^5.16.0", "@mui/material": "^5.16.0",
"@mui/system": "^5.16.0", "@mui/system": "^5.16.0",
"@vis.gl/react-google-maps": "^1.8.3",
"react": "^18.3.0", "react": "^18.3.0",
"react-dom": "^18.3.0", "react-dom": "^18.3.0"
"react-router-dom": "^7.14.1",
"zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@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; export default meta;
type Story = StoryObj<typeof MapPin>; type Story = StoryObj<typeof MapPin>;
/** Verified provider — promoted brand palette (dark copper bg, white text) */ /** Verified provider with name and price — warm brand label */
export const Verified: Story = { export const VerifiedWithPrice: Story = {
args: { args: {
name: 'H.Parsons Funeral Directors', name: 'H.Parsons Funeral Directors',
price: 900, price: 900,
@@ -31,7 +31,7 @@ export const Verified: Story = {
}; };
/** Unverified provider — neutral grey label */ /** Unverified provider — neutral grey label */
export const Unverified: Story = { export const UnverifiedWithPrice: Story = {
args: { args: {
name: 'Smith & Sons Funerals', name: 'Smith & Sons Funerals',
price: 1200, 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 = { export const CustomPriceLabel: Story = {
args: { args: {
name: 'Premium Services', name: 'Premium Services',
@@ -82,7 +141,7 @@ export const MapSimulation: Story = {
<MapPin name="H.Parsons" price={900} verified onClick={() => {}} /> <MapPin name="H.Parsons" price={900} verified onClick={() => {}} />
</Box> </Box>
<Box sx={{ position: 'absolute', top: 150, left: 280 }}> <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>
<Box sx={{ position: 'absolute', top: 260, left: 140 }}> <Box sx={{ position: 'absolute', top: 260, left: 140 }}>
<MapPin name="Mackay Family" price={2200} verified onClick={() => {}} /> <MapPin name="Mackay Family" price={2200} verified onClick={() => {}} />
@@ -93,7 +152,12 @@ export const MapSimulation: Story = {
<MapPin name="Smith & Sons" price={1100} onClick={() => {}} /> <MapPin name="Smith & Sons" price={1100} onClick={() => {}} />
</Box> </Box>
<Box sx={{ position: 'absolute', top: 300, left: 400 }}> <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> </Box>
</> </>
), ),

View File

@@ -6,14 +6,16 @@ import type { SxProps, Theme } from '@mui/material/styles';
/** Props for the FA MapPin atom */ /** Props for the FA MapPin atom */
export interface MapPinProps { export interface MapPinProps {
/** Provider or venue name (required — shown as line 1) */ /** Provider or venue name — omit for a price-only pill */
name: string; name?: string;
/** Starting package price in dollars — shown as "From $X" on line 2 */ /** Starting package price in dollars — shown as "From $X" */
price?: number; price?: number;
/** Custom price label (e.g. "POA") — overrides formatted price */ /** Custom price label (e.g. "POA") — overrides formatted price */
priceLabel?: string; 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; verified?: boolean;
/** Whether this pin is currently active/selected */
active?: boolean;
/** Click handler */ /** Click handler */
onClick?: (e: React.MouseEvent) => void; onClick?: (e: React.MouseEvent) => void;
/** MUI sx prop for the root element */ /** 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_PX = 'var(--fa-map-pin-padding-x)';
const PIN_RADIUS = 'var(--fa-map-pin-border-radius)'; const PIN_RADIUS = 'var(--fa-map-pin-border-radius)';
const NUB_SIZE = 'var(--fa-map-pin-nub-size)'; const NUB_SIZE = 'var(--fa-map-pin-nub-size)';
const MAX_WIDTH = 210; const MAX_WIDTH = 180;
// ─── Colour sets ──────────────────────────────────────────────────────────── // ─── Colour sets ────────────────────────────────────────────────────────────
const colours = { const colours = {
verified: { verified: {
bg: 'var(--fa-color-brand-700)', bg: 'var(--fa-color-brand-100)',
name: 'var(--fa-color-white)', name: 'var(--fa-color-brand-900)',
price: 'var(--fa-color-brand-200)', price: 'var(--fa-color-brand-600)',
nub: 'var(--fa-color-brand-700)', activeBg: 'var(--fa-color-brand-700)',
border: '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: { unverified: {
bg: 'var(--fa-color-neutral-100)', bg: 'var(--fa-color-neutral-100)',
name: 'var(--fa-color-neutral-800)', name: 'var(--fa-color-neutral-800)',
price: 'var(--fa-color-neutral-500)', 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)', nub: 'var(--fa-color-neutral-100)',
activeNub: 'var(--fa-color-neutral-700)',
border: 'var(--fa-color-neutral-300)', border: 'var(--fa-color-neutral-300)',
activeBorder: 'var(--fa-color-neutral-700)',
}, },
} as const; } as const;
@@ -56,25 +68,26 @@ const colours = {
* the exact map location. * the exact map location.
* *
* - **Line 1**: Provider name (bold, truncated) * - **Line 1**: Provider name (bold, truncated)
* - **Line 2**: "From $X" (smaller, secondary colour) * - **Line 2**: "From $X" (smaller, secondary colour) — optional
* *
* Visual distinction: * 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 * - **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 * Designed for use as custom HTML markers in Mapbox GL / Google Maps.
* canvas, no SVG dependency. Selection/popup behaviour is handled at the * Pure CSS — no canvas, no SVG dependency.
* organism level (ProviderMap swaps pin → popup on click).
* *
* Usage: * Usage:
* ```tsx * ```tsx
* <MapPin name="H.Parsons" price={900} verified onClick={...} /> * <MapPin name="H.Parsons" price={900} verified onClick={...} />
* <MapPin name="Smith & Sons" price={1200} /> * <MapPin name="Smith & Sons" /> {/* Name only, unverified *\/}
* <MapPin name="Botanical" priceLabel="POA" verified /> * <MapPin price={900} verified /> {/* Price-only pill, no name *\/}
* <MapPin name="H.Parsons" price={900} verified active />
* ``` * ```
*/ */
export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>( 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 palette = verified ? colours.verified : colours.unverified;
const hasPrice = price != null || priceLabel != null; const hasPrice = price != null || priceLabel != null;
@@ -93,7 +106,7 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
ref={ref} ref={ref}
role="button" role="button"
tabIndex={0} 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} onClick={onClick}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
sx={[ sx={[
@@ -103,13 +116,7 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
alignItems: 'center', alignItems: 'center',
cursor: 'pointer', cursor: 'pointer',
transition: 'transform 150ms ease-in-out', transition: 'transform 150ms ease-in-out',
// Fade in on mount — matches the popup's exit timing so the pin transform: active ? 'scale(1.08)' : 'scale(1)',
// reappears smoothly when a popup closes.
'@keyframes mapPinIn': {
from: { opacity: 0 },
to: { opacity: 1 },
},
animation: 'mapPinIn 180ms ease-out',
'&:hover': { '&:hover': {
transform: 'scale(1.08)', transform: 'scale(1.08)',
}, },
@@ -135,65 +142,53 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
py: 0.5, py: 0.5,
px: PIN_PX, px: PIN_PX,
borderRadius: PIN_RADIUS, borderRadius: PIN_RADIUS,
backgroundColor: palette.bg, backgroundColor: active ? palette.activeBg : palette.bg,
border: '1px solid', border: '1px solid',
borderColor: palette.border, borderColor: active ? palette.activeBorder : palette.border,
boxShadow: 'var(--fa-shadow-sm)', 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 */} {/* Name */}
<Box {name && (
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>
)}
<Box <Box
component="span" component="span"
sx={{ sx={{
fontSize: 12, fontSize: 12,
fontWeight: 700, fontWeight: 700,
fontFamily: 'var(--fa-font-family-body)', fontFamily: (t: Theme) => t.typography.fontFamily,
lineHeight: 1.3, lineHeight: 1.3,
color: palette.name, color: active ? palette.activeName : palette.name,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
minWidth: 0, maxWidth: '100%',
transition: 'color 150ms ease-in-out',
}} }}
> >
{name} {name}
</Box> </Box>
</Box> )}
{/* Price line */} {/* Price line */}
{hasPrice && ( {hasPrice && (
<Box <Box
component="span" component="span"
sx={{ sx={{
fontSize: 11, fontSize: !name ? 12 : 11,
fontWeight: 600, fontWeight: !name ? 700 : 600,
fontFamily: 'var(--fa-font-family-body)', fontFamily: (t: Theme) => t.typography.fontFamily,
lineHeight: 1.2, lineHeight: 1.2,
color: palette.price, color: !name
? active
? palette.activeName
: palette.name
: active
? palette.activePrice
: palette.price,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
transition: 'color 150ms ease-in-out',
}} }}
> >
{priceText} {priceText}
@@ -201,33 +196,19 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
)} )}
</Box> </Box>
{/* Nub — downward pointer. Two SVG paths: {/* Nub — downward pointer */}
• fill is an extended pentagon that overhangs 3 units *into* the <Box
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
aria-hidden aria-hidden
viewBox="0 0 16 8" sx={{
style={{ width: 0,
display: 'block', height: 0,
width: `calc(2 * ${NUB_SIZE})`, borderLeft: `${NUB_SIZE} solid transparent`,
height: NUB_SIZE, borderRight: `${NUB_SIZE} solid transparent`,
marginTop: '-1px', borderTop: `${NUB_SIZE} solid`,
overflow: 'visible', 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> </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 --------------------------------------------------------
/** Interactive demo — add packages and see the bar update */ /** Interactive demo — add packages and see the bar update */

View File

@@ -1,12 +1,8 @@
import React from 'react'; import React from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper'; import Paper from '@mui/material/Paper';
import Slide from '@mui/material/Slide'; import Slide from '@mui/material/Slide';
import useMediaQuery from '@mui/material/useMediaQuery'; import CompareArrowsIcon from '@mui/icons-material/CompareArrows';
import IconButton from '@mui/material/IconButton'; import type { SxProps, Theme } from '@mui/material/styles';
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 { Typography } from '../../atoms/Typography'; import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button'; import { Button } from '../../atoms/Button';
import { Badge } from '../../atoms/Badge'; import { Badge } from '../../atoms/Badge';
@@ -35,14 +31,6 @@ export interface CompareBarProps {
sx?: SxProps<Theme>; 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 ─────────────────────────────────────────────────────────────── // ─── 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. * Shows a fraction badge (1/3, 2/3, 3/3), contextual copy, and a Compare CTA.
* Present on both ProvidersStep and PackagesStep. * Present on both ProvidersStep and PackagesStep.
* *
* **Mobile collapse** (xs only): users can tap a right-chevron to retract * Composes Badge + Button + Typography.
* 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.
*/ */
export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>( export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
({ packages, onCompare, error, sx }, ref) => { ({ packages, onCompare, error, sx }, ref) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const count = packages.length; const count = packages.length;
const visible = count > 0; const visible = count > 0;
const canCompare = count >= 2; const canCompare = count >= 2;
const statusText = count === 1 ? 'Add another to compare' : 'Ready to compare'; 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 ( return (
<Slide direction="up" in={visible} mountOnEnter unmountOnExit> <Slide direction="up" in={visible} mountOnEnter unmountOnExit>
<Paper <Paper
@@ -108,123 +58,52 @@ export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
aria-live="polite" aria-live="polite"
aria-label={`${count} of 3 packages selected for comparison`} aria-label={`${count} of 3 packages selected for comparison`}
sx={[ sx={[
(t: Theme) => ({ (theme: Theme) => ({
position: 'fixed', position: 'fixed',
// Clear the sticky HelpBar (~40px) + breathing room. FA theme bottom: theme.spacing(3),
// uses a 4px spacing base, so spacing(16) = 64px. left: '50%',
bottom: t.spacing(16), transform: 'translateX(-50%)',
// z-index sits below the mobile map-view drawer (modal: 1300) zIndex: theme.zIndex.snackbar,
// 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',
borderRadius: '9999px', borderRadius: '9999px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: { xs: 1.25, md: 2 }, gap: 1.5,
px: { xs: 1.5, md: 3 }, px: 2.5,
py: { xs: 0.75, md: 1.5 }, py: 1.25,
maxWidth: { xs: 'calc(100vw - 32px)', md: 460 }, maxWidth: { xs: 'calc(100vw - 32px)', md: 420 },
overflow: 'hidden',
transition: `padding ${COLLAPSE_MS}ms ease-out`,
}), }),
...(Array.isArray(sx) ? sx : [sx]), ...(Array.isArray(sx) ? sx : [sx]),
]} ]}
> >
{/* Fraction badge — shows "N/3" when expanded, just "N" when {/* Fraction badge — 1/3, 2/3, 3/3 */}
collapsed on mobile (reads as a circle at mini size). */} <Badge color="brand" variant="soft" size="small" sx={{ flexShrink: 0 }}>
<Badge {count}/3
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`}
</Badge> </Badge>
{/* Middle content (status + Compare CTA) — animates to zero {/* Status text */}
max-width when collapsed, letting the pill shrink as one unit <Typography
with the right edge staying fixed. */} variant="body2"
<Box role={error ? 'alert' : undefined}
sx={{ sx={{
display: 'flex', fontWeight: 500,
alignItems: 'center', whiteSpace: 'nowrap',
gap: { xs: 1.25, md: 2 }, color: error ? 'var(--fa-color-text-brand)' : 'text.primary',
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`,
}} }}
> >
<Typography {error || statusText}
variant={isMobile ? 'body2' : 'body1'} </Typography>
role={error ? 'alert' : undefined}
sx={{
fontWeight: 500,
whiteSpace: 'nowrap',
color: error ? 'var(--fa-color-text-brand)' : 'text.primary',
flexShrink: 0,
}}
>
{error || statusText}
</Typography>
<Button {/* Compare CTA */}
variant="contained" <Button
size={isMobile ? 'small' : 'medium'} variant="contained"
onClick={onCompare} size="small"
disabled={!canCompare} startIcon={<CompareArrowsIcon />}
tabIndex={mobileCollapsed ? -1 : 0} onClick={onCompare}
sx={{ flexShrink: 0, borderRadius: '9999px' }} disabled={!canCompare}
> sx={{ flexShrink: 0, borderRadius: '9999px' }}
Compare >
</Button> Compare
</Box> </Button>
{/* 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> </Paper>
</Slide> </Slide>
); );

View File

@@ -36,11 +36,10 @@ function formatPrice(amount: number): string {
/** /**
* Desktop column header card for the ComparisonTable. * Desktop column header card for the ComparisonTable.
* *
* Shows provider info (verified/recommended badge, name, location, rating), * Shows provider info (verified badge, name, location, rating), package name,
* package name, total price, CTA button, and optional Remove link. The badge * total price, CTA button, and optional Remove link. The verified badge floats
* floats above the card's top edge — "Recommended" (primary fill) replaces * above the card's top edge. Recommended packages get a copper banner and warm
* "Verified" (soft) when the package is recommended. Recommended packages * selected card state.
* 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. * Used as the sticky header for each column in the desktop comparison grid.
* Mobile comparison uses ComparisonPackageCard instead. * Mobile comparison uses ComparisonPackageCard instead.
@@ -62,29 +61,23 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
...(Array.isArray(sx) ? sx : [sx]), ...(Array.isArray(sx) ? sx : [sx]),
]} ]}
> >
{/* Floating badge — Recommended (primary fill) takes priority over Verified (soft) */} {/* Floating verified badge — overlaps card top edge */}
{(pkg.isRecommended || pkg.provider.verified) && ( {pkg.provider.verified && (
<Badge <Badge
color="brand" color="brand"
variant={pkg.isRecommended ? 'filled' : 'soft'} variant="soft"
size="medium" size="small"
icon={ icon={<VerifiedOutlinedIcon sx={{ fontSize: 14 }} />}
pkg.isRecommended ? (
<StarRoundedIcon sx={{ fontSize: 16 }} />
) : (
<VerifiedOutlinedIcon sx={{ fontSize: 16 }} />
)
}
sx={{ sx={{
position: 'absolute', position: 'absolute',
top: -13, top: -12,
left: '50%', left: '50%',
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
zIndex: 1, zIndex: 1,
boxShadow: '0 1px 3px rgba(0,0,0,0.1)', boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
}} }}
> >
{pkg.isRecommended ? 'Recommended' : 'Verified'} Verified
</Badge> </Badge>
)} )}
@@ -92,16 +85,24 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
variant="outlined" variant="outlined"
selected={pkg.isRecommended} selected={pkg.isRecommended}
padding="none" padding="none"
sx={{ sx={{ overflow: 'hidden', flex: 1, display: 'flex', flexDirection: 'column' }}
overflow: 'hidden',
flex: 1,
display: 'flex',
flexDirection: 'column',
...(pkg.isRecommended && {
borderColor: 'var(--fa-color-brand-600)',
}),
}}
> >
{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 <Box
sx={{ sx={{
display: 'flex', display: 'flex',
@@ -109,66 +110,40 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
alignItems: 'center', alignItems: 'center',
textAlign: 'center', textAlign: 'center',
px: 2.5, px: 2.5,
pt: 5, py: 2.5,
pb: 3, pt: pkg.provider.verified ? 3 : 2.5,
gap: 1, gap: 0.5,
flex: 1, flex: 1,
}} }}
> >
{/* Provider name — always reserves space for 2 lines (via minHeight), {/* Provider name (truncated with tooltip) */}
content bottom-aligned so single-line names sit flush with the <Tooltip
next item below rather than floating high in the slot. */} title={pkg.provider.name}
<Box arrow
sx={{ placement="top"
display: 'flex', disableHoverListener={pkg.provider.name.length < 24}
alignItems: 'flex-end',
justifyContent: 'center',
gap: 0.75,
maxWidth: '100%',
minHeight: 36, // 2 × (14px label × 1.286 line-height)
}}
> >
{pkg.isRecommended && ( <Typography
<VerifiedOutlinedIcon variant="label"
sx={{ sx={{
fontSize: 16, fontWeight: 600,
color: 'var(--fa-color-brand-600)', overflow: 'hidden',
flexShrink: 0, textOverflow: 'ellipsis',
mb: '2px', whiteSpace: 'nowrap',
}} maxWidth: '100%',
aria-label="Verified provider" }}
/>
)}
<Tooltip
title={pkg.provider.name}
arrow
placement="top"
disableHoverListener={pkg.provider.name.length < 50}
> >
<Typography {pkg.provider.name}
variant="label" </Typography>
sx={{ </Tooltip>
fontWeight: 600,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: 0,
}}
>
{pkg.provider.name}
</Typography>
</Tooltip>
</Box>
{/* Location */} {/* Location */}
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{pkg.provider.location} {pkg.provider.location}
</Typography> </Typography>
{/* Rating (or dash placeholder to keep card heights consistent) */} {/* Rating */}
{pkg.provider.rating != null ? ( {pkg.provider.rating != null && (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}> <Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
<StarRoundedIcon <StarRoundedIcon
sx={{ fontSize: 16, color: 'var(--fa-color-brand-500)' }} sx={{ fontSize: 16, color: 'var(--fa-color-brand-500)' }}
@@ -179,35 +154,20 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
{pkg.provider.reviewCount != null && ` (${pkg.provider.reviewCount})`} {pkg.provider.reviewCount != null && ` (${pkg.provider.reviewCount})`}
</Typography> </Typography>
</Box> </Box>
) : (
<Typography variant="body2" color="text.secondary" aria-label="No reviews yet">
</Typography>
)} )}
<Divider sx={{ width: '100%', my: 1.5 }} /> <Divider sx={{ width: '100%', my: 1 }} />
<Typography variant="h6" component="p"> <Typography variant="h6" component="p">
{pkg.name} {pkg.name}
</Typography> </Typography>
{/* Price subgroup — tighter internal spacing than the outer gap <Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
so the label sits close to the amount it describes. */} Total package price
<Box </Typography>
sx={{ <Typography variant="h5" sx={{ color: 'primary.main', fontWeight: 700 }}>
display: 'flex', {formatPrice(pkg.price)}
flexDirection: 'column', </Typography>
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 */} {/* Spacer pushes CTA to bottom across all cards */}
<Box sx={{ flex: 1 }} /> <Box sx={{ flex: 1 }} />
@@ -217,33 +177,23 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
color={pkg.provider.verified ? 'primary' : 'secondary'} color={pkg.provider.verified ? 'primary' : 'secondary'}
size="medium" size="medium"
onClick={() => onArrange(pkg.id)} onClick={() => onArrange(pkg.id)}
sx={{ px: 4 }} sx={{ mt: 1.5, px: 4 }}
> >
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'} {pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
</Button> </Button>
{/* Always render the same Link element; hide when no Remove action {!pkg.isRecommended && onRemove && (
applies (recommended or no handler). Keeps the footer row <Link
identical across all cards so CTAs align. */} component="button"
{(() => { variant="body2"
const canRemove = !pkg.isRecommended && !!onRemove; color="text.secondary"
return ( underline="hover"
<Link onClick={() => onRemove(pkg.id)}
component="button" sx={{ mt: 0.5 }}
variant="caption" >
color="text.secondary" Remove
underline="hover" </Link>
onClick={canRemove ? () => onRemove!(pkg.id) : undefined} )}
tabIndex={canRemove ? 0 : -1}
aria-hidden={!canRemove}
sx={{
...(!canRemove && { visibility: 'hidden', pointerEvents: 'none' }),
}}
>
Remove
</Link>
);
})()}
</Box> </Box>
</Card> </Card>
</Box> </Box>

View File

@@ -9,6 +9,7 @@ import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import type { SxProps, Theme } from '@mui/material/styles'; import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography'; import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button'; import { Button } from '../../atoms/Button';
import { Badge } from '../../atoms/Badge';
import { Divider } from '../../atoms/Divider'; import { Divider } from '../../atoms/Divider';
import { Card } from '../../atoms/Card'; import { Card } from '../../atoms/Card';
import type { ComparisonPackage, ComparisonCellValue } from '../../organisms/ComparisonTable'; import type { ComparisonPackage, ComparisonCellValue } from '../../organisms/ComparisonTable';
@@ -124,21 +125,12 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
<Card <Card
ref={ref} ref={ref}
variant="outlined" variant="outlined"
selected={pkg.isRecommended}
padding="none" padding="none"
sx={[ sx={[
{ {
overflow: 'hidden', overflow: 'hidden',
boxShadow: 'var(--fa-shadow-sm)', 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]), ...(Array.isArray(sx) ? sx : [sx]),
]} ]}
@@ -166,38 +158,31 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
bgcolor: pkg.isRecommended bgcolor: pkg.isRecommended
? 'var(--fa-color-surface-warm)' ? 'var(--fa-color-surface-warm)'
: 'var(--fa-color-surface-subtle)', : 'var(--fa-color-surface-subtle)',
px: 3, px: 2.5,
pt: 3, pt: 2.5,
pb: 4, pb: 2,
}} }}
> >
{/* Provider name with optional inline verified icon (matches desktop {/* Verified badge */}
ComparisonColumnCard treatment) */} {pkg.provider.verified && (
<Box <Badge
sx={{ color="brand"
display: 'flex', variant="soft"
alignItems: 'center', size="small"
gap: 0.75, icon={<VerifiedOutlinedIcon sx={{ fontSize: 14 }} />}
mb: 1.25, sx={{ mb: 1 }}
}} >
> Verified
{pkg.provider.verified && ( </Badge>
<VerifiedOutlinedIcon )}
sx={{
fontSize: 16, {/* Provider name */}
color: 'var(--fa-color-brand-600)', <Typography variant="label" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
flexShrink: 0, {pkg.provider.name}
}} </Typography>
aria-label="Verified provider"
/>
)}
<Typography variant="label" sx={{ fontWeight: 600 }}>
{pkg.provider.name}
</Typography>
</Box>
{/* Location + Rating */} {/* Location + Rating */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<LocationOnOutlinedIcon sx={{ fontSize: 14, color: 'text.secondary' }} aria-hidden /> <LocationOnOutlinedIcon sx={{ fontSize: 14, color: 'text.secondary' }} aria-hidden />
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
@@ -218,22 +203,18 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
)} )}
</Box> </Box>
<Divider sx={{ my: 3 }} /> <Divider sx={{ mb: 1.5 }} />
{/* Package info group — name, label, price stacked with small internal gap */} {/* Package name + price */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}> <Typography variant="h5" component="p">
<Typography variant="h5" component="p"> {pkg.name}
{pkg.name} </Typography>
</Typography> <Typography variant="caption" color="text.secondary">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}> Total package price
<Typography variant="caption" color="text.secondary"> </Typography>
Total package price <Typography variant="h3" sx={{ color: 'primary.main', fontWeight: 700 }}>
</Typography> {formatPrice(pkg.price)}
<Typography variant="h3" sx={{ color: 'primary.main', fontWeight: 700 }}> </Typography>
{formatPrice(pkg.price)}
</Typography>
</Box>
</Box>
<Button <Button
variant={pkg.provider.verified ? 'contained' : 'soft'} variant={pkg.provider.verified ? 'contained' : 'soft'}
@@ -241,14 +222,14 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
size="medium" size="medium"
fullWidth fullWidth
onClick={() => onArrange(pkg.id)} onClick={() => onArrange(pkg.id)}
sx={{ mt: 3 }} sx={{ mt: 2 }}
> >
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'} {pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
</Button> </Button>
</Box> </Box>
{/* Sections — with left accent borders on headings */} {/* Sections — with left accent borders on headings */}
<Box sx={{ px: 2.5, pt: 3.5, pb: 3 }}> <Box sx={{ px: 2.5, py: 2.5 }}>
{pkg.itemizedAvailable === false ? ( {pkg.itemizedAvailable === false ? (
<Box sx={{ textAlign: 'center', py: 3 }}> <Box sx={{ textAlign: 'center', py: 3 }}>
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}> <Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
@@ -257,14 +238,15 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
</Box> </Box>
) : ( ) : (
pkg.sections.map((section, sIdx) => ( pkg.sections.map((section, sIdx) => (
<Box key={section.heading} sx={{ mb: sIdx < pkg.sections.length - 1 ? 5 : 0 }}> <Box key={section.heading} sx={{ mb: sIdx < pkg.sections.length - 1 ? 3 : 0 }}>
{/* Section heading with left accent */} {/* Section heading with left accent */}
<Box <Box
sx={{ sx={{
borderLeft: '3px solid', borderLeft: '3px solid',
borderLeftColor: 'var(--fa-color-brand-500)', borderLeftColor: 'var(--fa-color-brand-500)',
pl: 1.5, pl: 1.5,
mb: 2.5, mb: 1.5,
mt: sIdx > 0 ? 1 : 0,
}} }}
> >
<Typography variant="h6" component="h3"> <Typography variant="h6" component="h3">
@@ -280,7 +262,7 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
gap: 2, gap: 2,
py: 2, py: 1.5,
borderBottom: '1px solid', borderBottom: '1px solid',
borderColor: 'divider', borderColor: 'divider',
}} }}

View File

@@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import type { SxProps, Theme } from '@mui/material/styles'; import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography'; import { Typography } from '../../atoms/Typography';
import { Badge } from '../../atoms/Badge'; import { Badge } from '../../atoms/Badge';
@@ -59,15 +58,12 @@ export const ComparisonTabCard = React.forwardRef<HTMLDivElement, ComparisonTabC
...(Array.isArray(sx) ? sx : [sx]), ...(Array.isArray(sx) ? sx : [sx]),
]} ]}
> >
{/* Recommended badge in normal flow — overlaps card via negative mb. {/* 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 ? ( {pkg.isRecommended ? (
<Badge <Badge
color="brand" color="brand"
variant="filled" variant="soft"
size="small" size="small"
icon={<StarRoundedIcon sx={{ fontSize: 14 }} />}
sx={{ sx={{
mb: '-10px', mb: '-10px',
zIndex: 1, zIndex: 1,
@@ -93,18 +89,21 @@ export const ComparisonTabCard = React.forwardRef<HTMLDivElement, ComparisonTabC
onClick={onClick} onClick={onClick}
interactive interactive
sx={{ sx={{
width: 235, width: 210,
cursor: 'pointer', cursor: 'pointer',
boxShadow: 'var(--fa-shadow-sm)', boxShadow: 'var(--fa-shadow-sm)',
...(pkg.isRecommended && { ...(pkg.isRecommended && {
borderColor: 'var(--fa-color-brand-600)', borderColor: 'var(--fa-color-brand-500)',
boxShadow: '0 0 12px rgba(186, 131, 78, 0.3)',
}), }),
...(isActive && { ...(isActive && {
boxShadow: 'var(--fa-shadow-md)', boxShadow: pkg.isRecommended
? '0 0 14px rgba(186, 131, 78, 0.4)'
: 'var(--fa-shadow-md)',
}), }),
}} }}
> >
<Box sx={{ px: 2, pt: 3.5, pb: 2 }}> <Box sx={{ px: 2, pt: 2.4, pb: 2 }}>
<Typography <Typography
variant="labelSm" variant="labelSm"
sx={{ sx={{

View File

@@ -77,14 +77,8 @@ export const FilterPanel = React.forwardRef<HTMLDivElement, FilterPanelProps>(
title={label} title={label}
footer={ footer={
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{onClear ? ( {onClear && activeCount > 0 ? (
<Button <Button variant="text" size="small" color="secondary" onClick={() => onClear()}>
variant="text"
size="small"
color="secondary"
onClick={() => onClear()}
disabled={activeCount === 0}
>
Reset filters Reset filters
</Button> </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 verified
onClick={() => {}} 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; verified?: boolean;
/** Click handler — entire card is clickable */ /** Click handler — entire card is clickable */
onClick?: () => void; 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 */ /** MUI sx prop for the root element */
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
} }
@@ -88,7 +85,6 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
capacity, capacity,
verified = false, verified = false,
onClick, onClick,
exiting = false,
sx, sx,
}, },
ref, ref,
@@ -107,21 +103,12 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
} }
}, [name]); }, [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 ( return (
<Box <Box
ref={ref} ref={ref}
role={onClick ? 'button' : undefined} role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined} tabIndex={onClick ? 0 : undefined}
onClick={handleClick} onClick={onClick}
onKeyDown={ onKeyDown={
onClick onClick
? (e: React.KeyboardEvent) => { ? (e: React.KeyboardEvent) => {
@@ -140,21 +127,12 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
alignItems: 'center', alignItems: 'center',
filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.15))', filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.15))',
cursor: onClick ? 'pointer' : 'default', cursor: onClick ? 'pointer' : 'default',
transformOrigin: 'bottom center', transition: 'transform 150ms ease-in-out',
transition: 'opacity 180ms ease-out, transform 180ms ease-out', '&:hover': onClick
opacity: exiting ? 0 : 1, ? {
transform: exiting ? 'scale(0.9)' : 'scale(1)', transform: 'scale(1.02)',
'@keyframes mapPopupIn': { }
from: { opacity: 0, transform: 'scale(0.9)' }, : undefined,
to: { opacity: 1, transform: 'scale(1)' },
},
animation: exiting ? undefined : 'mapPopupIn 180ms ease-out',
'&:hover':
onClick && !exiting
? {
transform: 'scale(1.02)',
}
: undefined,
'&:focus-visible': { '&:focus-visible': {
outline: '2px solid var(--fa-color-interactive-focus)', outline: '2px solid var(--fa-color-interactive-focus)',
outlineOffset: '2px', outlineOffset: '2px',
@@ -171,7 +149,6 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
borderRadius: 'var(--fa-card-border-radius-default)', borderRadius: 'var(--fa-card-border-radius-default)',
overflow: 'hidden', overflow: 'hidden',
bgcolor: 'background.paper', bgcolor: 'background.paper',
position: 'relative',
}} }}
> >
{/* ── Image ── */} {/* ── Image ── */}
@@ -302,20 +279,19 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
</Box> </Box>
</Paper> </Paper>
{/* Nub — downward pointer. SVG (fill-only; MapPopup uses a drop-shadow {/* Nub — downward pointer connecting to pin */}
for depth instead of a hard border, so no stroke needed) */} <Box
<svg
aria-hidden aria-hidden
width={NUB_SIZE * 2} sx={{
height={NUB_SIZE} width: 0,
viewBox={`0 0 ${NUB_SIZE * 2} ${NUB_SIZE}`} height: 0,
style={{ display: 'block', marginTop: '-1px', overflow: 'visible' }} borderLeft: `${NUB_SIZE}px solid transparent`,
> borderRight: `${NUB_SIZE}px solid transparent`,
<path borderTop: `${NUB_SIZE}px solid`,
d={`M 0 0 L ${NUB_SIZE} ${NUB_SIZE} L ${NUB_SIZE * 2} 0`} borderTopColor: 'background.paper',
fill="var(--fa-color-white)" mt: '-1px',
/> }}
</svg> />
</Box> </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, width: LOGO_SIZE,
height: LOGO_SIZE, height: LOGO_SIZE,
borderRadius: LOGO_BORDER_RADIUS, borderRadius: LOGO_BORDER_RADIUS,
// 'contain' so wide/tall logos scale proportionally inside objectFit: 'cover',
// the square slot rather than cropping. Background fills any
// letterboxed space so it still reads as a tile.
objectFit: 'contain',
backgroundColor: 'background.paper', backgroundColor: 'background.paper',
boxShadow: 'var(--fa-shadow-sm)', boxShadow: 'var(--fa-shadow-sm)',
border: '2px solid var(--fa-color-white)', 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

@@ -346,7 +346,7 @@ export const MixedVerified: Story = {
// --- Missing Itemised Data --------------------------------------------------- // --- 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 = { export const MissingData: Story = {
args: { args: {
packages: [pkgWollongong, pkgNoItemised, pkgMackay], packages: [pkgWollongong, pkgNoItemised, pkgMackay],

View File

@@ -63,55 +63,7 @@ function formatPrice(amount: number): string {
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`; return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
} }
/** function CellValue({ value }: { value: ComparisonCellValue }) {
* 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;
}) {
switch (value.type) { switch (value.type) {
case 'price': case 'price':
return ( return (
@@ -127,31 +79,33 @@ function CellValue({
); );
case 'complimentary': case 'complimentary':
return ( return (
<CellIconText <Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
color="var(--fa-color-feedback-success)" <CheckCircleOutlineIcon
icon={ sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
<CheckCircleOutlineIcon aria-hidden
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }} />
aria-hidden <Typography
/> variant="body2"
} sx={{ color: 'var(--fa-color-feedback-success)', fontWeight: 500 }}
> >
Complimentary Complimentary
</CellIconText> </Typography>
</Box>
); );
case 'included': case 'included':
return ( return (
<CellIconText <Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
color="var(--fa-color-feedback-success)" <CheckCircleOutlineIcon
icon={ sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
<CheckCircleOutlineIcon aria-hidden
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }} />
aria-hidden <Typography
/> variant="body2"
} sx={{ color: 'var(--fa-color-feedback-success)', fontWeight: 500 }}
> >
Included Included
</CellIconText> </Typography>
</Box>
); );
case 'poa': case 'poa':
return ( return (
@@ -161,30 +115,20 @@ function CellValue({
); );
case 'unknown': case 'unknown':
return ( return (
<CellIconText <Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
color="var(--fa-color-neutral-500)"
iconPosition="trailing"
icon={
<InfoOutlinedIcon
sx={{ fontSize: 14, color: 'var(--fa-color-neutral-500)' }}
aria-hidden
/>
}
>
Unknown
</CellIconText>
);
case 'unavailable':
if (OPTIONAL_SECTION_HEADINGS.has(sectionHeading)) {
return (
<Typography <Typography
variant="body2" variant="body2"
sx={{ color: 'var(--fa-color-neutral-500)', fontWeight: 500 }} sx={{ color: 'var(--fa-color-neutral-500)', fontWeight: 500 }}
> >
Not Included Unknown
</Typography> </Typography>
); <InfoOutlinedIcon
} sx={{ fontSize: 14, color: 'var(--fa-color-neutral-500)' }}
aria-hidden
/>
</Box>
);
case 'unavailable':
return ( return (
<Typography variant="body2" sx={{ color: 'var(--fa-color-neutral-400)' }}> <Typography variant="body2" sx={{ color: 'var(--fa-color-neutral-400)' }}>
@@ -226,20 +170,11 @@ function lookupValue(
sectionHeading: string, sectionHeading: string,
itemName: string, itemName: string,
): ComparisonCellValue { ): ComparisonCellValue {
// For unverified providers, absence means "we don't know" — data is if (pkg.itemizedAvailable === false) return { type: 'unavailable' };
// 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;
const section = pkg.sections.find((s) => s.heading === sectionHeading); 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); const item = section.items.find((i) => i.name === itemName);
if (!item) return missing; if (!item) return { type: 'unavailable' };
return item.value; return item.value;
} }
@@ -272,18 +207,6 @@ const tableSx = {
bgcolor: 'background.paper', 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 ────────────────────────────────────────────────────────────── // ─── Component ──────────────────────────────────────────────────────────────
/** /**
@@ -296,10 +219,10 @@ const Z_STICKY_LEFT_SECTION = 25; // section heading left cell above body cells
*/ */
export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableProps>( export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableProps>(
({ packages, onArrange, onRemove, sx }, ref) => { ({ packages, onArrange, onRemove, sx }, ref) => {
const mergedSections = buildMergedSections(packages);
const colCount = packages.length + 1; const colCount = packages.length + 1;
const gridCols = `${COMPARISON_TABLE_COL_WIDTH}px repeat(${packages.length}, ${COMPARISON_TABLE_COL_WIDTH}px)`; const mergedSections = buildMergedSections(packages);
const recommendedColIdx = packages.findIndex((p) => p.isRecommended); const gridCols = `minmax(220px, 280px) repeat(${packages.length}, minmax(200px, 1fr))`;
const minW = packages.length > 3 ? 960 : packages.length > 2 ? 800 : 600;
return ( return (
<Box <Box
@@ -309,34 +232,32 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
sx={[ sx={[
{ {
display: { xs: 'none', md: 'block' }, display: { xs: 'none', md: 'block' },
width: COMPARISON_TABLE_COL_WIDTH * colCount, overflowX: 'auto',
}, },
...(Array.isArray(sx) ? sx : [sx]), ...(Array.isArray(sx) ? sx : [sx]),
]} ]}
> >
{/* ── Package header cards ── */} <Box sx={{ minWidth: minW }}>
<Box {/* ── Package header cards ── */}
role="row" <Box
sx={{ role="row"
display: 'grid', sx={{
gridTemplateColumns: gridCols, display: 'grid',
mb: 4, gridTemplateColumns: gridCols,
alignItems: 'stretch', gap: 2,
pt: 3, // Room for floating verified badges 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 {/* Info card — stretches to match package card height, text at top */}
scroll. The row labels below stay sticky on their own. */}
<Box sx={{ px: 2 }}>
<Card <Card
role="columnheader" role="columnheader"
variant="elevated" variant="elevated"
padding="default" padding="default"
sx={{ sx={{
bgcolor: 'var(--fa-color-surface-subtle)', bgcolor: 'var(--fa-color-surface-subtle)',
height: '100%', alignSelf: 'stretch',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
justifyContent: 'flex-start', justifyContent: 'flex-start',
@@ -355,117 +276,71 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
Review and compare features side-by-side to find the right fit. Review and compare features side-by-side to find the right fit.
</Typography> </Typography>
</Card> </Card>
</Box>
{packages.map((pkg) => ( {/* Package column header cards */}
<Box key={pkg.id} sx={{ px: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}> {packages.map((pkg) => (
<ComparisonColumnCard <ComparisonColumnCard
key={pkg.id}
pkg={pkg} pkg={pkg}
onArrange={onArrange} onArrange={onArrange}
onRemove={onRemove} onRemove={onRemove}
sx={{ flex: 1 }}
/> />
</Box> ))}
))} </Box>
</Box>
{/* ── Section tables (each separate with left accent headings) ── */} {/* ── Section tables (each separate with left accent headings) ── */}
{mergedSections.map((section) => ( {mergedSections.map((section) => (
<Box key={section.heading} sx={{ ...tableSx, gridTemplateColumns: gridCols, mb: 3 }}> <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}` }}>
<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',
}}
>
<SectionHeading>{section.heading}</SectionHeading> <SectionHeading>{section.heading}</SectionHeading>
</Box> </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) => ( {section.items.map((item) => (
<Box
key={item.name}
role="row"
sx={{
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)',
},
}}
>
{/* Row-label cell — sticky-left */}
<Box <Box
role="cell" key={item.name}
className="comparison-cell comparison-cell--label" role="row"
sx={{ sx={{
position: 'sticky', gridColumn: `1 / ${colCount + 1}`,
left: 0, display: 'grid',
zIndex: Z_STICKY_LEFT, gridTemplateColumns: 'subgrid',
bgcolor: 'background.paper',
px: 3,
py: 2,
borderTop: '1px solid',
borderColor: 'divider',
transition: 'background-color 0.15s ease', transition: 'background-color 0.15s ease',
'&:hover': { bgcolor: 'var(--fa-color-brand-50)' },
}} }}
> >
<Typography variant="body2" color="text.secondary" component="span"> <Box
{item.name} role="cell"
</Typography> sx={{
{item.info && ( px: 3,
<Box component="span" sx={{ whiteSpace: 'nowrap' }}> py: 2,
{'\u00A0'} borderTop: '1px solid',
<Tooltip title={item.info} arrow placement="top"> borderColor: 'divider',
<InfoOutlinedIcon }}
aria-label={`More information about ${item.name}`} >
sx={{ <Typography variant="body2" color="text.secondary" component="span">
fontSize: 14, {item.name}
color: 'var(--fa-color-neutral-400)', </Typography>
cursor: 'help', {item.info && (
verticalAlign: 'middle', <Box component="span" sx={{ whiteSpace: 'nowrap' }}>
}} {'\u00A0'}
/> <Tooltip title={item.info} arrow placement="top">
</Tooltip> <InfoOutlinedIcon
</Box> aria-label={`More information about ${item.name}`}
)} sx={{
</Box> fontSize: 14,
color: 'var(--fa-color-neutral-400)',
cursor: 'help',
verticalAlign: 'middle',
}}
/>
</Tooltip>
</Box>
)}
</Box>
{packages.map((pkg, idx) => { {packages.map((pkg) => (
const isRecommended = idx === recommendedColIdx;
return (
<Box <Box
key={pkg.id} key={pkg.id}
role="cell" role="cell"
className={
'comparison-cell' + (isRecommended ? ' comparison-cell--recommended' : '')
}
sx={{ sx={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -476,26 +351,23 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
borderColor: 'divider', borderColor: 'divider',
borderLeft: '1px solid', borderLeft: '1px solid',
borderLeftColor: 'divider', 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 <CellValue value={lookupValue(pkg, section.heading, item.name)} />
value={lookupValue(pkg, section.heading, item.name)}
sectionHeading={section.heading}
/>
</Box> </Box>
); ))}
})} </Box>
</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> </Box>
); );
}, },

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,17 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import { PackageDetail } from './PackageDetail'; 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 = [ const essentials = [
{ {
@@ -106,6 +117,41 @@ const extras = {
const termsText = 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.'; '* 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> = { const meta: Meta<typeof PackageDetail> = {
title: 'Organisms/PackageDetail', title: 'Organisms/PackageDetail',
component: 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 ---------------------------------------------------------- // --- Without Extras ----------------------------------------------------------
/** Simpler package with essentials and optionals only — no extras */ /** Simpler package with essentials and optionals only — no extras */
@@ -194,3 +222,132 @@ export const WithoutExtras: Story = {
onCompare: () => alert('Compare'), 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 React from 'react';
import Box from '@mui/material/Box'; 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 InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import type { SxProps, Theme } from '@mui/material/styles'; import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography'; import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button'; import { Button } from '../../atoms/Button';
@@ -56,11 +53,6 @@ export interface PackageDetailProps {
arrangeDisabled?: boolean; arrangeDisabled?: boolean;
/** Whether the compare button is in loading state */ /** Whether the compare button is in loading state */
compareLoading?: boolean; 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") */ /** Custom label for the arrange CTA button (default: "Make Arrangement") */
arrangeLabel?: string; arrangeLabel?: string;
/** Disclaimer shown below the price (e.g. for unverified/estimated pricing) */ /** Disclaimer shown below the price (e.g. for unverified/estimated pricing) */
@@ -132,7 +124,6 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
terms, terms,
onArrange, onArrange,
onCompare, onCompare,
inCart = false,
arrangeDisabled = false, arrangeDisabled = false,
compareLoading = false, compareLoading = false,
arrangeLabel = 'Make Arrangement', arrangeLabel = 'Make Arrangement',
@@ -142,11 +133,6 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
}, },
ref, 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 ( return (
<Box <Box
ref={ref} ref={ref}
@@ -155,7 +141,6 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
border: '1px solid', border: '1px solid',
borderColor: 'divider', borderColor: 'divider',
borderRadius: 'var(--fa-card-border-radius-default)', borderRadius: 'var(--fa-card-border-radius-default)',
boxShadow: 'var(--fa-card-shadow-default)',
overflow: 'hidden', overflow: 'hidden',
}, },
...(Array.isArray(sx) ? sx : [sx]), ...(Array.isArray(sx) ? sx : [sx]),
@@ -164,7 +149,7 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
{/* Header band — warm bg to separate from content */} {/* Header band — warm bg to separate from content */}
<Box <Box
sx={{ sx={{
bgcolor: 'background.paper', bgcolor: 'var(--fa-color-surface-warm)',
px: { xs: 2, sm: 3 }, px: { xs: 2, sm: 3 },
pt: 3, pt: 3,
pb: 2.5, pb: 2.5,
@@ -193,10 +178,10 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
sx={{ sx={{
display: 'flex', display: 'flex',
alignItems: 'flex-start', alignItems: 'flex-start',
gap: 1.25, gap: 1,
mt: 1.5, mt: 1.5,
px: 2, px: 1.5,
py: 1.5, py: 1,
bgcolor: 'var(--fa-color-surface-cool, #F5F7FA)', bgcolor: 'var(--fa-color-surface-cool, #F5F7FA)',
borderRadius: 'var(--fa-border-radius-sm, 6px)', borderRadius: 'var(--fa-border-radius-sm, 6px)',
border: '1px solid', border: '1px solid',
@@ -204,20 +189,22 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
}} }}
> >
<InfoOutlinedIcon <InfoOutlinedIcon
sx={{ fontSize: 16, color: 'text.secondary', mt: '3px', flexShrink: 0 }} sx={{ fontSize: 16, color: 'text.secondary', mt: '1px', flexShrink: 0 }}
aria-hidden aria-hidden
/> />
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.5 }}> <Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.4 }}>
{priceDisclaimer} {priceDisclaimer}
</Typography> </Typography>
</Box> </Box>
)} )}
{/* CTA buttons — always side-by-side */} {/* CTA buttons */}
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 1.5, mt: 2.5 }}> <Box
sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, gap: 1.5, mt: 2.5 }}
>
<Button <Button
variant="contained" variant="contained"
size={ctaSize} size="large"
fullWidth fullWidth
disabled={arrangeDisabled} disabled={arrangeDisabled}
onClick={onArrange} onClick={onArrange}
@@ -225,19 +212,12 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
{arrangeLabel} {arrangeLabel}
</Button> </Button>
{onCompare && ( {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 <Button
variant="soft" variant="soft"
color="secondary" color="secondary"
size={ctaSize} size="large"
loading={compareLoading} loading={compareLoading}
endIcon={inCart ? <CheckRoundedIcon /> : undefined}
onClick={onCompare} onClick={onCompare}
aria-pressed={inCart}
aria-label={inCart ? 'Remove from comparison' : 'Add to comparison'}
sx={{ flexShrink: 0 }} sx={{ flexShrink: 0 }}
> >
Compare 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', name: 'Everyday Funeral Package',
price: 5495.45, price: 5495.45,
provider: { provider: {
name: 'Mackay Family Funeral Directors & Cremation Services', name: 'Mackay Family Funerals',
location: 'Inglewood', location: 'Inglewood',
logoUrl: DEMO_LOGO, logoUrl: DEMO_LOGO,
rating: 4.6, rating: 4.6,

View File

@@ -1,21 +1,14 @@
import React, { useId, useState, useRef, useCallback } from 'react'; import React, { useId, useState, useRef, useCallback } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Divider from '@mui/material/Divider';
import useMediaQuery from '@mui/material/useMediaQuery'; import useMediaQuery from '@mui/material/useMediaQuery';
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
import ShareOutlinedIcon from '@mui/icons-material/ShareOutlined'; import ShareOutlinedIcon from '@mui/icons-material/ShareOutlined';
import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined'; import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
import type { SxProps, Theme } from '@mui/material/styles'; import type { SxProps, Theme } from '@mui/material/styles';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { Typography } from '../../atoms/Typography'; import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button'; import { Button } from '../../atoms/Button';
import { Link } from '../../atoms/Link';
import { WizardLayout } from '../../templates/WizardLayout'; import { WizardLayout } from '../../templates/WizardLayout';
import { import { ComparisonTable, type ComparisonPackage } from '../../organisms/ComparisonTable';
ComparisonTable,
COMPARISON_TABLE_COL_WIDTH,
type ComparisonPackage,
} from '../../organisms/ComparisonTable';
import { ComparisonPackageCard } from '../../molecules/ComparisonPackageCard'; import { ComparisonPackageCard } from '../../molecules/ComparisonPackageCard';
import { ComparisonTabCard } from '../../molecules/ComparisonTabCard'; import { ComparisonTabCard } from '../../molecules/ComparisonTabCard';
@@ -120,147 +113,27 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
// eslint-disable-next-line react-hooks/exhaustive-deps // 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 ( return (
<Box ref={ref} sx={sx}> <Box ref={ref} sx={sx}>
<WizardLayout <WizardLayout
variant={isMobile ? 'wide-form' : 'bleed'} variant="wide-form"
navigation={navigation} navigation={navigation}
showBackLink={isMobile} showBackLink
backLabel="Back" backLabel="Back"
onBack={onBack} onBack={onBack}
> >
{!isMobile && ( {/* Page header with Share/Print actions */}
<> <Box sx={{ mb: { xs: 3, md: 5 } }}>
{/* Page header zone — centred, bounded to the table's natural width */} <Box
<Box sx={{ display: 'flex', justifyContent: 'center', width: '100%' }}> sx={{
<Box display: 'flex',
sx={{ alignItems: 'flex-start',
width: '100%', justifyContent: 'space-between',
maxWidth: pageMaxWidth, gap: 2,
px: { xs: `${edgePadding.xs}px`, md: `${edgePadding.md}px` }, flexWrap: 'wrap',
pt: { xs: 2, md: 3 }, }}
pb: { xs: 3, md: 5 }, >
}} <Box>
>
<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>
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 2,
flexWrap: 'wrap',
}}
>
<Box>
<Typography variant="h2" component="h1" sx={{ mb: 1 }}>
Compare packages
</Typography>
<Typography variant="body1" color="text.secondary" aria-live="polite">
{subtitle}
</Typography>
</Box>
{(onShare || onPrint) && (
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
{onShare && (
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<ShareOutlinedIcon />}
onClick={onShare}
>
Share
</Button>
)}
{onPrint && (
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<PrintOutlinedIcon />}
onClick={onPrint}
>
Print
</Button>
)}
</Box>
)}
</Box>
</Box>
</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>
</>
)}
{/* Mobile: Tab rail + card view */}
{isMobile && allPackages.length > 0 && (
<>
<Box sx={{ mb: 3 }}>
<Typography variant="h2" component="h1" sx={{ mb: 1 }}> <Typography variant="h2" component="h1" sx={{ mb: 1 }}>
Compare packages Compare packages
</Typography> </Typography>
@@ -269,21 +142,50 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
</Typography> </Typography>
</Box> </Box>
<Divider sx={{ mb: 3 }} /> {/* Share + Print */}
{(onShare || onPrint) && (
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
{onShare && (
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<ShareOutlinedIcon />}
onClick={onShare}
>
Share
</Button>
)}
{onPrint && (
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<PrintOutlinedIcon />}
onClick={onPrint}
>
Print
</Button>
)}
</Box>
)}
</Box>
</Box>
<Typography {/* Desktop: ComparisonTable */}
id="comparison-rail-heading" {!isMobile && (
variant="label" <ComparisonTable packages={allPackages} onArrange={onArrange} onRemove={onRemove} />
component="h2" )}
sx={{ fontWeight: 600, display: 'block', mb: 1.5 }}
> {/* Mobile: Tab rail + card view */}
Choose a package to view {isMobile && allPackages.length > 0 && (
</Typography> <>
{/* Tab rail — mini cards showing provider + package + price */}
<Box <Box
ref={railRef} ref={railRef}
role="tablist" role="tablist"
id={tablistId} id={tablistId}
aria-labelledby="comparison-rail-heading" aria-label="Packages to compare"
sx={{ sx={{
display: 'flex', display: 'flex',
gap: 1.5, gap: 1.5,
@@ -291,7 +193,8 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
py: 2, py: 2,
px: 2, px: 2,
mx: -2, mx: -2,
mb: 1.5, mt: 1,
mb: 3,
scrollbarWidth: 'none', scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' }, '&::-webkit-scrollbar': { display: 'none' },
WebkitOverflowScrolling: 'touch', WebkitOverflowScrolling: 'touch',
@@ -313,54 +216,6 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
))} ))}
</Box> </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) => {
const isActive = idx === activeTabIdx;
return (
<Box
key={idx}
component="button"
type="button"
tabIndex={-1}
onClick={() => handleTabClick(idx)}
sx={{
appearance: 'none',
border: 0,
background: 'transparent',
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',
},
}}
>
<span />
</Box>
);
})}
</Box>
{activePackage && ( {activePackage && (
<Box <Box
role="tabpanel" role="tabpanel"

View File

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

View File

@@ -186,8 +186,8 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
discoverMapSlot, discoverMapSlot,
onSelectFeaturedProvider, onSelectFeaturedProvider,
features = [], features = [],
featuresHeading = '4 Reasons to use Funeral Arranger', featuresHeading = 'How it works',
featuresBody, featuresBody = 'Search local funeral directors, compare transparent pricing, and personalise a plan — all in your own time. No pressure, no hidden costs.',
googleRating, googleRating,
googleReviewCount, googleReviewCount,
testimonials = [], testimonials = [],
@@ -241,28 +241,17 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
}} }}
> >
<Container <Container
maxWidth={false} maxWidth="md"
sx={{ sx={{
position: 'relative', position: 'relative',
zIndex: 1, zIndex: 1,
textAlign: 'center', textAlign: 'center',
maxWidth: 990,
pt: { xs: 10, md: 14 }, pt: { xs: 10, md: 14 },
pb: { xs: 3, md: 4 }, pb: { xs: 3, md: 4 },
}} }}
> >
<Typography <Typography
variant="body1" variant="display3"
sx={{
color: 'rgba(255,255,255,0.85)',
fontStyle: 'italic',
mb: 2,
}}
>
Trusted by thousands of families across Australia
</Typography>
<Typography
variant="display2"
component="h1" component="h1"
id="hero-heading" id="hero-heading"
tabIndex={-1} tabIndex={-1}
@@ -291,7 +280,13 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
}} }}
> >
<Box sx={{ width: '100%', maxWidth: finderSlot ? 500 : 520, mx: 'auto' }}> <Box sx={{ width: '100%', maxWidth: finderSlot ? 500 : 520, mx: 'auto' }}>
{finderSlot || <FuneralFinderV3 onSearch={onSearch} loading={searchLoading} />} {finderSlot || (
<FuneralFinderV3
heading="Find your local providers"
onSearch={onSearch}
loading={searchLoading}
/>
)}
</Box> </Box>
</Box> </Box>
</Box> </Box>
@@ -321,7 +316,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
}} }}
> >
<Typography <Typography
variant="display2" variant="display3"
component="h1" component="h1"
id="hero-heading" id="hero-heading"
tabIndex={-1} tabIndex={-1}
@@ -374,7 +369,13 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
}} }}
> >
<Box sx={{ maxWidth: 620, mx: 'auto' }}> <Box sx={{ maxWidth: 620, mx: 'auto' }}>
{finderSlot || <FuneralFinderV3 onSearch={onSearch} loading={searchLoading} />} {finderSlot || (
<FuneralFinderV3
heading="Find your local providers"
onSearch={onSearch}
loading={searchLoading}
/>
)}
</Box> </Box>
</Box> </Box>
)} )}
@@ -800,30 +801,21 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
> >
<Container maxWidth="lg"> <Container maxWidth="lg">
<Box sx={{ textAlign: 'center', mb: { xs: 5, md: 8 } }}> <Box sx={{ textAlign: 'center', mb: { xs: 5, md: 8 } }}>
<Typography
variant="overline"
component="div"
sx={{ color: 'var(--fa-color-brand-600)', mb: 1.5 }}
>
Why Use Funeral Arranger
</Typography>
<Typography <Typography
variant="display3" variant="display3"
component="h2" component="h2"
id="features-heading" id="features-heading"
sx={{ mb: featuresBody ? 2.5 : 0, color: 'text.primary' }} sx={{ mb: 2.5, color: 'text.primary' }}
> >
{featuresHeading} {featuresHeading}
</Typography> </Typography>
{featuresBody && ( <Typography
<Typography variant="body1"
variant="body1" color="text.secondary"
color="text.secondary" sx={{ maxWidth: 560, mx: 'auto', fontSize: { xs: '0.875rem', md: '1rem' } }}
sx={{ maxWidth: 560, mx: 'auto', fontSize: { xs: '0.875rem', md: '1rem' } }} >
> {featuresBody}
{featuresBody} </Typography>
</Typography>
)}
</Box> </Box>
<Box <Box
@@ -874,17 +866,6 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
}} }}
> >
<Container maxWidth="md"> <Container maxWidth="md">
<Typography
variant="overline"
component="div"
sx={{
textAlign: 'center',
color: 'var(--fa-color-brand-600)',
mb: 1.5,
}}
>
Funeral Arranger Reviews
</Typography>
<Typography <Typography
variant="display3" variant="display3"
component="h2" component="h2"
@@ -997,7 +978,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
> >
{ctaHeading} {ctaHeading}
</Typography> </Typography>
<Button variant="contained" size="medium" onClick={onCtaClick}> <Button variant="text" size="large" onClick={onCtaClick}>
{ctaButtonLabel} {ctaButtonLabel}
</Button> </Button>
</Container> </Container>

View File

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

View File

@@ -38,16 +38,6 @@ const nav = (
<Navigation <Navigation
logo={<FALogo />} logo={<FALogo />}
items={[ items={[
{
label: 'Locations',
children: [
{ label: 'Melbourne', href: '/locations/melbourne' },
{ label: 'Brisbane', href: '/locations/brisbane' },
{ label: 'Sydney', href: '/locations/sydney' },
{ label: 'South Coast NSW', href: '/locations/south-coast-nsw' },
{ label: 'Central Coast NSW', href: '/locations/central-coast-nsw' },
],
},
{ label: 'FAQ', href: '/faq' }, { label: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' }, { label: 'Contact Us', href: '/contact' },
{ label: 'Log in', href: '/login' }, { label: 'Log in', href: '/login' },
@@ -268,7 +258,7 @@ export const Default: Story = {
args: { args: {
navigation: nav, navigation: nav,
footer, footer,
heroImageUrl: assetUrl('/images/heroes/hero-couple.jpg'), heroImageUrl: assetUrl('/images/heroes/hero-3.png'),
heroHeading: 'Compare funeral director pricing near you and arrange with confidence', heroHeading: 'Compare funeral director pricing near you and arrange with confidence',
heroSubheading: 'Transparent pricing \u00B7 No hidden fees \u00B7 Arrange 24/7', heroSubheading: 'Transparent pricing \u00B7 No hidden fees \u00B7 Arrange 24/7',
stats: trustStats, stats: trustStats,

View File

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

View File

@@ -1,9 +1,9 @@
import { useState } from 'react'; import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { PackagesStep } from './PackagesStep'; import { PackagesStep } from './PackagesStep';
import type { NearbyVerifiedProvider, PackageData, PackagesStepProvider } from './PackagesStep'; import type { PackageData, PackagesStepProvider } from './PackagesStep';
import { Navigation } from '../../organisms/Navigation'; import { Navigation } from '../../organisms/Navigation';
import Box from '@mui/material/Box';
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -35,19 +35,10 @@ const nav = (
/> />
); );
// ─── Mock data ─────────────────────────────────────────────────────────────── const mockProvider: PackagesStepProvider = {
const verifiedProvider: PackagesStepProvider = {
name: 'H.Parsons Funeral Directors',
location: 'Wentworth, NSW',
imageUrl: '/images/placeholder/hparsonsvenue.jpg',
rating: 4.6,
reviewCount: 7,
};
const unverifiedProvider: PackagesStepProvider = {
name: 'H.Parsons Funeral Directors', name: 'H.Parsons Funeral Directors',
location: 'Wentworth, NSW', location: 'Wentworth, NSW',
imageUrl: 'https://placehold.co/120x80/E8E0D6/8B6F47?text=H.Parsons',
rating: 4.6, rating: 4.6,
reviewCount: 7, 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 ──────────────────────────────────────────────────────────────────── // ─── Meta ────────────────────────────────────────────────────────────────────
const meta: Meta<typeof PackagesStep> = { const meta: Meta<typeof PackagesStep> = {
@@ -283,152 +161,22 @@ const meta: Meta<typeof PackagesStep> = {
export default meta; export default meta;
type Story = StoryObj<typeof PackagesStep>; type Story = StoryObj<typeof PackagesStep>;
// ─── Verified ──────────────────────────────────────────────────────────────── // ─── Interactive (default) ──────────────────────────────────────────────────
/** Verified provider — matching packages + up to 3 other packages from the same provider */ /** Matched + other packages — select a package, see detail, click Make Arrangement */
export const Verified: Story = { export const Default: 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 = {
render: () => { render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
return ( return (
<PackagesStep <PackagesStep
provider={verifiedProvider} provider={mockProvider}
providerTier="verified"
packages={matchedPackages} packages={matchedPackages}
secondaryList={{ kind: 'same-provider-more', packages: otherPackages }} otherPackages={otherPackages}
selectedPackageId={selectedId} selectedPackageId={selectedId}
onSelectPackage={setSelectedId} onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')} onArrange={() => alert('Open ArrangementDialog')}
onCompare={() => alert('Open compare view')} onProviderClick={() => alert('Open provider profile')}
onProviderClick={() => alert('Open provider profile (future)')}
onBack={() => alert('Back')} onBack={() => alert('Back')}
navigation={nav} navigation={nav}
/> />
@@ -436,21 +184,44 @@ export const NoSelection: Story = {
}, },
}; };
/** Verified provider with no "other packages" — primary list only */ // ─── With selection ─────────────────────────────────────────────────────────
export const VerifiedNoSecondary: Story = {
/** 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: () => { render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
return ( return (
<PackagesStep <PackagesStep
provider={verifiedProvider} provider={mockProvider}
providerTier="verified" packages={[...matchedPackages, ...otherPackages]}
packages={matchedPackages}
selectedPackageId={selectedId} selectedPackageId={selectedId}
onSelectPackage={setSelectedId} onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')} onArrange={() => alert('Open ArrangementDialog')}
onCompare={() => alert('Open compare view')} onProviderClick={() => alert('Open provider profile')}
onProviderClick={() => alert('Open provider profile (future)')}
onBack={() => alert('Back')} onBack={() => alert('Back')}
navigation={nav} navigation={nav}
/> />
@@ -458,6 +229,8 @@ export const VerifiedNoSecondary: Story = {
}, },
}; };
// ─── Pre-planning ───────────────────────────────────────────────────────────
/** Pre-planning flow — softer copy */ /** Pre-planning flow — softer copy */
export const PrePlanning: Story = { export const PrePlanning: Story = {
render: () => { render: () => {
@@ -465,15 +238,13 @@ export const PrePlanning: Story = {
return ( return (
<PackagesStep <PackagesStep
provider={verifiedProvider} provider={mockProvider}
providerTier="verified"
packages={matchedPackages} packages={matchedPackages}
secondaryList={{ kind: 'same-provider-more', packages: otherPackages }} otherPackages={otherPackages}
selectedPackageId={selectedId} selectedPackageId={selectedId}
onSelectPackage={setSelectedId} onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')} onArrange={() => alert('Open ArrangementDialog')}
onCompare={() => alert('Open compare view')} onProviderClick={() => alert('Open provider profile')}
onProviderClick={() => alert('Open provider profile (future)')}
onBack={() => alert('Back')} onBack={() => alert('Back')}
navigation={nav} navigation={nav}
isPrePlanning isPrePlanning
@@ -482,15 +253,16 @@ export const PrePlanning: Story = {
}, },
}; };
/** Validation error */ // ─── Validation error ───────────────────────────────────────────────────────
/** Error shown when no package selected */
export const WithError: Story = { export const WithError: Story = {
render: () => { render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
return ( return (
<PackagesStep <PackagesStep
provider={verifiedProvider} provider={mockProvider}
providerTier="verified"
packages={matchedPackages} packages={matchedPackages}
selectedPackageId={selectedId} selectedPackageId={selectedId}
onSelectPackage={setSelectedId} 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 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 type { SxProps, Theme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout'; import { WizardLayout } from '../../templates/WizardLayout';
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact'; import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
import { ServiceOption } from '../../molecules/ServiceOption'; import { ServiceOption } from '../../molecules/ServiceOption';
import { MiniCard } from '../../molecules/MiniCard';
import { PackageDetail } from '../../organisms/PackageDetail'; import { PackageDetail } from '../../organisms/PackageDetail';
import type { PackageSection } from '../../organisms/PackageDetail';
import { Typography } from '../../atoms/Typography'; import { Typography } from '../../atoms/Typography';
import { Divider } from '../../atoms/Divider'; import { Divider } from '../../atoms/Divider';
import { Link } from '../../atoms/Link';
import type { PackageData, PackagesStepProvider, ProviderTier, SecondaryList } from './types';
export type { // ─── Types ───────────────────────────────────────────────────────────────────
PackageData,
PackagesStepProvider,
NearbyVerifiedProvider,
ProviderTier,
SecondaryList,
} from './types';
// ─── Tier copy map ─────────────────────────────────────────────────────────── /** Provider summary for the compact card */
export interface PackagesStepProvider {
interface TierCopy { /** Provider name */
heading: string; name: string;
subheading: (isPrePlanning: boolean) => string; /** Location */
arrangeLabel: string; location: string;
priceDisclaimer?: string; /** Image URL */
itemizedUnavailable: boolean; imageUrl?: string;
emptyDetailMessage: string; /** Rating */
rating?: number;
/** Review count */
reviewCount?: number;
} }
const TIER_COPY: Record<ProviderTier, TierCopy> = { /** Package data for the selection list */
verified: { export interface PackageData {
heading: 'Choose a funeral package', /** Unique package ID */
subheading: (isPrePlanning) => id: string;
isPrePlanning /** Package display name */
? 'Compare packages to find what suits your wishes. Nothing is committed until you confirm.' name: string;
: 'Each package includes a set of services. You can customise your selections in the next steps.', /** Package price in dollars */
arrangeLabel: 'Make Arrangement', price: number;
itemizedUnavailable: false, /** Short description */
emptyDetailMessage: "Select a package to see what's included.", description?: string;
}, /** Line item sections for the detail panel */
tier3: { sections: PackageSection[];
heading: 'Explore available packages', /** Total price (may differ from base price with extras) */
subheading: (isPrePlanning) => total?: number;
isPrePlanning /** Extra items section (after total) */
? 'Browse estimated packages to get a sense of what this provider offers. Nothing is committed.' extras?: PackageSection;
: 'These packages are based on publicly available information. Make an enquiry to confirm details and pricing.', /** Terms and conditions */
arrangeLabel: 'Make an enquiry', terms?: string;
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 ───────────────────────────────────────────────────────────────────
/** Props for the PackagesStep page component */
export interface PackagesStepProps { export interface PackagesStepProps {
/** Provider shown at the top of the list panel */ /** Provider summary shown at top of the list panel */
provider: PackagesStepProvider; provider: PackagesStepProvider;
/** Provider tier — drives copy, CTA label, disclaimer, itemised-unavailable state */ /** Packages matching the user's filters from the previous step */
providerTier: ProviderTier;
/** Packages in the primary list (filtered by user preferences, or all when `showAllFromProvider`) */
packages: PackageData[]; packages: PackageData[];
/** Secondary list below the primary one — same-provider-more or nearby-verified. Suppressed when `showAllFromProvider` is true. */ /** Other packages from this provider that didn't match filters (shown in secondary group) */
secondaryList?: SecondaryList; otherPackages?: PackageData[];
/** Currently selected package ID */ /** Currently selected package ID */
selectedPackageId: string | null; selectedPackageId: string | null;
/** Callback when a primary-list package is selected (or cleared via mobile back) */ /** Callback when a package is selected */
onSelectPackage: (id: string | null) => void; onSelectPackage: (id: string) => void;
/** Callback when "Make Arrangement" / "Make an enquiry" is clicked */ /** Callback when "Make Arrangement" is clicked (opens ArrangementDialog) */
onArrange: () => void; onArrange: () => void;
/** Callback when the "Compare" button on the PackageDetail panel is clicked */ /** Callback when the provider card is clicked (opens provider profile popup) */
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) */
onProviderClick?: () => void; onProviderClick?: () => void;
/** Callback for the Back button */ /** Callback for the Back button */
onBack: () => void; 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 */ /** Validation error */
error?: string; error?: string;
/** Whether the arrange action is loading */ /** Whether the arrange action is loading */
@@ -132,61 +75,23 @@ export interface PackagesStepProps {
sx?: SxProps<Theme>; 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 ─────────────────────────────────────────────────────────────── // ─── 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 * List + Detail split layout. Left panel shows the selected provider
* `providerTier` prop. Header copy, CTA label, price disclaimer, and * (compact) and selectable package cards. Right panel shows the full
* itemised-unavailable state are derived from tier. * detail breakdown of the selected package with "Make Arrangement" CTA.
* *
* Left column layout varies by `secondaryList`: * Packages are split into two groups:
* - `same-provider-more` (verified): primary "Matching your preferences" * - **Matching your preferences**: packages that matched the user's filters
* list + "Other packages from [Provider]" list. If >3 other packages, * from the providers step
* shows top 3 + "See all N packages from [Provider] →" link that routes * - **Other packages from [Provider]**: remaining packages outside those
* to the same page with `showAllFromProvider`. * filters, shown below a divider for passive discovery
* - `nearby-verified` (unverified tiers): primary list + "Similar packages
* from verified providers" 2-column MiniCard grid, capped at 4. Every
* card is verified by definition.
* *
* When `showAllFromProvider` is true, renders a flat "All packages from * Selecting a package reveals its detail. Clicking "Make Arrangement"
* [Provider]" list with no grouping and no secondary list. The caller * on the detail panel triggers the ArrangementDialog (D-E).
* preserves `selectedPackageId` across this navigation.
* *
* Pure presentation component — props in, callbacks out. * Pure presentation component — props in, callbacks out.
* *
@@ -194,290 +99,191 @@ function GroupHeading({
*/ */
export const PackagesStep: React.FC<PackagesStepProps> = ({ export const PackagesStep: React.FC<PackagesStepProps> = ({
provider, provider,
providerTier,
packages, packages,
secondaryList, otherPackages = [],
selectedPackageId, selectedPackageId,
onSelectPackage, onSelectPackage,
onArrange, onArrange,
onCompare,
isSelectedPackageInCart = false,
onNearbyProviderClick,
onSeeAllPackages,
onProviderClick, onProviderClick,
onBack, onBack,
showAllFromProvider = false,
error, error,
loading = false, loading = false,
navigation, navigation,
isPrePlanning = false, isPrePlanning = false,
sx, sx,
}) => { }) => {
const copy = TIER_COPY[providerTier]; const allPackages = [...packages, ...otherPackages];
// Look up the selected package across BOTH the primary list and the const selectedPackage = allPackages.find((p) => p.id === selectedPackageId);
// same-provider-more secondary list — tapping "Premium Funeral Service" const hasOtherPackages = otherPackages.length > 0;
// 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);
// Mobile drill-in: on mobile, the list is the default view — only when the const subheading = isPrePlanning
// user explicitly taps a package do we swap in the detail panel. This ? 'Compare packages to find what suits your wishes. Nothing is committed until you confirm.'
// distinguishes "parent pre-selected first package for desktop auto-display" : 'Each package includes a set of services. You can customise your selections in the next steps.';
// (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';
return ( return (
<WizardLayout <WizardLayout
variant="list-detail" variant="list-detail"
navigation={navigation} navigation={navigation}
showBackLink showBackLink
backLabel={layoutBackLabel} backLabel="Back"
onBack={handleLayoutBack} onBack={onBack}
sx={sx} sx={sx}
secondaryPanel={ secondaryPanel={
<Box selectedPackage ? (
sx={{ <PackageDetail
display: { name={selectedPackage.name}
xs: mobileShowDetail ? 'block' : 'none', price={selectedPackage.price}
md: 'block', sections={selectedPackage.sections}
}, total={selectedPackage.total}
}} extras={selectedPackage.extras}
> terms={selectedPackage.terms}
{selectedPackage ? ( onArrange={onArrange}
<PackageDetail arrangeDisabled={loading}
name={selectedPackage.name} />
price={selectedPackage.price} ) : (
sections={selectedPackage.sections} <Box
total={selectedPackage.total} sx={{
extras={selectedPackage.extras} display: 'flex',
terms={selectedPackage.terms} alignItems: 'center',
onArrange={onArrange} justifyContent: 'center',
onCompare={onCompare} height: '100%',
inCart={isSelectedPackageInCart} minHeight: 300,
arrangeDisabled={loading} bgcolor: 'var(--fa-color-brand-50)',
arrangeLabel={copy.arrangeLabel} borderRadius: 2,
priceDisclaimer={copy.priceDisclaimer} p: 4,
itemizedUnavailable={copy.itemizedUnavailable} }}
/> >
) : ( <Typography variant="body1" color="text.secondary" sx={{ textAlign: 'center' }}>
<Box Select a package to see what&apos;s included.
sx={{ </Typography>
display: 'flex', </Box>
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' }}>
{copy.emptyDetailMessage}
</Typography>
</Box>
)}
</Box>
} }
> >
{/* List column — hidden on mobile when a package is selected (drill-in) */} {/* Provider compact card — clickable to open provider profile */}
<Box <Box sx={{ mb: 3 }}>
sx={{ <ProviderCardCompact
display: { name={provider.name}
xs: mobileShowDetail ? 'none' : 'block', location={provider.location}
md: 'block', imageUrl={provider.imageUrl}
}, rating={provider.rating}
}} reviewCount={provider.reviewCount}
> onClick={onProviderClick}
{/* Provider compact card */} />
<Box sx={{ mb: 6 }}>
<ProviderCardCompact
name={provider.name}
location={provider.location}
imageUrl={provider.imageUrl}
rating={provider.rating}
reviewCount={provider.reviewCount}
onClick={onProviderClick}
/>
</Box>
{/* Heading + subheading */}
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
{heading}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 6 }}>
{subheading}
</Typography>
{/* Error */}
{error && (
<Typography
variant="body2"
sx={{ mb: 2, color: 'var(--fa-color-text-brand)' }}
role="alert"
>
{error}
</Typography>
)}
{/* ─── Primary packages ─── */}
{/* Show "Matching your preferences" heading only when a secondary list follows */}
{hasSecondary && !showAllFromProvider && <GroupHeading label="Matching your preferences" />}
<Box
role="radiogroup"
aria-label={primaryListAriaLabel}
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 4 }}
>
{packages.map((pkg) => (
<ServiceOption
key={pkg.id}
name={pkg.name}
description={pkg.description}
price={pkg.price}
selected={selectedPackageId === pkg.id}
onClick={() => handleSelectPackage(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>
{/* ─── Secondary: same-provider-more ─── */}
{activeSecondaryList?.kind === 'same-provider-more' && sameProviderPackages.length > 0 && (
<>
<Divider sx={{ my: 8 }} />
<GroupHeading label={`Other packages from ${provider.name}`} emphasis="secondary" />
<Box
role="radiogroup"
aria-label={`Other packages from ${provider.name}`}
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
mb: sameProviderOverflow ? 2 : 3,
opacity: 0.85,
}}
>
{sameProviderVisible.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}
/>
))}
</Box>
</>
)}
</Box> </Box>
{/* Heading */}
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
Choose a funeral package
</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>
)}
{/* ─── 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="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>
{/* ─── Other packages (passive discovery) ─── */}
{hasOtherPackages && (
<>
<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: 3, opacity: 0.85 }}
>
{otherPackages.map((pkg) => (
<ServiceOption
key={pkg.id}
name={pkg.name}
description={pkg.description}
price={pkg.price}
selected={selectedPackageId === pkg.id}
onClick={() => onSelectPackage(pkg.id)}
/>
))}
</Box>
</>
)}
</WizardLayout> </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 Autocomplete from '@mui/material/Autocomplete';
import FormControlLabel from '@mui/material/FormControlLabel'; import FormControlLabel from '@mui/material/FormControlLabel';
import Slider from '@mui/material/Slider'; 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 ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import ToggleButton from '@mui/material/ToggleButton'; 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 ViewListOutlinedIcon from '@mui/icons-material/ViewListOutlined';
import MapOutlinedIcon from '@mui/icons-material/MapOutlined'; import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import type { SxProps, Theme } from '@mui/material/styles'; import type { SxProps, Theme } from '@mui/material/styles';
import { useTheme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout'; import { WizardLayout } from '../../templates/WizardLayout';
import { ProviderCard } from '../../molecules/ProviderCard'; import { ProviderCard } from '../../molecules/ProviderCard';
import { FilterPanel } from '../../molecules/FilterPanel'; import { FilterPanel } from '../../molecules/FilterPanel';
import { MapProviderDrawer } from '../../molecules/MapProviderDrawer'; import { Button } from '../../atoms/Button';
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 { Chip } from '../../atoms/Chip'; import { Chip } from '../../atoms/Chip';
import { Switch } from '../../atoms/Switch'; import { Switch } from '../../atoms/Switch';
import { Typography } from '../../atoms/Typography'; import { Typography } from '../../atoms/Typography';
@@ -55,8 +49,6 @@ export interface ProviderData {
distanceKm?: number; distanceKm?: number;
/** Brief description */ /** Brief description */
description?: string; description?: string;
/** Geographic coordinates for map display */
coords?: { lat: number; lng: number };
} }
/** A funeral type option for the filter */ /** A funeral type option for the filter */
@@ -173,8 +165,8 @@ const DEFAULT_FUNERAL_TYPES: FuneralTypeOption[] = [
const SORT_OPTIONS: { value: ProviderSortBy; label: string }[] = [ const SORT_OPTIONS: { value: ProviderSortBy; label: string }[] = [
{ value: 'recommended', label: 'Recommended' }, { value: 'recommended', label: 'Recommended' },
{ value: 'nearest', label: 'Nearest' }, { value: 'nearest', label: 'Nearest' },
{ value: 'price_low', label: 'Price low to high' }, { value: 'price_low', label: 'Price: Low to High' },
{ value: 'price_high', label: 'Price high to low' }, { value: 'price_high', label: 'Price: High to Low' },
]; ];
export const EMPTY_FILTER_VALUES: ProviderFilterValues = { export const EMPTY_FILTER_VALUES: ProviderFilterValues = {
@@ -202,98 +194,6 @@ const chipWrapSx = {
gap: 1, gap: 1,
} as const; } 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 ─────────────────────────────────────────────────────────────── // ─── 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.' ? '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.'; : 'These providers are near your location. Each has their own packages and pricing.';
// ─── Mobile map-first plumbing ─── // ─── Local state ───
const theme = useTheme(); const [sortAnchor, setSortAnchor] = React.useState<null | HTMLElement>(null);
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';
// ─── Price input local state (commits on blur / Enter) ─── // ─── Price input local state (commits on blur / Enter) ───
const [priceMinInput, setPriceMinInput] = React.useState(String(filterValues.priceRange[0])); const [priceMinInput, setPriceMinInput] = React.useState(String(filterValues.priceRange[0]));
@@ -398,257 +294,6 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
onFilterChange({ ...filterValues, funeralTypes: next }); onFilterChange({ ...filterValues, funeralTypes: next });
}; };
// ─── Shared JSX fragments (used by desktop + mobile-map layouts) ───────────
/** The full filter-dialog content — used by both desktop's sticky FilterPanel
* and the mobile-map floating FilterPanel. */
const filterDialogChildren = (
<>
{/* ── Service tradition ── */}
<Box>
<Typography variant="labelLg" sx={sectionHeadingSx}>
Service tradition
</Typography>
<Autocomplete
value={filterValues.tradition}
onChange={(_, newValue) => onFilterChange({ ...filterValues, tradition: newValue })}
options={traditionOptions}
renderInput={(params) => (
<TextField {...params} placeholder="Search traditions..." size="small" />
)}
clearOnEscape
size="small"
/>
</Box>
<Divider />
{/* ── Funeral type ── */}
<Box>
<Typography variant="labelLg" sx={sectionHeadingSx}>
Funeral type
</Typography>
<Box sx={chipWrapSx}>
{funeralTypeOptions.map((option) => (
<Chip
key={option.value}
label={option.label}
selected={filterValues.funeralTypes.includes(option.value)}
onClick={() => handleFuneralTypeToggle(option.value)}
variant="outlined"
size="medium"
/>
))}
</Box>
</Box>
<Divider />
{/* ── Provider features ── Switch aligned to the first text line so
wrapped labels read cleanly on narrow screens */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<FormControlLabel
control={
<Switch
checked={filterValues.verifiedOnly}
onChange={(_, checked) => onFilterChange({ ...filterValues, verifiedOnly: checked })}
/>
}
label="Verified providers only"
sx={{
mx: 0,
alignItems: 'flex-start',
'& .MuiFormControlLabel-label': { pt: 0.75 },
}}
/>
<FormControlLabel
control={
<Switch
checked={filterValues.onlineArrangements}
onChange={(_, checked) =>
onFilterChange({ ...filterValues, onlineArrangements: checked })
}
/>
}
label="Online arrangements available"
sx={{
mx: 0,
alignItems: 'flex-start',
'& .MuiFormControlLabel-label': { pt: 0.75 },
}}
/>
</Box>
<Divider />
{/* ── Price range ── */}
<Box>
<Typography variant="labelLg" sx={sectionHeadingSx}>
Price range
</Typography>
<Box sx={{ px: 2.5, mb: 1 }}>
<Slider
value={filterValues.priceRange}
onChange={(_, newValue) =>
onFilterChange({
...filterValues,
priceRange: newValue as [number, number],
})
}
min={minPrice}
max={maxPrice}
step={100}
valueLabelDisplay="auto"
valueLabelFormat={(v) => `$${v.toLocaleString('en-AU')}`}
color="primary"
/>
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
size="small"
value={priceMinInput}
onChange={(e) => setPriceMinInput(e.target.value.replace(/[^0-9]/g, ''))}
onBlur={commitPriceRange}
onKeyDown={(e) => e.key === 'Enter' && commitPriceRange()}
InputProps={{
startAdornment: <InputAdornment position="start">$</InputAdornment>,
}}
inputProps={{
inputMode: 'numeric',
'aria-label': 'Minimum price',
style: { padding: '6px 0' },
}}
sx={{ flex: 1, '& .MuiOutlinedInput-root': { fontSize: '0.875rem' } }}
/>
<Typography variant="caption" color="text.secondary">
</Typography>
<TextField
size="small"
value={priceMaxInput}
onChange={(e) => setPriceMaxInput(e.target.value.replace(/[^0-9]/g, ''))}
onBlur={commitPriceRange}
onKeyDown={(e) => e.key === 'Enter' && commitPriceRange()}
InputProps={{
startAdornment: <InputAdornment position="start">$</InputAdornment>,
}}
inputProps={{
inputMode: 'numeric',
'aria-label': 'Maximum price',
style: { padding: '6px 0' },
}}
sx={{ flex: 1, '& .MuiOutlinedInput-root': { fontSize: '0.875rem' } }}
/>
</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)}
size="small"
aria-label="View mode"
sx={[{ 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>
</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 ( return (
<WizardLayout <WizardLayout
variant="list-map" variant="list-map"
@@ -661,19 +306,38 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
sx={sx} sx={sx}
secondaryPanel={ secondaryPanel={
<Box sx={{ position: 'relative', flex: 1, display: 'flex' }}> <Box sx={{ position: 'relative', flex: 1, display: 'flex' }}>
{/* Floating view toggle — same chrome as the sticky-bar controls, {/* Floating view toggle */}
anchored to the map panel's top-left. */}
<ToggleButtonGroup <ToggleButtonGroup
value={viewMode} value={viewMode}
exclusive exclusive
onChange={(_, val) => val && onViewModeChange?.(val as ListViewMode)} onChange={(_, val) => val && onViewModeChange?.(val as ListViewMode)}
size="small" size="small"
aria-label="View mode" aria-label="View mode"
sx={[ sx={{
{ position: 'absolute', top: 12, left: 12, zIndex: 1 }, position: 'absolute',
controlToggleSx, top: 12,
{ '& .MuiToggleButton-root': { gap: 0.75 } }, 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"> <ToggleButton value="list" aria-label="List view">
<ViewListOutlinedIcon sx={{ fontSize: 16 }} /> <ViewListOutlinedIcon sx={{ fontSize: 16 }} />
@@ -729,15 +393,28 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
borderColor: 'divider', borderColor: 'divider',
}} }}
> >
{/* Location search — committed location renders as a chip inside {/* Location search */}
the input. Shared with the mobile-map floating strip via the <TextField
LocationSearchInput molecule. */} placeholder="Search a town or suburb..."
<LocationSearchInput
value={searchQuery}
onChange={onSearchChange}
onCommit={onSearch}
aria-label="Search providers by town or suburb" aria-label="Search providers by town or suburb"
sx={[controlInputSx, { mb: 1.5 }]} 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 */} {/* Control bar — filters + sort */}
@@ -748,42 +425,216 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
gap: 1, gap: 1,
}} }}
> >
<FilterPanel activeCount={activeCount} onClear={handleClear} sx={filterTriggerSx}> {/* Filters */}
{filterDialogChildren} <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}>
Service tradition
</Typography>
<Autocomplete
value={filterValues.tradition}
onChange={(_, newValue) => onFilterChange({ ...filterValues, tradition: newValue })}
options={traditionOptions}
renderInput={(params) => (
<TextField {...params} placeholder="Search traditions..." size="small" />
)}
clearOnEscape
size="small"
/>
</Box>
<Divider />
{/* ── Funeral type ── */}
<Box>
<Typography variant="labelLg" sx={sectionHeadingSx}>
Funeral type
</Typography>
<Box sx={chipWrapSx}>
{funeralTypeOptions.map((option) => (
<Chip
key={option.value}
label={option.label}
selected={filterValues.funeralTypes.includes(option.value)}
onClick={() => handleFuneralTypeToggle(option.value)}
variant="outlined"
size="small"
/>
))}
</Box>
</Box>
<Divider />
{/* ── Provider features ── */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<FormControlLabel
control={
<Switch
checked={filterValues.verifiedOnly}
onChange={(_, checked) =>
onFilterChange({ ...filterValues, verifiedOnly: checked })
}
/>
}
label="Verified providers only"
sx={{ mx: 0 }}
/>
<FormControlLabel
control={
<Switch
checked={filterValues.onlineArrangements}
onChange={(_, checked) =>
onFilterChange({ ...filterValues, onlineArrangements: checked })
}
/>
}
label="Online arrangements available"
sx={{ mx: 0 }}
/>
</Box>
<Divider />
{/* ── Price range ── */}
<Box>
<Typography variant="labelLg" sx={sectionHeadingSx}>
Price range
</Typography>
<Box sx={{ px: 2.5, mb: 1 }}>
<Slider
value={filterValues.priceRange}
onChange={(_, newValue) =>
onFilterChange({
...filterValues,
priceRange: newValue as [number, number],
})
}
min={minPrice}
max={maxPrice}
step={100}
valueLabelDisplay="auto"
valueLabelFormat={(v) => `$${v.toLocaleString('en-AU')}`}
color="primary"
/>
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
size="small"
value={priceMinInput}
onChange={(e) => setPriceMinInput(e.target.value.replace(/[^0-9]/g, ''))}
onBlur={commitPriceRange}
onKeyDown={(e) => e.key === 'Enter' && commitPriceRange()}
InputProps={{
startAdornment: <InputAdornment position="start">$</InputAdornment>,
}}
inputProps={{
inputMode: 'numeric',
'aria-label': 'Minimum price',
style: { padding: '6px 0' },
}}
sx={{ flex: 1, '& .MuiOutlinedInput-root': { fontSize: '0.875rem' } }}
/>
<Typography variant="caption" color="text.secondary">
</Typography>
<TextField
size="small"
value={priceMaxInput}
onChange={(e) => setPriceMaxInput(e.target.value.replace(/[^0-9]/g, ''))}
onBlur={commitPriceRange}
onKeyDown={(e) => e.key === 'Enter' && commitPriceRange()}
InputProps={{
startAdornment: <InputAdornment position="start">$</InputAdornment>,
}}
inputProps={{
inputMode: 'numeric',
'aria-label': 'Maximum price',
style: { padding: '6px 0' },
}}
sx={{ flex: 1, '& .MuiOutlinedInput-root': { fontSize: '0.875rem' } }}
/>
</Box>
</Box>
</FilterPanel> </FilterPanel>
{/* Sort — compact "Sort by" on mobile (grouped left next to {/* Sort — compact menu button, pushed right */}
Filters); verbose "Sort: <label>" on desktop (pushed right). */} <Box sx={{ ml: 'auto' }}>
<Box sx={{ ml: { xs: 0, md: 'auto' } }}> <Button
<SortMenu variant="outlined"
value={sortBy} color="secondary"
onChange={(v) => onSortChange?.(v as ProviderSortBy)} size="small"
options={SORT_OPTIONS} startIcon={<SwapVertIcon sx={{ fontSize: 16 }} />}
variant={isMobile ? 'compact' : 'verbose'} onClick={(e) => setSortAnchor(e.currentTarget)}
sx={controlButtonSx} aria-haspopup="listbox"
/> sx={{ textTransform: 'none' }}
>
{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' }}
>
{SORT_OPTIONS.map((opt) => (
<MenuItem
key={opt.value}
selected={opt.value === sortBy}
onClick={() => {
onSortChange?.(opt.value);
setSortAnchor(null);
}}
sx={{ fontSize: '0.813rem' }}
>
{opt.label}
</MenuItem>
))}
</Menu>
</Box> </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> </Box>
{/* Results count — below controls */} {/* Results count — below controls */}
@@ -793,10 +644,7 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
sx={{ mt: 3, display: 'block' }} sx={{ mt: 3, display: 'block' }}
aria-live="polite" aria-live="polite"
> >
<Box component="span" sx={{ fontWeight: 600, color: 'text.primary' }}> {providers.length} provider{providers.length !== 1 ? 's' : ''} found
{providers.length}
</Box>{' '}
provider{providers.length !== 1 ? 's' : ''} found
</Typography> </Typography>
</Box> </Box>
@@ -807,7 +655,7 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
sx={{ sx={{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: 4, gap: 2,
pb: 3, pb: 3,
pt: 2, pt: 2,
px: { xs: 2, md: 3 }, 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 Box from '@mui/material/Box';
import Container from '@mui/material/Container'; import Container from '@mui/material/Container';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import PhoneIcon from '@mui/icons-material/Phone';
import type { SxProps, Theme } from '@mui/material/styles'; import type { SxProps, Theme } from '@mui/material/styles';
import { Link } from '../../atoms/Link'; import { Link } from '../../atoms/Link';
import { HelpBar } from '../../molecules/HelpBar'; import { Typography } from '../../atoms/Typography';
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
@@ -15,8 +16,7 @@ export type WizardLayoutVariant =
| 'list-map' | 'list-map'
| 'list-detail' | 'list-detail'
| 'grid-sidebar' | 'grid-sidebar'
| 'detail-toggles' | 'detail-toggles';
| 'bleed';
/** Props for the WizardLayout template */ /** Props for the WizardLayout template */
export interface WizardLayoutProps { export interface WizardLayoutProps {
@@ -50,6 +50,33 @@ export interface WizardLayoutProps {
sx?: SxProps<Theme>; 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 ─────────────────────────────────────────────────────────────── // ─── Back link ───────────────────────────────────────────────────────────────
const BackLink: React.FC<{ label: string; onClick?: () => void }> = ({ label, onClick }) => ( const BackLink: React.FC<{ label: string; onClick?: () => void }> = ({ label, onClick }) => (
@@ -335,30 +362,6 @@ const DetailTogglesLayout: React.FC<{
</Box> </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 ───────────────────────────────────────────────────────────── // ─── Variant map ─────────────────────────────────────────────────────────────
const LAYOUT_MAP: Record< const LAYOUT_MAP: Record<
@@ -375,7 +378,6 @@ const LAYOUT_MAP: Record<
'list-detail': ListDetailLayout, 'list-detail': ListDetailLayout,
'grid-sidebar': GridSidebarLayout, 'grid-sidebar': GridSidebarLayout,
'detail-toggles': DetailTogglesLayout, 'detail-toggles': DetailTogglesLayout,
bleed: BleedLayout,
}; };
/* Stepper bar renders on any variant when progressStepper or runningTotal is provided */ /* 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. * 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.) * - **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-map**: Split view with scrollable card list and map panel (providers)
* - **list-detail**: Master-detail split for selection + detail (packages, preview) * - **list-detail**: Master-detail split for selection + detail (packages, preview)
* - **grid-sidebar**: Filter sidebar + card grid (coffins) * - **grid-sidebar**: Filter sidebar + card grid (coffins)
* - **detail-toggles**: Hero image + info column (venue, coffin details) * - **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, * All variants share: navigation slot, optional back link, sticky help bar,
* and optional progress stepper + running total bar (shown when props provided). * and optional progress stepper + running total bar (shown when props provided).
@@ -427,8 +426,8 @@ export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
flexDirection: 'column', flexDirection: 'column',
minHeight: '100vh', minHeight: '100vh',
bgcolor: 'background.default', bgcolor: 'background.default',
// list-map + detail-toggles + bleed: lock to viewport so panels scroll independently // list-map + detail-toggles: lock to viewport so panels scroll independently
...((variant === 'list-map' || variant === 'detail-toggles' || variant === 'bleed') && { ...((variant === 'list-map' || variant === 'detail-toggles') && {
height: '100vh', height: '100vh',
overflow: 'hidden', overflow: 'hidden',
}), }),
@@ -446,19 +445,15 @@ export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
{/* Stepper + running total bar (grid-sidebar, detail-toggles only) */} {/* Stepper + running total bar (grid-sidebar, detail-toggles only) */}
<StepperBar stepper={progressStepper} total={runningTotal} /> <StepperBar stepper={progressStepper} total={runningTotal} />
{/* Back link — inside children for list-map/detail-toggles/bleed (scrolls with content), {/* Back link — inside left panel for list-map/detail-toggles, above content for others */}
above content for other variants */} {showBackLink && variant !== 'list-map' && variant !== 'detail-toggles' && (
{showBackLink && <Container
variant !== 'list-map' && maxWidth={variant === 'centered-form' ? 'sm' : 'lg'}
variant !== 'detail-toggles' && sx={{ pt: 2, px: { xs: 4, md: 3 } }}
variant !== 'bleed' && ( >
<Container <BackLink label={backLabel} onClick={onBack} />
maxWidth={variant === 'centered-form' ? 'sm' : 'lg'} </Container>
sx={{ pt: 2, px: { xs: 4, md: 3 } }} )}
>
<BackLink label={backLabel} onClick={onBack} />
</Container>
)}
{/* Main content area */} {/* Main content area */}
<Box <Box
@@ -468,8 +463,7 @@ export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
<LayoutComponent <LayoutComponent
secondaryPanel={secondaryPanel} secondaryPanel={secondaryPanel}
backLink={ backLink={
showBackLink && showBackLink && (variant === 'list-map' || variant === 'detail-toggles') ? (
(variant === 'list-map' || variant === 'detail-toggles' || variant === 'bleed') ? (
<Box sx={{ pt: 1.5 }}> <Box sx={{ pt: 1.5 }}>
<BackLink label={backLabel} onClick={onBack} /> <BackLink label={backLabel} onClick={onBack} />
</Box> </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,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,
},
};
});