Compare commits
1 Commits
main
...
a9e5843375
| Author | SHA1 | Date | |
|---|---|---|---|
| a9e5843375 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,6 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-demo/
|
||||
storybook-static/
|
||||
tokens/export/
|
||||
*.local
|
||||
@@ -29,9 +28,6 @@ docs/reference/how-to-work-with-both-tools.md
|
||||
docs/reference/mcp-setup.md
|
||||
docs/reference/retroactive-review-plan.md
|
||||
|
||||
# Deploy scripts (contain credentials)
|
||||
scripts/
|
||||
|
||||
# Build logs
|
||||
build-storybook.log
|
||||
|
||||
@@ -43,6 +39,3 @@ temp-db/
|
||||
|
||||
# Root-level screenshots
|
||||
/*.png
|
||||
|
||||
# IDE-specific
|
||||
*.code-workspace
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<script src="https://mcp.figma.com/mcp/html-to-design/capture.js" async></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB |
@@ -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
173
package-lock.json
generated
@@ -10,15 +10,11 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.0",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@googlemaps/markerclusterer": "^2.6.2",
|
||||
"@mui/icons-material": "^5.16.0",
|
||||
"@mui/material": "^5.16.0",
|
||||
"@mui/system": "^5.16.0",
|
||||
"@vis.gl/react-google-maps": "^1.8.3",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-router-dom": "^7.14.1",
|
||||
"zustand": "^5.0.12"
|
||||
"react-dom": "^18.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
@@ -1462,26 +1458,6 @@
|
||||
"react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@googlemaps/js-api-loader": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-2.0.2.tgz",
|
||||
"integrity": "sha512-bKVuTqatS8Jven5aFqVB7rCHF1VFEzpzyi0ruzO0GUR+A7m9oMqMgtnmpANj7kMYEvvhty8Fk7TnJ1MKjWHu+Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/google.maps": "^3.53.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@googlemaps/markerclusterer": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@googlemaps/markerclusterer/-/markerclusterer-2.6.2.tgz",
|
||||
"integrity": "sha512-U6uVhq8iWhiIckA89sgRu8OK35mjd6/3CuoZKWakKEf0QmRRWpatlsPb3kqXkoWSmbcZkopRiI4dnW6DQSd7bQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"fast-equals": "^5.2.2",
|
||||
"supercluster": "^8.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@@ -4130,18 +4106,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/google.maps": {
|
||||
"version": "3.64.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.64.0.tgz",
|
||||
"integrity": "sha512-dN0H6tB4lgLQLovcbPXFYYOEV41TpyyJghzb5jrzjB96FZmjeOghevVdC+BMGd6YqyCqXaggyEtqRXLRjzCBZA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -4205,15 +4169,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/supercluster": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
|
||||
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
@@ -4523,21 +4478,6 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@vis.gl/react-google-maps": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@vis.gl/react-google-maps/-/react-google-maps-1.8.3.tgz",
|
||||
"integrity": "sha512-DW7nEuvOJ299DmdBnvGiUARrgS/+sTEO1iJgG9J8YaErZqLoq7S4TJ22f3EjJvR4dti4L4gft43JEK77nnKXDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@googlemaps/js-api-loader": "^2.0.2",
|
||||
"@types/google.maps": "^3.54.10",
|
||||
"fast-deep-equal": "^3.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": ">=16.8.0 || ^19.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
@@ -5447,19 +5387,6 @@
|
||||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cosmiconfig": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
|
||||
@@ -6531,17 +6458,9 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
||||
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
@@ -7931,12 +7850,6 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/kdbush": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
|
||||
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -9617,44 +9530,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.14.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.1.tgz",
|
||||
"integrity": "sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.14.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.1.tgz",
|
||||
"integrity": "sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.14.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
@@ -10026,12 +9901,6 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
@@ -10635,15 +10504,6 @@
|
||||
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/supercluster": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
|
||||
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
@@ -12239,35 +12099,6 @@
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
|
||||
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,23 +19,16 @@
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"chromatic": "chromatic --exit-zero-on-changes --build-script-name=build:storybook",
|
||||
"demo:dev": "vite -c vite.demo.config.ts --mode arrangement",
|
||||
"demo:build": "vite build -c vite.demo.config.ts",
|
||||
"demo:publish": "npm run demo:build -- --mode arrangement && ./scripts/deploy-demo.sh arrangement",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.0",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@googlemaps/markerclusterer": "^2.6.2",
|
||||
"@mui/icons-material": "^5.16.0",
|
||||
"@mui/material": "^5.16.0",
|
||||
"@mui/system": "^5.16.0",
|
||||
"@vis.gl/react-google-maps": "^1.8.3",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-router-dom": "^7.14.1",
|
||||
"zustand": "^5.0.12"
|
||||
"react-dom": "^18.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
@@ -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;
|
||||
@@ -1 +0,0 @@
|
||||
export { ClusterMarker, type ClusterMarkerProps } from './ClusterMarker';
|
||||
@@ -21,8 +21,8 @@ const meta: Meta<typeof MapPin> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof MapPin>;
|
||||
|
||||
/** Verified provider — promoted brand palette (dark copper bg, white text) */
|
||||
export const Verified: Story = {
|
||||
/** Verified provider with name and price — warm brand label */
|
||||
export const VerifiedWithPrice: Story = {
|
||||
args: {
|
||||
name: 'H.Parsons Funeral Directors',
|
||||
price: 900,
|
||||
@@ -31,7 +31,7 @@ export const Verified: Story = {
|
||||
};
|
||||
|
||||
/** Unverified provider — neutral grey label */
|
||||
export const Unverified: Story = {
|
||||
export const UnverifiedWithPrice: Story = {
|
||||
args: {
|
||||
name: 'Smith & Sons Funerals',
|
||||
price: 1200,
|
||||
@@ -39,7 +39,66 @@ export const Unverified: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Custom price label (e.g. "POA" for providers without a fixed starting price) */
|
||||
/** Active/selected state — inverted colours, slight scale-up */
|
||||
export const Active: Story = {
|
||||
args: {
|
||||
name: 'H.Parsons Funeral Directors',
|
||||
price: 900,
|
||||
verified: true,
|
||||
active: true,
|
||||
},
|
||||
};
|
||||
|
||||
/** Active unverified */
|
||||
export const ActiveUnverified: Story = {
|
||||
args: {
|
||||
name: 'Smith & Sons Funerals',
|
||||
price: 1200,
|
||||
verified: false,
|
||||
active: true,
|
||||
},
|
||||
};
|
||||
|
||||
/** Name only — no price line */
|
||||
export const NameOnly: Story = {
|
||||
args: {
|
||||
name: 'Lady Anne Funerals',
|
||||
verified: true,
|
||||
},
|
||||
};
|
||||
|
||||
/** Name only, unverified */
|
||||
export const NameOnlyUnverified: Story = {
|
||||
args: {
|
||||
name: 'Local Funeral Services',
|
||||
},
|
||||
};
|
||||
|
||||
/** Price-only pill — no name, verified */
|
||||
export const PriceOnly: Story = {
|
||||
args: {
|
||||
price: 900,
|
||||
verified: true,
|
||||
},
|
||||
};
|
||||
|
||||
/** Price-only pill — unverified */
|
||||
export const PriceOnlyUnverified: Story = {
|
||||
args: {
|
||||
price: 1200,
|
||||
},
|
||||
};
|
||||
|
||||
/** Price-only pill — active */
|
||||
export const PriceOnlyActive: Story = {
|
||||
args: {
|
||||
price: 900,
|
||||
verified: true,
|
||||
active: true,
|
||||
},
|
||||
};
|
||||
|
||||
/** Custom price label */
|
||||
export const CustomPriceLabel: Story = {
|
||||
args: {
|
||||
name: 'Premium Services',
|
||||
@@ -82,7 +141,7 @@ export const MapSimulation: Story = {
|
||||
<MapPin name="H.Parsons" price={900} verified onClick={() => {}} />
|
||||
</Box>
|
||||
<Box sx={{ position: 'absolute', top: 150, left: 280 }}>
|
||||
<MapPin name="Lady Anne Funerals" price={1450} verified onClick={() => {}} />
|
||||
<MapPin name="Lady Anne Funerals" price={1450} verified active onClick={() => {}} />
|
||||
</Box>
|
||||
<Box sx={{ position: 'absolute', top: 260, left: 140 }}>
|
||||
<MapPin name="Mackay Family" price={2200} verified onClick={() => {}} />
|
||||
@@ -93,7 +152,12 @@ export const MapSimulation: Story = {
|
||||
<MapPin name="Smith & Sons" price={1100} onClick={() => {}} />
|
||||
</Box>
|
||||
<Box sx={{ position: 'absolute', top: 300, left: 400 }}>
|
||||
<MapPin name="Local Provider" price={1600} onClick={() => {}} />
|
||||
<MapPin name="Local Provider" onClick={() => {}} />
|
||||
</Box>
|
||||
|
||||
{/* Name only verified */}
|
||||
<Box sx={{ position: 'absolute', top: 40, left: 500 }}>
|
||||
<MapPin name="Kenneallys" verified onClick={() => {}} />
|
||||
</Box>
|
||||
</>
|
||||
),
|
||||
|
||||
@@ -6,14 +6,16 @@ import type { SxProps, Theme } from '@mui/material/styles';
|
||||
|
||||
/** Props for the FA MapPin atom */
|
||||
export interface MapPinProps {
|
||||
/** Provider or venue name (required — shown as line 1) */
|
||||
name: string;
|
||||
/** Starting package price in dollars — shown as "From $X" on line 2 */
|
||||
/** Provider or venue name — omit for a price-only pill */
|
||||
name?: string;
|
||||
/** Starting package price in dollars — shown as "From $X" */
|
||||
price?: number;
|
||||
/** Custom price label (e.g. "POA") — overrides formatted price */
|
||||
priceLabel?: string;
|
||||
/** Whether this provider/venue is verified (brand palette vs neutral palette) */
|
||||
/** Whether this provider/venue is verified (brand colour vs neutral) */
|
||||
verified?: boolean;
|
||||
/** Whether this pin is currently active/selected */
|
||||
active?: boolean;
|
||||
/** Click handler */
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
/** MUI sx prop for the root element */
|
||||
@@ -25,24 +27,34 @@ export interface MapPinProps {
|
||||
const PIN_PX = 'var(--fa-map-pin-padding-x)';
|
||||
const PIN_RADIUS = 'var(--fa-map-pin-border-radius)';
|
||||
const NUB_SIZE = 'var(--fa-map-pin-nub-size)';
|
||||
const MAX_WIDTH = 210;
|
||||
const MAX_WIDTH = 180;
|
||||
|
||||
// ─── Colour sets ────────────────────────────────────────────────────────────
|
||||
|
||||
const colours = {
|
||||
verified: {
|
||||
bg: 'var(--fa-color-brand-700)',
|
||||
name: 'var(--fa-color-white)',
|
||||
price: 'var(--fa-color-brand-200)',
|
||||
nub: 'var(--fa-color-brand-700)',
|
||||
border: 'var(--fa-color-brand-700)',
|
||||
bg: 'var(--fa-color-brand-100)',
|
||||
name: 'var(--fa-color-brand-900)',
|
||||
price: 'var(--fa-color-brand-600)',
|
||||
activeBg: 'var(--fa-color-brand-700)',
|
||||
activeName: 'var(--fa-color-white)',
|
||||
activePrice: 'var(--fa-color-brand-200)',
|
||||
nub: 'var(--fa-color-brand-100)',
|
||||
activeNub: 'var(--fa-color-brand-700)',
|
||||
border: 'var(--fa-color-brand-300)',
|
||||
activeBorder: 'var(--fa-color-brand-700)',
|
||||
},
|
||||
unverified: {
|
||||
bg: 'var(--fa-color-neutral-100)',
|
||||
name: 'var(--fa-color-neutral-800)',
|
||||
price: 'var(--fa-color-neutral-500)',
|
||||
activeBg: 'var(--fa-color-neutral-700)',
|
||||
activeName: 'var(--fa-color-white)',
|
||||
activePrice: 'var(--fa-color-neutral-200)',
|
||||
nub: 'var(--fa-color-neutral-100)',
|
||||
activeNub: 'var(--fa-color-neutral-700)',
|
||||
border: 'var(--fa-color-neutral-300)',
|
||||
activeBorder: 'var(--fa-color-neutral-700)',
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -56,25 +68,26 @@ const colours = {
|
||||
* the exact map location.
|
||||
*
|
||||
* - **Line 1**: Provider name (bold, truncated)
|
||||
* - **Line 2**: "From $X" (smaller, secondary colour)
|
||||
* - **Line 2**: "From $X" (smaller, secondary colour) — optional
|
||||
*
|
||||
* Visual distinction:
|
||||
* - **Verified** providers: warm brand palette (dark copper bg, white text)
|
||||
* - **Verified** providers: warm brand palette (gold bg, copper text)
|
||||
* - **Unverified** providers: neutral grey palette
|
||||
* - **Active/selected**: inverted colours (dark bg, white text) + scale-up
|
||||
*
|
||||
* Designed for use as custom HTML markers in Google Maps. Pure CSS — no
|
||||
* canvas, no SVG dependency. Selection/popup behaviour is handled at the
|
||||
* organism level (ProviderMap swaps pin → popup on click).
|
||||
* Designed for use as custom HTML markers in Mapbox GL / Google Maps.
|
||||
* Pure CSS — no canvas, no SVG dependency.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* <MapPin name="H.Parsons" price={900} verified onClick={...} />
|
||||
* <MapPin name="Smith & Sons" price={1200} />
|
||||
* <MapPin name="Botanical" priceLabel="POA" verified />
|
||||
* <MapPin name="Smith & Sons" /> {/* Name only, unverified *\/}
|
||||
* <MapPin price={900} verified /> {/* Price-only pill, no name *\/}
|
||||
* <MapPin name="H.Parsons" price={900} verified active />
|
||||
* ```
|
||||
*/
|
||||
export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
|
||||
({ name, price, priceLabel, verified = false, onClick, sx }, ref) => {
|
||||
({ name, price, priceLabel, verified = false, active = false, onClick, sx }, ref) => {
|
||||
const palette = verified ? colours.verified : colours.unverified;
|
||||
const hasPrice = price != null || priceLabel != null;
|
||||
|
||||
@@ -93,7 +106,7 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
|
||||
ref={ref}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${name}${hasPrice ? `, packages from ${priceLabel ?? `$${price?.toLocaleString('en-AU')}`}` : ''}${verified ? ', verified' : ''}`}
|
||||
aria-label={`${name ?? (verified ? 'Verified' : 'Unverified') + ' provider'}${hasPrice ? `, packages from $${price?.toLocaleString('en-AU') ?? priceLabel}` : ''}${verified ? ', verified' : ''}${active ? ' (selected)' : ''}`}
|
||||
onClick={onClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
sx={[
|
||||
@@ -103,13 +116,7 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 150ms ease-in-out',
|
||||
// Fade in on mount — matches the popup's exit timing so the pin
|
||||
// reappears smoothly when a popup closes.
|
||||
'@keyframes mapPinIn': {
|
||||
from: { opacity: 0 },
|
||||
to: { opacity: 1 },
|
||||
},
|
||||
animation: 'mapPinIn 180ms ease-out',
|
||||
transform: active ? 'scale(1.08)' : 'scale(1)',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.08)',
|
||||
},
|
||||
@@ -135,65 +142,53 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
|
||||
py: 0.5,
|
||||
px: PIN_PX,
|
||||
borderRadius: PIN_RADIUS,
|
||||
backgroundColor: palette.bg,
|
||||
backgroundColor: active ? palette.activeBg : palette.bg,
|
||||
border: '1px solid',
|
||||
borderColor: palette.border,
|
||||
boxShadow: 'var(--fa-shadow-sm)',
|
||||
borderColor: active ? palette.activeBorder : palette.border,
|
||||
boxShadow: active ? 'var(--fa-shadow-md)' : 'var(--fa-shadow-sm)',
|
||||
transition:
|
||||
'background-color 150ms ease-in-out, border-color 150ms ease-in-out, box-shadow 150ms ease-in-out',
|
||||
}}
|
||||
>
|
||||
{/* Name row — verified icon (left) + name */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{verified && (
|
||||
// Inline SVG of Material's Verified (outlined) icon. Kept as
|
||||
// inline SVG because MapPin is mounted via createRoot outside
|
||||
// the MUI ThemeProvider, so @mui/icons-material wouldn't pick
|
||||
// up theme defaults.
|
||||
<svg
|
||||
aria-hidden
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ flexShrink: 0, fill: palette.name }}
|
||||
>
|
||||
<path d="M23 11.99l-2.44-2.79.34-3.69-3.61-.82-1.89-3.2L12 2.96 8.6 1.49 6.71 4.69 3.1 5.5l.34 3.7L1 11.99l2.44 2.79-.34 3.7 3.61.82 1.89 3.2L12 21.03l3.4 1.47 1.89-3.2 3.61-.82-.34-3.69L23 11.99zm-12.91 4.72l-3.8-3.81 1.48-1.48 2.32 2.33 5.85-5.87 1.48 1.48-7.33 7.35z" />
|
||||
</svg>
|
||||
)}
|
||||
{/* Name */}
|
||||
{name && (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
fontFamily: 'var(--fa-font-family-body)',
|
||||
fontFamily: (t: Theme) => t.typography.fontFamily,
|
||||
lineHeight: 1.3,
|
||||
color: palette.name,
|
||||
color: active ? palette.activeName : palette.name,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
minWidth: 0,
|
||||
maxWidth: '100%',
|
||||
transition: 'color 150ms ease-in-out',
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Price line */}
|
||||
{hasPrice && (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
fontFamily: 'var(--fa-font-family-body)',
|
||||
fontSize: !name ? 12 : 11,
|
||||
fontWeight: !name ? 700 : 600,
|
||||
fontFamily: (t: Theme) => t.typography.fontFamily,
|
||||
lineHeight: 1.2,
|
||||
color: palette.price,
|
||||
color: !name
|
||||
? active
|
||||
? palette.activeName
|
||||
: palette.name
|
||||
: active
|
||||
? palette.activePrice
|
||||
: palette.price,
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'color 150ms ease-in-out',
|
||||
}}
|
||||
>
|
||||
{priceText}
|
||||
@@ -201,33 +196,19 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Nub — downward pointer. Two SVG paths:
|
||||
• fill is an extended pentagon that overhangs 3 units *into* the
|
||||
pill's bg so sub-pixel scaling artifacts (hover transform) can't
|
||||
expose the pill's bottom border through the seam;
|
||||
• stroke is a separate open path on the two slanted sides only,
|
||||
so the nub outline is continuous with the pill's border.
|
||||
overflow: visible lets the fill render above the viewBox. */}
|
||||
<svg
|
||||
{/* Nub — downward pointer */}
|
||||
<Box
|
||||
aria-hidden
|
||||
viewBox="0 0 16 8"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: `calc(2 * ${NUB_SIZE})`,
|
||||
height: NUB_SIZE,
|
||||
marginTop: '-1px',
|
||||
overflow: 'visible',
|
||||
sx={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: `${NUB_SIZE} solid transparent`,
|
||||
borderRight: `${NUB_SIZE} solid transparent`,
|
||||
borderTop: `${NUB_SIZE} solid`,
|
||||
borderTopColor: active ? palette.activeNub : palette.nub,
|
||||
mt: '-1px',
|
||||
}}
|
||||
>
|
||||
<path d="M 0 -3 L 16 -3 L 16 0 L 8 8 L 0 0 Z" fill={palette.bg} />
|
||||
<path
|
||||
d="M 0 0 L 8 8 L 16 0"
|
||||
fill="none"
|
||||
stroke={palette.border}
|
||||
strokeWidth={1}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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: () => {},
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -1 +0,0 @@
|
||||
export { ClusterPopup, type ClusterPopupProps, type ClusterPopupProvider } from './ClusterPopup';
|
||||
@@ -85,36 +85,6 @@ export const Empty: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
// --- Mobile ------------------------------------------------------------------
|
||||
|
||||
/** Mobile viewport — expanded by default, with a grey-filled right-chevron
|
||||
* on the right of the pill. Tap the chevron to retract the pill to the
|
||||
* right corner (the middle content animates to width:0, so the pill
|
||||
* visually shrinks as one unit rather than swapping into a separate mini
|
||||
* pill). Tap the left-chevron on the collapsed pill to expand. On add
|
||||
* while collapsed, the full bar auto-peeks for 3s, then re-collapses. */
|
||||
export const Mobile: Story = {
|
||||
args: {
|
||||
packages: samplePackages.slice(0, 2),
|
||||
onCompare: () => alert('Compare clicked'),
|
||||
},
|
||||
parameters: {
|
||||
viewport: { defaultViewport: 'mobile1' },
|
||||
},
|
||||
};
|
||||
|
||||
/** Mobile — single package state. Same behaviour as `Mobile`, Compare
|
||||
* CTA disabled ("Add another to compare"). */
|
||||
export const MobileSingle: Story = {
|
||||
args: {
|
||||
packages: samplePackages.slice(0, 1),
|
||||
onCompare: () => alert('Compare clicked'),
|
||||
},
|
||||
parameters: {
|
||||
viewport: { defaultViewport: 'mobile1' },
|
||||
},
|
||||
};
|
||||
|
||||
// --- Interactive Demo --------------------------------------------------------
|
||||
|
||||
/** Interactive demo — add packages and see the bar update */
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Slide from '@mui/material/Slide';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded';
|
||||
import ChevronLeftRoundedIcon from '@mui/icons-material/ChevronLeftRounded';
|
||||
import { useTheme, type SxProps, type Theme } from '@mui/material/styles';
|
||||
import CompareArrowsIcon from '@mui/icons-material/CompareArrows';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
import { Button } from '../../atoms/Button';
|
||||
import { Badge } from '../../atoms/Badge';
|
||||
@@ -35,14 +31,6 @@ export interface CompareBarProps {
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** How long the bar stays expanded after a new package is added while
|
||||
* collapsed. Long enough to read, short enough not to obstruct. */
|
||||
const PEEK_DURATION_MS = 3000;
|
||||
/** Middle-content expand/collapse duration (width + opacity). */
|
||||
const COLLAPSE_MS = 300;
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -51,54 +39,16 @@ const COLLAPSE_MS = 300;
|
||||
* Shows a fraction badge (1/3, 2/3, 3/3), contextual copy, and a Compare CTA.
|
||||
* Present on both ProvidersStep and PackagesStep.
|
||||
*
|
||||
* **Mobile collapse** (xs only): users can tap a right-chevron to retract
|
||||
* the pill to the right edge — the middle content (status text + Compare
|
||||
* button) animates to width:0 while the pill stays anchored at the same
|
||||
* right offset, so the whole thing appears to shrink into the corner as
|
||||
* one unit rather than two separate elements. Tap again to expand. When
|
||||
* a new package is added while collapsed, the bar auto-peeks for
|
||||
* `PEEK_DURATION_MS` so the user sees the tally update, then re-collapses.
|
||||
*
|
||||
* Desktop (md+) stays expanded — there's plenty of space, and the
|
||||
* collapse chevron is not rendered.
|
||||
*
|
||||
* Composes Badge + Button + Typography + IconButton.
|
||||
* Composes Badge + Button + Typography.
|
||||
*/
|
||||
export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
|
||||
({ packages, onCompare, error, sx }, ref) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const count = packages.length;
|
||||
const visible = count > 0;
|
||||
const canCompare = count >= 2;
|
||||
|
||||
const statusText = count === 1 ? 'Add another to compare' : 'Ready to compare';
|
||||
|
||||
// Collapse state — mobile only. Starts expanded; when the basket empties
|
||||
// we reset so the next fresh fill starts visible.
|
||||
const [collapsed, setCollapsed] = React.useState(false);
|
||||
const [peeking, setPeeking] = React.useState(false);
|
||||
const lastCountRef = React.useRef(count);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!visible) setCollapsed(false);
|
||||
}, [visible]);
|
||||
|
||||
// Auto-peek when a package is added while collapsed.
|
||||
React.useEffect(() => {
|
||||
const prev = lastCountRef.current;
|
||||
lastCountRef.current = count;
|
||||
if (collapsed && count > prev) {
|
||||
setPeeking(true);
|
||||
const t = window.setTimeout(() => setPeeking(false), PEEK_DURATION_MS);
|
||||
return () => window.clearTimeout(t);
|
||||
}
|
||||
}, [count, collapsed]);
|
||||
|
||||
/** Effective "is the middle content hidden?" — only on mobile, when the
|
||||
* user has collapsed and we're not currently peeking. */
|
||||
const mobileCollapsed = isMobile && collapsed && !peeking;
|
||||
|
||||
return (
|
||||
<Slide direction="up" in={visible} mountOnEnter unmountOnExit>
|
||||
<Paper
|
||||
@@ -108,123 +58,52 @@ export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
|
||||
aria-live="polite"
|
||||
aria-label={`${count} of 3 packages selected for comparison`}
|
||||
sx={[
|
||||
(t: Theme) => ({
|
||||
(theme: Theme) => ({
|
||||
position: 'fixed',
|
||||
// Clear the sticky HelpBar (~40px) + breathing room. FA theme
|
||||
// uses a 4px spacing base, so spacing(16) = 64px.
|
||||
bottom: t.spacing(16),
|
||||
// z-index sits below the mobile map-view drawer (modal: 1300)
|
||||
// but above app chrome (appBar: 1100). snackbar (1400) was too
|
||||
// aggressive — the drawer visually covers this bar on mobile.
|
||||
zIndex: t.zIndex.drawer,
|
||||
// Mobile: right-anchored so when the middle collapses the pill
|
||||
// appears to retract to the right corner. Desktop: centered.
|
||||
...(isMobile
|
||||
? { right: t.spacing(4), left: 'auto' }
|
||||
: { left: 0, right: 0, mx: 'auto' }),
|
||||
width: 'fit-content',
|
||||
bottom: theme.spacing(3),
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: theme.zIndex.snackbar,
|
||||
borderRadius: '9999px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: { xs: 1.25, md: 2 },
|
||||
px: { xs: 1.5, md: 3 },
|
||||
py: { xs: 0.75, md: 1.5 },
|
||||
maxWidth: { xs: 'calc(100vw - 32px)', md: 460 },
|
||||
overflow: 'hidden',
|
||||
transition: `padding ${COLLAPSE_MS}ms ease-out`,
|
||||
gap: 1.5,
|
||||
px: 2.5,
|
||||
py: 1.25,
|
||||
maxWidth: { xs: 'calc(100vw - 32px)', md: 420 },
|
||||
}),
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
>
|
||||
{/* Fraction badge — shows "N/3" when expanded, just "N" when
|
||||
collapsed on mobile (reads as a circle at mini size). */}
|
||||
<Badge
|
||||
color="brand"
|
||||
variant="soft"
|
||||
size={isMobile ? 'medium' : 'large'}
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
// When collapsed, force the badge toward a circle by
|
||||
// equalising min-width and min-height at the medium-badge
|
||||
// height (26px).
|
||||
...(mobileCollapsed && {
|
||||
minWidth: 'var(--fa-badge-height-md)',
|
||||
justifyContent: 'center',
|
||||
px: 0,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{mobileCollapsed ? count : `${count}/3`}
|
||||
{/* Fraction badge — 1/3, 2/3, 3/3 */}
|
||||
<Badge color="brand" variant="soft" size="small" sx={{ flexShrink: 0 }}>
|
||||
{count}/3
|
||||
</Badge>
|
||||
|
||||
{/* Middle content (status + Compare CTA) — animates to zero
|
||||
max-width when collapsed, letting the pill shrink as one unit
|
||||
with the right edge staying fixed. */}
|
||||
<Box
|
||||
{/* Status text */}
|
||||
<Typography
|
||||
variant="body2"
|
||||
role={error ? 'alert' : undefined}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: { xs: 1.25, md: 2 },
|
||||
maxWidth: mobileCollapsed ? 0 : 600,
|
||||
opacity: mobileCollapsed ? 0 : 1,
|
||||
overflow: 'hidden',
|
||||
transition: `max-width ${COLLAPSE_MS}ms ease-out, opacity ${Math.round(
|
||||
COLLAPSE_MS * 0.6,
|
||||
)}ms ease-out`,
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'nowrap',
|
||||
color: error ? 'var(--fa-color-text-brand)' : 'text.primary',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant={isMobile ? 'body2' : 'body1'}
|
||||
role={error ? 'alert' : undefined}
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'nowrap',
|
||||
color: error ? 'var(--fa-color-text-brand)' : 'text.primary',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{error || statusText}
|
||||
</Typography>
|
||||
{error || statusText}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
size={isMobile ? 'small' : 'medium'}
|
||||
onClick={onCompare}
|
||||
disabled={!canCompare}
|
||||
tabIndex={mobileCollapsed ? -1 : 0}
|
||||
sx={{ flexShrink: 0, borderRadius: '9999px' }}
|
||||
>
|
||||
Compare
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Mobile-only collapse/expand chevron — grey-filled circle that
|
||||
swaps icon direction based on state. Rendered at all times so
|
||||
the IconButton container stays in the layout and the icon swap
|
||||
happens in place without mount/unmount. */}
|
||||
{isMobile && (
|
||||
<IconButton
|
||||
aria-label={mobileCollapsed ? 'Show comparison basket' : 'Hide comparison basket'}
|
||||
aria-expanded={!mobileCollapsed}
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
size="small"
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'var(--fa-color-neutral-200)',
|
||||
color: 'text.secondary',
|
||||
'&:hover': { bgcolor: 'var(--fa-color-neutral-300)' },
|
||||
}}
|
||||
>
|
||||
{mobileCollapsed ? (
|
||||
<ChevronLeftRoundedIcon fontSize="small" />
|
||||
) : (
|
||||
<ChevronRightRoundedIcon fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
{/* Compare CTA */}
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<CompareArrowsIcon />}
|
||||
onClick={onCompare}
|
||||
disabled={!canCompare}
|
||||
sx={{ flexShrink: 0, borderRadius: '9999px' }}
|
||||
>
|
||||
Compare
|
||||
</Button>
|
||||
</Paper>
|
||||
</Slide>
|
||||
);
|
||||
|
||||
@@ -36,11 +36,10 @@ function formatPrice(amount: number): string {
|
||||
/**
|
||||
* Desktop column header card for the ComparisonTable.
|
||||
*
|
||||
* Shows provider info (verified/recommended badge, name, location, rating),
|
||||
* package name, total price, CTA button, and optional Remove link. The badge
|
||||
* floats above the card's top edge — "Recommended" (primary fill) replaces
|
||||
* "Verified" (soft) when the package is recommended. Recommended packages
|
||||
* also get a warm selected card state with a brand-600 border.
|
||||
* Shows provider info (verified badge, name, location, rating), package name,
|
||||
* total price, CTA button, and optional Remove link. The verified badge floats
|
||||
* above the card's top edge. Recommended packages get a copper banner and warm
|
||||
* selected card state.
|
||||
*
|
||||
* Used as the sticky header for each column in the desktop comparison grid.
|
||||
* Mobile comparison uses ComparisonPackageCard instead.
|
||||
@@ -62,29 +61,23 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
>
|
||||
{/* Floating badge — Recommended (primary fill) takes priority over Verified (soft) */}
|
||||
{(pkg.isRecommended || pkg.provider.verified) && (
|
||||
{/* Floating verified badge — overlaps card top edge */}
|
||||
{pkg.provider.verified && (
|
||||
<Badge
|
||||
color="brand"
|
||||
variant={pkg.isRecommended ? 'filled' : 'soft'}
|
||||
size="medium"
|
||||
icon={
|
||||
pkg.isRecommended ? (
|
||||
<StarRoundedIcon sx={{ fontSize: 16 }} />
|
||||
) : (
|
||||
<VerifiedOutlinedIcon sx={{ fontSize: 16 }} />
|
||||
)
|
||||
}
|
||||
variant="soft"
|
||||
size="small"
|
||||
icon={<VerifiedOutlinedIcon sx={{ fontSize: 14 }} />}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -13,
|
||||
top: -12,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
{pkg.isRecommended ? 'Recommended' : 'Verified'}
|
||||
Verified
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@@ -92,16 +85,24 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
|
||||
variant="outlined"
|
||||
selected={pkg.isRecommended}
|
||||
padding="none"
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
...(pkg.isRecommended && {
|
||||
borderColor: 'var(--fa-color-brand-600)',
|
||||
}),
|
||||
}}
|
||||
sx={{ overflow: 'hidden', flex: 1, display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
{pkg.isRecommended && (
|
||||
<Box sx={{ bgcolor: 'var(--fa-color-brand-600)', py: 0.75, textAlign: 'center' }}>
|
||||
<Typography
|
||||
variant="labelSm"
|
||||
sx={{
|
||||
color: 'var(--fa-color-white)',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.05em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Recommended
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -109,66 +110,40 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
px: 2.5,
|
||||
pt: 5,
|
||||
pb: 3,
|
||||
gap: 1,
|
||||
py: 2.5,
|
||||
pt: pkg.provider.verified ? 3 : 2.5,
|
||||
gap: 0.5,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{/* Provider name — always reserves space for 2 lines (via minHeight),
|
||||
content bottom-aligned so single-line names sit flush with the
|
||||
next item below rather than floating high in the slot. */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
gap: 0.75,
|
||||
maxWidth: '100%',
|
||||
minHeight: 36, // 2 × (14px label × 1.286 line-height)
|
||||
}}
|
||||
{/* Provider name (truncated with tooltip) */}
|
||||
<Tooltip
|
||||
title={pkg.provider.name}
|
||||
arrow
|
||||
placement="top"
|
||||
disableHoverListener={pkg.provider.name.length < 24}
|
||||
>
|
||||
{pkg.isRecommended && (
|
||||
<VerifiedOutlinedIcon
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
color: 'var(--fa-color-brand-600)',
|
||||
flexShrink: 0,
|
||||
mb: '2px',
|
||||
}}
|
||||
aria-label="Verified provider"
|
||||
/>
|
||||
)}
|
||||
<Tooltip
|
||||
title={pkg.provider.name}
|
||||
arrow
|
||||
placement="top"
|
||||
disableHoverListener={pkg.provider.name.length < 50}
|
||||
<Typography
|
||||
variant="label"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="label"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{pkg.provider.name}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
{pkg.provider.name}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
|
||||
{/* Location */}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{pkg.provider.location}
|
||||
</Typography>
|
||||
|
||||
{/* Rating (or dash placeholder to keep card heights consistent) */}
|
||||
{pkg.provider.rating != null ? (
|
||||
{/* Rating */}
|
||||
{pkg.provider.rating != null && (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<StarRoundedIcon
|
||||
sx={{ fontSize: 16, color: 'var(--fa-color-brand-500)' }}
|
||||
@@ -179,35 +154,20 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
|
||||
{pkg.provider.reviewCount != null && ` (${pkg.provider.reviewCount})`}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" aria-label="No reviews yet">
|
||||
—
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Divider sx={{ width: '100%', my: 1.5 }} />
|
||||
<Divider sx={{ width: '100%', my: 1 }} />
|
||||
|
||||
<Typography variant="h6" component="p">
|
||||
{pkg.name}
|
||||
</Typography>
|
||||
|
||||
{/* Price subgroup — tighter internal spacing than the outer gap
|
||||
so the label sits close to the amount it describes. */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 0.25,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Total package price
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{ color: 'primary.main', fontWeight: 700 }}>
|
||||
{formatPrice(pkg.price)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
Total package price
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{ color: 'primary.main', fontWeight: 700 }}>
|
||||
{formatPrice(pkg.price)}
|
||||
</Typography>
|
||||
|
||||
{/* Spacer pushes CTA to bottom across all cards */}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
@@ -217,33 +177,23 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
|
||||
color={pkg.provider.verified ? 'primary' : 'secondary'}
|
||||
size="medium"
|
||||
onClick={() => onArrange(pkg.id)}
|
||||
sx={{ px: 4 }}
|
||||
sx={{ mt: 1.5, px: 4 }}
|
||||
>
|
||||
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
|
||||
</Button>
|
||||
|
||||
{/* Always render the same Link element; hide when no Remove action
|
||||
applies (recommended or no handler). Keeps the footer row
|
||||
identical across all cards so CTAs align. */}
|
||||
{(() => {
|
||||
const canRemove = !pkg.isRecommended && !!onRemove;
|
||||
return (
|
||||
<Link
|
||||
component="button"
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
underline="hover"
|
||||
onClick={canRemove ? () => onRemove!(pkg.id) : undefined}
|
||||
tabIndex={canRemove ? 0 : -1}
|
||||
aria-hidden={!canRemove}
|
||||
sx={{
|
||||
...(!canRemove && { visibility: 'hidden', pointerEvents: 'none' }),
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Link>
|
||||
);
|
||||
})()}
|
||||
{!pkg.isRecommended && onRemove && (
|
||||
<Link
|
||||
component="button"
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
underline="hover"
|
||||
onClick={() => onRemove(pkg.id)}
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
Remove
|
||||
</Link>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
@@ -9,6 +9,7 @@ import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
import { Button } from '../../atoms/Button';
|
||||
import { Badge } from '../../atoms/Badge';
|
||||
import { Divider } from '../../atoms/Divider';
|
||||
import { Card } from '../../atoms/Card';
|
||||
import type { ComparisonPackage, ComparisonCellValue } from '../../organisms/ComparisonTable';
|
||||
@@ -124,21 +125,12 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
|
||||
<Card
|
||||
ref={ref}
|
||||
variant="outlined"
|
||||
selected={pkg.isRecommended}
|
||||
padding="none"
|
||||
sx={[
|
||||
{
|
||||
overflow: 'hidden',
|
||||
boxShadow: 'var(--fa-shadow-sm)',
|
||||
// Body defaults to white; only the header carries the warm/subtle
|
||||
// tint so the tint signals "provider" rather than washing the
|
||||
// whole card.
|
||||
bgcolor: 'background.paper',
|
||||
// Match the desktop ComparisonColumnCard recommended treatment:
|
||||
// explicit 2px brand-600 border (same as Card's selected state,
|
||||
// but without the warm background wash that `selected` applies).
|
||||
...(pkg.isRecommended && {
|
||||
border: '2px solid var(--fa-color-brand-600)',
|
||||
}),
|
||||
},
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
@@ -166,38 +158,31 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
|
||||
bgcolor: pkg.isRecommended
|
||||
? 'var(--fa-color-surface-warm)'
|
||||
: 'var(--fa-color-surface-subtle)',
|
||||
px: 3,
|
||||
pt: 3,
|
||||
pb: 4,
|
||||
px: 2.5,
|
||||
pt: 2.5,
|
||||
pb: 2,
|
||||
}}
|
||||
>
|
||||
{/* Provider name with optional inline verified icon (matches desktop
|
||||
ComparisonColumnCard treatment) */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
mb: 1.25,
|
||||
}}
|
||||
>
|
||||
{pkg.provider.verified && (
|
||||
<VerifiedOutlinedIcon
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
color: 'var(--fa-color-brand-600)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
aria-label="Verified provider"
|
||||
/>
|
||||
)}
|
||||
<Typography variant="label" sx={{ fontWeight: 600 }}>
|
||||
{pkg.provider.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
{/* Verified badge */}
|
||||
{pkg.provider.verified && (
|
||||
<Badge
|
||||
color="brand"
|
||||
variant="soft"
|
||||
size="small"
|
||||
icon={<VerifiedOutlinedIcon sx={{ fontSize: 14 }} />}
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
Verified
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Provider name */}
|
||||
<Typography variant="label" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
|
||||
{pkg.provider.name}
|
||||
</Typography>
|
||||
|
||||
{/* Location + Rating */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
||||
<LocationOnOutlinedIcon sx={{ fontSize: 14, color: 'text.secondary' }} aria-hidden />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
@@ -218,22 +203,18 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
<Divider sx={{ mb: 1.5 }} />
|
||||
|
||||
{/* Package info group — name, label, price stacked with small internal gap */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
<Typography variant="h5" component="p">
|
||||
{pkg.name}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Total package price
|
||||
</Typography>
|
||||
<Typography variant="h3" sx={{ color: 'primary.main', fontWeight: 700 }}>
|
||||
{formatPrice(pkg.price)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{/* Package name + price */}
|
||||
<Typography variant="h5" component="p">
|
||||
{pkg.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Total package price
|
||||
</Typography>
|
||||
<Typography variant="h3" sx={{ color: 'primary.main', fontWeight: 700 }}>
|
||||
{formatPrice(pkg.price)}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant={pkg.provider.verified ? 'contained' : 'soft'}
|
||||
@@ -241,14 +222,14 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
|
||||
size="medium"
|
||||
fullWidth
|
||||
onClick={() => onArrange(pkg.id)}
|
||||
sx={{ mt: 3 }}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Sections — with left accent borders on headings */}
|
||||
<Box sx={{ px: 2.5, pt: 3.5, pb: 3 }}>
|
||||
<Box sx={{ px: 2.5, py: 2.5 }}>
|
||||
{pkg.itemizedAvailable === false ? (
|
||||
<Box sx={{ textAlign: 'center', py: 3 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||
@@ -257,14 +238,15 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
|
||||
</Box>
|
||||
) : (
|
||||
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 */}
|
||||
<Box
|
||||
sx={{
|
||||
borderLeft: '3px solid',
|
||||
borderLeftColor: 'var(--fa-color-brand-500)',
|
||||
pl: 1.5,
|
||||
mb: 2.5,
|
||||
mb: 1.5,
|
||||
mt: sIdx > 0 ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" component="h3">
|
||||
@@ -280,7 +262,7 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
py: 2,
|
||||
py: 1.5,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import StarRoundedIcon from '@mui/icons-material/StarRounded';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
import { Badge } from '../../atoms/Badge';
|
||||
@@ -59,15 +58,12 @@ export const ComparisonTabCard = React.forwardRef<HTMLDivElement, ComparisonTabC
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
>
|
||||
{/* Recommended badge in normal flow — overlaps card via negative mb.
|
||||
Matches the desktop ComparisonColumnCard styling (filled brand +
|
||||
star icon) for consistency between surfaces. */}
|
||||
{/* Recommended badge in normal flow — overlaps card via negative mb */}
|
||||
{pkg.isRecommended ? (
|
||||
<Badge
|
||||
color="brand"
|
||||
variant="filled"
|
||||
variant="soft"
|
||||
size="small"
|
||||
icon={<StarRoundedIcon sx={{ fontSize: 14 }} />}
|
||||
sx={{
|
||||
mb: '-10px',
|
||||
zIndex: 1,
|
||||
@@ -93,18 +89,21 @@ export const ComparisonTabCard = React.forwardRef<HTMLDivElement, ComparisonTabC
|
||||
onClick={onClick}
|
||||
interactive
|
||||
sx={{
|
||||
width: 235,
|
||||
width: 210,
|
||||
cursor: 'pointer',
|
||||
boxShadow: 'var(--fa-shadow-sm)',
|
||||
...(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 && {
|
||||
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
|
||||
variant="labelSm"
|
||||
sx={{
|
||||
|
||||
@@ -77,14 +77,8 @@ export const FilterPanel = React.forwardRef<HTMLDivElement, FilterPanelProps>(
|
||||
title={label}
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
{onClear ? (
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
color="secondary"
|
||||
onClick={() => onClear()}
|
||||
disabled={activeCount === 0}
|
||||
>
|
||||
{onClear && activeCount > 0 ? (
|
||||
<Button variant="text" size="small" color="secondary" onClick={() => onClear()}>
|
||||
Reset filters
|
||||
</Button>
|
||||
) : (
|
||||
|
||||
@@ -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' },
|
||||
};
|
||||
@@ -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;
|
||||
@@ -1 +0,0 @@
|
||||
export { HelpBar, type HelpBarProps } from './HelpBar';
|
||||
@@ -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' },
|
||||
};
|
||||
@@ -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;
|
||||
@@ -1 +0,0 @@
|
||||
export { LocationSearchInput, type LocationSearchInputProps } from './LocationSearchInput';
|
||||
@@ -132,7 +132,7 @@ export const WithPin: Story = {
|
||||
verified
|
||||
onClick={() => {}}
|
||||
/>
|
||||
<MapPin name="H.Parsons" price={900} verified />
|
||||
<MapPin name="H.Parsons" price={900} verified active />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -31,9 +31,6 @@ export interface MapPopupProps {
|
||||
verified?: boolean;
|
||||
/** Click handler — entire card is clickable */
|
||||
onClick?: () => void;
|
||||
/** When true, animates the popup out (opacity + scale) without unmounting.
|
||||
* Callers should unmount after the transition completes (180ms). */
|
||||
exiting?: boolean;
|
||||
/** MUI sx prop for the root element */
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
@@ -88,7 +85,6 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
|
||||
capacity,
|
||||
verified = false,
|
||||
onClick,
|
||||
exiting = false,
|
||||
sx,
|
||||
},
|
||||
ref,
|
||||
@@ -107,21 +103,12 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
|
||||
}
|
||||
}, [name]);
|
||||
|
||||
// Swallow clicks on the popup so they don't bubble to an enclosing
|
||||
// Map.onClick (which would close the popup mid-click). Always applied,
|
||||
// even when onClick is unset, because callers consistently render this
|
||||
// molecule inside a map context where ambient clicks should not escape.
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
onClick={handleClick}
|
||||
onClick={onClick}
|
||||
onKeyDown={
|
||||
onClick
|
||||
? (e: React.KeyboardEvent) => {
|
||||
@@ -140,21 +127,12 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
|
||||
alignItems: 'center',
|
||||
filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.15))',
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
transformOrigin: 'bottom center',
|
||||
transition: 'opacity 180ms ease-out, transform 180ms ease-out',
|
||||
opacity: exiting ? 0 : 1,
|
||||
transform: exiting ? 'scale(0.9)' : 'scale(1)',
|
||||
'@keyframes mapPopupIn': {
|
||||
from: { opacity: 0, transform: 'scale(0.9)' },
|
||||
to: { opacity: 1, transform: 'scale(1)' },
|
||||
},
|
||||
animation: exiting ? undefined : 'mapPopupIn 180ms ease-out',
|
||||
'&:hover':
|
||||
onClick && !exiting
|
||||
? {
|
||||
transform: 'scale(1.02)',
|
||||
}
|
||||
: undefined,
|
||||
transition: 'transform 150ms ease-in-out',
|
||||
'&:hover': onClick
|
||||
? {
|
||||
transform: 'scale(1.02)',
|
||||
}
|
||||
: undefined,
|
||||
'&:focus-visible': {
|
||||
outline: '2px solid var(--fa-color-interactive-focus)',
|
||||
outlineOffset: '2px',
|
||||
@@ -171,7 +149,6 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
|
||||
borderRadius: 'var(--fa-card-border-radius-default)',
|
||||
overflow: 'hidden',
|
||||
bgcolor: 'background.paper',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* ── Image ── */}
|
||||
@@ -302,20 +279,19 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Nub — downward pointer. SVG (fill-only; MapPopup uses a drop-shadow
|
||||
for depth instead of a hard border, so no stroke needed) */}
|
||||
<svg
|
||||
{/* Nub — downward pointer connecting to pin */}
|
||||
<Box
|
||||
aria-hidden
|
||||
width={NUB_SIZE * 2}
|
||||
height={NUB_SIZE}
|
||||
viewBox={`0 0 ${NUB_SIZE * 2} ${NUB_SIZE}`}
|
||||
style={{ display: 'block', marginTop: '-1px', overflow: 'visible' }}
|
||||
>
|
||||
<path
|
||||
d={`M 0 0 L ${NUB_SIZE} ${NUB_SIZE} L ${NUB_SIZE * 2} 0`}
|
||||
fill="var(--fa-color-white)"
|
||||
/>
|
||||
</svg>
|
||||
sx={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: `${NUB_SIZE}px solid transparent`,
|
||||
borderRight: `${NUB_SIZE}px solid transparent`,
|
||||
borderTop: `${NUB_SIZE}px solid`,
|
||||
borderTopColor: 'background.paper',
|
||||
mt: '-1px',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -1 +0,0 @@
|
||||
export { MapProviderDrawer, type MapProviderDrawerProps } from './MapProviderDrawer';
|
||||
@@ -172,10 +172,7 @@ export const ProviderCard = React.forwardRef<HTMLDivElement, ProviderCardProps>(
|
||||
width: LOGO_SIZE,
|
||||
height: LOGO_SIZE,
|
||||
borderRadius: LOGO_BORDER_RADIUS,
|
||||
// 'contain' so wide/tall logos scale proportionally inside
|
||||
// the square slot rather than cropping. Background fills any
|
||||
// letterboxed space so it still reads as a tile.
|
||||
objectFit: 'contain',
|
||||
objectFit: 'cover',
|
||||
backgroundColor: 'background.paper',
|
||||
boxShadow: 'var(--fa-shadow-sm)',
|
||||
border: '2px solid var(--fa-color-white)',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -1 +0,0 @@
|
||||
export { SortMenu, type SortMenuProps, type SortOption } from './SortMenu';
|
||||
@@ -346,7 +346,7 @@ export const MixedVerified: Story = {
|
||||
|
||||
// --- Missing Itemised Data ---------------------------------------------------
|
||||
|
||||
/** One provider has no itemised breakdown — unverified cells show "Unknown" */
|
||||
/** One provider has no itemised breakdown — cells show "—" */
|
||||
export const MissingData: Story = {
|
||||
args: {
|
||||
packages: [pkgWollongong, pkgNoItemised, pkgMackay],
|
||||
|
||||
@@ -63,55 +63,7 @@ function formatPrice(amount: number): string {
|
||||
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline icon + label wrapper with optically aligned centres.
|
||||
*
|
||||
* body2's line-height adds vertical padding above/below the glyphs. Flex
|
||||
* centring then aligns geometric centres, which puts the icon slightly
|
||||
* above the text's visual centre. Setting `lineHeight: 1` on the row
|
||||
* collapses the text line-box to the font size so geometric and visual
|
||||
* centres match.
|
||||
*/
|
||||
function CellIconText({
|
||||
icon,
|
||||
iconPosition = 'leading',
|
||||
color,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
iconPosition?: 'leading' | 'trailing';
|
||||
color: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{iconPosition === 'leading' && icon}
|
||||
<Typography variant="body2" sx={{ color, fontWeight: 500, lineHeight: 1 }} component="span">
|
||||
{children}
|
||||
</Typography>
|
||||
{iconPosition === 'trailing' && icon}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sections where a missing item is better expressed as "Not Included"
|
||||
* than a bare em-dash — these are opt-in items, so absence is meaningful. */
|
||||
const OPTIONAL_SECTION_HEADINGS = new Set(['Optionals', 'Extras']);
|
||||
|
||||
function CellValue({
|
||||
value,
|
||||
sectionHeading,
|
||||
}: {
|
||||
value: ComparisonCellValue;
|
||||
sectionHeading: string;
|
||||
}) {
|
||||
function CellValue({ value }: { value: ComparisonCellValue }) {
|
||||
switch (value.type) {
|
||||
case 'price':
|
||||
return (
|
||||
@@ -127,31 +79,33 @@ function CellValue({
|
||||
);
|
||||
case 'complimentary':
|
||||
return (
|
||||
<CellIconText
|
||||
color="var(--fa-color-feedback-success)"
|
||||
icon={
|
||||
<CheckCircleOutlineIcon
|
||||
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
}
|
||||
>
|
||||
Complimentary
|
||||
</CellIconText>
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<CheckCircleOutlineIcon
|
||||
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: 'var(--fa-color-feedback-success)', fontWeight: 500 }}
|
||||
>
|
||||
Complimentary
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
case 'included':
|
||||
return (
|
||||
<CellIconText
|
||||
color="var(--fa-color-feedback-success)"
|
||||
icon={
|
||||
<CheckCircleOutlineIcon
|
||||
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
}
|
||||
>
|
||||
Included
|
||||
</CellIconText>
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<CheckCircleOutlineIcon
|
||||
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: 'var(--fa-color-feedback-success)', fontWeight: 500 }}
|
||||
>
|
||||
Included
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
case 'poa':
|
||||
return (
|
||||
@@ -161,30 +115,20 @@ function CellValue({
|
||||
);
|
||||
case 'unknown':
|
||||
return (
|
||||
<CellIconText
|
||||
color="var(--fa-color-neutral-500)"
|
||||
iconPosition="trailing"
|
||||
icon={
|
||||
<InfoOutlinedIcon
|
||||
sx={{ fontSize: 14, color: 'var(--fa-color-neutral-500)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
}
|
||||
>
|
||||
Unknown
|
||||
</CellIconText>
|
||||
);
|
||||
case 'unavailable':
|
||||
if (OPTIONAL_SECTION_HEADINGS.has(sectionHeading)) {
|
||||
return (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: 'var(--fa-color-neutral-500)', fontWeight: 500 }}
|
||||
>
|
||||
Not Included
|
||||
Unknown
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
<InfoOutlinedIcon
|
||||
sx={{ fontSize: 14, color: 'var(--fa-color-neutral-500)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
case 'unavailable':
|
||||
return (
|
||||
<Typography variant="body2" sx={{ color: 'var(--fa-color-neutral-400)' }}>
|
||||
—
|
||||
@@ -226,20 +170,11 @@ function lookupValue(
|
||||
sectionHeading: string,
|
||||
itemName: string,
|
||||
): ComparisonCellValue {
|
||||
// For unverified providers, absence means "we don't know" — data is
|
||||
// scraped/estimated. For verified providers, absence means the package
|
||||
// explicitly doesn't include this item (→ "Not Included" in Optionals/
|
||||
// Extras; em-dash in Essentials as a safety net — canonical-essentials
|
||||
// rule says every verified package has all 9, so this path shouldn't
|
||||
// fire in practice).
|
||||
const missing: ComparisonCellValue = pkg.provider.verified
|
||||
? { type: 'unavailable' }
|
||||
: { type: 'unknown' };
|
||||
if (pkg.itemizedAvailable === false) return missing;
|
||||
if (pkg.itemizedAvailable === false) return { type: 'unavailable' };
|
||||
const section = pkg.sections.find((s) => s.heading === sectionHeading);
|
||||
if (!section) return missing;
|
||||
if (!section) return { type: 'unavailable' };
|
||||
const item = section.items.find((i) => i.name === itemName);
|
||||
if (!item) return missing;
|
||||
if (!item) return { type: 'unavailable' };
|
||||
return item.value;
|
||||
}
|
||||
|
||||
@@ -272,18 +207,6 @@ const tableSx = {
|
||||
bgcolor: 'background.paper',
|
||||
};
|
||||
|
||||
/**
|
||||
* Fixed column width for both the row-label column and each package column.
|
||||
* Natural table width = COMPARISON_TABLE_COL_WIDTH × (packages.length + 1).
|
||||
* Exposed so ComparisonPage can size its width-matching page header container
|
||||
* to align left edges with the table on horizontal overflow.
|
||||
*/
|
||||
export const COMPARISON_TABLE_COL_WIDTH = 300;
|
||||
|
||||
/** z-index scale for sticky layers inside the table. */
|
||||
const Z_STICKY_LEFT = 20;
|
||||
const Z_STICKY_LEFT_SECTION = 25; // section heading left cell above body cells
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -296,10 +219,10 @@ const Z_STICKY_LEFT_SECTION = 25; // section heading left cell above body cells
|
||||
*/
|
||||
export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableProps>(
|
||||
({ packages, onArrange, onRemove, sx }, ref) => {
|
||||
const mergedSections = buildMergedSections(packages);
|
||||
const colCount = packages.length + 1;
|
||||
const gridCols = `${COMPARISON_TABLE_COL_WIDTH}px repeat(${packages.length}, ${COMPARISON_TABLE_COL_WIDTH}px)`;
|
||||
const recommendedColIdx = packages.findIndex((p) => p.isRecommended);
|
||||
const mergedSections = buildMergedSections(packages);
|
||||
const gridCols = `minmax(220px, 280px) repeat(${packages.length}, minmax(200px, 1fr))`;
|
||||
const minW = packages.length > 3 ? 960 : packages.length > 2 ? 800 : 600;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -309,34 +232,32 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
|
||||
sx={[
|
||||
{
|
||||
display: { xs: 'none', md: 'block' },
|
||||
width: COMPARISON_TABLE_COL_WIDTH * colCount,
|
||||
overflowX: 'auto',
|
||||
},
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
>
|
||||
{/* ── Package header cards ── */}
|
||||
<Box
|
||||
role="row"
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: gridCols,
|
||||
mb: 4,
|
||||
alignItems: 'stretch',
|
||||
pt: 3, // Room for floating verified badges
|
||||
}}
|
||||
>
|
||||
{/* Info card — scrolls with the package columns. Previously
|
||||
sticky-left to mirror the row-label column, but that pinned
|
||||
it over the leftmost (recommended) package on horizontal
|
||||
scroll. The row labels below stay sticky on their own. */}
|
||||
<Box sx={{ px: 2 }}>
|
||||
<Box sx={{ minWidth: minW }}>
|
||||
{/* ── Package header cards ── */}
|
||||
<Box
|
||||
role="row"
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: gridCols,
|
||||
gap: 2,
|
||||
mb: 4,
|
||||
alignItems: 'stretch',
|
||||
pt: 3, // Room for floating verified badges
|
||||
}}
|
||||
>
|
||||
{/* Info card — stretches to match package card height, text at top */}
|
||||
<Card
|
||||
role="columnheader"
|
||||
variant="elevated"
|
||||
padding="default"
|
||||
sx={{
|
||||
bgcolor: 'var(--fa-color-surface-subtle)',
|
||||
height: '100%',
|
||||
alignSelf: 'stretch',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
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.
|
||||
</Typography>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
{packages.map((pkg) => (
|
||||
<Box key={pkg.id} sx={{ px: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
{/* Package column header cards */}
|
||||
{packages.map((pkg) => (
|
||||
<ComparisonColumnCard
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
onArrange={onArrange}
|
||||
onRemove={onRemove}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* ── Section tables (each separate with left accent headings) ── */}
|
||||
{mergedSections.map((section) => (
|
||||
<Box key={section.heading} sx={{ ...tableSx, gridTemplateColumns: gridCols, mb: 3 }}>
|
||||
{/* Section heading row — left cell sticky so label stays visible on horizontal scroll */}
|
||||
<Box
|
||||
role="row"
|
||||
sx={{
|
||||
gridColumn: `1 / ${colCount + 1}`,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'subgrid',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'sticky',
|
||||
left: 0,
|
||||
zIndex: Z_STICKY_LEFT_SECTION,
|
||||
gridColumn: '1 / 2',
|
||||
}}
|
||||
>
|
||||
{/* ── Section tables (each separate with left accent headings) ── */}
|
||||
{mergedSections.map((section) => (
|
||||
<Box key={section.heading} sx={{ ...tableSx, gridTemplateColumns: gridCols, mb: 3 }}>
|
||||
<Box role="row" sx={{ gridColumn: `1 / ${colCount + 1}` }}>
|
||||
<SectionHeading>{section.heading}</SectionHeading>
|
||||
</Box>
|
||||
{/* Background continuation for the remaining columns so they
|
||||
share the heading's surface-subtle wash. */}
|
||||
<Box
|
||||
aria-hidden
|
||||
sx={{
|
||||
gridColumn: `2 / ${colCount + 1}`,
|
||||
bgcolor: 'var(--fa-color-surface-subtle)',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{section.items.map((item) => (
|
||||
<Box
|
||||
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 */}
|
||||
{section.items.map((item) => (
|
||||
<Box
|
||||
role="cell"
|
||||
className="comparison-cell comparison-cell--label"
|
||||
key={item.name}
|
||||
role="row"
|
||||
sx={{
|
||||
position: 'sticky',
|
||||
left: 0,
|
||||
zIndex: Z_STICKY_LEFT,
|
||||
bgcolor: 'background.paper',
|
||||
px: 3,
|
||||
py: 2,
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'divider',
|
||||
gridColumn: `1 / ${colCount + 1}`,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'subgrid',
|
||||
transition: 'background-color 0.15s ease',
|
||||
'&:hover': { bgcolor: 'var(--fa-color-brand-50)' },
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary" component="span">
|
||||
{item.name}
|
||||
</Typography>
|
||||
{item.info && (
|
||||
<Box component="span" sx={{ whiteSpace: 'nowrap' }}>
|
||||
{'\u00A0'}
|
||||
<Tooltip title={item.info} arrow placement="top">
|
||||
<InfoOutlinedIcon
|
||||
aria-label={`More information about ${item.name}`}
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
color: 'var(--fa-color-neutral-400)',
|
||||
cursor: 'help',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
role="cell"
|
||||
sx={{
|
||||
px: 3,
|
||||
py: 2,
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary" component="span">
|
||||
{item.name}
|
||||
</Typography>
|
||||
{item.info && (
|
||||
<Box component="span" sx={{ whiteSpace: 'nowrap' }}>
|
||||
{'\u00A0'}
|
||||
<Tooltip title={item.info} arrow placement="top">
|
||||
<InfoOutlinedIcon
|
||||
aria-label={`More information about ${item.name}`}
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
color: 'var(--fa-color-neutral-400)',
|
||||
cursor: 'help',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{packages.map((pkg, idx) => {
|
||||
const isRecommended = idx === recommendedColIdx;
|
||||
return (
|
||||
{packages.map((pkg) => (
|
||||
<Box
|
||||
key={pkg.id}
|
||||
role="cell"
|
||||
className={
|
||||
'comparison-cell' + (isRecommended ? ' comparison-cell--recommended' : '')
|
||||
}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -476,26 +351,23 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
|
||||
borderColor: 'divider',
|
||||
borderLeft: '1px solid',
|
||||
borderLeftColor: 'divider',
|
||||
transition: 'background-color 0.15s ease',
|
||||
// Resting tint for the recommended column so it reads
|
||||
// as the default column even without hover.
|
||||
...(isRecommended && {
|
||||
bgcolor:
|
||||
'color-mix(in srgb, var(--fa-color-surface-warm) 50%, transparent)',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<CellValue
|
||||
value={lookupValue(pkg, section.heading, item.name)}
|
||||
sectionHeading={section.heading}
|
||||
/>
|
||||
<CellValue value={lookupValue(pkg, section.heading, item.name)} />
|
||||
</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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { ComparisonTable, COMPARISON_TABLE_COL_WIDTH, default } from './ComparisonTable';
|
||||
export { ComparisonTable, default } from './ComparisonTable';
|
||||
export type {
|
||||
ComparisonTableProps,
|
||||
ComparisonPackage,
|
||||
|
||||
@@ -41,6 +41,10 @@ export interface FuneralFinderV3Props {
|
||||
onSearch?: (params: FuneralFinderV3SearchParams) => void;
|
||||
/** Shows loading state on the CTA */
|
||||
loading?: boolean;
|
||||
/** Optional heading override */
|
||||
heading?: string;
|
||||
/** Optional subheading override */
|
||||
subheading?: string;
|
||||
/** MUI sx override for the root container */
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
@@ -247,7 +251,13 @@ const selectMenuProps = {
|
||||
*/
|
||||
export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3Props>(
|
||||
(props, ref) => {
|
||||
const { onSearch, loading = false, sx } = props;
|
||||
const {
|
||||
onSearch,
|
||||
loading = false,
|
||||
heading = 'Find funeral directors near you',
|
||||
subheading,
|
||||
sx,
|
||||
} = props;
|
||||
|
||||
// ─── IDs for aria-labelledby ──────────────────────────────
|
||||
const id = React.useId();
|
||||
@@ -382,6 +392,29 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
>
|
||||
{/* ── Header ──────────────────────────────────────────── */}
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography
|
||||
variant="h3"
|
||||
component="h2"
|
||||
sx={{
|
||||
fontFamily: 'var(--fa-font-family-display)',
|
||||
fontWeight: 600,
|
||||
fontSize: { xs: '1.25rem', sm: '1.5rem' },
|
||||
mb: subheading ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
{heading}
|
||||
</Typography>
|
||||
{subheading && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{subheading}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* ── How can we help ─────────────────────────────────── */}
|
||||
<Box ref={statusSectionRef}>
|
||||
<SectionLabel id={statusLabelId}>How Can We Help</SectionLabel>
|
||||
|
||||
@@ -143,28 +143,3 @@ export const ExtendedNavigation: Story = {
|
||||
ctaLabel: 'Start planning',
|
||||
},
|
||||
};
|
||||
|
||||
// --- With Dropdown -----------------------------------------------------------
|
||||
|
||||
/** Items with `children` render as a dropdown on desktop and a collapsible
|
||||
* section in the mobile drawer */
|
||||
export const WithDropdown: Story = {
|
||||
args: {
|
||||
logo: <FALogo />,
|
||||
items: [
|
||||
{
|
||||
label: 'Locations',
|
||||
children: [
|
||||
{ label: 'Melbourne', href: '/locations/melbourne' },
|
||||
{ label: 'Brisbane', href: '/locations/brisbane' },
|
||||
{ label: 'Sydney', href: '/locations/sydney' },
|
||||
{ label: 'South Coast NSW', href: '/locations/south-coast-nsw' },
|
||||
{ label: 'Central Coast NSW', href: '/locations/central-coast-nsw' },
|
||||
],
|
||||
},
|
||||
{ label: 'FAQ', href: '/faq' },
|
||||
{ label: 'Contact Us', href: '/contact' },
|
||||
{ label: 'Log in', href: '/login' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,14 +6,9 @@ import Drawer from '@mui/material/Drawer';
|
||||
import List from '@mui/material/List';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import { IconButton } from '../../atoms/IconButton';
|
||||
import { Link } from '../../atoms/Link';
|
||||
@@ -23,16 +18,14 @@ import { Divider } from '../../atoms/Divider';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A navigation link item. May have children to render as a dropdown. */
|
||||
/** A navigation link item */
|
||||
export interface NavItem {
|
||||
/** Display label */
|
||||
label: string;
|
||||
/** URL to navigate to (ignored when `children` is provided) */
|
||||
href?: string;
|
||||
/** URL to navigate to */
|
||||
href: string;
|
||||
/** Click handler (alternative to href for SPA navigation) */
|
||||
onClick?: () => void;
|
||||
/** Sub-items rendered as a dropdown (desktop) or collapsible (mobile) */
|
||||
children?: NavItem[];
|
||||
}
|
||||
|
||||
/** Props for the FA Navigation organism */
|
||||
@@ -51,163 +44,6 @@ export interface NavigationProps {
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
// ─── Desktop dropdown link ───────────────────────────────────────────────────
|
||||
|
||||
interface DesktopDropdownProps {
|
||||
item: NavItem;
|
||||
}
|
||||
|
||||
const DesktopDropdown: React.FC<DesktopDropdownProps> = ({ item }) => {
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
const handleOpen = (event: React.MouseEvent<HTMLElement>) => setAnchorEl(event.currentTarget);
|
||||
const handleClose = () => setAnchorEl(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={handleOpen}
|
||||
sx={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--fa-color-brand-900)',
|
||||
fontFamily: 'inherit',
|
||||
fontWeight: 600,
|
||||
fontSize: '1rem',
|
||||
'&:hover': {
|
||||
color: 'primary.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Box>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
mt: 1,
|
||||
minWidth: 200,
|
||||
borderRadius: 'var(--fa-border-radius-md, 8px)',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.08)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{item.children?.map((child) => (
|
||||
<MenuItem
|
||||
key={child.label}
|
||||
component="a"
|
||||
href={child.href}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (child.onClick) {
|
||||
e.preventDefault();
|
||||
child.onClick();
|
||||
}
|
||||
handleClose();
|
||||
}}
|
||||
sx={{
|
||||
color: 'var(--fa-color-brand-900)',
|
||||
fontWeight: 500,
|
||||
py: 1.25,
|
||||
'&:hover': {
|
||||
bgcolor: 'var(--fa-color-brand-100)',
|
||||
color: 'primary.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{child.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Mobile collapsible item ─────────────────────────────────────────────────
|
||||
|
||||
interface MobileCollapsibleProps {
|
||||
item: NavItem;
|
||||
onItemClick: () => void;
|
||||
}
|
||||
|
||||
const MobileCollapsible: React.FC<MobileCollapsibleProps> = ({ item, onItemClick }) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItemButton
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
aria-expanded={open}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
px: 3,
|
||||
minHeight: 44,
|
||||
'&:hover': {
|
||||
bgcolor: 'var(--fa-color-brand-100)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
primaryTypographyProps={{
|
||||
fontWeight: 500,
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
/>
|
||||
{open ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</ListItemButton>
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<List component="div" disablePadding>
|
||||
{item.children?.map((child) => (
|
||||
<ListItemButton
|
||||
key={child.label}
|
||||
component="a"
|
||||
href={child.href}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (child.onClick) {
|
||||
e.preventDefault();
|
||||
child.onClick();
|
||||
}
|
||||
onItemClick();
|
||||
}}
|
||||
sx={{
|
||||
py: 1.25,
|
||||
pl: 5,
|
||||
pr: 3,
|
||||
minHeight: 44,
|
||||
'&:hover': {
|
||||
bgcolor: 'var(--fa-color-brand-100)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={child.label}
|
||||
primaryTypographyProps={{
|
||||
fontWeight: 400,
|
||||
fontSize: '0.9375rem',
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -215,13 +51,26 @@ const MobileCollapsible: React.FC<MobileCollapsibleProps> = ({ item, onItemClick
|
||||
*
|
||||
* Responsive header with logo, navigation links, and optional CTA.
|
||||
* Desktop shows links inline; mobile collapses to hamburger + drawer.
|
||||
* Items with `children` render as a dropdown (desktop) or collapsible
|
||||
* section (mobile).
|
||||
*
|
||||
* Maps to Figma "Main Nav" (14:108) desktop and "Mobile Header"
|
||||
* (2391:41508) mobile patterns.
|
||||
*
|
||||
* Composes AppBar + Link + IconButton + Button + Divider + Drawer + Menu.
|
||||
* Composes AppBar + Link + IconButton + Button + Divider + Drawer.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* <Navigation
|
||||
* logo={<img src="/logo.svg" alt="Funeral Arranger" height={40} />}
|
||||
* onLogoClick={() => navigate('/')}
|
||||
* items={[
|
||||
* { label: 'FAQ', href: '/faq' },
|
||||
* { label: 'Contact Us', href: '/contact' },
|
||||
* { label: 'Log in', href: '/login' },
|
||||
* ]}
|
||||
* ctaLabel="Start planning"
|
||||
* onCtaClick={() => navigate('/arrange')}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
|
||||
({ logo, onLogoClick, items = [], ctaLabel, onCtaClick, sx }, ref) => {
|
||||
@@ -229,7 +78,6 @@ export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
|
||||
const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down('md'));
|
||||
|
||||
const handleDrawerToggle = () => setDrawerOpen((prev) => !prev);
|
||||
const closeDrawer = () => setDrawerOpen(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -299,28 +147,24 @@ export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
|
||||
aria-label="Main navigation"
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 3.5 }}
|
||||
>
|
||||
{items.map((item) =>
|
||||
item.children && item.children.length > 0 ? (
|
||||
<DesktopDropdown key={item.label} item={item} />
|
||||
) : (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
onClick={item.onClick}
|
||||
underline="hover"
|
||||
sx={{
|
||||
color: 'var(--fa-color-brand-900)',
|
||||
fontWeight: 600,
|
||||
fontSize: '1rem',
|
||||
'&:hover': {
|
||||
color: 'primary.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
),
|
||||
)}
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
onClick={item.onClick}
|
||||
underline="hover"
|
||||
sx={{
|
||||
color: 'var(--fa-color-brand-900)',
|
||||
fontWeight: 600,
|
||||
fontSize: '1rem',
|
||||
'&:hover': {
|
||||
color: 'primary.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{ctaLabel && (
|
||||
<Button variant="contained" size="medium" onClick={onCtaClick}>
|
||||
@@ -366,40 +210,36 @@ export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
|
||||
|
||||
{/* Nav items */}
|
||||
<List component="nav" aria-label="Main navigation">
|
||||
{items.map((item) =>
|
||||
item.children && item.children.length > 0 ? (
|
||||
<MobileCollapsible key={item.label} item={item} onItemClick={closeDrawer} />
|
||||
) : (
|
||||
<ListItemButton
|
||||
key={item.label}
|
||||
component="a"
|
||||
href={item.href}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (item.onClick) {
|
||||
e.preventDefault();
|
||||
item.onClick();
|
||||
}
|
||||
closeDrawer();
|
||||
{items.map((item) => (
|
||||
<ListItemButton
|
||||
key={item.label}
|
||||
component="a"
|
||||
href={item.href}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (item.onClick) {
|
||||
e.preventDefault();
|
||||
item.onClick();
|
||||
}
|
||||
setDrawerOpen(false);
|
||||
}}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
px: 3,
|
||||
minHeight: 44,
|
||||
'&:hover': {
|
||||
bgcolor: 'var(--fa-color-brand-100)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
primaryTypographyProps={{
|
||||
fontWeight: 500,
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
px: 3,
|
||||
minHeight: 44,
|
||||
'&:hover': {
|
||||
bgcolor: 'var(--fa-color-brand-100)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
primaryTypographyProps={{
|
||||
fontWeight: 500,
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
),
|
||||
)}
|
||||
/>
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
|
||||
{ctaLabel && (
|
||||
@@ -410,7 +250,7 @@ export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
if (onCtaClick) onCtaClick();
|
||||
closeDrawer();
|
||||
setDrawerOpen(false);
|
||||
}}
|
||||
>
|
||||
{ctaLabel}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { PackageDetail } from './PackageDetail';
|
||||
import { ServiceOption } from '../../molecules/ServiceOption';
|
||||
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
|
||||
import { Chip } from '../../atoms/Chip';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
import { Button } from '../../atoms/Button';
|
||||
import { Navigation } from '../Navigation';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
|
||||
const DEMO_IMAGE =
|
||||
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400&h=300&fit=crop';
|
||||
|
||||
const essentials = [
|
||||
{
|
||||
@@ -106,6 +117,41 @@ const extras = {
|
||||
const termsText =
|
||||
'* This package includes a funeral service at a chapel or a church with a funeral procession following to the crematorium. It includes many of the most commonly selected funeral options preselected for you. Many people choose this package for the extended funeral rituals — of course, you can tailor the funeral service to meet your needs and budget as you go through the selections.';
|
||||
|
||||
const packages = [
|
||||
{
|
||||
id: 'everyday',
|
||||
name: 'Everyday Funeral Package',
|
||||
price: 900,
|
||||
description:
|
||||
'Our most popular package with all essential services included. Suitable for a traditional chapel or church service.',
|
||||
},
|
||||
{
|
||||
id: 'deluxe',
|
||||
name: 'Deluxe Funeral Package',
|
||||
price: 1200,
|
||||
description: 'An enhanced package with premium coffin and additional floral arrangements.',
|
||||
},
|
||||
{
|
||||
id: 'essential',
|
||||
name: 'Essential Funeral Package',
|
||||
price: 600,
|
||||
description: 'A simple, dignified service covering all necessary arrangements.',
|
||||
},
|
||||
{
|
||||
id: 'catholic',
|
||||
name: 'Catholic Service',
|
||||
price: 950,
|
||||
description:
|
||||
'A service tailored for Catholic traditions including prayers and church ceremony.',
|
||||
},
|
||||
];
|
||||
|
||||
const funeralTypes = ['All', 'Cremation', 'Burial', 'Memorial', 'Catholic', 'Direct Cremation'];
|
||||
|
||||
const FALogoNav = () => (
|
||||
<Box component="img" src="/brandlogo/logo-full.svg" alt="Funeral Arranger" sx={{ height: 28 }} />
|
||||
);
|
||||
|
||||
const meta: Meta<typeof PackageDetail> = {
|
||||
title: 'Organisms/PackageDetail',
|
||||
component: PackageDetail,
|
||||
@@ -159,24 +205,6 @@ export const CompareLoading: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** "Added to comparison" state — package is already in the basket.
|
||||
* The Compare button keeps its default soft/secondary chrome + "Compare"
|
||||
* label, and gains a trailing check icon. Click is a toggle — the
|
||||
* caller wires `onCompare` to add-or-remove based on the `inCart` prop
|
||||
* it's passing in (e.g. via `basket.toggle(key)`). aria-pressed and the
|
||||
* aria-label spell out the state for SR users. */
|
||||
export const InCart: Story = {
|
||||
args: {
|
||||
name: 'Traditional Family Cremation Service',
|
||||
price: 6966,
|
||||
sections: [{ heading: 'Essentials', items: essentials.slice(0, 4) }],
|
||||
total: 6966,
|
||||
onArrange: () => alert('Make Arrangement'),
|
||||
onCompare: () => {},
|
||||
inCart: true,
|
||||
},
|
||||
};
|
||||
|
||||
// --- Without Extras ----------------------------------------------------------
|
||||
|
||||
/** Simpler package with essentials and optionals only — no extras */
|
||||
@@ -194,3 +222,132 @@ export const WithoutExtras: Story = {
|
||||
onCompare: () => alert('Compare'),
|
||||
},
|
||||
};
|
||||
|
||||
// --- Package Select Page Layout ----------------------------------------------
|
||||
|
||||
/** Full page layout — left: package list, right: detail panel */
|
||||
export const PackageSelectPage: Story = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<Box sx={{ maxWidth: 'none', width: '100%' }}>
|
||||
<Story />
|
||||
</Box>
|
||||
),
|
||||
],
|
||||
render: () => {
|
||||
const [selectedPkg, setSelectedPkg] = useState('everyday');
|
||||
const [activeFilter, setActiveFilter] = useState('Cremation');
|
||||
const [comparing, setComparing] = useState(false);
|
||||
|
||||
const handleCompare = () => {
|
||||
setComparing(true);
|
||||
setTimeout(() => setComparing(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Navigation
|
||||
logo={<FALogoNav />}
|
||||
items={[
|
||||
{ label: 'Provider Portal', href: '/provider-portal' },
|
||||
{ label: 'FAQ', href: '/faq' },
|
||||
{ label: 'Contact Us', href: '/contact' },
|
||||
{ label: 'Log in', href: '/login' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' },
|
||||
gap: { xs: 3, md: 4 },
|
||||
maxWidth: 'lg',
|
||||
mx: 'auto',
|
||||
px: { xs: 2, md: 4 },
|
||||
py: { xs: 2, md: 4 },
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
{/* Left column */}
|
||||
<Box>
|
||||
<Button
|
||||
variant="text"
|
||||
color="secondary"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{ mb: 2, ml: -1 }}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Typography variant="h2" sx={{ mb: 3 }}>
|
||||
Select a package
|
||||
</Typography>
|
||||
|
||||
<ProviderCardCompact
|
||||
name="H.Parsons"
|
||||
location="Wentworth"
|
||||
imageUrl={DEMO_IMAGE}
|
||||
rating={4.5}
|
||||
reviewCount={11}
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
|
||||
{/* Funeral type filter */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 3 }}>
|
||||
{funeralTypes.map((type) => (
|
||||
<Chip
|
||||
key={type}
|
||||
label={type}
|
||||
variant={activeFilter === type ? 'filled' : 'outlined'}
|
||||
selected={activeFilter === type}
|
||||
onClick={() => setActiveFilter(type)}
|
||||
size="small"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Typography variant="h4" sx={{ mb: 2 }}>
|
||||
Packages
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
role="radiogroup"
|
||||
aria-label="Available packages"
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}
|
||||
>
|
||||
{packages.map((pkg) => (
|
||||
<ServiceOption
|
||||
key={pkg.id}
|
||||
name={pkg.name}
|
||||
price={pkg.price}
|
||||
description={pkg.description}
|
||||
selected={selectedPkg === pkg.id}
|
||||
onClick={() => setSelectedPkg(pkg.id)}
|
||||
maxDescriptionLines={2}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Right column: package detail */}
|
||||
<Box sx={{ position: { md: 'sticky' }, top: { md: 96 } }}>
|
||||
<PackageDetail
|
||||
name={packages.find((p) => p.id === selectedPkg)?.name ?? ''}
|
||||
price={packages.find((p) => p.id === selectedPkg)?.price ?? 0}
|
||||
sections={[
|
||||
{ heading: 'Essentials', items: essentials },
|
||||
{ heading: 'Optionals', items: optionals },
|
||||
]}
|
||||
total={6966}
|
||||
extras={extras}
|
||||
terms={termsText}
|
||||
onArrange={() => alert(`Making arrangement for: ${selectedPkg}`)}
|
||||
onCompare={handleCompare}
|
||||
compareLoading={comparing}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
import { Button } from '../../atoms/Button';
|
||||
@@ -56,11 +53,6 @@ export interface PackageDetailProps {
|
||||
arrangeDisabled?: boolean;
|
||||
/** Whether the compare button is in loading state */
|
||||
compareLoading?: boolean;
|
||||
/** Whether this package is already in the comparison basket. When true,
|
||||
* the Compare button swaps its label to "Added" and adds a leading check
|
||||
* icon. The button remains clickable — the caller is expected to treat
|
||||
* `onCompare` as a toggle (add when not in cart, remove when in cart). */
|
||||
inCart?: boolean;
|
||||
/** Custom label for the arrange CTA button (default: "Make Arrangement") */
|
||||
arrangeLabel?: string;
|
||||
/** Disclaimer shown below the price (e.g. for unverified/estimated pricing) */
|
||||
@@ -132,7 +124,6 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
|
||||
terms,
|
||||
onArrange,
|
||||
onCompare,
|
||||
inCart = false,
|
||||
arrangeDisabled = false,
|
||||
compareLoading = false,
|
||||
arrangeLabel = 'Make Arrangement',
|
||||
@@ -142,11 +133,6 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
// CTA buttons stay side-by-side on all viewports; size down on xs so
|
||||
// "Make Arrangement" + "Compare" fit a ~360px mobile column without wrap.
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const ctaSize = isMobile ? 'medium' : 'large';
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
@@ -155,7 +141,6 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 'var(--fa-card-border-radius-default)',
|
||||
boxShadow: 'var(--fa-card-shadow-default)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
@@ -164,7 +149,7 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
|
||||
{/* Header band — warm bg to separate from content */}
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
bgcolor: 'var(--fa-color-surface-warm)',
|
||||
px: { xs: 2, sm: 3 },
|
||||
pt: 3,
|
||||
pb: 2.5,
|
||||
@@ -193,10 +178,10 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 1.25,
|
||||
gap: 1,
|
||||
mt: 1.5,
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
bgcolor: 'var(--fa-color-surface-cool, #F5F7FA)',
|
||||
borderRadius: 'var(--fa-border-radius-sm, 6px)',
|
||||
border: '1px solid',
|
||||
@@ -204,20 +189,22 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
|
||||
}}
|
||||
>
|
||||
<InfoOutlinedIcon
|
||||
sx={{ fontSize: 16, color: 'text.secondary', mt: '3px', flexShrink: 0 }}
|
||||
sx={{ fontSize: 16, color: 'text.secondary', mt: '1px', flexShrink: 0 }}
|
||||
aria-hidden
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.5 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.4 }}>
|
||||
{priceDisclaimer}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* CTA buttons — always side-by-side */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 1.5, mt: 2.5 }}>
|
||||
{/* CTA buttons */}
|
||||
<Box
|
||||
sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, gap: 1.5, mt: 2.5 }}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
size={ctaSize}
|
||||
size="large"
|
||||
fullWidth
|
||||
disabled={arrangeDisabled}
|
||||
onClick={onArrange}
|
||||
@@ -225,19 +212,12 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
|
||||
{arrangeLabel}
|
||||
</Button>
|
||||
{onCompare && (
|
||||
// Same soft/secondary chrome + "Compare" label in both states;
|
||||
// when the package is in the basket a trailing check icon
|
||||
// appears. Click is a toggle — caller decides to add or remove
|
||||
// based on the `inCart` it's passing in.
|
||||
<Button
|
||||
variant="soft"
|
||||
color="secondary"
|
||||
size={ctaSize}
|
||||
size="large"
|
||||
loading={compareLoading}
|
||||
endIcon={inCart ? <CheckRoundedIcon /> : undefined}
|
||||
onClick={onCompare}
|
||||
aria-pressed={inCart}
|
||||
aria-label={inCart ? 'Remove from comparison' : 'Add to comparison'}
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
Compare
|
||||
|
||||
@@ -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: () => {},
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -1,6 +0,0 @@
|
||||
export {
|
||||
ProviderMap,
|
||||
type ProviderMapProps,
|
||||
type ProviderMapHandle,
|
||||
type ProviderMapActiveState,
|
||||
} from './ProviderMap';
|
||||
@@ -122,7 +122,7 @@ const pkgMackay: ComparisonPackage = {
|
||||
name: 'Everyday Funeral Package',
|
||||
price: 5495.45,
|
||||
provider: {
|
||||
name: 'Mackay Family Funeral Directors & Cremation Services',
|
||||
name: 'Mackay Family Funerals',
|
||||
location: 'Inglewood',
|
||||
logoUrl: DEMO_LOGO,
|
||||
rating: 4.6,
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import React, { useId, useState, useRef, useCallback } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import ShareOutlinedIcon from '@mui/icons-material/ShareOutlined';
|
||||
import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
import { Button } from '../../atoms/Button';
|
||||
import { Link } from '../../atoms/Link';
|
||||
import { WizardLayout } from '../../templates/WizardLayout';
|
||||
import {
|
||||
ComparisonTable,
|
||||
COMPARISON_TABLE_COL_WIDTH,
|
||||
type ComparisonPackage,
|
||||
} from '../../organisms/ComparisonTable';
|
||||
import { ComparisonTable, type ComparisonPackage } from '../../organisms/ComparisonTable';
|
||||
import { ComparisonPackageCard } from '../../molecules/ComparisonPackageCard';
|
||||
import { ComparisonTabCard } from '../../molecules/ComparisonTabCard';
|
||||
|
||||
@@ -120,147 +113,27 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Natural table width = (row-label col) + (pkg col × n), matches page header maxWidth.
|
||||
// Page header container reaches this same width so the table's left edge aligns
|
||||
// with the page header's left edge when the table overflows horizontally.
|
||||
const tableNaturalWidth = COMPARISON_TABLE_COL_WIDTH * (allPackages.length + 1);
|
||||
const pageMaxWidth = COMPARISON_TABLE_COL_WIDTH * 4; // fits 3-package case flush
|
||||
|
||||
// Matching horizontal padding between the page header container and the
|
||||
// table-zone spacers keeps inner-content left edges aligned on all viewports.
|
||||
const edgePadding = { xs: 16, md: 24 };
|
||||
|
||||
return (
|
||||
<Box ref={ref} sx={sx}>
|
||||
<WizardLayout
|
||||
variant={isMobile ? 'wide-form' : 'bleed'}
|
||||
variant="wide-form"
|
||||
navigation={navigation}
|
||||
showBackLink={isMobile}
|
||||
showBackLink
|
||||
backLabel="Back"
|
||||
onBack={onBack}
|
||||
>
|
||||
{!isMobile && (
|
||||
<>
|
||||
{/* Page header zone — centred, bounded to the table's natural width */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', width: '100%' }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: pageMaxWidth,
|
||||
px: { xs: `${edgePadding.xs}px`, md: `${edgePadding.md}px` },
|
||||
pt: { xs: 2, md: 3 },
|
||||
pb: { xs: 3, md: 5 },
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
component="button"
|
||||
onClick={onBack}
|
||||
underline="hover"
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
color: 'text.secondary',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
mb: 2,
|
||||
'&:hover': { color: 'text.primary' },
|
||||
}}
|
||||
>
|
||||
<ArrowBackIcon sx={{ fontSize: 18 }} />
|
||||
Back
|
||||
</Link>
|
||||
<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 }}>
|
||||
{/* Page header with Share/Print actions */}
|
||||
<Box sx={{ mb: { xs: 3, md: 5 } }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="h2" component="h1" sx={{ mb: 1 }}>
|
||||
Compare packages
|
||||
</Typography>
|
||||
@@ -269,21 +142,50 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
|
||||
</Typography>
|
||||
</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
|
||||
id="comparison-rail-heading"
|
||||
variant="label"
|
||||
component="h2"
|
||||
sx={{ fontWeight: 600, display: 'block', mb: 1.5 }}
|
||||
>
|
||||
Choose a package to view
|
||||
</Typography>
|
||||
{/* Desktop: ComparisonTable */}
|
||||
{!isMobile && (
|
||||
<ComparisonTable packages={allPackages} onArrange={onArrange} onRemove={onRemove} />
|
||||
)}
|
||||
|
||||
{/* Mobile: Tab rail + card view */}
|
||||
{isMobile && allPackages.length > 0 && (
|
||||
<>
|
||||
{/* Tab rail — mini cards showing provider + package + price */}
|
||||
<Box
|
||||
ref={railRef}
|
||||
role="tablist"
|
||||
id={tablistId}
|
||||
aria-labelledby="comparison-rail-heading"
|
||||
aria-label="Packages to compare"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 1.5,
|
||||
@@ -291,7 +193,8 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
|
||||
py: 2,
|
||||
px: 2,
|
||||
mx: -2,
|
||||
mb: 1.5,
|
||||
mt: 1,
|
||||
mb: 3,
|
||||
scrollbarWidth: 'none',
|
||||
'&::-webkit-scrollbar': { display: 'none' },
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
@@ -313,54 +216,6 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
|
||||
))}
|
||||
</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 && (
|
||||
<Box
|
||||
role="tabpanel"
|
||||
|
||||
@@ -40,16 +40,6 @@ const nav = (
|
||||
<Navigation
|
||||
logo={<FALogo />}
|
||||
items={[
|
||||
{
|
||||
label: 'Locations',
|
||||
children: [
|
||||
{ label: 'Melbourne', href: '/locations/melbourne' },
|
||||
{ label: 'Brisbane', href: '/locations/brisbane' },
|
||||
{ label: 'Sydney', href: '/locations/sydney' },
|
||||
{ label: 'South Coast NSW', href: '/locations/south-coast-nsw' },
|
||||
{ label: 'Central Coast NSW', href: '/locations/central-coast-nsw' },
|
||||
],
|
||||
},
|
||||
{ label: 'FAQ', href: '/faq' },
|
||||
{ label: 'Contact Us', href: '/contact' },
|
||||
{ label: 'Log in', href: '/login' },
|
||||
|
||||
@@ -13,7 +13,6 @@ import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
import { Button } from '../../atoms/Button';
|
||||
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
|
||||
import { assetUrl } from '../../../utils/assetUrl';
|
||||
import { Divider } from '../../atoms/Divider';
|
||||
import { FuneralFinderV3, type FuneralFinderV3SearchParams } from '../../organisms/FuneralFinder';
|
||||
|
||||
@@ -186,8 +185,8 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
discoverMapSlot,
|
||||
onSelectFeaturedProvider,
|
||||
features = [],
|
||||
featuresHeading = '4 Reasons to use Funeral Arranger',
|
||||
featuresBody,
|
||||
featuresHeading = 'How it works',
|
||||
featuresBody = 'Search local funeral directors, compare transparent pricing, and personalise a plan — all in your own time. No pressure, no hidden costs.',
|
||||
googleRating,
|
||||
googleReviewCount,
|
||||
testimonials = [],
|
||||
@@ -241,32 +240,21 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
}}
|
||||
>
|
||||
<Container
|
||||
maxWidth={false}
|
||||
maxWidth="md"
|
||||
sx={{
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
textAlign: 'center',
|
||||
maxWidth: 990,
|
||||
pt: { xs: 10, md: 14 },
|
||||
pb: { xs: 3, md: 4 },
|
||||
pt: { xs: 8, md: 11 },
|
||||
pb: 4,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
fontStyle: 'italic',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
Trusted by thousands of families across Australia
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="display2"
|
||||
variant="display3"
|
||||
component="h1"
|
||||
id="hero-heading"
|
||||
tabIndex={-1}
|
||||
sx={{ mb: 5, color: 'var(--fa-color-white)' }}
|
||||
sx={{ mb: 3, color: 'var(--fa-color-white)' }}
|
||||
>
|
||||
{heroHeading}
|
||||
</Typography>
|
||||
@@ -284,14 +272,20 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
width: '100%',
|
||||
px: { xs: 3, md: 2 },
|
||||
pt: 6,
|
||||
px: 2,
|
||||
pt: 2,
|
||||
pb: 0,
|
||||
mb: { xs: -14, md: -18 },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: '100%', maxWidth: finderSlot ? 500 : 520, mx: 'auto' }}>
|
||||
{finderSlot || <FuneralFinderV3 onSearch={onSearch} loading={searchLoading} />}
|
||||
{finderSlot || (
|
||||
<FuneralFinderV3
|
||||
heading="Find your local providers"
|
||||
onSearch={onSearch}
|
||||
loading={searchLoading}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -321,7 +315,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="display2"
|
||||
variant="display3"
|
||||
component="h1"
|
||||
id="hero-heading"
|
||||
tabIndex={-1}
|
||||
@@ -374,7 +368,13 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
@@ -498,7 +498,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{ maxWidth: 520, mx: 'auto', fontSize: { xs: '0.875rem', md: '1rem' } }}
|
||||
sx={{ maxWidth: 520, mx: 'auto' }}
|
||||
>
|
||||
From trusted local providers to personalised options, find the right care near
|
||||
you.
|
||||
@@ -571,7 +571,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
{/* CTA */}
|
||||
<Box sx={{ textAlign: 'center', mt: 4 }}>
|
||||
<Button variant="text" size="medium" onClick={onCtaClick}>
|
||||
Start exploring
|
||||
Start exploring →
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
@@ -601,7 +601,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
}}
|
||||
>
|
||||
{/* Text */}
|
||||
<Box sx={{ textAlign: { xs: 'center', md: 'left' } }}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="overline"
|
||||
component="div"
|
||||
@@ -617,11 +617,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
>
|
||||
Making an impossible time a little easier
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{ fontSize: { xs: '0.875rem', md: '1rem' } }}
|
||||
>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Funeral planning doesn’t have to be overwhelming. Whether a loved one has
|
||||
just passed, is imminent, or you’re pre-planning the future for yourself.
|
||||
Compare transparent pricing from local funeral directors. Explore the service
|
||||
@@ -642,7 +638,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={assetUrl('/images/Homepage/people.png')}
|
||||
src="/brandassets/images/Homepage/people.png"
|
||||
alt="Family planning together with care and confidence"
|
||||
/>
|
||||
</Box>
|
||||
@@ -800,30 +796,21 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
>
|
||||
<Container maxWidth="lg">
|
||||
<Box sx={{ textAlign: 'center', mb: { xs: 5, md: 8 } }}>
|
||||
<Typography
|
||||
variant="overline"
|
||||
component="div"
|
||||
sx={{ color: 'var(--fa-color-brand-600)', mb: 1.5 }}
|
||||
>
|
||||
Why Use Funeral Arranger
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="display3"
|
||||
component="h2"
|
||||
id="features-heading"
|
||||
sx={{ mb: featuresBody ? 2.5 : 0, color: 'text.primary' }}
|
||||
sx={{ mb: 2.5, color: 'text.primary' }}
|
||||
>
|
||||
{featuresHeading}
|
||||
</Typography>
|
||||
{featuresBody && (
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{ maxWidth: 560, mx: 'auto', fontSize: { xs: '0.875rem', md: '1rem' } }}
|
||||
>
|
||||
{featuresBody}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{ maxWidth: 560, mx: 'auto' }}
|
||||
>
|
||||
{featuresBody}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
@@ -874,17 +861,6 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="md">
|
||||
<Typography
|
||||
variant="overline"
|
||||
component="div"
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
color: 'var(--fa-color-brand-600)',
|
||||
mb: 1.5,
|
||||
}}
|
||||
>
|
||||
Funeral Arranger Reviews
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="display3"
|
||||
component="h2"
|
||||
@@ -997,7 +973,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
>
|
||||
{ctaHeading}
|
||||
</Typography>
|
||||
<Button variant="contained" size="medium" onClick={onCtaClick}>
|
||||
<Button variant="text" size="large" onClick={onCtaClick}>
|
||||
{ctaButtonLabel}
|
||||
</Button>
|
||||
</Container>
|
||||
@@ -1043,13 +1019,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
}}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0, py: 1.5 }}>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
fontSize: { xs: '0.875rem', md: '1rem' },
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
{item.question}
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
|
||||
@@ -8,7 +8,6 @@ import { HomePage } from './HomePage';
|
||||
import type { FeaturedProvider, TrustStat } from './HomePage';
|
||||
import { Navigation } from '../../organisms/Navigation';
|
||||
import { Footer } from '../../organisms/Footer';
|
||||
import { assetUrl } from '../../../utils/assetUrl';
|
||||
|
||||
// ─── Shared helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -42,16 +41,6 @@ const nav = (
|
||||
<Navigation
|
||||
logo={<FALogo />}
|
||||
items={[
|
||||
{
|
||||
label: 'Locations',
|
||||
children: [
|
||||
{ label: 'Melbourne', href: '/locations/melbourne' },
|
||||
{ label: 'Brisbane', href: '/locations/brisbane' },
|
||||
{ label: 'Sydney', href: '/locations/sydney' },
|
||||
{ label: 'South Coast NSW', href: '/locations/south-coast-nsw' },
|
||||
{ label: 'Central Coast NSW', href: '/locations/central-coast-nsw' },
|
||||
],
|
||||
},
|
||||
{ label: 'FAQ', href: '/faq' },
|
||||
{ label: 'Contact Us', href: '/contact' },
|
||||
{ label: 'Log in', href: '/login' },
|
||||
@@ -242,7 +231,7 @@ export const Default: Story = {
|
||||
args: {
|
||||
navigation: nav,
|
||||
footer,
|
||||
heroImageUrl: assetUrl('/images/heroes/parsonshero.png'),
|
||||
heroImageUrl: '/brandassets/images/heroes/parsonshero.png',
|
||||
stats: trustStats,
|
||||
featuredProviders,
|
||||
onSelectFeaturedProvider: (id) => console.log('Featured provider:', id),
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { FeaturedProvider, TrustStat, PartnerLogo } from './HomePage';
|
||||
import React from 'react';
|
||||
import { Navigation } from '../../organisms/Navigation';
|
||||
import { Footer } from '../../organisms/Footer';
|
||||
import { assetUrl } from '../../../utils/assetUrl';
|
||||
|
||||
// ─── Shared helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -38,16 +37,6 @@ const nav = (
|
||||
<Navigation
|
||||
logo={<FALogo />}
|
||||
items={[
|
||||
{
|
||||
label: 'Locations',
|
||||
children: [
|
||||
{ label: 'Melbourne', href: '/locations/melbourne' },
|
||||
{ label: 'Brisbane', href: '/locations/brisbane' },
|
||||
{ label: 'Sydney', href: '/locations/sydney' },
|
||||
{ label: 'South Coast NSW', href: '/locations/south-coast-nsw' },
|
||||
{ label: 'Central Coast NSW', href: '/locations/central-coast-nsw' },
|
||||
],
|
||||
},
|
||||
{ label: 'FAQ', href: '/faq' },
|
||||
{ label: 'Contact Us', href: '/contact' },
|
||||
{ label: 'Log in', href: '/login' },
|
||||
@@ -188,8 +177,8 @@ const featuredProviders: FeaturedProvider[] = [
|
||||
name: 'H.Parsons Funeral Directors',
|
||||
location: 'Wollongong, NSW',
|
||||
verified: true,
|
||||
imageUrl: assetUrl('/images/venues/hparsons-funeral-home-kiama/01.jpg'),
|
||||
logoUrl: assetUrl('/images/providers/hparsons-funeral-directors/logo.png'),
|
||||
imageUrl: '/brandassets/images/venues/hparsons-funeral-home-kiama/01.jpg',
|
||||
logoUrl: '/brandassets/images/providers/hparsons-funeral-directors/logo.png',
|
||||
rating: 4.6,
|
||||
reviewCount: 7,
|
||||
startingPrice: 900,
|
||||
@@ -199,8 +188,8 @@ const featuredProviders: FeaturedProvider[] = [
|
||||
name: 'Rankins Funerals',
|
||||
location: 'Wollongong, NSW',
|
||||
verified: true,
|
||||
imageUrl: assetUrl('/images/venues/rankins-funeral-home-warrawong/01.jpg'),
|
||||
logoUrl: assetUrl('/images/providers/rankins-funerals/logo.png'),
|
||||
imageUrl: '/brandassets/images/venues/rankins-funeral-home-warrawong/01.jpg',
|
||||
logoUrl: '/brandassets/images/providers/rankins-funerals/logo.png',
|
||||
rating: 4.8,
|
||||
reviewCount: 23,
|
||||
startingPrice: 1200,
|
||||
@@ -210,8 +199,8 @@ const featuredProviders: FeaturedProvider[] = [
|
||||
name: 'Easy Funerals',
|
||||
location: 'Sydney, NSW',
|
||||
verified: true,
|
||||
imageUrl: assetUrl('/images/venues/lakeside-memorial-park-chapel/01.jpg'),
|
||||
logoUrl: assetUrl('/images/providers/easy-funerals/logo.png'),
|
||||
imageUrl: '/brandassets/images/venues/lakeside-memorial-park-chapel/01.jpg',
|
||||
logoUrl: '/brandassets/images/providers/easy-funerals/logo.png',
|
||||
rating: 4.5,
|
||||
reviewCount: 42,
|
||||
startingPrice: 850,
|
||||
@@ -220,30 +209,30 @@ const featuredProviders: FeaturedProvider[] = [
|
||||
|
||||
const partnerLogos: PartnerLogo[] = [
|
||||
{
|
||||
src: assetUrl('/images/providers/hparsons-funeral-directors/logo.png'),
|
||||
src: '/brandassets/images/providers/hparsons-funeral-directors/logo.png',
|
||||
alt: 'H.Parsons Funeral Directors',
|
||||
},
|
||||
{ src: assetUrl('/images/providers/rankins-funerals/logo.png'), alt: 'Rankins Funerals' },
|
||||
{ src: assetUrl('/images/providers/easy-funerals/logo.png'), alt: 'Easy Funerals' },
|
||||
{ src: assetUrl('/images/providers/lady-anne-funerals/logo.png'), alt: 'Lady Anne Funerals' },
|
||||
{ src: '/brandassets/images/providers/rankins-funerals/logo.png', alt: 'Rankins Funerals' },
|
||||
{ src: '/brandassets/images/providers/easy-funerals/logo.png', alt: 'Easy Funerals' },
|
||||
{ src: '/brandassets/images/providers/lady-anne-funerals/logo.png', alt: 'Lady Anne Funerals' },
|
||||
{
|
||||
src: assetUrl('/images/providers/killick-family-funerals/logo.png'),
|
||||
src: '/brandassets/images/providers/killick-family-funerals/logo.png',
|
||||
alt: 'Killick Family Funerals',
|
||||
},
|
||||
{
|
||||
src: assetUrl('/images/providers/kenneallys-funerals/logo.png'),
|
||||
src: '/brandassets/images/providers/kenneallys-funerals/logo.png',
|
||||
alt: "Kenneally's Funerals",
|
||||
},
|
||||
{
|
||||
src: assetUrl('/images/providers/wollongong-city-funerals/logo.png'),
|
||||
src: '/brandassets/images/providers/wollongong-city-funerals/logo.png',
|
||||
alt: 'Wollongong City Funerals',
|
||||
},
|
||||
{
|
||||
src: assetUrl('/images/providers/hparsons-funeral-directors-shoalhaven/logo.png'),
|
||||
src: '/brandassets/images/providers/hparsons-funeral-directors-shoalhaven/logo.png',
|
||||
alt: 'H.Parsons Shoalhaven',
|
||||
},
|
||||
{
|
||||
src: assetUrl('/images/providers/mackay-family-funerals/logo.webp'),
|
||||
src: '/brandassets/images/providers/mackay-family-funerals/logo.webp',
|
||||
alt: 'Mackay Family Funerals',
|
||||
},
|
||||
];
|
||||
@@ -268,13 +257,13 @@ export const Default: Story = {
|
||||
args: {
|
||||
navigation: nav,
|
||||
footer,
|
||||
heroImageUrl: assetUrl('/images/heroes/hero-couple.jpg'),
|
||||
heroImageUrl: '/brandassets/images/heroes/hero-3.png',
|
||||
heroHeading: 'Compare funeral director pricing near you and arrange with confidence',
|
||||
heroSubheading: 'Transparent pricing \u00B7 No hidden fees \u00B7 Arrange 24/7',
|
||||
stats: trustStats,
|
||||
featuredProviders,
|
||||
discoverMapSlot: React.createElement('img', {
|
||||
src: assetUrl('/images/placeholder/map.png'),
|
||||
src: '/brandassets/images/placeholder/map.png',
|
||||
alt: 'Map showing provider locations',
|
||||
style: { width: '100%', height: '100%', objectFit: 'cover' },
|
||||
}),
|
||||
|
||||
@@ -10,7 +10,6 @@ import { FuneralFinderV4 } from '../../organisms/FuneralFinder/FuneralFinderV4';
|
||||
import React from 'react';
|
||||
import { Navigation } from '../../organisms/Navigation';
|
||||
import { Footer } from '../../organisms/Footer';
|
||||
import { assetUrl } from '../../../utils/assetUrl';
|
||||
|
||||
// ─── Shared helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -39,16 +38,6 @@ const nav = (
|
||||
<Navigation
|
||||
logo={<FALogo />}
|
||||
items={[
|
||||
{
|
||||
label: 'Locations',
|
||||
children: [
|
||||
{ label: 'Melbourne', href: '/locations/melbourne' },
|
||||
{ label: 'Brisbane', href: '/locations/brisbane' },
|
||||
{ label: 'Sydney', href: '/locations/sydney' },
|
||||
{ label: 'South Coast NSW', href: '/locations/south-coast-nsw' },
|
||||
{ label: 'Central Coast NSW', href: '/locations/central-coast-nsw' },
|
||||
],
|
||||
},
|
||||
{ label: 'FAQ', href: '/faq' },
|
||||
{ label: 'Contact Us', href: '/contact' },
|
||||
{ label: 'Log in', href: '/login' },
|
||||
@@ -189,8 +178,8 @@ const featuredProviders: FeaturedProvider[] = [
|
||||
name: 'H.Parsons Funeral Directors',
|
||||
location: 'Wollongong, NSW',
|
||||
verified: true,
|
||||
imageUrl: assetUrl('/images/venues/hparsons-funeral-home-kiama/01.jpg'),
|
||||
logoUrl: assetUrl('/images/providers/hparsons-funeral-directors/logo.png'),
|
||||
imageUrl: '/brandassets/images/venues/hparsons-funeral-home-kiama/01.jpg',
|
||||
logoUrl: '/brandassets/images/providers/hparsons-funeral-directors/logo.png',
|
||||
rating: 4.6,
|
||||
reviewCount: 7,
|
||||
startingPrice: 900,
|
||||
@@ -200,8 +189,8 @@ const featuredProviders: FeaturedProvider[] = [
|
||||
name: 'Rankins Funerals',
|
||||
location: 'Wollongong, NSW',
|
||||
verified: true,
|
||||
imageUrl: assetUrl('/images/venues/rankins-funeral-home-warrawong/01.jpg'),
|
||||
logoUrl: assetUrl('/images/providers/rankins-funerals/logo.png'),
|
||||
imageUrl: '/brandassets/images/venues/rankins-funeral-home-warrawong/01.jpg',
|
||||
logoUrl: '/brandassets/images/providers/rankins-funerals/logo.png',
|
||||
rating: 4.8,
|
||||
reviewCount: 23,
|
||||
startingPrice: 1200,
|
||||
@@ -211,8 +200,8 @@ const featuredProviders: FeaturedProvider[] = [
|
||||
name: 'Easy Funerals',
|
||||
location: 'Sydney, NSW',
|
||||
verified: true,
|
||||
imageUrl: assetUrl('/images/venues/lakeside-memorial-park-chapel/01.jpg'),
|
||||
logoUrl: assetUrl('/images/providers/easy-funerals/logo.png'),
|
||||
imageUrl: '/brandassets/images/venues/lakeside-memorial-park-chapel/01.jpg',
|
||||
logoUrl: '/brandassets/images/providers/easy-funerals/logo.png',
|
||||
rating: 4.5,
|
||||
reviewCount: 42,
|
||||
startingPrice: 850,
|
||||
@@ -221,30 +210,30 @@ const featuredProviders: FeaturedProvider[] = [
|
||||
|
||||
const partnerLogos: PartnerLogo[] = [
|
||||
{
|
||||
src: assetUrl('/images/providers/hparsons-funeral-directors/logo.png'),
|
||||
src: '/brandassets/images/providers/hparsons-funeral-directors/logo.png',
|
||||
alt: 'H.Parsons Funeral Directors',
|
||||
},
|
||||
{ src: assetUrl('/images/providers/rankins-funerals/logo.png'), alt: 'Rankins Funerals' },
|
||||
{ src: assetUrl('/images/providers/easy-funerals/logo.png'), alt: 'Easy Funerals' },
|
||||
{ src: assetUrl('/images/providers/lady-anne-funerals/logo.png'), alt: 'Lady Anne Funerals' },
|
||||
{ src: '/brandassets/images/providers/rankins-funerals/logo.png', alt: 'Rankins Funerals' },
|
||||
{ src: '/brandassets/images/providers/easy-funerals/logo.png', alt: 'Easy Funerals' },
|
||||
{ src: '/brandassets/images/providers/lady-anne-funerals/logo.png', alt: 'Lady Anne Funerals' },
|
||||
{
|
||||
src: assetUrl('/images/providers/killick-family-funerals/logo.png'),
|
||||
src: '/brandassets/images/providers/killick-family-funerals/logo.png',
|
||||
alt: 'Killick Family Funerals',
|
||||
},
|
||||
{
|
||||
src: assetUrl('/images/providers/kenneallys-funerals/logo.png'),
|
||||
src: '/brandassets/images/providers/kenneallys-funerals/logo.png',
|
||||
alt: "Kenneally's Funerals",
|
||||
},
|
||||
{
|
||||
src: assetUrl('/images/providers/wollongong-city-funerals/logo.png'),
|
||||
src: '/brandassets/images/providers/wollongong-city-funerals/logo.png',
|
||||
alt: 'Wollongong City Funerals',
|
||||
},
|
||||
{
|
||||
src: assetUrl('/images/providers/hparsons-funeral-directors-shoalhaven/logo.png'),
|
||||
src: '/brandassets/images/providers/hparsons-funeral-directors-shoalhaven/logo.png',
|
||||
alt: 'H.Parsons Shoalhaven',
|
||||
},
|
||||
{
|
||||
src: assetUrl('/images/providers/mackay-family-funerals/logo.webp'),
|
||||
src: '/brandassets/images/providers/mackay-family-funerals/logo.webp',
|
||||
alt: 'Mackay Family Funerals',
|
||||
},
|
||||
];
|
||||
@@ -269,7 +258,7 @@ export const Default: Story = {
|
||||
args: {
|
||||
navigation: nav,
|
||||
footer,
|
||||
heroImageUrl: assetUrl('/images/heroes/hero-3.png'),
|
||||
heroImageUrl: '/brandassets/images/heroes/hero-3.png',
|
||||
heroHeading: 'Compare funeral directors pricing near you and arrange with confidence',
|
||||
heroSubheading: 'Transparent pricing \u00B7 No hidden fees \u00B7 Arrange 24/7',
|
||||
finderSlot: React.createElement(FuneralFinderV4, {
|
||||
@@ -278,7 +267,7 @@ export const Default: Story = {
|
||||
stats: trustStats,
|
||||
featuredProviders,
|
||||
discoverMapSlot: React.createElement('img', {
|
||||
src: assetUrl('/images/placeholder/map.png'),
|
||||
src: '/brandassets/images/placeholder/map.png',
|
||||
alt: 'Map showing provider locations',
|
||||
style: { width: '100%', height: '100%', objectFit: 'cover' },
|
||||
}),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { PackagesStep } from './PackagesStep';
|
||||
import type { NearbyVerifiedProvider, PackageData, PackagesStepProvider } from './PackagesStep';
|
||||
import type { PackageData, PackagesStepProvider } from './PackagesStep';
|
||||
import { Navigation } from '../../organisms/Navigation';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -35,19 +35,10 @@ const nav = (
|
||||
/>
|
||||
);
|
||||
|
||||
// ─── Mock data ───────────────────────────────────────────────────────────────
|
||||
|
||||
const verifiedProvider: PackagesStepProvider = {
|
||||
name: 'H.Parsons Funeral Directors',
|
||||
location: 'Wentworth, NSW',
|
||||
imageUrl: '/images/placeholder/hparsonsvenue.jpg',
|
||||
rating: 4.6,
|
||||
reviewCount: 7,
|
||||
};
|
||||
|
||||
const unverifiedProvider: PackagesStepProvider = {
|
||||
const mockProvider: PackagesStepProvider = {
|
||||
name: 'H.Parsons Funeral Directors',
|
||||
location: 'Wentworth, NSW',
|
||||
imageUrl: 'https://placehold.co/120x80/E8E0D6/8B6F47?text=H.Parsons',
|
||||
rating: 4.6,
|
||||
reviewCount: 7,
|
||||
};
|
||||
@@ -156,119 +147,6 @@ const otherPackages: PackageData[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const manyOtherPackages: PackageData[] = [
|
||||
...otherPackages,
|
||||
{
|
||||
id: 'memorial',
|
||||
name: 'Memorial Service',
|
||||
price: 2400,
|
||||
description: 'A celebration-of-life service without burial or cremation on the same day.',
|
||||
sections: [
|
||||
{
|
||||
heading: 'Essentials',
|
||||
items: [
|
||||
{ name: 'Professional Service Fee', price: 1200 },
|
||||
{ name: 'Venue coordination', price: 600 },
|
||||
{ name: 'Memorial book', price: 100 },
|
||||
],
|
||||
},
|
||||
],
|
||||
total: 2400,
|
||||
},
|
||||
{
|
||||
id: 'graveside',
|
||||
name: 'Graveside Service',
|
||||
price: 2900,
|
||||
description: 'A simple graveside committal, ideal for smaller family gatherings.',
|
||||
sections: [
|
||||
{
|
||||
heading: 'Essentials',
|
||||
items: [
|
||||
{ name: 'Professional Mortuary Care', price: 1000 },
|
||||
{ name: 'Professional Service Fee', price: 1100 },
|
||||
{ name: 'Cemetery coordination', price: 400 },
|
||||
],
|
||||
},
|
||||
],
|
||||
total: 2900,
|
||||
},
|
||||
{
|
||||
id: 'prepaid-basic',
|
||||
name: 'Prepaid Basic Plan',
|
||||
price: 3600,
|
||||
description: 'Lock in today’s price for a basic cremation package, paid over 12 months.',
|
||||
sections: [
|
||||
{
|
||||
heading: 'Essentials',
|
||||
items: [
|
||||
{ name: 'Locked-in pricing', price: 0, priceLabel: 'Complimentary' },
|
||||
{ name: 'Professional Service Fee', price: 1200 },
|
||||
{ name: 'Professional Mortuary Care', price: 1000 },
|
||||
],
|
||||
},
|
||||
],
|
||||
total: 3600,
|
||||
},
|
||||
];
|
||||
|
||||
const nearbyVerifiedProviders: NearbyVerifiedProvider[] = [
|
||||
{
|
||||
id: 'rankins',
|
||||
name: 'Rankins Funerals',
|
||||
imageUrl: '/images/placeholder/hparsonsvenue.jpg',
|
||||
location: 'Warrawong, NSW',
|
||||
startingPrice: 2450,
|
||||
rating: 4.8,
|
||||
reviewCount: 23,
|
||||
},
|
||||
{
|
||||
id: 'mannings',
|
||||
name: 'Mannings Funerals',
|
||||
imageUrl: '/images/placeholder/hparsonsvenue.jpg',
|
||||
location: 'Bega, NSW',
|
||||
startingPrice: 1950,
|
||||
rating: 4.7,
|
||||
reviewCount: 42,
|
||||
},
|
||||
{
|
||||
id: 'killick',
|
||||
name: 'Killick Family Funerals',
|
||||
imageUrl: '/images/placeholder/hparsonsvenue.jpg',
|
||||
location: 'Kingaroy, QLD',
|
||||
startingPrice: 3100,
|
||||
rating: 4.9,
|
||||
reviewCount: 15,
|
||||
},
|
||||
{
|
||||
id: 'mackay',
|
||||
name: 'Mackay Family Funerals',
|
||||
imageUrl: '/images/placeholder/hparsonsvenue.jpg',
|
||||
location: 'Ourimbah, NSW',
|
||||
startingPrice: 2780,
|
||||
rating: 4.6,
|
||||
reviewCount: 19,
|
||||
},
|
||||
];
|
||||
|
||||
const tier2Packages: PackageData[] = [
|
||||
{
|
||||
id: 't2-standard',
|
||||
name: 'Standard Funeral Service',
|
||||
price: 5200,
|
||||
description:
|
||||
'A full-service package based on publicly available information. Breakdown not available — make an enquiry to confirm what is included.',
|
||||
sections: [],
|
||||
},
|
||||
{
|
||||
id: 't2-basic',
|
||||
name: 'Basic Cremation',
|
||||
price: 3400,
|
||||
description:
|
||||
'An entry-level package based on publicly available information. Pricing is indicative only.',
|
||||
sections: [],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Meta ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const meta: Meta<typeof PackagesStep> = {
|
||||
@@ -283,152 +161,22 @@ const meta: Meta<typeof PackagesStep> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PackagesStep>;
|
||||
|
||||
// ─── Verified ────────────────────────────────────────────────────────────────
|
||||
// ─── Interactive (default) ──────────────────────────────────────────────────
|
||||
|
||||
/** Verified provider — matching packages + up to 3 other packages from the same provider */
|
||||
export const Verified: Story = {
|
||||
render: () => {
|
||||
const [selectedId, setSelectedId] = useState<string | null>('everyday');
|
||||
|
||||
return (
|
||||
<PackagesStep
|
||||
provider={verifiedProvider}
|
||||
providerTier="verified"
|
||||
packages={matchedPackages}
|
||||
secondaryList={{ kind: 'same-provider-more', packages: otherPackages }}
|
||||
selectedPackageId={selectedId}
|
||||
onSelectPackage={setSelectedId}
|
||||
onArrange={() => alert('Open ArrangementDialog')}
|
||||
onCompare={() => alert('Open compare view')}
|
||||
onProviderClick={() => alert('Open provider profile (future)')}
|
||||
onBack={() => alert('Back')}
|
||||
navigation={nav}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Verified — with "See all" link ─────────────────────────────────────────
|
||||
|
||||
/** Verified provider with 5+ other packages — shows first 3 + "See all N packages" link */
|
||||
export const VerifiedWithManyOtherPackages: Story = {
|
||||
render: () => {
|
||||
const [selectedId, setSelectedId] = useState<string | null>('everyday');
|
||||
|
||||
return (
|
||||
<PackagesStep
|
||||
provider={verifiedProvider}
|
||||
providerTier="verified"
|
||||
packages={matchedPackages}
|
||||
secondaryList={{ kind: 'same-provider-more', packages: manyOtherPackages }}
|
||||
selectedPackageId={selectedId}
|
||||
onSelectPackage={setSelectedId}
|
||||
onArrange={() => alert('Open ArrangementDialog')}
|
||||
onSeeAllPackages={() => alert('Route to showAllFromProvider variant')}
|
||||
onProviderClick={() => alert('Open provider profile (future)')}
|
||||
onBack={() => alert('Back')}
|
||||
navigation={nav}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// ─── "Show all from provider" variant ───────────────────────────────────────
|
||||
|
||||
/** Flat "All packages from [Provider]" view — no grouping, selected package preserved */
|
||||
export const AllFromProvider: Story = {
|
||||
render: () => {
|
||||
const [selectedId, setSelectedId] = useState<string | null>('everyday');
|
||||
const allPackages = [...matchedPackages, ...manyOtherPackages];
|
||||
|
||||
return (
|
||||
<PackagesStep
|
||||
provider={verifiedProvider}
|
||||
providerTier="verified"
|
||||
packages={allPackages}
|
||||
showAllFromProvider
|
||||
selectedPackageId={selectedId}
|
||||
onSelectPackage={setSelectedId}
|
||||
onArrange={() => alert('Open ArrangementDialog')}
|
||||
onCompare={() => alert('Open compare view')}
|
||||
onProviderClick={() => alert('Open provider profile (future)')}
|
||||
onBack={() => alert('Back')}
|
||||
navigation={nav}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Tier 3 (itemised breakdown) ────────────────────────────────────────────
|
||||
|
||||
/** Tier 3 unverified — itemised breakdown + "Make an enquiry" + nearby verified alternatives */
|
||||
export const Tier3: Story = {
|
||||
render: () => {
|
||||
const [selectedId, setSelectedId] = useState<string | null>('everyday');
|
||||
|
||||
return (
|
||||
<PackagesStep
|
||||
provider={unverifiedProvider}
|
||||
providerTier="tier3"
|
||||
packages={matchedPackages}
|
||||
secondaryList={{ kind: 'nearby-verified', providers: nearbyVerifiedProviders }}
|
||||
selectedPackageId={selectedId}
|
||||
onSelectPackage={setSelectedId}
|
||||
onArrange={() => alert('Make an enquiry')}
|
||||
onCompare={() => alert('Open compare view')}
|
||||
onNearbyProviderClick={(id) => alert(`Route to verified provider: ${id}`)}
|
||||
onProviderClick={() => alert('Open provider profile (future)')}
|
||||
onBack={() => alert('Back')}
|
||||
navigation={nav}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Tier 2 (price only, no breakdown) ──────────────────────────────────────
|
||||
|
||||
/** Tier 2 unverified — price only, detail panel shows "Itemised Pricing Unavailable" */
|
||||
export const Tier2: Story = {
|
||||
render: () => {
|
||||
const [selectedId, setSelectedId] = useState<string | null>('t2-standard');
|
||||
|
||||
return (
|
||||
<PackagesStep
|
||||
provider={unverifiedProvider}
|
||||
providerTier="tier2"
|
||||
packages={tier2Packages}
|
||||
secondaryList={{ kind: 'nearby-verified', providers: nearbyVerifiedProviders }}
|
||||
selectedPackageId={selectedId}
|
||||
onSelectPackage={setSelectedId}
|
||||
onArrange={() => alert('Make an enquiry')}
|
||||
onCompare={() => alert('Open compare view')}
|
||||
onNearbyProviderClick={(id) => alert(`Route to verified provider: ${id}`)}
|
||||
onProviderClick={() => alert('Open provider profile (future)')}
|
||||
onBack={() => alert('Back')}
|
||||
navigation={nav}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Edge cases ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** No selection yet — empty detail panel */
|
||||
export const NoSelection: Story = {
|
||||
/** Matched + other packages — select a package, see detail, click Make Arrangement */
|
||||
export const Default: Story = {
|
||||
render: () => {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<PackagesStep
|
||||
provider={verifiedProvider}
|
||||
providerTier="verified"
|
||||
provider={mockProvider}
|
||||
packages={matchedPackages}
|
||||
secondaryList={{ kind: 'same-provider-more', packages: otherPackages }}
|
||||
otherPackages={otherPackages}
|
||||
selectedPackageId={selectedId}
|
||||
onSelectPackage={setSelectedId}
|
||||
onArrange={() => alert('Open ArrangementDialog')}
|
||||
onCompare={() => alert('Open compare view')}
|
||||
onProviderClick={() => alert('Open provider profile (future)')}
|
||||
onProviderClick={() => alert('Open provider profile')}
|
||||
onBack={() => alert('Back')}
|
||||
navigation={nav}
|
||||
/>
|
||||
@@ -436,21 +184,44 @@ export const NoSelection: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Verified provider with no "other packages" — primary list only */
|
||||
export const VerifiedNoSecondary: Story = {
|
||||
// ─── With selection ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Package already selected — detail panel visible */
|
||||
export const WithSelection: Story = {
|
||||
render: () => {
|
||||
const [selectedId, setSelectedId] = useState<string | null>('everyday');
|
||||
|
||||
return (
|
||||
<PackagesStep
|
||||
provider={mockProvider}
|
||||
packages={matchedPackages}
|
||||
otherPackages={otherPackages}
|
||||
selectedPackageId={selectedId}
|
||||
onSelectPackage={setSelectedId}
|
||||
onArrange={() => alert('Open ArrangementDialog')}
|
||||
onProviderClick={() => alert('Open provider profile')}
|
||||
onBack={() => alert('Back')}
|
||||
navigation={nav}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// ─── No other packages (all match) ─────────────────────────────────────────
|
||||
|
||||
/** All packages match filters — no "Other packages" section */
|
||||
export const AllMatching: Story = {
|
||||
render: () => {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<PackagesStep
|
||||
provider={verifiedProvider}
|
||||
providerTier="verified"
|
||||
packages={matchedPackages}
|
||||
provider={mockProvider}
|
||||
packages={[...matchedPackages, ...otherPackages]}
|
||||
selectedPackageId={selectedId}
|
||||
onSelectPackage={setSelectedId}
|
||||
onArrange={() => alert('Open ArrangementDialog')}
|
||||
onCompare={() => alert('Open compare view')}
|
||||
onProviderClick={() => alert('Open provider profile (future)')}
|
||||
onProviderClick={() => alert('Open provider profile')}
|
||||
onBack={() => alert('Back')}
|
||||
navigation={nav}
|
||||
/>
|
||||
@@ -458,6 +229,8 @@ export const VerifiedNoSecondary: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Pre-planning ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Pre-planning flow — softer copy */
|
||||
export const PrePlanning: Story = {
|
||||
render: () => {
|
||||
@@ -465,15 +238,13 @@ export const PrePlanning: Story = {
|
||||
|
||||
return (
|
||||
<PackagesStep
|
||||
provider={verifiedProvider}
|
||||
providerTier="verified"
|
||||
provider={mockProvider}
|
||||
packages={matchedPackages}
|
||||
secondaryList={{ kind: 'same-provider-more', packages: otherPackages }}
|
||||
otherPackages={otherPackages}
|
||||
selectedPackageId={selectedId}
|
||||
onSelectPackage={setSelectedId}
|
||||
onArrange={() => alert('Open ArrangementDialog')}
|
||||
onCompare={() => alert('Open compare view')}
|
||||
onProviderClick={() => alert('Open provider profile (future)')}
|
||||
onProviderClick={() => alert('Open provider profile')}
|
||||
onBack={() => alert('Back')}
|
||||
navigation={nav}
|
||||
isPrePlanning
|
||||
@@ -482,15 +253,16 @@ export const PrePlanning: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Validation error */
|
||||
// ─── Validation error ───────────────────────────────────────────────────────
|
||||
|
||||
/** Error shown when no package selected */
|
||||
export const WithError: Story = {
|
||||
render: () => {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<PackagesStep
|
||||
provider={verifiedProvider}
|
||||
providerTier="verified"
|
||||
provider={mockProvider}
|
||||
packages={matchedPackages}
|
||||
selectedPackageId={selectedId}
|
||||
onSelectPackage={setSelectedId}
|
||||
|
||||
@@ -1,125 +1,68 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import { WizardLayout } from '../../templates/WizardLayout';
|
||||
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
|
||||
import { ServiceOption } from '../../molecules/ServiceOption';
|
||||
import { MiniCard } from '../../molecules/MiniCard';
|
||||
import { PackageDetail } from '../../organisms/PackageDetail';
|
||||
import type { PackageSection } from '../../organisms/PackageDetail';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
import { Divider } from '../../atoms/Divider';
|
||||
import { Link } from '../../atoms/Link';
|
||||
import type { PackageData, PackagesStepProvider, ProviderTier, SecondaryList } from './types';
|
||||
|
||||
export type {
|
||||
PackageData,
|
||||
PackagesStepProvider,
|
||||
NearbyVerifiedProvider,
|
||||
ProviderTier,
|
||||
SecondaryList,
|
||||
} from './types';
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// ─── Tier copy map ───────────────────────────────────────────────────────────
|
||||
|
||||
interface TierCopy {
|
||||
heading: string;
|
||||
subheading: (isPrePlanning: boolean) => string;
|
||||
arrangeLabel: string;
|
||||
priceDisclaimer?: string;
|
||||
itemizedUnavailable: boolean;
|
||||
emptyDetailMessage: string;
|
||||
/** Provider summary for the compact card */
|
||||
export interface PackagesStepProvider {
|
||||
/** Provider name */
|
||||
name: string;
|
||||
/** Location */
|
||||
location: string;
|
||||
/** Image URL */
|
||||
imageUrl?: string;
|
||||
/** Rating */
|
||||
rating?: number;
|
||||
/** Review count */
|
||||
reviewCount?: number;
|
||||
}
|
||||
|
||||
const TIER_COPY: Record<ProviderTier, TierCopy> = {
|
||||
verified: {
|
||||
heading: 'Choose a funeral package',
|
||||
subheading: (isPrePlanning) =>
|
||||
isPrePlanning
|
||||
? 'Compare packages to find what suits your wishes. Nothing is committed until you confirm.'
|
||||
: 'Each package includes a set of services. You can customise your selections in the next steps.',
|
||||
arrangeLabel: 'Make Arrangement',
|
||||
itemizedUnavailable: false,
|
||||
emptyDetailMessage: "Select a package to see what's included.",
|
||||
},
|
||||
tier3: {
|
||||
heading: 'Explore available packages',
|
||||
subheading: (isPrePlanning) =>
|
||||
isPrePlanning
|
||||
? 'Browse estimated packages to get a sense of what this provider offers. Nothing is committed.'
|
||||
: 'These packages are based on publicly available information. Make an enquiry to confirm details and pricing.',
|
||||
arrangeLabel: 'Make an enquiry',
|
||||
priceDisclaimer:
|
||||
"Prices are estimates based on publicly available information and may not reflect the provider's current pricing.",
|
||||
itemizedUnavailable: false,
|
||||
emptyDetailMessage: "Select a package to see what's included.",
|
||||
},
|
||||
tier2: {
|
||||
heading: 'Explore available packages',
|
||||
subheading: (isPrePlanning) =>
|
||||
isPrePlanning
|
||||
? 'Browse estimated packages to get a sense of what this provider offers. Nothing is committed.'
|
||||
: 'These packages are based on publicly available information. Make an enquiry to confirm details and pricing.',
|
||||
arrangeLabel: 'Make an enquiry',
|
||||
priceDisclaimer:
|
||||
"Prices are estimates based on publicly available information and may not reflect the provider's current pricing.",
|
||||
itemizedUnavailable: true,
|
||||
emptyDetailMessage: 'Select a package to see more details.',
|
||||
},
|
||||
};
|
||||
|
||||
// Show at most this many "other packages from this provider" inline before
|
||||
// switching to "top N + See all →" behaviour.
|
||||
const SAME_PROVIDER_INLINE_LIMIT = 3;
|
||||
|
||||
// Max number of verified provider MiniCards in the "Similar packages from
|
||||
// verified providers" grid on unverified pages.
|
||||
const NEARBY_VERIFIED_LIMIT = 4;
|
||||
|
||||
// ─── Props ───────────────────────────────────────────────────────────────────
|
||||
/** Package data for the selection list */
|
||||
export interface PackageData {
|
||||
/** Unique package ID */
|
||||
id: string;
|
||||
/** Package display name */
|
||||
name: string;
|
||||
/** Package price in dollars */
|
||||
price: number;
|
||||
/** Short description */
|
||||
description?: string;
|
||||
/** Line item sections for the detail panel */
|
||||
sections: PackageSection[];
|
||||
/** Total price (may differ from base price with extras) */
|
||||
total?: number;
|
||||
/** Extra items section (after total) */
|
||||
extras?: PackageSection;
|
||||
/** Terms and conditions */
|
||||
terms?: string;
|
||||
}
|
||||
|
||||
/** Props for the PackagesStep page component */
|
||||
export interface PackagesStepProps {
|
||||
/** Provider shown at the top of the list panel */
|
||||
/** Provider summary shown at top of the list panel */
|
||||
provider: PackagesStepProvider;
|
||||
/** Provider tier — drives copy, CTA label, disclaimer, itemised-unavailable state */
|
||||
providerTier: ProviderTier;
|
||||
/** Packages in the primary list (filtered by user preferences, or all when `showAllFromProvider`) */
|
||||
/** Packages matching the user's filters from the previous step */
|
||||
packages: PackageData[];
|
||||
/** Secondary list below the primary one — same-provider-more or nearby-verified. Suppressed when `showAllFromProvider` is true. */
|
||||
secondaryList?: SecondaryList;
|
||||
/** Other packages from this provider that didn't match filters (shown in secondary group) */
|
||||
otherPackages?: PackageData[];
|
||||
/** Currently selected package ID */
|
||||
selectedPackageId: string | null;
|
||||
/** Callback when a primary-list package is selected (or cleared via mobile back) */
|
||||
onSelectPackage: (id: string | null) => void;
|
||||
/** Callback when "Make Arrangement" / "Make an enquiry" is clicked */
|
||||
/** Callback when a package is selected */
|
||||
onSelectPackage: (id: string) => void;
|
||||
/** Callback when "Make Arrangement" is clicked (opens ArrangementDialog) */
|
||||
onArrange: () => void;
|
||||
/** Callback when the "Compare" button on the PackageDetail panel is clicked */
|
||||
onCompare?: () => void;
|
||||
/** Whether the currently-selected package is already in the comparison
|
||||
* basket. When true, PackageDetail swaps its Compare button into the
|
||||
* "In comparison" selected-state (inert; removal via CompareBar). */
|
||||
isSelectedPackageInCart?: boolean;
|
||||
/** Callback when a nearby-verified provider card is clicked (route change to that provider's PackagesStep) */
|
||||
onNearbyProviderClick?: (id: string) => void;
|
||||
/**
|
||||
* Callback when "See all N packages from [Provider]" is clicked.
|
||||
* Expected to route to the same PackagesStep with `showAllFromProvider` set.
|
||||
* Only used when secondaryList.kind === 'same-provider-more' and list length > 3.
|
||||
*/
|
||||
onSeeAllPackages?: () => void;
|
||||
/** Callback when the provider card is clicked (future: opens provider profile) */
|
||||
/** Callback when the provider card is clicked (opens provider profile popup) */
|
||||
onProviderClick?: () => void;
|
||||
/** Callback for the Back button */
|
||||
onBack: () => void;
|
||||
/**
|
||||
* When true, renders the "All packages from [Provider]" variant:
|
||||
* flat list, no grouping, no secondary list, no "Matching your preferences" heading.
|
||||
* Caller passes the full package list in `packages`.
|
||||
*/
|
||||
showAllFromProvider?: boolean;
|
||||
/** Validation error */
|
||||
error?: string;
|
||||
/** Whether the arrange action is loading */
|
||||
@@ -132,61 +75,23 @@ export interface PackagesStepProps {
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Accent bar + label — used for both "Matching your preferences" and "Other packages from [X]". */
|
||||
function GroupHeading({
|
||||
label,
|
||||
emphasis = 'primary',
|
||||
}: {
|
||||
label: string;
|
||||
emphasis?: 'primary' | 'secondary';
|
||||
}) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 3,
|
||||
height: 20,
|
||||
borderRadius: 1,
|
||||
bgcolor: emphasis === 'primary' ? 'primary.main' : 'text.secondary',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: emphasis === 'primary' ? 'text.primary' : 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Package selection step — tier-aware, unified page component.
|
||||
* Step 3 — Package selection page for the FA arrangement wizard.
|
||||
*
|
||||
* Handles all three provider tiers (verified, tier3, tier2) via the
|
||||
* `providerTier` prop. Header copy, CTA label, price disclaimer, and
|
||||
* itemised-unavailable state are derived from tier.
|
||||
* List + Detail split layout. Left panel shows the selected provider
|
||||
* (compact) and selectable package cards. Right panel shows the full
|
||||
* detail breakdown of the selected package with "Make Arrangement" CTA.
|
||||
*
|
||||
* Left column layout varies by `secondaryList`:
|
||||
* - `same-provider-more` (verified): primary "Matching your preferences"
|
||||
* list + "Other packages from [Provider]" list. If >3 other packages,
|
||||
* shows top 3 + "See all N packages from [Provider] →" link that routes
|
||||
* to the same page with `showAllFromProvider`.
|
||||
* - `nearby-verified` (unverified tiers): primary list + "Similar packages
|
||||
* from verified providers" 2-column MiniCard grid, capped at 4. Every
|
||||
* card is verified by definition.
|
||||
* Packages are split into two groups:
|
||||
* - **Matching your preferences**: packages that matched the user's filters
|
||||
* from the providers step
|
||||
* - **Other packages from [Provider]**: remaining packages outside those
|
||||
* filters, shown below a divider for passive discovery
|
||||
*
|
||||
* When `showAllFromProvider` is true, renders a flat "All packages from
|
||||
* [Provider]" list with no grouping and no secondary list. The caller
|
||||
* preserves `selectedPackageId` across this navigation.
|
||||
* Selecting a package reveals its detail. Clicking "Make Arrangement"
|
||||
* on the detail panel triggers the ArrangementDialog (D-E).
|
||||
*
|
||||
* Pure presentation component — props in, callbacks out.
|
||||
*
|
||||
@@ -194,290 +99,191 @@ function GroupHeading({
|
||||
*/
|
||||
export const PackagesStep: React.FC<PackagesStepProps> = ({
|
||||
provider,
|
||||
providerTier,
|
||||
packages,
|
||||
secondaryList,
|
||||
otherPackages = [],
|
||||
selectedPackageId,
|
||||
onSelectPackage,
|
||||
onArrange,
|
||||
onCompare,
|
||||
isSelectedPackageInCart = false,
|
||||
onNearbyProviderClick,
|
||||
onSeeAllPackages,
|
||||
onProviderClick,
|
||||
onBack,
|
||||
showAllFromProvider = false,
|
||||
error,
|
||||
loading = false,
|
||||
navigation,
|
||||
isPrePlanning = false,
|
||||
sx,
|
||||
}) => {
|
||||
const copy = TIER_COPY[providerTier];
|
||||
// Look up the selected package across BOTH the primary list and the
|
||||
// same-provider-more secondary list — tapping "Premium Funeral Service"
|
||||
// in the "Other packages from X" section should surface its detail too.
|
||||
const selectedPackage =
|
||||
packages.find((p) => p.id === selectedPackageId) ??
|
||||
(secondaryList?.kind === 'same-provider-more'
|
||||
? secondaryList.packages.find((p) => p.id === selectedPackageId)
|
||||
: undefined);
|
||||
const allPackages = [...packages, ...otherPackages];
|
||||
const selectedPackage = allPackages.find((p) => p.id === selectedPackageId);
|
||||
const hasOtherPackages = otherPackages.length > 0;
|
||||
|
||||
// Mobile drill-in: on mobile, the list is the default view — only when the
|
||||
// user explicitly taps a package do we swap in the detail panel. This
|
||||
// distinguishes "parent pre-selected first package for desktop auto-display"
|
||||
// (which should NOT jump to detail on mobile) from "user tapped a package".
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const [hasDrilledIn, setHasDrilledIn] = useState(false);
|
||||
const mobileShowDetail = isMobile && hasDrilledIn && selectedPackageId != null;
|
||||
|
||||
const handleSelectPackage = (id: string | null) => {
|
||||
setHasDrilledIn(id != null);
|
||||
onSelectPackage(id);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (mobileShowDetail) window.scrollTo({ top: 0, behavior: 'auto' });
|
||||
}, [mobileShowDetail]);
|
||||
|
||||
const handleLayoutBack = mobileShowDetail ? () => handleSelectPackage(null) : onBack;
|
||||
const layoutBackLabel = mobileShowDetail ? 'Back to packages' : 'Back';
|
||||
|
||||
// Secondary list suppressed in "show all" mode.
|
||||
const activeSecondaryList = showAllFromProvider ? undefined : secondaryList;
|
||||
const hasSecondary = Boolean(activeSecondaryList);
|
||||
|
||||
// For same-provider-more, show top N inline; surface "See all" when over limit.
|
||||
const sameProviderPackages =
|
||||
activeSecondaryList?.kind === 'same-provider-more' ? activeSecondaryList.packages : [];
|
||||
const sameProviderOverflow = sameProviderPackages.length > SAME_PROVIDER_INLINE_LIMIT;
|
||||
const sameProviderVisible = sameProviderOverflow
|
||||
? sameProviderPackages.slice(0, SAME_PROVIDER_INLINE_LIMIT)
|
||||
: sameProviderPackages;
|
||||
|
||||
const heading = showAllFromProvider ? `All packages from ${provider.name}` : copy.heading;
|
||||
const subheading = showAllFromProvider
|
||||
? `Every package ${provider.name} offers, including those outside your preferences.`
|
||||
: copy.subheading(isPrePlanning);
|
||||
|
||||
const primaryListAriaLabel = showAllFromProvider
|
||||
? `All packages from ${provider.name}`
|
||||
: 'Funeral packages';
|
||||
const subheading = isPrePlanning
|
||||
? 'Compare packages to find what suits your wishes. Nothing is committed until you confirm.'
|
||||
: 'Each package includes a set of services. You can customise your selections in the next steps.';
|
||||
|
||||
return (
|
||||
<WizardLayout
|
||||
variant="list-detail"
|
||||
navigation={navigation}
|
||||
showBackLink
|
||||
backLabel={layoutBackLabel}
|
||||
onBack={handleLayoutBack}
|
||||
backLabel="Back"
|
||||
onBack={onBack}
|
||||
sx={sx}
|
||||
secondaryPanel={
|
||||
<Box
|
||||
sx={{
|
||||
display: {
|
||||
xs: mobileShowDetail ? 'block' : 'none',
|
||||
md: 'block',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{selectedPackage ? (
|
||||
<PackageDetail
|
||||
name={selectedPackage.name}
|
||||
price={selectedPackage.price}
|
||||
sections={selectedPackage.sections}
|
||||
total={selectedPackage.total}
|
||||
extras={selectedPackage.extras}
|
||||
terms={selectedPackage.terms}
|
||||
onArrange={onArrange}
|
||||
onCompare={onCompare}
|
||||
inCart={isSelectedPackageInCart}
|
||||
arrangeDisabled={loading}
|
||||
arrangeLabel={copy.arrangeLabel}
|
||||
priceDisclaimer={copy.priceDisclaimer}
|
||||
itemizedUnavailable={copy.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' }}>
|
||||
{copy.emptyDetailMessage}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
selectedPackage ? (
|
||||
<PackageDetail
|
||||
name={selectedPackage.name}
|
||||
price={selectedPackage.price}
|
||||
sections={selectedPackage.sections}
|
||||
total={selectedPackage.total}
|
||||
extras={selectedPackage.extras}
|
||||
terms={selectedPackage.terms}
|
||||
onArrange={onArrange}
|
||||
arrangeDisabled={loading}
|
||||
/>
|
||||
) : (
|
||||
<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's included.
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
>
|
||||
{/* List column — hidden on mobile when a package is selected (drill-in) */}
|
||||
<Box
|
||||
sx={{
|
||||
display: {
|
||||
xs: mobileShowDetail ? 'none' : 'block',
|
||||
md: 'block',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Provider compact card */}
|
||||
<Box sx={{ mb: 6 }}>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{/* Provider compact card — clickable to open provider profile */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<ProviderCardCompact
|
||||
name={provider.name}
|
||||
location={provider.location}
|
||||
imageUrl={provider.imageUrl}
|
||||
rating={provider.rating}
|
||||
reviewCount={provider.reviewCount}
|
||||
onClick={onProviderClick}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -5,25 +5,19 @@ import InputAdornment from '@mui/material/InputAdornment';
|
||||
import Autocomplete from '@mui/material/Autocomplete';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Slider from '@mui/material/Slider';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||
import ToggleButton from '@mui/material/ToggleButton';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import SwapVertIcon from '@mui/icons-material/SwapVert';
|
||||
import ViewListOutlinedIcon from '@mui/icons-material/ViewListOutlined';
|
||||
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
|
||||
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { WizardLayout } from '../../templates/WizardLayout';
|
||||
import { ProviderCard } from '../../molecules/ProviderCard';
|
||||
import { FilterPanel } from '../../molecules/FilterPanel';
|
||||
import { MapProviderDrawer } from '../../molecules/MapProviderDrawer';
|
||||
import { LocationSearchInput } from '../../molecules/LocationSearchInput';
|
||||
import { HelpBar } from '../../molecules/HelpBar';
|
||||
import { SortMenu } from '../../molecules/SortMenu';
|
||||
import {
|
||||
ProviderMap,
|
||||
type ProviderMapActiveState,
|
||||
type ProviderMapHandle,
|
||||
} from '../../organisms/ProviderMap';
|
||||
import { Button } from '../../atoms/Button';
|
||||
import { Chip } from '../../atoms/Chip';
|
||||
import { Switch } from '../../atoms/Switch';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
@@ -55,8 +49,6 @@ export interface ProviderData {
|
||||
distanceKm?: number;
|
||||
/** Brief description */
|
||||
description?: string;
|
||||
/** Geographic coordinates for map display */
|
||||
coords?: { lat: number; lng: number };
|
||||
}
|
||||
|
||||
/** A funeral type option for the filter */
|
||||
@@ -173,8 +165,8 @@ const DEFAULT_FUNERAL_TYPES: FuneralTypeOption[] = [
|
||||
const SORT_OPTIONS: { value: ProviderSortBy; label: string }[] = [
|
||||
{ value: 'recommended', label: 'Recommended' },
|
||||
{ value: 'nearest', label: 'Nearest' },
|
||||
{ value: 'price_low', label: 'Price low to high' },
|
||||
{ value: 'price_high', label: 'Price high to low' },
|
||||
{ value: 'price_low', label: 'Price: Low to High' },
|
||||
{ value: 'price_high', label: 'Price: High to Low' },
|
||||
];
|
||||
|
||||
export const EMPTY_FILTER_VALUES: ProviderFilterValues = {
|
||||
@@ -202,98 +194,6 @@ const chipWrapSx = {
|
||||
gap: 1,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Shared visual tokens for the ProvidersStep control chips. Search, Filters,
|
||||
* Sort by, and the List/Map toggle all reference these so their outline /
|
||||
* radius / fill / shadow / height read as one coherent set. Kept on the page
|
||||
* (not promoted to a design-system-wide primitive) because this is a
|
||||
* page-local "control cluster" pattern — Button and Input already own their
|
||||
* own radii in the theme.
|
||||
*/
|
||||
const CONTROL_CHROME = {
|
||||
height: 32,
|
||||
borderColor: 'var(--fa-color-neutral-300)',
|
||||
borderRadius: 'var(--fa-button-border-radius-default)',
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: 'var(--fa-shadow-sm)',
|
||||
} as const;
|
||||
|
||||
/** sx for an outlined Button carrying CONTROL_CHROME (used for Sort by). */
|
||||
const controlButtonSx = {
|
||||
height: CONTROL_CHROME.height,
|
||||
bgcolor: CONTROL_CHROME.bgcolor,
|
||||
borderColor: CONTROL_CHROME.borderColor,
|
||||
borderRadius: CONTROL_CHROME.borderRadius,
|
||||
boxShadow: CONTROL_CHROME.boxShadow,
|
||||
textTransform: 'none',
|
||||
'&:hover': { bgcolor: CONTROL_CHROME.bgcolor, borderColor: CONTROL_CHROME.borderColor },
|
||||
'&:focus-visible': { outline: 'none' },
|
||||
} as const;
|
||||
|
||||
/** sx for the FilterPanel wrapper — targets its internal trigger Button. */
|
||||
const filterTriggerSx = {
|
||||
'& .MuiButton-root': controlButtonSx,
|
||||
} as const;
|
||||
|
||||
/** sx for a ToggleButtonGroup carrying CONTROL_CHROME (used for List/Map). */
|
||||
const controlToggleSx = {
|
||||
borderRadius: CONTROL_CHROME.borderRadius,
|
||||
boxShadow: CONTROL_CHROME.boxShadow,
|
||||
'& .MuiToggleButton-root': {
|
||||
height: CONTROL_CHROME.height,
|
||||
px: 1.5,
|
||||
py: 0,
|
||||
textTransform: 'none',
|
||||
fontSize: 'var(--fa-button-font-size-sm)',
|
||||
fontWeight: 600,
|
||||
borderColor: CONTROL_CHROME.borderColor,
|
||||
bgcolor: CONTROL_CHROME.bgcolor,
|
||||
'&:hover': { bgcolor: CONTROL_CHROME.bgcolor },
|
||||
'&.Mui-selected': {
|
||||
bgcolor: 'var(--fa-color-brand-100)',
|
||||
color: 'primary.main',
|
||||
'&:hover': { bgcolor: 'var(--fa-color-brand-200)' },
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** sx for the Autocomplete/TextField search input carrying CONTROL_CHROME.
|
||||
* Absolute-anchors the end adornment (commit button) to the right edge —
|
||||
* MUI's stock Autocomplete does this on `.MuiAutocomplete-endAdornment`,
|
||||
* but overriding `InputProps.endAdornment` puts the content in a
|
||||
* `.MuiInputAdornment-positionEnd` (which is static by default), so the
|
||||
* button slides left as chips/draft fill the input. `paddingRight` on the
|
||||
* OutlinedInput reserves the lane so input content can't run under it. */
|
||||
const controlInputSx = {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
bgcolor: CONTROL_CHROME.bgcolor,
|
||||
boxShadow: CONTROL_CHROME.boxShadow,
|
||||
borderRadius: CONTROL_CHROME.borderRadius,
|
||||
pr: 5,
|
||||
position: 'relative',
|
||||
},
|
||||
'& .MuiOutlinedInput-root .MuiInputAdornment-positionEnd': {
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
height: 'auto',
|
||||
maxHeight: 'none',
|
||||
m: 0,
|
||||
},
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: CONTROL_CHROME.borderColor,
|
||||
borderWidth: 1,
|
||||
},
|
||||
'& .MuiOutlinedInput-root.Mui-focused': {
|
||||
boxShadow: CONTROL_CHROME.boxShadow,
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: CONTROL_CHROME.borderColor,
|
||||
borderWidth: 1,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -342,12 +242,8 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
||||
? 'Take your time exploring providers. You can always come back and choose a different one.'
|
||||
: 'These providers are near your location. Each has their own packages and pricing.';
|
||||
|
||||
// ─── Mobile map-first plumbing ───
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const mapRef = React.useRef<ProviderMapHandle>(null);
|
||||
const [mapActive, setMapActive] = React.useState<ProviderMapActiveState | null>(null);
|
||||
const showMobileMapLayout = isMobile && viewMode === 'map';
|
||||
// ─── Local state ───
|
||||
const [sortAnchor, setSortAnchor] = React.useState<null | HTMLElement>(null);
|
||||
|
||||
// ─── Price input local state (commits on blur / Enter) ───
|
||||
const [priceMinInput, setPriceMinInput] = React.useState(String(filterValues.priceRange[0]));
|
||||
@@ -398,257 +294,6 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
||||
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 (
|
||||
<WizardLayout
|
||||
variant="list-map"
|
||||
@@ -661,19 +306,38 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
||||
sx={sx}
|
||||
secondaryPanel={
|
||||
<Box sx={{ position: 'relative', flex: 1, display: 'flex' }}>
|
||||
{/* Floating view toggle — same chrome as the sticky-bar controls,
|
||||
anchored to the map panel's top-left. */}
|
||||
{/* Floating view toggle */}
|
||||
<ToggleButtonGroup
|
||||
value={viewMode}
|
||||
exclusive
|
||||
onChange={(_, val) => val && onViewModeChange?.(val as ListViewMode)}
|
||||
size="small"
|
||||
aria-label="View mode"
|
||||
sx={[
|
||||
{ position: 'absolute', top: 12, left: 12, zIndex: 1 },
|
||||
controlToggleSx,
|
||||
{ '& .MuiToggleButton-root': { gap: 0.75 } },
|
||||
]}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
left: 12,
|
||||
zIndex: 1,
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: 'var(--fa-shadow-md)',
|
||||
borderRadius: 1,
|
||||
'& .MuiToggleButton-root': {
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
gap: 0.5,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
textTransform: 'none',
|
||||
'&.Mui-selected': {
|
||||
bgcolor: 'var(--fa-color-brand-100)',
|
||||
color: 'primary.main',
|
||||
borderColor: 'primary.main',
|
||||
'&:hover': { bgcolor: 'var(--fa-color-brand-200)' },
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="list" aria-label="List view">
|
||||
<ViewListOutlinedIcon sx={{ fontSize: 16 }} />
|
||||
@@ -729,15 +393,28 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
{/* Location search — committed location renders as a chip inside
|
||||
the input. Shared with the mobile-map floating strip via the
|
||||
LocationSearchInput molecule. */}
|
||||
<LocationSearchInput
|
||||
value={searchQuery}
|
||||
onChange={onSearchChange}
|
||||
onCommit={onSearch}
|
||||
{/* Location search */}
|
||||
<TextField
|
||||
placeholder="Search a 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 */}
|
||||
@@ -748,42 +425,216 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<FilterPanel activeCount={activeCount} onClear={handleClear} sx={filterTriggerSx}>
|
||||
{filterDialogChildren}
|
||||
{/* Filters */}
|
||||
<FilterPanel activeCount={activeCount} onClear={handleClear}>
|
||||
{/* ── Location ── */}
|
||||
<Box>
|
||||
<Typography variant="labelLg" sx={sectionHeadingSx}>
|
||||
Location
|
||||
</Typography>
|
||||
<Autocomplete
|
||||
multiple
|
||||
freeSolo
|
||||
value={searchQuery.trim() ? [searchQuery.trim()] : []}
|
||||
onChange={(_, newValue) => {
|
||||
// Take the last entered value as the active search
|
||||
const last = newValue[newValue.length - 1] ?? '';
|
||||
onSearchChange(typeof last === 'string' ? last : '');
|
||||
}}
|
||||
options={[]}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={searchQuery.trim() ? '' : 'Search a town or suburb...'}
|
||||
size="small"
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
startAdornment: (
|
||||
<>
|
||||
<InputAdornment position="start" sx={{ ml: 0.5 }}>
|
||||
<LocationOnOutlinedIcon
|
||||
sx={{ color: 'text.secondary', fontSize: 18 }}
|
||||
/>
|
||||
</InputAdornment>
|
||||
{params.InputProps.startAdornment}
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* ── Service tradition ── */}
|
||||
<Box>
|
||||
<Typography variant="labelLg" sx={sectionHeadingSx}>
|
||||
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>
|
||||
|
||||
{/* Sort — compact "Sort by" on mobile (grouped left next to
|
||||
Filters); verbose "Sort: <label>" on desktop (pushed right). */}
|
||||
<Box sx={{ ml: { xs: 0, md: 'auto' } }}>
|
||||
<SortMenu
|
||||
value={sortBy}
|
||||
onChange={(v) => onSortChange?.(v as ProviderSortBy)}
|
||||
options={SORT_OPTIONS}
|
||||
variant={isMobile ? 'compact' : 'verbose'}
|
||||
sx={controlButtonSx}
|
||||
/>
|
||||
{/* Sort — compact menu button, pushed right */}
|
||||
<Box sx={{ ml: 'auto' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="small"
|
||||
startIcon={<SwapVertIcon sx={{ fontSize: 16 }} />}
|
||||
onClick={(e) => setSortAnchor(e.currentTarget)}
|
||||
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>
|
||||
|
||||
{/* Mobile-only view toggle — pinned to the right via ml: auto on xs.
|
||||
Shares the same CONTROL_CHROME as Filters + Sort. */}
|
||||
<ToggleButtonGroup
|
||||
value={viewMode}
|
||||
exclusive
|
||||
onChange={(_, val) => val && onViewModeChange?.(val as ListViewMode)}
|
||||
size="small"
|
||||
aria-label="View mode"
|
||||
sx={[
|
||||
{ display: { xs: 'inline-flex', md: 'none' }, ml: 'auto', flexShrink: 0 },
|
||||
controlToggleSx,
|
||||
]}
|
||||
>
|
||||
<ToggleButton value="list" aria-label="List view">
|
||||
List
|
||||
</ToggleButton>
|
||||
<ToggleButton value="map" aria-label="Map view">
|
||||
Map
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
|
||||
{/* Results count — below controls */}
|
||||
@@ -793,10 +644,7 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
||||
sx={{ mt: 3, display: 'block' }}
|
||||
aria-live="polite"
|
||||
>
|
||||
<Box component="span" sx={{ fontWeight: 600, color: 'text.primary' }}>
|
||||
{providers.length}
|
||||
</Box>{' '}
|
||||
provider{providers.length !== 1 ? 's' : ''} found
|
||||
{providers.length} provider{providers.length !== 1 ? 's' : ''} found
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -807,7 +655,7 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
gap: 2,
|
||||
pb: 3,
|
||||
pt: 2,
|
||||
px: { xs: 2, md: 3 },
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
318
src/components/pages/UnverifiedPackageT2/UnverifiedPackageT2.tsx
Normal file
318
src/components/pages/UnverifiedPackageT2/UnverifiedPackageT2.tsx
Normal 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">
|
||||
·
|
||||
</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">
|
||||
·
|
||||
</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;
|
||||
2
src/components/pages/UnverifiedPackageT2/index.ts
Normal file
2
src/components/pages/UnverifiedPackageT2/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './UnverifiedPackageT2';
|
||||
export * from './UnverifiedPackageT2';
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
333
src/components/pages/UnverifiedPackageT3/UnverifiedPackageT3.tsx
Normal file
333
src/components/pages/UnverifiedPackageT3/UnverifiedPackageT3.tsx
Normal 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'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">
|
||||
·
|
||||
</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">
|
||||
·
|
||||
</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;
|
||||
2
src/components/pages/UnverifiedPackageT3/index.ts
Normal file
2
src/components/pages/UnverifiedPackageT3/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './UnverifiedPackageT3';
|
||||
export * from './UnverifiedPackageT3';
|
||||
@@ -2,9 +2,10 @@ import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Container from '@mui/material/Container';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import PhoneIcon from '@mui/icons-material/Phone';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import { Link } from '../../atoms/Link';
|
||||
import { HelpBar } from '../../molecules/HelpBar';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -15,8 +16,7 @@ export type WizardLayoutVariant =
|
||||
| 'list-map'
|
||||
| 'list-detail'
|
||||
| 'grid-sidebar'
|
||||
| 'detail-toggles'
|
||||
| 'bleed';
|
||||
| 'detail-toggles';
|
||||
|
||||
/** Props for the WizardLayout template */
|
||||
export interface WizardLayoutProps {
|
||||
@@ -50,6 +50,33 @@ export interface WizardLayoutProps {
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
// ─── Help bar ────────────────────────────────────────────────────────────────
|
||||
|
||||
const HelpBar: React.FC<{ phone: string }> = ({ phone }) => (
|
||||
<Box
|
||||
component="footer"
|
||||
sx={{
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
zIndex: 10,
|
||||
bgcolor: 'background.paper',
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'divider',
|
||||
py: 1.5,
|
||||
px: { xs: 2, md: 4 },
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary" component="span">
|
||||
<PhoneIcon sx={{ fontSize: 16, verticalAlign: 'text-bottom', mr: 0.5 }} />
|
||||
Need help? Call us on{' '}
|
||||
<Link href={`tel:${phone.replace(/\s/g, '')}`} sx={{ fontWeight: 600 }}>
|
||||
{phone}
|
||||
</Link>
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
// ─── Back link ───────────────────────────────────────────────────────────────
|
||||
|
||||
const BackLink: React.FC<{ label: string; onClick?: () => void }> = ({ label, onClick }) => (
|
||||
@@ -335,30 +362,6 @@ const DetailTogglesLayout: React.FC<{
|
||||
</Box>
|
||||
);
|
||||
|
||||
/** Bleed: full-width scroll host. Main becomes the single scroll container
|
||||
* (both axes). No inner Container — children are full-bleed. Back link is
|
||||
* passed into children so it scrolls with the page content. Used by pages
|
||||
* that own their own width + alignment logic (e.g. ComparisonPage). */
|
||||
const BleedLayout: React.FC<{
|
||||
children: React.ReactNode;
|
||||
backLink?: React.ReactNode;
|
||||
}> = ({ children, backLink }) => (
|
||||
<Box
|
||||
id="wizard-scroll"
|
||||
data-wizard-scroll
|
||||
sx={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{backLink}
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
// ─── Variant map ─────────────────────────────────────────────────────────────
|
||||
|
||||
const LAYOUT_MAP: Record<
|
||||
@@ -375,7 +378,6 @@ const LAYOUT_MAP: Record<
|
||||
'list-detail': ListDetailLayout,
|
||||
'grid-sidebar': GridSidebarLayout,
|
||||
'detail-toggles': DetailTogglesLayout,
|
||||
bleed: BleedLayout,
|
||||
};
|
||||
|
||||
/* Stepper bar renders on any variant when progressStepper or runningTotal is provided */
|
||||
@@ -385,15 +387,12 @@ const LAYOUT_MAP: Record<
|
||||
/**
|
||||
* Page-level layout template for the FA arrangement wizard.
|
||||
*
|
||||
* Provides 6 layout variants matching the wizard page templates:
|
||||
* Provides 5 layout variants matching the wizard page templates:
|
||||
* - **centered-form**: Single centered column for form steps (intro, auth, date/time, etc.)
|
||||
* - **wide-form**: Wider single column for card grids (coffins, etc.)
|
||||
* - **list-map**: Split view with scrollable card list and map panel (providers)
|
||||
* - **list-detail**: Master-detail split for selection + detail (packages, preview)
|
||||
* - **grid-sidebar**: Filter sidebar + card grid (coffins)
|
||||
* - **detail-toggles**: Hero image + info column (venue, coffin details)
|
||||
* - **bleed**: Viewport-locked, full-width scroll host with no inner container —
|
||||
* the page owns its own alignment (comparison page)
|
||||
*
|
||||
* All variants share: navigation slot, optional back link, sticky help bar,
|
||||
* and optional progress stepper + running total bar (shown when props provided).
|
||||
@@ -427,8 +426,8 @@ export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
|
||||
flexDirection: 'column',
|
||||
minHeight: '100vh',
|
||||
bgcolor: 'background.default',
|
||||
// list-map + detail-toggles + bleed: lock to viewport so panels scroll independently
|
||||
...((variant === 'list-map' || variant === 'detail-toggles' || variant === 'bleed') && {
|
||||
// list-map + detail-toggles: lock to viewport so panels scroll independently
|
||||
...((variant === 'list-map' || variant === 'detail-toggles') && {
|
||||
height: '100vh',
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
@@ -446,19 +445,15 @@ export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
|
||||
{/* Stepper + running total bar (grid-sidebar, detail-toggles only) */}
|
||||
<StepperBar stepper={progressStepper} total={runningTotal} />
|
||||
|
||||
{/* Back link — inside children for list-map/detail-toggles/bleed (scrolls with content),
|
||||
above content for other variants */}
|
||||
{showBackLink &&
|
||||
variant !== 'list-map' &&
|
||||
variant !== 'detail-toggles' &&
|
||||
variant !== 'bleed' && (
|
||||
<Container
|
||||
maxWidth={variant === 'centered-form' ? 'sm' : 'lg'}
|
||||
sx={{ pt: 2, px: { xs: 4, md: 3 } }}
|
||||
>
|
||||
<BackLink label={backLabel} onClick={onBack} />
|
||||
</Container>
|
||||
)}
|
||||
{/* Back link — inside left panel for list-map/detail-toggles, above content for others */}
|
||||
{showBackLink && variant !== 'list-map' && variant !== 'detail-toggles' && (
|
||||
<Container
|
||||
maxWidth={variant === 'centered-form' ? 'sm' : 'lg'}
|
||||
sx={{ pt: 2, px: { xs: 4, md: 3 } }}
|
||||
>
|
||||
<BackLink label={backLabel} onClick={onBack} />
|
||||
</Container>
|
||||
)}
|
||||
|
||||
{/* Main content area */}
|
||||
<Box
|
||||
@@ -468,8 +463,7 @@ export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
|
||||
<LayoutComponent
|
||||
secondaryPanel={secondaryPanel}
|
||||
backLink={
|
||||
showBackLink &&
|
||||
(variant === 'list-map' || variant === 'detail-toggles' || variant === 'bleed') ? (
|
||||
showBackLink && (variant === 'list-map' || variant === 'detail-toggles') ? (
|
||||
<Box sx={{ pt: 1.5 }}>
|
||||
<BackLink label={backLabel} onClick={onBack} />
|
||||
</Box>
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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: '#' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
@@ -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>
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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`)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,10 +0,0 @@
|
||||
/**
|
||||
* Resolves a static asset path. In local dev the path is served by Storybook's
|
||||
* staticDirs; when STORYBOOK_ASSET_BASE is set (e.g. Chromatic builds) it
|
||||
* prepends the external host URL so images load from Gitea.
|
||||
*/
|
||||
export const assetUrl = (path: string): string => {
|
||||
const base =
|
||||
typeof import.meta !== 'undefined' ? (import.meta.env?.STORYBOOK_ASSET_BASE ?? '') : '';
|
||||
return `${base}${path}`;
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user