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