From 45d73759c185221b2fb304fe4bf33abe5db45fed Mon Sep 17 00:00:00 2001 From: Richie Date: Mon, 20 Apr 2026 14:55:21 +1000 Subject: [PATCH] Scaffold arrangement demo slice with CompareBar wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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// - 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 --- .gitignore | 4 + docs/reference/client-demo-hosting-plan.md | 218 +++++ package-lock.json | 90 +- package.json | 6 +- .../molecules/ProviderCard/ProviderCard.tsx | 5 +- src/demo/apps/arrangement/App.tsx | 22 + src/demo/apps/arrangement/AppCompareBar.tsx | 56 ++ src/demo/apps/arrangement/DemoNav.tsx | 30 + src/demo/apps/arrangement/index.html | 18 + src/demo/apps/arrangement/main.tsx | 23 + .../apps/arrangement/routes/Comparison.tsx | 65 ++ src/demo/apps/arrangement/routes/Packages.tsx | 63 ++ .../apps/arrangement/routes/Providers.tsx | 38 + src/demo/shared/fixtures/packages.ts | 908 ++++++++++++++++++ src/demo/shared/fixtures/providers.ts | 121 +++ src/demo/shared/state/useBasketUrlSync.ts | 59 ++ src/demo/shared/state/useComparisonBasket.ts | 49 + vite.demo.config.ts | 44 + 18 files changed, 1816 insertions(+), 3 deletions(-) create mode 100644 docs/reference/client-demo-hosting-plan.md create mode 100644 src/demo/apps/arrangement/App.tsx create mode 100644 src/demo/apps/arrangement/AppCompareBar.tsx create mode 100644 src/demo/apps/arrangement/DemoNav.tsx create mode 100644 src/demo/apps/arrangement/index.html create mode 100644 src/demo/apps/arrangement/main.tsx create mode 100644 src/demo/apps/arrangement/routes/Comparison.tsx create mode 100644 src/demo/apps/arrangement/routes/Packages.tsx create mode 100644 src/demo/apps/arrangement/routes/Providers.tsx create mode 100644 src/demo/shared/fixtures/packages.ts create mode 100644 src/demo/shared/fixtures/providers.ts create mode 100644 src/demo/shared/state/useBasketUrlSync.ts create mode 100644 src/demo/shared/state/useComparisonBasket.ts create mode 100644 vite.demo.config.ts diff --git a/.gitignore b/.gitignore index fbf9e4e..c0a1b2a 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/docs/reference/client-demo-hosting-plan.md b/docs/reference/client-demo-hosting-plan.md new file mode 100644 index 0000000..6bee41d --- /dev/null +++ b/docs/reference/client-demo-hosting-plan.md @@ -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/`, 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//`) 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/` + +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 # vite build -c vite.demo.config.ts --mode + deploy-demo.sh # rsync dist-demo// 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//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=$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 ~ ^/(?[^/]+)/ { + 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). diff --git a/package-lock.json b/package-lock.json index ca24ffa..4229368 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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 + } + } } } } diff --git a/package.json b/package.json index 6ce879e..dbeaaed 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/molecules/ProviderCard/ProviderCard.tsx b/src/components/molecules/ProviderCard/ProviderCard.tsx index 98e8793..463cf5c 100644 --- a/src/components/molecules/ProviderCard/ProviderCard.tsx +++ b/src/components/molecules/ProviderCard/ProviderCard.tsx @@ -172,7 +172,10 @@ export const ProviderCard = React.forwardRef( 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)', diff --git a/src/demo/apps/arrangement/App.tsx b/src/demo/apps/arrangement/App.tsx new file mode 100644 index 0000000..6f64d80 --- /dev/null +++ b/src/demo/apps/arrangement/App.tsx @@ -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 ( + <> + + } /> + } /> + } /> + } /> + + + + ); +} diff --git a/src/demo/apps/arrangement/AppCompareBar.tsx b/src/demo/apps/arrangement/AppCompareBar.tsx new file mode 100644 index 0000000..0629105 --- /dev/null +++ b/src/demo/apps/arrangement/AppCompareBar.tsx @@ -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 ( + navigate('/comparison')} + error={lastError ?? undefined} + /> + ); +} diff --git a/src/demo/apps/arrangement/DemoNav.tsx b/src/demo/apps/arrangement/DemoNav.tsx new file mode 100644 index 0000000..cb79bb8 --- /dev/null +++ b/src/demo/apps/arrangement/DemoNav.tsx @@ -0,0 +1,30 @@ +import Box from '@mui/material/Box'; +import { Navigation } from '../../../components/organisms/Navigation'; + +const FALogo = () => ( + + + + +); + +export const demoNav = ( + } + items={[ + { label: 'FAQ', href: '#' }, + { label: 'Contact Us', href: '#' }, + { label: 'Log in', href: '#' }, + ]} + /> +); diff --git a/src/demo/apps/arrangement/index.html b/src/demo/apps/arrangement/index.html new file mode 100644 index 0000000..b1a341e --- /dev/null +++ b/src/demo/apps/arrangement/index.html @@ -0,0 +1,18 @@ + + + + + + Arrangement Demo — Funeral Arranger + + + + + +
+ + + diff --git a/src/demo/apps/arrangement/main.tsx b/src/demo/apps/arrangement/main.tsx new file mode 100644 index 0000000..538f06e --- /dev/null +++ b/src/demo/apps/arrangement/main.tsx @@ -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( + + + + + + + + , +); diff --git a/src/demo/apps/arrangement/routes/Comparison.tsx b/src/demo/apps/arrangement/routes/Comparison.tsx new file mode 100644 index 0000000..ae49ed7 --- /dev/null +++ b/src/demo/apps/arrangement/routes/Comparison.tsx @@ -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> } => + x !== null, + ); + + if (packages.length === 0) { + return ( + + {demoNav} + + Nothing to compare yet + + Pick a provider, choose a package, then tap Compare. + + + + + ); + } + + return ( + 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} + /> + ); +} diff --git a/src/demo/apps/arrangement/routes/Packages.tsx b/src/demo/apps/arrangement/routes/Packages.tsx new file mode 100644 index 0000000..45c431e --- /dev/null +++ b/src/demo/apps/arrangement/routes/Packages.tsx @@ -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(bundle?.matching[0]?.id ?? null); + + if (!provider || !bundle) return ; + + // 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 ( + 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} + /> + ); +} diff --git a/src/demo/apps/arrangement/routes/Providers.tsx b/src/demo/apps/arrangement/routes/Providers.tsx new file mode 100644 index 0000000..7a83e93 --- /dev/null +++ b/src/demo/apps/arrangement/routes/Providers.tsx @@ -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(EMPTY_FILTER_VALUES); + const [sort, setSort] = useState('recommended'); + const [view, setView] = useState('list'); + + const filtered = providers.filter((p) => p.location.toLowerCase().includes(query.toLowerCase())); + + return ( + 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} + /> + ); +} diff --git a/src/demo/shared/fixtures/packages.ts b/src/demo/shared/fixtures/packages.ts new file mode 100644 index 0000000..744298e --- /dev/null +++ b/src/demo/shared/fixtures/packages.ts @@ -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((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((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 = { + 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, + }, +]; diff --git a/src/demo/shared/fixtures/providers.ts b/src/demo/shared/fixtures/providers.ts new file mode 100644 index 0000000..80a639c --- /dev/null +++ b/src/demo/shared/fixtures/providers.ts @@ -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 = providers.reduce( + (acc, p) => { + acc[p.id] = p; + return acc; + }, + {} as Record, +); + +/** + * 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, + }; +} diff --git a/src/demo/shared/state/useBasketUrlSync.ts b/src/demo/shared/state/useBasketUrlSync.ts new file mode 100644 index 0000000..fd872cb --- /dev/null +++ b/src/demo/shared/state/useBasketUrlSync.ts @@ -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]); +} diff --git a/src/demo/shared/state/useComparisonBasket.ts b/src/demo/shared/state/useComparisonBasket.ts new file mode 100644 index 0000000..a523d89 --- /dev/null +++ b/src/demo/shared/state/useComparisonBasket.ts @@ -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((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; diff --git a/vite.demo.config.ts b/vite.demo.config.ts new file mode 100644 index 0000000..7a440a3 --- /dev/null +++ b/vite.demo.config.ts @@ -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 ` 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 // 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, + }, + }; +});