From 6ac706f8e739405c54fc741bce390536a27943f3 Mon Sep 17 00:00:00 2001 From: Richie Date: Fri, 22 May 2026 14:32:16 +1000 Subject: [PATCH] Add arrangement demo site from original project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copies the multi-page demo app (Providers → Packages → Comparison flow) with Zustand basket state, URL sync, and per-slice Vite build config. All pages render correctly on React 19 + MUI v7 with zero code changes needed. Co-Authored-By: Claude Opus 4.6 --- package.json | 4 +- src/demo/apps/arrangement/App.tsx | 22 + src/demo/apps/arrangement/AppCompareBar.tsx | 56 + src/demo/apps/arrangement/DemoNav.tsx | 31 + src/demo/apps/arrangement/index.html | 18 + src/demo/apps/arrangement/main.tsx | 23 + .../apps/arrangement/routes/Comparison.tsx | 73 + src/demo/apps/arrangement/routes/Packages.tsx | 76 + .../apps/arrangement/routes/Providers.tsx | 45 + src/demo/shared/assets.ts | 17 + src/demo/shared/fixtures/packages.ts | 1276 +++++++++++++++++ src/demo/shared/fixtures/providers.ts | 129 ++ src/demo/shared/state/useBasketUrlSync.ts | 82 ++ src/demo/shared/state/useComparisonBasket.ts | 49 + vite.demo.config.ts | 41 + 15 files changed, 1941 insertions(+), 1 deletion(-) 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/assets.ts 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/package.json b/package.json index 04e086b..99a533a 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,9 @@ "format": "prettier --write 'src/**/*.{ts,tsx}'", "format:check": "prettier --check 'src/**/*.{ts,tsx}'", "test": "vitest run --passWithNoTests", - "test:watch": "vitest" + "test:watch": "vitest", + "demo:dev": "vite -c vite.demo.config.ts --mode arrangement", + "demo:build": "vite build -c vite.demo.config.ts --mode arrangement" }, "dependencies": { "@emotion/react": "^11.13.0", 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..a9c1f2d --- /dev/null +++ b/src/demo/apps/arrangement/DemoNav.tsx @@ -0,0 +1,31 @@ +import Box from '@mui/material/Box'; +import { Navigation } from '../../../components/organisms/Navigation'; +import { assetUrl } from '../../shared/assets'; + +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..f943aef --- /dev/null +++ b/src/demo/apps/arrangement/routes/Comparison.tsx @@ -0,0 +1,73 @@ +import { useNavigate } from 'react-router-dom'; +import Box from '@mui/material/Box'; +import { ComparisonPage } from '../../../../components/pages/ComparisonPage'; +import { Typography } from '../../../../components/atoms/Typography'; +import { Button } from '../../../../components/atoms/Button'; +import { useComparisonBasket } from '../../../shared/state/useComparisonBasket'; +import { resolveComparisonPackage, DEMO_RECOMMENDED_KEY } from '../../../shared/fixtures/packages'; +import { demoNav } from '../DemoNav'; + +export function ComparisonRoute() { + const navigate = useNavigate(); + const packageKeys = useComparisonBasket((s) => s.packageKeys); + const remove = useComparisonBasket((s) => s.remove); + + // The system-recommended package is shown as an extra column on top of + // the user's basket. Dedupe against the basket so it never renders twice. + const recommendedPackage = resolveComparisonPackage(DEMO_RECOMMENDED_KEY) ?? undefined; + + const packages = packageKeys + .filter((key) => key !== DEMO_RECOMMENDED_KEY) + .map((key) => { + const resolved = resolveComparisonPackage(key); + return resolved ? { key, pkg: resolved } : null; + }) + .filter( + (x): x is { key: string; pkg: NonNullable> } => + x !== null, + ); + + // Empty state only when there's genuinely nothing to show — normally the + // recommended package will always resolve, so this branch is defensive. + if (packages.length === 0 && !recommendedPackage) { + return ( + + {demoNav} + + Nothing to compare yet + + Pick a provider, choose a package, then tap Compare. + + + + + ); + } + + return ( + p.pkg)} + recommendedPackage={recommendedPackage} + onArrange={(id) => alert(`Arrange "${id}" — would route to next wizard step.`)} + onRemove={(id) => { + // ComparisonPackage.id is the bare package id; we need the basket's + // compound key. Find it back via the parallel array. + const entry = packages.find((p) => p.pkg.id === id); + if (entry) remove(entry.key); + }} + onBack={() => navigate(-1)} + navigation={demoNav} + /> + ); +} diff --git a/src/demo/apps/arrangement/routes/Packages.tsx b/src/demo/apps/arrangement/routes/Packages.tsx new file mode 100644 index 0000000..16df26a --- /dev/null +++ b/src/demo/apps/arrangement/routes/Packages.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react'; +import { Navigate, useNavigate, useParams } from 'react-router-dom'; +import { PackagesStep } from '../../../../components/pages/PackagesStep'; +import { providersById, toPackagesStepProvider } from '../../../shared/fixtures/providers'; +import { + packagesByProvider, + makeBasketKey, + nearbyVerifiedProviders, +} from '../../../shared/fixtures/packages'; +import { useComparisonBasket } from '../../../shared/state/useComparisonBasket'; +import { demoNav } from '../DemoNav'; + +export function PackagesRoute() { + const { providerId = '' } = useParams(); + const navigate = useNavigate(); + const provider = providersById[providerId]; + const bundle = packagesByProvider[providerId]; + const basket = useComparisonBasket(); + + const [selectedId, setSelectedId] = useState(bundle?.matching[0]?.id ?? null); + + if (!provider || !bundle) return ; + + // Compare CTA on the PackageDetail panel toggles the selection in the + // basket — adds when absent, removes when present. The button's visible + // state (Compare / Added + ✓) reflects `isSelectedInCart` below. The + // floating CompareBar (mounted in App.tsx) handles navigation once the + // user has 2+ packages selected. + const handleCompare = () => { + if (selectedId) basket.toggle(makeBasketKey(provider.id, selectedId)); + }; + + // When the selected package is already in the basket, PackageDetail swaps + // the Compare button into its "In comparison" selected state. + const isSelectedInCart = selectedId ? basket.has(makeBasketKey(provider.id, selectedId)) : false; + + // Tier-3 / tier-2 providers show verified-provider MiniCards instead of + // "more from this provider". Exclude the current provider from the + // "similar" list in case we ever add a verified id that collides. + const secondaryList = + provider.tier === 'verified' + ? { kind: 'same-provider-more' as const, packages: bundle.other } + : { + kind: 'nearby-verified' as const, + providers: nearbyVerifiedProviders.filter((p) => p.id !== provider.id), + }; + + const secondaryHasItems = + secondaryList.kind === 'same-provider-more' + ? secondaryList.packages.length > 0 + : secondaryList.providers.length > 0; + + return ( + + alert( + provider.tier === 'verified' + ? 'Make Arrangement — would route to next wizard step.' + : 'Make an enquiry — would open enquiry form.', + ) + } + onCompare={handleCompare} + isSelectedPackageInCart={isSelectedInCart} + onNearbyProviderClick={(id) => navigate(`/providers/${id}/packages`)} + onProviderClick={() => alert('Provider profile — not built in this demo slice.')} + onBack={() => navigate('/')} + navigation={demoNav} + /> + ); +} diff --git a/src/demo/apps/arrangement/routes/Providers.tsx b/src/demo/apps/arrangement/routes/Providers.tsx new file mode 100644 index 0000000..c4cc552 --- /dev/null +++ b/src/demo/apps/arrangement/routes/Providers.tsx @@ -0,0 +1,45 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + ProvidersStep, + EMPTY_FILTER_VALUES, + type ProviderFilterValues, + type ProviderSortBy, + type ListViewMode, +} from '../../../../components/pages/ProvidersStep'; +import { ProviderMap } from '../../../../components/organisms/ProviderMap'; +import { providers } from '../../../shared/fixtures/providers'; +import { demoNav } from '../DemoNav'; + +export function ProvidersRoute() { + const navigate = useNavigate(); + const [query, setQuery] = useState(''); + const [filters, setFilters] = useState(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} + mapPanel={ + navigate(`/providers/${id}/packages`)} + /> + } + /> + ); +} diff --git a/src/demo/shared/assets.ts b/src/demo/shared/assets.ts new file mode 100644 index 0000000..e05e305 --- /dev/null +++ b/src/demo/shared/assets.ts @@ -0,0 +1,17 @@ +/** + * Resolve a public-asset path against Vite's base URL. + * + * In dev `import.meta.env.BASE_URL === '/'`, so `assetUrl('/images/foo.png')` + * returns `/images/foo.png` unchanged. In production the build sets base to + * `/arrangement/` (or whatever `--mode ` was passed), and the same + * call returns `/arrangement/images/foo.png` so the bundled assets resolve + * correctly under the slice subpath. + * + * Always pass leading-slash paths — they're relative to the publicDir root. + */ +export const assetUrl = (path: string): string => { + const base = import.meta.env.BASE_URL; + const cleanBase = base.endsWith('/') ? base.slice(0, -1) : base; + const cleanPath = path.startsWith('/') ? path : `/${path}`; + return `${cleanBase}${cleanPath}`; +}; diff --git a/src/demo/shared/fixtures/packages.ts b/src/demo/shared/fixtures/packages.ts new file mode 100644 index 0000000..2abfae3 --- /dev/null +++ b/src/demo/shared/fixtures/packages.ts @@ -0,0 +1,1276 @@ +import type { PackageData, NearbyVerifiedProvider } from '../../../components/pages/PackagesStep'; +import type { PackageSection, PackageLineItem } from '../../../components/organisms/PackageDetail'; +import type { + ComparisonPackage, + ComparisonSection, +} from '../../../components/organisms/ComparisonTable'; +import { providers, 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 ─────────────────────────────────────────────────────── +// +// Rule: Optionals an Extras list an item ONLY when that package actually +// offers it. Missing items are not placeholders — they're just absent. In +// PackageDetail that means the row isn't rendered. In ComparisonTable the +// cross-join (see `buildMergedSections` + `lookupValue`) surfaces the row +// as "Not Included" for any column that doesn't include it. + +interface Optional { + name: string; + treatment: IncludedTreatment; +} + +const optionalsForStep = (items: Optional[]): PackageSection => ({ + heading: 'Optionals', + items: items.map((it) => ({ + 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 }, + })), +}); + +// ─── 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 parsonsTraditionalEssentials: EssentialsPrices = { + coffin: 2200, + cremationCert: 350, + crematorium: 660, + deathReg: 70, + dressing: 'complimentary', + govLevy: 45.1, + mortuary: 440, + service: 4534.9, + transport: 'complimentary', +}; +const parsonsMemorialEssentials: EssentialsPrices = { + coffin: 900, + cremationCert: 350, + crematorium: 660, + deathReg: 70, + dressing: 'complimentary', + govLevy: 45.1, + mortuary: 440, + service: 3034.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' }, + ]), + }, + { + id: 'traditional-burial', + name: 'Traditional Burial Package', + price: sumEssentials(parsonsTraditionalEssentials), + description: + 'A traditional funeral service followed by burial, including a full procession and graveside committal.', + sections: [ + essentialsForStep(parsonsTraditionalEssentials), + 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: 600 } }, + { name: 'Catering', value: 1100 }, + { name: 'Newspaper Notice', value: 350 }, + { name: 'Saturday Service Fee', value: 880 }, + ]), + }, + { + id: 'memorial-service', + name: 'Memorial Service Package', + price: sumEssentials(parsonsMemorialEssentials), + description: + 'A memorial service held after the cremation, giving families time to plan a personalised gathering.', + sections: [ + essentialsForStep(parsonsMemorialEssentials), + optionalsForStep([ + { name: 'Online Notice', treatment: 'complimentary' }, + { name: 'Viewing Fee', treatment: 'complimentary' }, + { name: 'Flowers', treatment: 'complimentary' }, + ]), + ], + extras: extrasForStep([ + { name: 'Allowance for Celebrant', value: { allowance: 500 } }, + { name: 'Catering', value: 'poa' }, + { name: 'Newspaper Notice', value: 280 }, + ]), + }, +]; + +const parsonsForComparison: ComparisonPackage[] = parsonsForStep.map((pkg, idx) => { + const ess = [ + parsonsEverydayEssentials, + parsonsEssentialEssentials, + parsonsDeluxeEssentials, + parsonsTraditionalEssentials, + parsonsMemorialEssentials, + ][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' }, + ], + [ + { 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: 'Flowers', 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 }, + ], + [ + { name: 'Allowance for Celebrant', value: { allowance: 600 } }, + { name: 'Catering', value: 1100 }, + { name: 'Newspaper Notice', value: 350 }, + { name: 'Saturday Service Fee', value: 880 }, + ], + [ + { name: 'Allowance for Celebrant', value: { allowance: 500 } }, + { name: 'Catering', value: 'poa' as const }, + { name: 'Newspaper Notice', value: 280 }, + ], + ][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 rankinsDirectEssentials: EssentialsPrices = { + coffin: 800, + cremationCert: 350, + crematorium: 660, + deathReg: 70, + dressing: 'included', + govLevy: 45.1, + mortuary: 440, + service: 434.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: '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 }, + ]), + }, + { + id: 'direct-cremation', + name: 'Direct Cremation', + price: sumEssentials(rankinsDirectEssentials), + description: 'An unattended cremation with no service — the lowest-cost option.', + sections: [essentialsForStep(rankinsDirectEssentials), optionalsForStep([])], + extras: extrasForStep([]), + }, +]; + +const rankinsForComparison: ComparisonPackage[] = rankinsForStep.map((pkg, idx) => { + const ess = [rankinsStandardEssentials, rankinsPremiumEssentials, rankinsDirectEssentials][idx]; + const allOptionals: Optional[][] = [ + [ + { 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 killickTraditionalEssentials: EssentialsPrices = { + coffin: 2500, + cremationCert: 350, + crematorium: 660, + deathReg: 70, + dressing: 'complimentary', + govLevy: 45.1, + govLevyLabel: 'Government Levy — Cremation', + mortuary: 440, + service: 4234.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' }, + ]), + ], + 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' }]), + ], + extras: extrasForStep([{ name: 'Allowance for Celebrant', value: { allowance: 350 } }]), + }, + { + id: 'traditional-burial', + name: 'Traditional Burial Package', + price: sumEssentials(killickTraditionalEssentials), + description: + 'A traditional burial service with chapel use, full procession, and graveside committal.', + sections: [ + essentialsForStep(killickTraditionalEssentials), + optionalsForStep([ + { name: 'Digital Recording', treatment: 'complimentary' }, + { name: 'Online Notice', treatment: 'complimentary' }, + { name: 'Viewing Fee', treatment: 'complimentary' }, + ]), + ], + extras: extrasForStep([ + { name: 'Allowance for Celebrant', value: { allowance: 550 } }, + { name: 'Catering', value: 'poa' }, + { name: 'Saturday Service Fee', value: 800 }, + ]), + }, +]; + +const killickForComparison: ComparisonPackage[] = killickForStep.map((pkg, idx) => { + const ess = [killickClassicEssentials, killickSimpleEssentials, killickTraditionalEssentials][ + idx + ]; + const allOptionals: Optional[][] = [ + [ + { name: 'Digital Recording', treatment: 'complimentary' }, + { name: 'Online Notice', treatment: 'complimentary' }, + { name: 'Viewing Fee', treatment: 'complimentary' }, + ], + [{ name: 'Online Notice', treatment: 'complimentary' }], + [ + { name: 'Digital Recording', treatment: 'complimentary' }, + { name: 'Online Notice', treatment: 'complimentary' }, + { name: 'Viewing Fee', treatment: 'complimentary' }, + ], + ]; + 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 } }], + [ + { name: 'Allowance for Celebrant', value: { allowance: 550 } }, + { name: 'Catering', value: 'poa' as const }, + { name: 'Saturday Service Fee', value: 800 }, + ], + ][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 mackayPremiumEssentials: EssentialsPrices = { + coffin: 2200, + cremationCert: 350, + crematorium: 660, + deathReg: 70, + dressing: 'included', + govLevy: 45.1, + mortuary: 440, + service: 4034.9, + transport: 'included', +}; +const mackaySimpleEssentials: EssentialsPrices = { + coffin: 800, + cremationCert: 350, + crematorium: 660, + deathReg: 70, + dressing: 'included', + govLevy: 45.1, + mortuary: 440, + service: 434.9, + transport: 'included', +}; +const mackayMemorialEssentials: EssentialsPrices = { + coffin: 900, + cremationCert: 350, + crematorium: 660, + deathReg: 70, + dressing: 'included', + govLevy: 45.1, + mortuary: 440, + service: 2834.9, + 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: '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(mackayPremiumEssentials), + description: 'An enhanced service with premium coffin selection and expanded inclusions.', + sections: [ + essentialsForStep(mackayPremiumEssentials), + 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: 1100 }, + { name: 'Newspaper Notice', value: 320 }, + ]), + }, + { + id: 'simple', + name: 'Simple Cremation', + price: sumEssentials(mackaySimpleEssentials), + description: 'A direct cremation without a formal service.', + sections: [ + essentialsForStep(mackaySimpleEssentials), + optionalsForStep([{ name: 'Online Notice', treatment: 'included' }]), + ], + extras: extrasForStep([{ name: 'Allowance for Celebrant', value: { allowance: 350 } }]), + }, + { + id: 'memorial-service', + name: 'Memorial Service Package', + price: sumEssentials(mackayMemorialEssentials), + description: + 'A memorial service held separately from the cremation, allowing time for planning and family gatherings.', + sections: [ + essentialsForStep(mackayMemorialEssentials), + optionalsForStep([ + { name: 'Online Notice', treatment: 'included' }, + { name: 'Viewing Fee', treatment: 'included' }, + ]), + ], + extras: extrasForStep([ + { name: 'Allowance for Celebrant', value: { allowance: 500 } }, + { name: 'Catering', value: 'poa' }, + ]), + }, +]; + +const mackayForComparison: ComparisonPackage[] = mackayForStep.map((pkg, idx) => { + const ess = [ + mackayEverydayEssentials, + mackayPremiumEssentials, + mackaySimpleEssentials, + mackayMemorialEssentials, + ][idx]; + const allOptionals: Optional[][] = [ + [ + { 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' }, + ], + [{ name: 'Online Notice', treatment: 'included' }], + [ + { name: 'Online Notice', treatment: 'included' }, + { name: 'Viewing Fee', 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: 1100 }, + { name: 'Newspaper Notice', value: 320 }, + ], + [{ name: 'Allowance for Celebrant', value: { allowance: 350 } }], + [ + { name: 'Allowance for Celebrant', value: { allowance: 500 } }, + { name: 'Catering', value: 'poa' as const }, + ], + ][idx]; + return { + 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(ess), + optionalsForComparison(optionals), + extrasForComparison(extras), + ], + }; +}); + +// ─── 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 manningsPremiumEssentials: EssentialsPrices = { + coffin: 2100, + cremationCert: 350, + crematorium: 660, + deathReg: 70, + dressing: 'included', + govLevy: 45.1, + mortuary: 440, + service: 3734.9, + transport: 'included', +}; +const manningsSimpleEssentials: EssentialsPrices = { + coffin: 750, + cremationCert: 350, + crematorium: 660, + deathReg: 70, + dressing: 'included', + govLevy: 45.1, + mortuary: 440, + service: 284.9, + transport: 'included', +}; +const manningsDirectEssentials: EssentialsPrices = { + coffin: 600, + cremationCert: 350, + crematorium: 660, + deathReg: 70, + dressing: 'included', + govLevy: 45.1, + mortuary: 440, + service: 34.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' }, + ]), + ], + extras: extrasForStep([ + { name: 'Allowance for Celebrant', value: { allowance: 400 } }, + { name: 'Catering', value: 'poa' }, + ]), + }, + { + id: 'premium', + name: 'Premium Funeral Service', + price: sumEssentials(manningsPremiumEssentials), + description: 'A more elaborate service with an upgraded coffin and broader inclusions.', + sections: [ + essentialsForStep(manningsPremiumEssentials), + 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 }, + ]), + }, + { + id: 'simple', + name: 'Simple Cremation', + price: sumEssentials(manningsSimpleEssentials), + description: 'A straightforward cremation without a chapel service.', + sections: [ + essentialsForStep(manningsSimpleEssentials), + optionalsForStep([{ name: 'Online Notice', treatment: 'included' }]), + ], + extras: extrasForStep([{ name: 'Allowance for Celebrant', value: { allowance: 350 } }]), + }, + { + id: 'direct-cremation', + name: 'Direct Cremation', + price: sumEssentials(manningsDirectEssentials), + description: 'An unattended cremation with no service — the lowest-cost option.', + sections: [essentialsForStep(manningsDirectEssentials), optionalsForStep([])], + extras: extrasForStep([]), + }, +]; + +const manningsForComparison: ComparisonPackage[] = manningsForStep.map((pkg, idx) => { + const ess = [ + manningsStandardEssentials, + manningsPremiumEssentials, + manningsSimpleEssentials, + manningsDirectEssentials, + ][idx]; + const allOptionals: Optional[][] = [ + [ + { name: 'Online Notice', treatment: 'included' }, + { name: 'Viewing Fee', treatment: 'included' }, + ], + [ + { name: 'Digital Recording', treatment: 'included' }, + { name: 'Online Notice', treatment: 'included' }, + { name: 'Viewing Fee', treatment: 'included' }, + { name: 'Flowers', treatment: 'included' }, + ], + [{ name: 'Online Notice', treatment: 'included' }], + [], + ]; + const optionals = allOptionals[idx]; + const extras: Extra[] = [ + [ + { name: 'Allowance for Celebrant', value: { allowance: 400 } }, + { name: 'Catering', value: 'poa' as const }, + ], + [ + { name: 'Allowance for Celebrant', value: { allowance: 600 } }, + { name: 'Catering', value: 950 }, + { name: 'Newspaper Notice', value: 280 }, + ], + [{ name: 'Allowance for Celebrant', value: { allowance: 350 } }], + [], + ][idx]; + return { + 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(ess), + optionalsForComparison(optionals), + extrasForComparison(extras), + ], + }; +}); + +// ─── 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.slice(0, 1), + other: mackayForStep.slice(1), + forComparison: mackayForComparison, + }, + mannings: { + matching: manningsForStep.slice(0, 1), + other: manningsForStep.slice(1), + 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; +} + +/** + * Demo recommendation: the package ComparisonPage always surfaces as the + * system-recommended option. Not part of the user's basket and doesn't + * count against the 3-package basket cap — it's an editorial suggestion + * layered on top of whatever the user has selected. If the same package + * is already in the basket, the Comparison route dedupes so it appears + * once (as the recommended column). + */ +export const DEMO_RECOMMENDED_KEY: BasketKey = 'parsons:deluxe'; + +/** + * Verified providers surfaced under the "Similar packages from verified + * providers" grid on unverified tier-2 / tier-3 pages. Derived from the + * main `providers` fixture filtered to tier === 'verified', with + * `startingPrice` taken from their first matching package. Only providers + * that actually have an image in the fixture are eligible (MiniCard + * requires `imageUrl`). + */ +export const nearbyVerifiedProviders: NearbyVerifiedProvider[] = providers + .filter((p) => p.tier === 'verified' && p.imageUrl) + .map((p) => ({ + id: p.id, + name: p.name, + imageUrl: p.imageUrl!, + location: p.location, + startingPrice: packagesByProvider[p.id]?.matching[0]?.price ?? p.startingPrice ?? 0, + rating: p.rating, + reviewCount: p.reviewCount, + })); diff --git a/src/demo/shared/fixtures/providers.ts b/src/demo/shared/fixtures/providers.ts new file mode 100644 index 0000000..9450413 --- /dev/null +++ b/src/demo/shared/fixtures/providers.ts @@ -0,0 +1,129 @@ +import type { ProviderData } from '../../../components/pages/ProvidersStep'; +import type { PackagesStepProvider, ProviderTier } from '../../../components/pages/PackagesStep'; +import { assetUrl } from '../assets'; + +export interface DemoProvider extends ProviderData { + id: string; + tier: ProviderTier; +} + +export const providers: DemoProvider[] = [ + { + id: 'parsons', + name: 'H.Parsons Funeral Directors', + location: 'Wentworth, NSW', + verified: true, + tier: 'verified', + imageUrl: assetUrl('/images/venues/hparsons-funeral-home-wollongong/01.jpg'), + logoUrl: assetUrl('/images/providers/hparsons-funeral-directors/logo.png'), + rating: 4.6, + reviewCount: 7, + startingPrice: 1800, + distanceKm: 2.3, + coords: { lat: -34.1074, lng: 141.9166 }, + description: + 'H.Parsons delivers premium funeral services with exceptional care and support, guiding families through every step with empathy and expertise.', + }, + { + id: 'rankins', + name: 'Rankins Funeral Services', + location: 'Wollongong, NSW', + verified: true, + tier: 'verified', + imageUrl: assetUrl('/images/venues/rankins-funeral-home-warrawong/01.jpg'), + logoUrl: assetUrl('/images/providers/rankins-funerals/logo.png'), + rating: 4.8, + reviewCount: 23, + startingPrice: 2450, + distanceKm: 5.1, + coords: { lat: -34.487, lng: 150.897 }, + }, + { + id: 'wollongong-city', + name: 'Wollongong City Funerals', + location: 'Wollongong, NSW', + verified: false, + tier: 'tier3', + rating: 4.2, + reviewCount: 15, + startingPrice: 3400, + distanceKm: 6.8, + coords: { lat: -34.4278, lng: 150.8931 }, + }, + { + id: 'killick', + name: 'Killick Family Funerals', + location: 'Kingaroy, QLD', + verified: true, + tier: 'verified', + imageUrl: assetUrl('/images/venues/killick-family-funerals-chapel-kingaroy/01.jpg'), + logoUrl: assetUrl('/images/providers/killick-family-funerals/logo.png'), + rating: 4.9, + reviewCount: 15, + startingPrice: 3100, + distanceKm: 8.4, + coords: { lat: -26.5408, lng: 151.8388 }, + }, + { + id: 'mackay', + name: 'Mackay Family Funeral Directors', + location: 'Ourimbah, NSW', + verified: true, + tier: 'verified', + imageUrl: assetUrl('/images/venues/mackay-family-garden-estate/01.jpg'), + logoUrl: assetUrl('/images/providers/mackay-family-funerals/logo.webp'), + rating: 4.6, + reviewCount: 87, + startingPrice: 2800, + distanceKm: 18.2, + coords: { lat: -33.3644, lng: 151.3728 }, + }, + { + id: 'mannings', + name: 'Mannings Funerals', + location: 'Bega, NSW', + verified: true, + tier: 'verified', + imageUrl: assetUrl('/images/venues/mannings-chapel/01.jpg'), + logoUrl: assetUrl('/images/providers/mannings-funerals/logo.png'), + rating: 4.7, + reviewCount: 31, + startingPrice: 2600, + distanceKm: 22.0, + coords: { lat: -36.6742, lng: 149.8417 }, + }, + { + id: 'botanical', + name: 'Botanical Funerals', + location: 'Newtown, NSW', + verified: false, + tier: 'tier2', + rating: 4.9, + reviewCount: 8, + startingPrice: 5200, + distanceKm: 15.0, + coords: { lat: -33.8988, lng: 151.1794 }, + }, +]; + +export const providersById: Record = 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..98c641d --- /dev/null +++ b/src/demo/shared/state/useBasketUrlSync.ts @@ -0,0 +1,82 @@ +import { useEffect, useRef } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { useComparisonBasket } from './useComparisonBasket'; + +const PARAM = 'compare'; + +const serialise = (keys: string[]): string => keys.join(','); +const deserialise = (raw: string | null): string[] => + raw + ? raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : []; + +/** + * Two-way sync between the basket store and the `?compare=a:b,c:d` search param. + * + * Mount once near the router root. URL is the source of truth on initial load + * (so a shared link restores the basket); after that, store changes write + * through to the URL so the current basket is always shareable. + * + * In-app navigation from a page that carries `?compare=...` to one that + * doesn't (e.g. Back from PackagesStep to the providers map) would drop the + * param — to avoid wiping the store, we re-attach the store's keys to the + * new URL instead of treating the empty URL as a "clear" signal. External + * URL changes that DO carry params still push back into the store (shared + * links, manual edits, browser Back after a store write). + */ +export function useBasketUrlSync(): void { + const [searchParams, setSearchParams] = useSearchParams(); + const initialised = useRef(false); + + useEffect(() => { + const urlKeys = deserialise(searchParams.get(PARAM)); + const storeKeys = useComparisonBasket.getState().packageKeys; + + if (!initialised.current) { + initialised.current = true; + if (urlKeys.length > 0 && serialise(urlKeys) !== serialise(storeKeys)) { + useComparisonBasket.getState().setAll(urlKeys); + } + return; + } + + if (serialise(urlKeys) === serialise(storeKeys)) return; + + // URL empty + store has items → in-app navigation dropped the param. + // Re-attach the store's keys so the basket stays sticky across routes + // (and the current URL remains shareable). + if (urlKeys.length === 0 && storeKeys.length > 0) { + setSearchParams( + (current) => { + const next = new URLSearchParams(current); + next.set(PARAM, serialise(storeKeys)); + return next; + }, + { replace: true }, + ); + return; + } + + // Otherwise URL is authoritative (shared link, manual edit, browser Back + // after a store write) — push it into the store. + useComparisonBasket.getState().setAll(urlKeys); + }, [searchParams, setSearchParams]); + + useEffect(() => { + return useComparisonBasket.subscribe((state, prev) => { + if (serialise(state.packageKeys) === serialise(prev.packageKeys)) return; + setSearchParams( + (current) => { + const next = new URLSearchParams(current); + if (state.packageKeys.length === 0) next.delete(PARAM); + else next.set(PARAM, serialise(state.packageKeys)); + return next; + }, + { replace: true }, + ); + }); + }, [setSearchParams]); +} 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..5da45d8 --- /dev/null +++ b/vite.demo.config.ts @@ -0,0 +1,41 @@ +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, + envDir: __dirname, + base: command === 'build' ? `/${slice}/` : '/', + 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, + }, + }; +});