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,
+ },
+ };
+});