Scaffold arrangement demo slice with CompareBar wiring
Add a self-contained demo build target for the Providers → Packages → Comparison flow, deployable as a static SPA at /arrangement/. - vite.demo.config.ts: per-slice build via --mode, base path flips for dev vs prod, output to dist-demo/<slice>/ - src/demo/: shared fixtures (7 providers across verified/tier3/tier2 with real venue photography from brandassets) + Zustand basket store with ?compare= URL persistence - Verified-provider packages now share the nine canonical Essentials line items per FA convention; only Optionals/Extras vary - App-level CompareBar surfaces "Already added" / "Maximum 3" feedback via transient store error - ProviderCard logo objectFit cover→contain so wide logos don't crop - npm scripts demo:dev / demo:build, deps zustand + react-router-dom
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-demo/
|
||||
storybook-static/
|
||||
tokens/export/
|
||||
*.local
|
||||
@@ -42,3 +43,6 @@ temp-db/
|
||||
|
||||
# Root-level screenshots
|
||||
/*.png
|
||||
|
||||
# IDE-specific
|
||||
*.code-workspace
|
||||
|
||||
218
docs/reference/client-demo-hosting-plan.md
Normal file
218
docs/reference/client-demo-hosting-plan.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Client demo hosting plan
|
||||
|
||||
**Status:** scoped, not implemented.
|
||||
**Target:** share self-contained, interactive demos of the FA design system with clients via `parsons.tensordesign.com.au/<slice>`, gated behind basic auth, independently buildable per slice.
|
||||
|
||||
---
|
||||
|
||||
## Why not Storybook or Chromatic alone
|
||||
|
||||
- **Storybook** — great for isolated components and per-story state, but cross-page flows with persistent state (comparison basket, map selections, route navigation) are outside its shape. You can fake flows with story parameters, but it's brittle and doesn't feel like a product.
|
||||
- **Chromatic** — built for visual regression diffs + internal review. UX is Storybook-shaped, which is noisy for non-technical clients. Keep Chromatic for the internal review workflow; use self-hosted demos for client-facing previews.
|
||||
|
||||
The demo-hosting solution is **additive** — it doesn't replace either of those.
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
1. Multiple demo "slices" per project — e.g. `/arrangement`, `/home`, `/compare` — each independently buildable and deployable.
|
||||
2. Each slice behaves like a real product: real URLs, real navigation, real state (comparison basket persists across pages, selections survive drill-in, etc.).
|
||||
3. Dummy data only — no CMS, no real users, no auth beyond a single shared htpasswd for the whole demo host.
|
||||
4. Zero disruption to the component library — demos consume the existing page components as-is.
|
||||
5. Demos live alongside the codebase but build into their own output tree (`dist-demo/<slice>/`) so neither the component library nor Storybook is affected.
|
||||
|
||||
---
|
||||
|
||||
## URL shape
|
||||
|
||||
**Single subdomain, subpath per slice:**
|
||||
|
||||
- `parsons.tensordesign.com.au/` → tiny index page listing available demos (optional, but handy once there are 3+)
|
||||
- `parsons.tensordesign.com.au/arrangement` → Providers → Packages → Comparison flow
|
||||
- `parsons.tensordesign.com.au/home` → homepage exploration
|
||||
- `parsons.tensordesign.com.au/<future slice>`
|
||||
|
||||
One SSL cert (Let's Encrypt), one nginx server block, one htpasswd covering the whole host.
|
||||
|
||||
**Why subpath-per-slice over subdomain-per-slice:** subdomain-per-slice needs wildcard DNS + wildcard cert + per-demo nginx blocks. More moving parts for no client-facing benefit. Subpath works cleanly when each Vite build declares its own `base` at build time.
|
||||
|
||||
**Why this host over a wildcard demo subdomain:** `parsons.tensordesign.com.au` reads clearly to clients ("this is the Parsons project"). If you later run demos for other clients, add siblings (`acme.tensordesign.com.au`) rather than splitting Parsons across multiple hosts.
|
||||
|
||||
---
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
src/demo/
|
||||
shared/
|
||||
fixtures/ # cross-slice mock data (providers, packages, venues)
|
||||
state/ # common stores — comparison basket, nav shell
|
||||
theme/ # ThemeProvider wrapper (mirrors Storybook decorators)
|
||||
apps/
|
||||
arrangement/
|
||||
main.tsx # Vite entry
|
||||
App.tsx # Router shell
|
||||
routes/
|
||||
providers.tsx
|
||||
packages.tsx
|
||||
comparison.tsx
|
||||
fixtures/ # arrangement-specific overrides
|
||||
home/
|
||||
main.tsx
|
||||
App.tsx
|
||||
routes/
|
||||
landing.tsx
|
||||
index-demo.html # template — slot in slice-specific title/base
|
||||
|
||||
scripts/
|
||||
build-demo.sh <name> # vite build -c vite.demo.config.ts --mode <name>
|
||||
deploy-demo.sh <name> # rsync dist-demo/<name>/ to server path
|
||||
|
||||
vite.demo.config.ts # reads slice name from --mode, sets base + outDir
|
||||
|
||||
docs/reference/
|
||||
client-demo-hosting-plan.md # this file
|
||||
client-demo-deploy.md # once implemented — ops runbook
|
||||
```
|
||||
|
||||
**Fixture-sharing rule:** anything that could plausibly appear in two slices lives in `src/demo/shared/fixtures/`. Slice-specific overrides live in `apps/<slice>/fixtures/`. Stories continue to use their own fixtures unchanged — no cross-contamination.
|
||||
|
||||
---
|
||||
|
||||
## State shape (the one thing worth being deliberate about)
|
||||
|
||||
The comparison basket is cross-page state. Whatever we pick here will likely inform the real app's state layer later, so treat it as the prototype for production, not throwaway glue.
|
||||
|
||||
**Recommended: Zustand with URL-persistence for package IDs.**
|
||||
|
||||
```ts
|
||||
// src/demo/shared/state/useComparisonBasket.ts
|
||||
interface ComparisonBasket {
|
||||
packageIds: string[]; // ordered — insertion order = display order
|
||||
add: (id: string) => void;
|
||||
remove: (id: string) => void;
|
||||
clear: () => void;
|
||||
isFull: () => boolean; // 4 max per FA convention
|
||||
}
|
||||
```
|
||||
|
||||
- **Zustand** over Context: less boilerplate, better Devtools story, selector-based subscriptions avoid re-render cascades.
|
||||
- **URL-persistence** (`?compare=a,b,c`) so a client can bookmark a specific comparison and reload. Easy `useEffect` hook that syncs store ↔ URL search param.
|
||||
- **Insertion order preserved** — matches ComparisonPage columns left-to-right.
|
||||
- **No localStorage** — keeps demo stateless between sessions unless client explicitly shares a URL. Makes client demos predictable ("click here, you'll see exactly what I saw").
|
||||
|
||||
Other state that might need a shared store: selected provider (persists across routes), map viewport (so map doesn't reset on drill-in). Start with just the basket; add others when a route actually needs them.
|
||||
|
||||
---
|
||||
|
||||
## Vite build
|
||||
|
||||
One config file, slice name passed via `--mode`:
|
||||
|
||||
```ts
|
||||
// vite.demo.config.ts
|
||||
export default defineConfig(({ mode }) => ({
|
||||
root: `src/demo/apps/${mode}`,
|
||||
base: `/${mode}/`,
|
||||
build: {
|
||||
outDir: `../../../../dist-demo/${mode}`,
|
||||
emptyOutDir: true,
|
||||
},
|
||||
// ... shared plugins, resolve aliases identical to main vite.config
|
||||
}));
|
||||
```
|
||||
|
||||
`npm run build:demo -- --mode arrangement` produces `dist-demo/arrangement/` ready to ship.
|
||||
|
||||
**Shared scripts:**
|
||||
```json
|
||||
"scripts": {
|
||||
"demo:dev": "vite -c vite.demo.config.ts",
|
||||
"demo:build": "vite build -c vite.demo.config.ts",
|
||||
"demo:deploy": "./scripts/deploy-demo.sh"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
# scripts/deploy-demo.sh <slice>
|
||||
SLICE=$1
|
||||
rsync -az --delete dist-demo/$SLICE/ richie@tensordesign.com.au:/var/www/parsons-demos/$SLICE/
|
||||
```
|
||||
|
||||
Slice-by-slice deploy means polishing one demo doesn't require rebuilding or redeploying others. Add the index page as a trivial static HTML (no build) committed to `scripts/demo-index.html` and rsynced to the host root.
|
||||
|
||||
Consider a tiny pre-flight check in the deploy script — abort if the build output is missing or empty, to avoid rsync-ing a half-built bundle over a working demo.
|
||||
|
||||
---
|
||||
|
||||
## nginx + basic auth
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name parsons.tensordesign.com.au;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/parsons.tensordesign.com.au/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/parsons.tensordesign.com.au/privkey.pem;
|
||||
|
||||
root /var/www/parsons-demos;
|
||||
index index.html;
|
||||
|
||||
auth_basic "Parsons demos";
|
||||
auth_basic_user_file /etc/nginx/htpasswd/parsons;
|
||||
|
||||
# SPA fallback per slice — each demo has its own index.html
|
||||
location ~ ^/(?<slice>[^/]+)/ {
|
||||
try_files $uri $uri/ /$slice/index.html;
|
||||
}
|
||||
|
||||
# Root lists available demos
|
||||
location = / {
|
||||
try_files /index.html =404;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
htpasswd setup:
|
||||
```bash
|
||||
sudo htpasswd -c /etc/nginx/htpasswd/parsons client
|
||||
```
|
||||
|
||||
Single credential shared across all slices. If a client project ever needs isolated access, split at the location block with per-slice `auth_basic_user_file`.
|
||||
|
||||
---
|
||||
|
||||
## Rollout order (next session)
|
||||
|
||||
1. **Scaffold** `src/demo/shared/` (fixtures extracted from `PackagesStep.stories.tsx` + `ComparisonPage.stories.tsx`) and the Zustand basket store.
|
||||
2. **First slice — `arrangement`** with three routes (Providers → Packages → Comparison). Prove the basket persists across navigation, the back button works, drill-in still fires on mobile.
|
||||
3. **`vite.demo.config.ts` + `build-demo.sh`** — confirm `dist-demo/arrangement/` builds and serves standalone via `npx serve dist-demo/arrangement`.
|
||||
4. **nginx + htpasswd + Let's Encrypt** on the server. One-time ops setup.
|
||||
5. **Deploy script** — rsync wired to a single command.
|
||||
6. **Test end-to-end** — visit `parsons.tensordesign.com.au/arrangement`, click through the flow, verify basket state, verify URL bookmark restores state.
|
||||
7. **Optional: index page + second slice** once the first is proven.
|
||||
|
||||
Estimated effort: half-day scaffold + ~day for the arrangement slice + half-day ops setup. Total ~2 days before the first client-shareable link.
|
||||
|
||||
---
|
||||
|
||||
## Non-goals (for this iteration)
|
||||
|
||||
- No real backend, no CMS, no user accounts, no persistence beyond URL params.
|
||||
- No analytics or telemetry (demos are short-lived; instrumenting them is noise).
|
||||
- No E2E tests for demo routes (fixtures + trusted component library = low ROI).
|
||||
- No automated deploy on merge — manual `npm run demo:deploy arrangement` is fine at this volume. Revisit if demos are updated daily.
|
||||
- No custom domain per client — stick with subpath-per-slice under `parsons.tensordesign.com.au`.
|
||||
|
||||
---
|
||||
|
||||
## Open questions for next session
|
||||
|
||||
- Exact path for the server-side document root (`/var/www/parsons-demos/` is a placeholder — confirm what tensordesign.com.au's existing nginx expects).
|
||||
- Whether to seed the `home` slice or a different one as the second demo after `arrangement` is proven.
|
||||
- Whether the comparison basket should persist across browser tabs (probably no — URL-only is cleaner for demos) or across reloads without a URL (probably no for the same reason).
|
||||
90
package-lock.json
generated
90
package-lock.json
generated
@@ -14,7 +14,9 @@
|
||||
"@mui/material": "^5.16.0",
|
||||
"@mui/system": "^5.16.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": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
@@ -5387,6 +5389,19 @@
|
||||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cosmiconfig": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
|
||||
@@ -9530,6 +9545,44 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.14.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.1.tgz",
|
||||
"integrity": "sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.14.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.1.tgz",
|
||||
"integrity": "sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.14.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
@@ -9901,6 +9954,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
@@ -12099,6 +12158,35 @@
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
|
||||
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"chromatic": "chromatic --exit-zero-on-changes --build-script-name=build:storybook",
|
||||
"demo:dev": "vite -c vite.demo.config.ts",
|
||||
"demo:build": "vite build -c vite.demo.config.ts",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -28,7 +30,9 @@
|
||||
"@mui/material": "^5.16.0",
|
||||
"@mui/system": "^5.16.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": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
|
||||
@@ -172,7 +172,10 @@ export const ProviderCard = React.forwardRef<HTMLDivElement, ProviderCardProps>(
|
||||
width: LOGO_SIZE,
|
||||
height: LOGO_SIZE,
|
||||
borderRadius: LOGO_BORDER_RADIUS,
|
||||
objectFit: 'cover',
|
||||
// 'contain' so wide/tall logos scale proportionally inside
|
||||
// the square slot rather than cropping. Background fills any
|
||||
// letterboxed space so it still reads as a tile.
|
||||
objectFit: 'contain',
|
||||
backgroundColor: 'background.paper',
|
||||
boxShadow: 'var(--fa-shadow-sm)',
|
||||
border: '2px solid var(--fa-color-white)',
|
||||
|
||||
22
src/demo/apps/arrangement/App.tsx
Normal file
22
src/demo/apps/arrangement/App.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
56
src/demo/apps/arrangement/AppCompareBar.tsx
Normal file
56
src/demo/apps/arrangement/AppCompareBar.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
30
src/demo/apps/arrangement/DemoNav.tsx
Normal file
30
src/demo/apps/arrangement/DemoNav.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import { Navigation } from '../../../components/organisms/Navigation';
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
export const demoNav = (
|
||||
<Navigation
|
||||
logo={<FALogo />}
|
||||
items={[
|
||||
{ label: 'FAQ', href: '#' },
|
||||
{ label: 'Contact Us', href: '#' },
|
||||
{ label: 'Log in', href: '#' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
18
src/demo/apps/arrangement/index.html
Normal file
18
src/demo/apps/arrangement/index.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!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>
|
||||
23
src/demo/apps/arrangement/main.tsx
Normal file
23
src/demo/apps/arrangement/main.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
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>,
|
||||
);
|
||||
65
src/demo/apps/arrangement/routes/Comparison.tsx
Normal file
65
src/demo/apps/arrangement/routes/Comparison.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
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 } 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);
|
||||
|
||||
const packages = packageKeys
|
||||
.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,
|
||||
);
|
||||
|
||||
if (packages.length === 0) {
|
||||
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)}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
63
src/demo/apps/arrangement/routes/Packages.tsx
Normal file
63
src/demo/apps/arrangement/routes/Packages.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
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,
|
||||
nearbyVerifiedSamples,
|
||||
} 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 just adds the selection to the
|
||||
// basket. The floating CompareBar (mounted in App.tsx) handles navigation
|
||||
// once the user has 2+ packages selected.
|
||||
const handleCompare = () => {
|
||||
if (selectedId) basket.add(makeBasketKey(provider.id, selectedId));
|
||||
};
|
||||
|
||||
// Tier-3 / tier-2 providers show "nearby verified" cards instead of
|
||||
// "more from this provider".
|
||||
const secondaryList =
|
||||
provider.tier === 'verified'
|
||||
? { kind: 'same-provider-more' as const, packages: bundle.other }
|
||||
: { kind: 'nearby-verified' as const, packages: nearbyVerifiedSamples };
|
||||
|
||||
return (
|
||||
<PackagesStep
|
||||
provider={toPackagesStepProvider(provider)}
|
||||
providerTier={provider.tier}
|
||||
packages={bundle.matching}
|
||||
secondaryList={secondaryList.packages.length > 0 ? 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}
|
||||
onNearbyPackageClick={(key) => {
|
||||
const [otherProviderId] = key.split(':');
|
||||
if (otherProviderId) navigate(`/providers/${otherProviderId}/packages`);
|
||||
}}
|
||||
onProviderClick={() => alert('Provider profile — not built in this demo slice.')}
|
||||
onBack={() => navigate('/')}
|
||||
navigation={demoNav}
|
||||
/>
|
||||
);
|
||||
}
|
||||
38
src/demo/apps/arrangement/routes/Providers.tsx
Normal file
38
src/demo/apps/arrangement/routes/Providers.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
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 { 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
908
src/demo/shared/fixtures/packages.ts
Normal file
908
src/demo/shared/fixtures/packages.ts
Normal file
@@ -0,0 +1,908 @@
|
||||
import type { PackageData, NearbyVerifiedPackage } from '../../../components/pages/PackagesStep';
|
||||
import type { PackageSection, PackageLineItem } from '../../../components/organisms/PackageDetail';
|
||||
import type {
|
||||
ComparisonPackage,
|
||||
ComparisonSection,
|
||||
} from '../../../components/organisms/ComparisonTable';
|
||||
import { providersById } from './providers';
|
||||
|
||||
/**
|
||||
* Packages live keyed by providerId. Each provider has TWO PackageData lists
|
||||
* (matching = primary "matching your preferences", other = secondary "more
|
||||
* from this provider") and a parallel ComparisonPackage projection that
|
||||
* concatenates both for use by ComparisonPage. Same `id` across all three
|
||||
* lists so the basket can hold a single compound key:
|
||||
* `${providerId}:${packageId}`.
|
||||
*
|
||||
* Verified-provider packages all share the same nine canonical Essentials
|
||||
* line items per FA convention — only prices/treatment vary. Optionals and
|
||||
* Extras are free to vary per package. See memory:
|
||||
* project_canonical_essentials.md for the rule and reasoning.
|
||||
*/
|
||||
|
||||
interface PackageBundle {
|
||||
matching: PackageData[];
|
||||
other: PackageData[];
|
||||
forComparison: ComparisonPackage[];
|
||||
}
|
||||
|
||||
// ─── Canonical Essentials factories ──────────────────────────────────────────
|
||||
|
||||
type IncludedTreatment = 'complimentary' | 'included';
|
||||
|
||||
interface EssentialsPrices {
|
||||
coffin: number; // rendered as allowance
|
||||
cremationCert: number;
|
||||
crematorium: number;
|
||||
deathReg: number;
|
||||
dressing: number | IncludedTreatment;
|
||||
govLevy: number;
|
||||
govLevyLabel?: string; // override to "Government Levy — Cremation" for non-NSW
|
||||
mortuary: number;
|
||||
service: number;
|
||||
transport: number | IncludedTreatment;
|
||||
}
|
||||
|
||||
const labelFor = (kind: IncludedTreatment): string =>
|
||||
kind === 'complimentary' ? 'Complimentary' : 'Included';
|
||||
|
||||
function essentialsForStep(p: EssentialsPrices): PackageSection {
|
||||
const item = (name: string, value: number | IncludedTreatment): PackageLineItem =>
|
||||
typeof value === 'number'
|
||||
? { name, price: value }
|
||||
: { name, price: 0, priceLabel: labelFor(value) };
|
||||
|
||||
return {
|
||||
heading: 'Essentials',
|
||||
items: [
|
||||
{ name: 'Allowance for Coffin', price: p.coffin, isAllowance: true },
|
||||
item('Cremation Certificate/Permit', p.cremationCert),
|
||||
item('Crematorium', p.crematorium),
|
||||
item('Death Registration Certificate', p.deathReg),
|
||||
item('Dressing Fee', p.dressing),
|
||||
item(p.govLevyLabel ?? 'NSW Government Levy — Cremation', p.govLevy),
|
||||
item('Professional Mortuary Care', p.mortuary),
|
||||
item('Professional Service Fee', p.service),
|
||||
item('Transportation Service Fee', p.transport),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function essentialsForComparison(p: EssentialsPrices): ComparisonSection {
|
||||
const cell = (value: number | IncludedTreatment) =>
|
||||
typeof value === 'number'
|
||||
? ({ type: 'price', amount: value } as const)
|
||||
: ({ type: value } as const);
|
||||
|
||||
return {
|
||||
heading: 'Essentials',
|
||||
items: [
|
||||
{
|
||||
name: 'Allowance for Coffin',
|
||||
info: 'Allowance amount — upgrade options available.',
|
||||
value: { type: 'allowance', amount: p.coffin },
|
||||
},
|
||||
{
|
||||
name: 'Cremation Certificate/Permit',
|
||||
info: 'Statutory medical referee fee.',
|
||||
value: cell(p.cremationCert),
|
||||
},
|
||||
{ name: 'Crematorium', info: 'Cremation facility fees.', value: cell(p.crematorium) },
|
||||
{
|
||||
name: 'Death Registration Certificate',
|
||||
info: 'Lodgement with the registry.',
|
||||
value: cell(p.deathReg),
|
||||
},
|
||||
{ name: 'Dressing Fee', info: 'Dressing and preparation.', value: cell(p.dressing) },
|
||||
{
|
||||
name: p.govLevyLabel ?? 'NSW Government Levy — Cremation',
|
||||
info: 'Government cremation levy.',
|
||||
value: cell(p.govLevy),
|
||||
},
|
||||
{
|
||||
name: 'Professional Mortuary Care',
|
||||
info: 'Preparation and care.',
|
||||
value: cell(p.mortuary),
|
||||
},
|
||||
{
|
||||
name: 'Professional Service Fee',
|
||||
info: 'Coordination of arrangements.',
|
||||
value: cell(p.service),
|
||||
},
|
||||
{
|
||||
name: 'Transportation Service Fee',
|
||||
info: 'Transfer of the deceased.',
|
||||
value: cell(p.transport),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const sumEssentials = (p: EssentialsPrices): number => {
|
||||
const num = (v: number | IncludedTreatment) => (typeof v === 'number' ? v : 0);
|
||||
return (
|
||||
p.coffin +
|
||||
num(p.cremationCert) +
|
||||
num(p.crematorium) +
|
||||
num(p.deathReg) +
|
||||
num(p.dressing) +
|
||||
p.govLevy +
|
||||
p.mortuary +
|
||||
p.service +
|
||||
num(p.transport)
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Optionals helpers ───────────────────────────────────────────────────────
|
||||
|
||||
interface Optional {
|
||||
name: string;
|
||||
treatment: IncludedTreatment | 'unknown';
|
||||
}
|
||||
|
||||
const optionalsForStep = (items: Optional[]): PackageSection => ({
|
||||
heading: 'Optionals',
|
||||
items: items.map<PackageLineItem>((it) =>
|
||||
it.treatment === 'unknown'
|
||||
? { name: it.name, price: 0, priceLabel: '—' }
|
||||
: { name: it.name, price: 0, priceLabel: labelFor(it.treatment) },
|
||||
),
|
||||
});
|
||||
|
||||
const optionalsForComparison = (items: Optional[]): ComparisonSection => ({
|
||||
heading: 'Optionals',
|
||||
items: items.map((it) => ({
|
||||
name: it.name,
|
||||
value: { type: it.treatment === 'unknown' ? 'unknown' : it.treatment },
|
||||
})),
|
||||
});
|
||||
|
||||
// ─── Extras helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
interface Extra {
|
||||
name: string;
|
||||
/** number = fixed price; { allowance } = allowance amount; 'poa' = price on application; 'complimentary' = free */
|
||||
value: number | { allowance: number } | 'poa' | 'complimentary';
|
||||
}
|
||||
|
||||
const extrasForStep = (items: Extra[]): PackageSection => ({
|
||||
heading: 'Extras',
|
||||
items: items.map<PackageLineItem>((it) => {
|
||||
if (typeof it.value === 'number') return { name: it.name, price: it.value };
|
||||
if (it.value === 'poa') return { name: it.name, price: 0, priceLabel: 'POA' };
|
||||
if (it.value === 'complimentary')
|
||||
return { name: it.name, price: 0, priceLabel: 'Complimentary' };
|
||||
return { name: it.name, price: it.value.allowance, isAllowance: true };
|
||||
}),
|
||||
});
|
||||
|
||||
const extrasForComparison = (items: Extra[]): ComparisonSection => ({
|
||||
heading: 'Extras',
|
||||
items: items.map((it) => ({
|
||||
name: it.name,
|
||||
value:
|
||||
typeof it.value === 'number'
|
||||
? { type: 'price', amount: it.value }
|
||||
: it.value === 'poa'
|
||||
? { type: 'poa' }
|
||||
: it.value === 'complimentary'
|
||||
? { type: 'complimentary' }
|
||||
: { type: 'allowance', amount: it.value.allowance },
|
||||
})),
|
||||
});
|
||||
|
||||
// ─── H.Parsons (verified — premium tier, complimentary treatments) ───────────
|
||||
|
||||
const parsonsEverydayEssentials: EssentialsPrices = {
|
||||
coffin: 1750,
|
||||
cremationCert: 350,
|
||||
crematorium: 660,
|
||||
deathReg: 70,
|
||||
dressing: 'complimentary',
|
||||
govLevy: 45.1,
|
||||
mortuary: 440,
|
||||
service: 3650.9,
|
||||
transport: 'complimentary',
|
||||
};
|
||||
const parsonsEssentialEssentials: EssentialsPrices = {
|
||||
coffin: 1200,
|
||||
cremationCert: 350,
|
||||
crematorium: 660,
|
||||
deathReg: 70,
|
||||
dressing: 'complimentary',
|
||||
govLevy: 45.1,
|
||||
mortuary: 440,
|
||||
service: 1729.9,
|
||||
transport: 'complimentary',
|
||||
};
|
||||
const parsonsDeluxeEssentials: EssentialsPrices = {
|
||||
coffin: 2500,
|
||||
cremationCert: 350,
|
||||
crematorium: 660,
|
||||
deathReg: 70,
|
||||
dressing: 'complimentary',
|
||||
govLevy: 45.1,
|
||||
mortuary: 440,
|
||||
service: 5384.9,
|
||||
transport: 'complimentary',
|
||||
};
|
||||
|
||||
const parsonsForStep: PackageData[] = [
|
||||
{
|
||||
id: 'everyday',
|
||||
name: 'Everyday Funeral Package',
|
||||
price: sumEssentials(parsonsEverydayEssentials),
|
||||
description:
|
||||
'Funeral service at a chapel or church with a procession. Includes the most commonly selected funeral options.',
|
||||
sections: [
|
||||
essentialsForStep(parsonsEverydayEssentials),
|
||||
optionalsForStep([
|
||||
{ name: 'Digital Recording', treatment: 'complimentary' },
|
||||
{ name: 'Online Notice', treatment: 'complimentary' },
|
||||
{ name: 'Viewing Fee', treatment: 'complimentary' },
|
||||
{ name: 'Flowers', treatment: 'complimentary' },
|
||||
]),
|
||||
],
|
||||
extras: extrasForStep([
|
||||
{ name: 'Allowance for Celebrant', value: { allowance: 550 } },
|
||||
{ name: 'Catering', value: 'poa' },
|
||||
{ name: 'Newspaper Notice', value: 'poa' },
|
||||
{ name: 'Saturday Service Fee', value: 880 },
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'essential',
|
||||
name: 'Essential Funeral Package',
|
||||
price: sumEssentials(parsonsEssentialEssentials),
|
||||
description: 'A simple, dignified option covering the essential requirements for a cremation.',
|
||||
sections: [
|
||||
essentialsForStep(parsonsEssentialEssentials),
|
||||
optionalsForStep([
|
||||
{ name: 'Online Notice', treatment: 'complimentary' },
|
||||
{ name: 'Viewing Fee', treatment: 'complimentary' },
|
||||
]),
|
||||
],
|
||||
extras: extrasForStep([
|
||||
{ name: 'Allowance for Celebrant', value: { allowance: 400 } },
|
||||
{ name: 'Catering', value: 'poa' },
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'deluxe',
|
||||
name: 'Deluxe Funeral Package',
|
||||
price: sumEssentials(parsonsDeluxeEssentials),
|
||||
description:
|
||||
'Premium inclusions, higher-quality coffin selection, expanded service options for a more personalised farewell.',
|
||||
sections: [
|
||||
essentialsForStep(parsonsDeluxeEssentials),
|
||||
optionalsForStep([
|
||||
{ name: 'Digital Recording', treatment: 'complimentary' },
|
||||
{ name: 'Online Notice', treatment: 'complimentary' },
|
||||
{ name: 'Viewing Fee', treatment: 'complimentary' },
|
||||
{ name: 'Flowers', treatment: 'complimentary' },
|
||||
{ name: 'Webstreaming', treatment: 'complimentary' },
|
||||
]),
|
||||
],
|
||||
extras: extrasForStep([
|
||||
{ name: 'Allowance for Celebrant', value: { allowance: 700 } },
|
||||
{ name: 'Catering', value: 1200 },
|
||||
{ name: 'Newspaper Notice', value: 350 },
|
||||
{ name: 'Saturday Service Fee', value: 'complimentary' },
|
||||
]),
|
||||
},
|
||||
];
|
||||
|
||||
const parsonsForComparison: ComparisonPackage[] = parsonsForStep.map((pkg, idx) => {
|
||||
const ess = [parsonsEverydayEssentials, parsonsEssentialEssentials, parsonsDeluxeEssentials][idx];
|
||||
const allOptionals: Optional[][] = [
|
||||
[
|
||||
{ name: 'Digital Recording', treatment: 'complimentary' },
|
||||
{ name: 'Online Notice', treatment: 'complimentary' },
|
||||
{ name: 'Viewing Fee', treatment: 'complimentary' },
|
||||
{ name: 'Flowers', treatment: 'complimentary' },
|
||||
],
|
||||
[
|
||||
{ name: 'Online Notice', treatment: 'complimentary' },
|
||||
{ name: 'Viewing Fee', treatment: 'complimentary' },
|
||||
],
|
||||
[
|
||||
{ name: 'Digital Recording', treatment: 'complimentary' },
|
||||
{ name: 'Online Notice', treatment: 'complimentary' },
|
||||
{ name: 'Viewing Fee', treatment: 'complimentary' },
|
||||
{ name: 'Flowers', treatment: 'complimentary' },
|
||||
{ name: 'Webstreaming', treatment: 'complimentary' },
|
||||
],
|
||||
];
|
||||
const optionals = allOptionals[idx];
|
||||
const extras: Extra[] = [
|
||||
[
|
||||
{ name: 'Allowance for Celebrant', value: { allowance: 550 } },
|
||||
{ name: 'Catering', value: 'poa' as const },
|
||||
{ name: 'Newspaper Notice', value: 'poa' as const },
|
||||
{ name: 'Saturday Service Fee', value: 880 },
|
||||
],
|
||||
[
|
||||
{ name: 'Allowance for Celebrant', value: { allowance: 400 } },
|
||||
{ name: 'Catering', value: 'poa' as const },
|
||||
],
|
||||
[
|
||||
{ name: 'Allowance for Celebrant', value: { allowance: 700 } },
|
||||
{ name: 'Catering', value: 1200 },
|
||||
{ name: 'Newspaper Notice', value: 350 },
|
||||
{ name: 'Saturday Service Fee', value: 'complimentary' as const },
|
||||
],
|
||||
][idx];
|
||||
return {
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
price: pkg.price,
|
||||
provider: {
|
||||
name: 'H.Parsons Funeral Directors',
|
||||
location: 'Wentworth, NSW',
|
||||
logoUrl: providersById['parsons'].logoUrl,
|
||||
rating: 4.6,
|
||||
reviewCount: 7,
|
||||
verified: true,
|
||||
},
|
||||
sections: [
|
||||
essentialsForComparison(ess),
|
||||
optionalsForComparison(optionals),
|
||||
extrasForComparison(extras),
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Rankins (verified — mid-market, "included" treatment) ───────────────────
|
||||
|
||||
const rankinsStandardEssentials: EssentialsPrices = {
|
||||
coffin: 1500,
|
||||
cremationCert: 350,
|
||||
crematorium: 660,
|
||||
deathReg: 70,
|
||||
dressing: 'included',
|
||||
govLevy: 45.1,
|
||||
mortuary: 440,
|
||||
service: 2430.35,
|
||||
transport: 'included',
|
||||
};
|
||||
const rankinsPremiumEssentials: EssentialsPrices = {
|
||||
coffin: 2200,
|
||||
cremationCert: 350,
|
||||
crematorium: 660,
|
||||
deathReg: 70,
|
||||
dressing: 'included',
|
||||
govLevy: 45.1,
|
||||
mortuary: 440,
|
||||
service: 4034.9,
|
||||
transport: 'included',
|
||||
};
|
||||
|
||||
const rankinsForStep: PackageData[] = [
|
||||
{
|
||||
id: 'standard',
|
||||
name: 'Standard Cremation Package',
|
||||
price: sumEssentials(rankinsStandardEssentials),
|
||||
description: 'A balanced cremation package suitable for most families.',
|
||||
sections: [
|
||||
essentialsForStep(rankinsStandardEssentials),
|
||||
optionalsForStep([
|
||||
{ name: 'Digital Recording', treatment: 'unknown' },
|
||||
{ name: 'Online Notice', treatment: 'included' },
|
||||
{ name: 'Viewing Fee', treatment: 'included' },
|
||||
{ name: 'Flowers', treatment: 'included' },
|
||||
]),
|
||||
],
|
||||
extras: extrasForStep([
|
||||
{ name: 'Allowance for Celebrant', value: { allowance: 450 } },
|
||||
{ name: 'Catering', value: 'poa' },
|
||||
{ name: 'Saturday Service Fee', value: 750 },
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'premium',
|
||||
name: 'Premium Funeral Service',
|
||||
price: sumEssentials(rankinsPremiumEssentials),
|
||||
description: 'A more personalised service with venue and celebrant coordination.',
|
||||
sections: [
|
||||
essentialsForStep(rankinsPremiumEssentials),
|
||||
optionalsForStep([
|
||||
{ name: 'Digital Recording', treatment: 'included' },
|
||||
{ name: 'Online Notice', treatment: 'included' },
|
||||
{ name: 'Viewing Fee', treatment: 'included' },
|
||||
{ name: 'Flowers', treatment: 'included' },
|
||||
]),
|
||||
],
|
||||
extras: extrasForStep([
|
||||
{ name: 'Allowance for Celebrant', value: { allowance: 600 } },
|
||||
{ name: 'Catering', value: 950 },
|
||||
{ name: 'Newspaper Notice', value: 280 },
|
||||
]),
|
||||
},
|
||||
];
|
||||
|
||||
const rankinsForComparison: ComparisonPackage[] = rankinsForStep.map((pkg, idx) => {
|
||||
const ess = [rankinsStandardEssentials, rankinsPremiumEssentials][idx];
|
||||
const allOptionals: Optional[][] = [
|
||||
[
|
||||
{ name: 'Digital Recording', treatment: 'unknown' },
|
||||
{ name: 'Online Notice', treatment: 'included' },
|
||||
{ name: 'Viewing Fee', treatment: 'included' },
|
||||
{ name: 'Flowers', treatment: 'included' },
|
||||
],
|
||||
[
|
||||
{ name: 'Digital Recording', treatment: 'included' },
|
||||
{ name: 'Online Notice', treatment: 'included' },
|
||||
{ name: 'Viewing Fee', treatment: 'included' },
|
||||
{ name: 'Flowers', treatment: 'included' },
|
||||
],
|
||||
];
|
||||
const optionals = allOptionals[idx];
|
||||
const extras: Extra[] = [
|
||||
[
|
||||
{ name: 'Allowance for Celebrant', value: { allowance: 450 } },
|
||||
{ name: 'Catering', value: 'poa' as const },
|
||||
{ name: 'Saturday Service Fee', value: 750 },
|
||||
],
|
||||
[
|
||||
{ name: 'Allowance for Celebrant', value: { allowance: 600 } },
|
||||
{ name: 'Catering', value: 950 },
|
||||
{ name: 'Newspaper Notice', value: 280 },
|
||||
],
|
||||
][idx];
|
||||
return {
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
price: pkg.price,
|
||||
provider: {
|
||||
name: 'Rankins Funeral Services',
|
||||
location: 'Wollongong, NSW',
|
||||
logoUrl: providersById['rankins'].logoUrl,
|
||||
rating: 4.8,
|
||||
reviewCount: 23,
|
||||
verified: true,
|
||||
},
|
||||
sections: [
|
||||
essentialsForComparison(ess),
|
||||
optionalsForComparison(optionals),
|
||||
extrasForComparison(extras),
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Killick (verified, QLD — generic levy label) ────────────────────────────
|
||||
|
||||
const killickClassicEssentials: EssentialsPrices = {
|
||||
coffin: 1600,
|
||||
cremationCert: 350,
|
||||
crematorium: 660,
|
||||
deathReg: 70,
|
||||
dressing: 'complimentary',
|
||||
govLevy: 45.1,
|
||||
govLevyLabel: 'Government Levy — Cremation',
|
||||
mortuary: 440,
|
||||
service: 2614.9,
|
||||
transport: 'complimentary',
|
||||
};
|
||||
const killickSimpleEssentials: EssentialsPrices = {
|
||||
coffin: 1100,
|
||||
cremationCert: 350,
|
||||
crematorium: 660,
|
||||
deathReg: 70,
|
||||
dressing: 'complimentary',
|
||||
govLevy: 45.1,
|
||||
govLevyLabel: 'Government Levy — Cremation',
|
||||
mortuary: 440,
|
||||
service: 1534.9,
|
||||
transport: 'complimentary',
|
||||
};
|
||||
|
||||
const killickForStep: PackageData[] = [
|
||||
{
|
||||
id: 'classic',
|
||||
name: 'Classic Farewell Package',
|
||||
price: sumEssentials(killickClassicEssentials),
|
||||
description: 'A complete farewell service with chapel use and graveside committal.',
|
||||
sections: [
|
||||
essentialsForStep(killickClassicEssentials),
|
||||
optionalsForStep([
|
||||
{ name: 'Digital Recording', treatment: 'complimentary' },
|
||||
{ name: 'Online Notice', treatment: 'complimentary' },
|
||||
{ name: 'Viewing Fee', treatment: 'complimentary' },
|
||||
{ name: 'Flowers', treatment: 'unknown' },
|
||||
]),
|
||||
],
|
||||
extras: extrasForStep([
|
||||
{ name: 'Allowance for Celebrant', value: { allowance: 500 } },
|
||||
{ name: 'Catering', value: 'poa' },
|
||||
{ name: 'Saturday Service Fee', value: 800 },
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'simple',
|
||||
name: 'Simple Cremation',
|
||||
price: sumEssentials(killickSimpleEssentials),
|
||||
description: 'A direct cremation without a service.',
|
||||
sections: [
|
||||
essentialsForStep(killickSimpleEssentials),
|
||||
optionalsForStep([
|
||||
{ name: 'Online Notice', treatment: 'complimentary' },
|
||||
{ name: 'Viewing Fee', treatment: 'unknown' },
|
||||
]),
|
||||
],
|
||||
extras: extrasForStep([{ name: 'Allowance for Celebrant', value: { allowance: 350 } }]),
|
||||
},
|
||||
];
|
||||
|
||||
const killickForComparison: ComparisonPackage[] = killickForStep.map((pkg, idx) => {
|
||||
const ess = [killickClassicEssentials, killickSimpleEssentials][idx];
|
||||
const allOptionals: Optional[][] = [
|
||||
[
|
||||
{ name: 'Digital Recording', treatment: 'complimentary' },
|
||||
{ name: 'Online Notice', treatment: 'complimentary' },
|
||||
{ name: 'Viewing Fee', treatment: 'complimentary' },
|
||||
{ name: 'Flowers', treatment: 'unknown' },
|
||||
],
|
||||
[
|
||||
{ name: 'Online Notice', treatment: 'complimentary' },
|
||||
{ name: 'Viewing Fee', treatment: 'unknown' },
|
||||
],
|
||||
];
|
||||
const optionals = allOptionals[idx];
|
||||
const extras: Extra[] = [
|
||||
[
|
||||
{ name: 'Allowance for Celebrant', value: { allowance: 500 } },
|
||||
{ name: 'Catering', value: 'poa' as const },
|
||||
{ name: 'Saturday Service Fee', value: 800 },
|
||||
],
|
||||
[{ name: 'Allowance for Celebrant', value: { allowance: 350 } }],
|
||||
][idx];
|
||||
return {
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
price: pkg.price,
|
||||
provider: {
|
||||
name: 'Killick Family Funerals',
|
||||
location: 'Kingaroy, QLD',
|
||||
logoUrl: providersById['killick'].logoUrl,
|
||||
rating: 4.9,
|
||||
reviewCount: 15,
|
||||
verified: true,
|
||||
},
|
||||
sections: [
|
||||
essentialsForComparison(ess),
|
||||
optionalsForComparison(optionals),
|
||||
extrasForComparison(extras),
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Mackay (verified, NSW) ──────────────────────────────────────────────────
|
||||
|
||||
const mackayEverydayEssentials: EssentialsPrices = {
|
||||
coffin: 1500,
|
||||
cremationCert: 350,
|
||||
crematorium: 660,
|
||||
deathReg: 70,
|
||||
dressing: 'included',
|
||||
govLevy: 45.1,
|
||||
mortuary: 440,
|
||||
service: 2430.35,
|
||||
transport: 'included',
|
||||
};
|
||||
|
||||
const mackayForStep: PackageData[] = [
|
||||
{
|
||||
id: 'everyday',
|
||||
name: 'Everyday Funeral Package',
|
||||
price: sumEssentials(mackayEverydayEssentials),
|
||||
description: 'A complete funeral service with a chapel ceremony.',
|
||||
sections: [
|
||||
essentialsForStep(mackayEverydayEssentials),
|
||||
optionalsForStep([
|
||||
{ name: 'Digital Recording', treatment: 'unknown' },
|
||||
{ name: 'Online Notice', treatment: 'included' },
|
||||
{ name: 'Viewing Fee', treatment: 'included' },
|
||||
{ name: 'Flowers', treatment: 'included' },
|
||||
]),
|
||||
],
|
||||
extras: extrasForStep([
|
||||
{ name: 'Allowance for Celebrant', value: { allowance: 450 } },
|
||||
{ name: 'Catering', value: 'poa' },
|
||||
{ name: 'Saturday Service Fee', value: 750 },
|
||||
]),
|
||||
},
|
||||
];
|
||||
|
||||
const mackayForComparison: ComparisonPackage[] = mackayForStep.map((pkg) => ({
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
price: pkg.price,
|
||||
provider: {
|
||||
name: 'Mackay Family Funeral Directors',
|
||||
location: 'Ourimbah, NSW',
|
||||
logoUrl: providersById['mackay'].logoUrl,
|
||||
rating: 4.6,
|
||||
reviewCount: 87,
|
||||
verified: true,
|
||||
},
|
||||
sections: [
|
||||
essentialsForComparison(mackayEverydayEssentials),
|
||||
optionalsForComparison([
|
||||
{ name: 'Digital Recording', treatment: 'unknown' },
|
||||
{ name: 'Online Notice', treatment: 'included' },
|
||||
{ name: 'Viewing Fee', treatment: 'included' },
|
||||
{ name: 'Flowers', treatment: 'included' },
|
||||
]),
|
||||
extrasForComparison([
|
||||
{ name: 'Allowance for Celebrant', value: { allowance: 450 } },
|
||||
{ name: 'Catering', value: 'poa' },
|
||||
{ name: 'Saturday Service Fee', value: 750 },
|
||||
]),
|
||||
],
|
||||
}));
|
||||
|
||||
// ─── Mannings (verified, NSW) ────────────────────────────────────────────────
|
||||
|
||||
const manningsStandardEssentials: EssentialsPrices = {
|
||||
coffin: 1300,
|
||||
cremationCert: 350,
|
||||
crematorium: 660,
|
||||
deathReg: 70,
|
||||
dressing: 'included',
|
||||
govLevy: 45.1,
|
||||
mortuary: 440,
|
||||
service: 2114.9,
|
||||
transport: 'included',
|
||||
};
|
||||
|
||||
const manningsForStep: PackageData[] = [
|
||||
{
|
||||
id: 'standard',
|
||||
name: 'Standard Cremation Package',
|
||||
price: sumEssentials(manningsStandardEssentials),
|
||||
description: 'A respectful cremation with chapel service.',
|
||||
sections: [
|
||||
essentialsForStep(manningsStandardEssentials),
|
||||
optionalsForStep([
|
||||
{ name: 'Online Notice', treatment: 'included' },
|
||||
{ name: 'Viewing Fee', treatment: 'included' },
|
||||
{ name: 'Flowers', treatment: 'unknown' },
|
||||
]),
|
||||
],
|
||||
extras: extrasForStep([
|
||||
{ name: 'Allowance for Celebrant', value: { allowance: 400 } },
|
||||
{ name: 'Catering', value: 'poa' },
|
||||
]),
|
||||
},
|
||||
];
|
||||
|
||||
const manningsForComparison: ComparisonPackage[] = manningsForStep.map((pkg) => ({
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
price: pkg.price,
|
||||
provider: {
|
||||
name: 'Mannings Funerals',
|
||||
location: 'Bega, NSW',
|
||||
logoUrl: providersById['mannings'].logoUrl,
|
||||
rating: 4.7,
|
||||
reviewCount: 31,
|
||||
verified: true,
|
||||
},
|
||||
sections: [
|
||||
essentialsForComparison(manningsStandardEssentials),
|
||||
optionalsForComparison([
|
||||
{ name: 'Online Notice', treatment: 'included' },
|
||||
{ name: 'Viewing Fee', treatment: 'included' },
|
||||
{ name: 'Flowers', treatment: 'unknown' },
|
||||
]),
|
||||
extrasForComparison([
|
||||
{ name: 'Allowance for Celebrant', value: { allowance: 400 } },
|
||||
{ name: 'Catering', value: 'poa' },
|
||||
]),
|
||||
],
|
||||
}));
|
||||
|
||||
// ─── Wollongong City (tier 3 — itemised but unverified, mostly unknowns) ─────
|
||||
|
||||
// Tier-3 step view: simpler — show what we know, omit unknowns from breakdown.
|
||||
const wollongongForStep: PackageData[] = [
|
||||
{
|
||||
id: 'standard',
|
||||
name: 'Standard Funeral Service',
|
||||
price: 3400,
|
||||
description:
|
||||
'Itemised package based on publicly available information. Make an enquiry to confirm details.',
|
||||
sections: [
|
||||
{
|
||||
heading: 'Essentials (estimated)',
|
||||
items: [
|
||||
{ name: 'Allowance for Coffin', price: 1400, isAllowance: true },
|
||||
{ name: 'Cremation Certificate/Permit', price: 350 },
|
||||
{ name: 'Crematorium', price: 660 },
|
||||
{ name: 'Professional Service Fee', price: 990 },
|
||||
],
|
||||
},
|
||||
],
|
||||
total: 3400,
|
||||
},
|
||||
];
|
||||
|
||||
const wollongongForComparison: ComparisonPackage[] = [
|
||||
{
|
||||
id: 'standard',
|
||||
name: 'Standard Funeral Service',
|
||||
price: 3400,
|
||||
provider: {
|
||||
name: 'Wollongong City Funerals',
|
||||
location: 'Wollongong, NSW',
|
||||
rating: 4.2,
|
||||
reviewCount: 15,
|
||||
verified: false,
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
heading: 'Essentials',
|
||||
items: [
|
||||
{ name: 'Allowance for Coffin', value: { type: 'allowance', amount: 1400 } },
|
||||
{ name: 'Cremation Certificate/Permit', value: { type: 'price', amount: 350 } },
|
||||
{ name: 'Crematorium', value: { type: 'price', amount: 660 } },
|
||||
{ name: 'Death Registration Certificate', value: { type: 'unknown' } },
|
||||
{ name: 'Dressing Fee', value: { type: 'unknown' } },
|
||||
{ name: 'NSW Government Levy — Cremation', value: { type: 'unknown' } },
|
||||
{ name: 'Professional Mortuary Care', value: { type: 'unknown' } },
|
||||
{ name: 'Professional Service Fee', value: { type: 'price', amount: 990 } },
|
||||
{ name: 'Transportation Service Fee', value: { type: 'unknown' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Botanical (tier 2 — price only, no breakdown) ──────────────────────────
|
||||
|
||||
const botanicalForStep: PackageData[] = [
|
||||
{
|
||||
id: 'standard',
|
||||
name: 'Standard Funeral Service',
|
||||
price: 5200,
|
||||
description:
|
||||
'A full-service package based on publicly available information. Breakdown not available.',
|
||||
sections: [],
|
||||
},
|
||||
{
|
||||
id: 'basic',
|
||||
name: 'Basic Cremation',
|
||||
price: 3400,
|
||||
description: 'Entry-level package. Pricing is indicative only.',
|
||||
sections: [],
|
||||
},
|
||||
];
|
||||
|
||||
const botanicalForComparison: ComparisonPackage[] = [
|
||||
{
|
||||
id: 'standard',
|
||||
name: 'Standard Funeral Service',
|
||||
price: 5200,
|
||||
provider: {
|
||||
name: 'Botanical Funerals',
|
||||
location: 'Newtown, NSW',
|
||||
rating: 4.9,
|
||||
reviewCount: 8,
|
||||
verified: false,
|
||||
},
|
||||
itemizedAvailable: false,
|
||||
sections: [],
|
||||
},
|
||||
{
|
||||
id: 'basic',
|
||||
name: 'Basic Cremation',
|
||||
price: 3400,
|
||||
provider: {
|
||||
name: 'Botanical Funerals',
|
||||
location: 'Newtown, NSW',
|
||||
rating: 4.9,
|
||||
reviewCount: 8,
|
||||
verified: false,
|
||||
},
|
||||
itemizedAvailable: false,
|
||||
sections: [],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Bundle map ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Per-provider matching/other split. The "matching your preferences" list is
|
||||
// the recommended set; "other" is everything else from the same provider.
|
||||
// Tier-3/2 providers don't show an "other" list — they show nearby-verified
|
||||
// alternatives instead — so their `other` arrays stay empty.
|
||||
export const packagesByProvider: Record<string, PackageBundle> = {
|
||||
parsons: {
|
||||
matching: parsonsForStep.slice(0, 1),
|
||||
other: parsonsForStep.slice(1),
|
||||
forComparison: parsonsForComparison,
|
||||
},
|
||||
rankins: {
|
||||
matching: rankinsForStep.slice(0, 1),
|
||||
other: rankinsForStep.slice(1),
|
||||
forComparison: rankinsForComparison,
|
||||
},
|
||||
killick: {
|
||||
matching: killickForStep.slice(0, 1),
|
||||
other: killickForStep.slice(1),
|
||||
forComparison: killickForComparison,
|
||||
},
|
||||
mackay: {
|
||||
matching: mackayForStep,
|
||||
other: [],
|
||||
forComparison: mackayForComparison,
|
||||
},
|
||||
mannings: {
|
||||
matching: manningsForStep,
|
||||
other: [],
|
||||
forComparison: manningsForComparison,
|
||||
},
|
||||
'wollongong-city': {
|
||||
matching: wollongongForStep,
|
||||
other: [],
|
||||
forComparison: wollongongForComparison,
|
||||
},
|
||||
botanical: {
|
||||
matching: botanicalForStep,
|
||||
other: [],
|
||||
forComparison: botanicalForComparison,
|
||||
},
|
||||
};
|
||||
|
||||
/** Compound basket key: `${providerId}:${packageId}` */
|
||||
export type BasketKey = string;
|
||||
|
||||
export const makeBasketKey = (providerId: string, packageId: string): BasketKey =>
|
||||
`${providerId}:${packageId}`;
|
||||
|
||||
export const parseBasketKey = (
|
||||
key: BasketKey,
|
||||
): { providerId: string; packageId: string } | null => {
|
||||
const [providerId, packageId] = key.split(':');
|
||||
if (!providerId || !packageId) return null;
|
||||
return { providerId, packageId };
|
||||
};
|
||||
|
||||
/** Resolve a basket key to its ComparisonPackage, or null if missing. */
|
||||
export function resolveComparisonPackage(key: BasketKey): ComparisonPackage | null {
|
||||
const parsed = parseBasketKey(key);
|
||||
if (!parsed) return null;
|
||||
const bundle = packagesByProvider[parsed.providerId];
|
||||
if (!bundle) return null;
|
||||
return bundle.forComparison.find((p) => p.id === parsed.packageId) ?? null;
|
||||
}
|
||||
|
||||
/** "Nearby verified" cards shown under tier-3 / tier-2 lists. */
|
||||
export const nearbyVerifiedSamples: NearbyVerifiedPackage[] = [
|
||||
{
|
||||
id: 'rankins:standard',
|
||||
packageName: 'Standard Cremation Package',
|
||||
price: rankinsForStep[0].price,
|
||||
providerName: 'Rankins Funeral Services',
|
||||
location: 'Wollongong, NSW',
|
||||
rating: 4.8,
|
||||
reviewCount: 23,
|
||||
},
|
||||
{
|
||||
id: 'mannings:standard',
|
||||
packageName: 'Standard Cremation Package',
|
||||
price: manningsForStep[0].price,
|
||||
providerName: 'Mannings Funerals',
|
||||
location: 'Bega, NSW',
|
||||
rating: 4.7,
|
||||
reviewCount: 31,
|
||||
},
|
||||
{
|
||||
id: 'killick:classic',
|
||||
packageName: 'Classic Farewell Package',
|
||||
price: killickForStep[0].price,
|
||||
providerName: 'Killick Family Funerals',
|
||||
location: 'Kingaroy, QLD',
|
||||
rating: 4.9,
|
||||
reviewCount: 15,
|
||||
},
|
||||
];
|
||||
121
src/demo/shared/fixtures/providers.ts
Normal file
121
src/demo/shared/fixtures/providers.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { ProviderData } from '../../../components/pages/ProvidersStep';
|
||||
import type { PackagesStepProvider, ProviderTier } from '../../../components/pages/PackagesStep';
|
||||
|
||||
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: '/images/venues/hparsons-funeral-home-wollongong/01.jpg',
|
||||
logoUrl: '/images/providers/hparsons-funeral-directors/logo.png',
|
||||
rating: 4.6,
|
||||
reviewCount: 7,
|
||||
startingPrice: 1800,
|
||||
distanceKm: 2.3,
|
||||
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: '/images/venues/rankins-funeral-home-warrawong/01.jpg',
|
||||
logoUrl: '/images/providers/rankins-funerals/logo.png',
|
||||
rating: 4.8,
|
||||
reviewCount: 23,
|
||||
startingPrice: 2450,
|
||||
distanceKm: 5.1,
|
||||
},
|
||||
{
|
||||
id: 'wollongong-city',
|
||||
name: 'Wollongong City Funerals',
|
||||
location: 'Wollongong, NSW',
|
||||
verified: false,
|
||||
tier: 'tier3',
|
||||
rating: 4.2,
|
||||
reviewCount: 15,
|
||||
startingPrice: 3400,
|
||||
distanceKm: 6.8,
|
||||
},
|
||||
{
|
||||
id: 'killick',
|
||||
name: 'Killick Family Funerals',
|
||||
location: 'Kingaroy, QLD',
|
||||
verified: true,
|
||||
tier: 'verified',
|
||||
imageUrl: '/images/venues/killick-family-funerals-chapel-kingaroy/01.jpg',
|
||||
logoUrl: '/images/providers/killick-family-funerals/logo.png',
|
||||
rating: 4.9,
|
||||
reviewCount: 15,
|
||||
startingPrice: 3100,
|
||||
distanceKm: 8.4,
|
||||
},
|
||||
{
|
||||
id: 'mackay',
|
||||
name: 'Mackay Family Funeral Directors',
|
||||
location: 'Ourimbah, NSW',
|
||||
verified: true,
|
||||
tier: 'verified',
|
||||
imageUrl: '/images/venues/mackay-family-garden-estate/01.jpg',
|
||||
logoUrl: '/images/providers/mackay-family-funerals/logo.webp',
|
||||
rating: 4.6,
|
||||
reviewCount: 87,
|
||||
startingPrice: 2800,
|
||||
distanceKm: 18.2,
|
||||
},
|
||||
{
|
||||
id: 'mannings',
|
||||
name: 'Mannings Funerals',
|
||||
location: 'Bega, NSW',
|
||||
verified: true,
|
||||
tier: 'verified',
|
||||
imageUrl: '/images/venues/mannings-chapel/01.jpg',
|
||||
logoUrl: '/images/providers/mannings-funerals/logo.png',
|
||||
rating: 4.7,
|
||||
reviewCount: 31,
|
||||
startingPrice: 2600,
|
||||
distanceKm: 22.0,
|
||||
},
|
||||
{
|
||||
id: 'botanical',
|
||||
name: 'Botanical Funerals',
|
||||
location: 'Newtown, NSW',
|
||||
verified: false,
|
||||
tier: 'tier2',
|
||||
rating: 4.9,
|
||||
reviewCount: 8,
|
||||
startingPrice: 5200,
|
||||
distanceKm: 15.0,
|
||||
},
|
||||
];
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
59
src/demo/shared/state/useBasketUrlSync.ts
Normal file
59
src/demo/shared/state/useBasketUrlSync.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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 and external URL changes (back/forward, manual edits)
|
||||
* push back into the store.
|
||||
*/
|
||||
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)) {
|
||||
useComparisonBasket.getState().setAll(urlKeys);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
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]);
|
||||
}
|
||||
49
src/demo/shared/state/useComparisonBasket.ts
Normal file
49
src/demo/shared/state/useComparisonBasket.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
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;
|
||||
44
vite.demo.config.ts
Normal file
44
vite.demo.config.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
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,
|
||||
// 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