Scaffold arrangement demo slice with CompareBar wiring

Add a self-contained demo build target for the Providers → Packages →
Comparison flow, deployable as a static SPA at /arrangement/.

- vite.demo.config.ts: per-slice build via --mode, base path flips for
  dev vs prod, output to dist-demo/<slice>/
- src/demo/: shared fixtures (7 providers across verified/tier3/tier2
  with real venue photography from brandassets) + Zustand basket store
  with ?compare= URL persistence
- Verified-provider packages now share the nine canonical Essentials
  line items per FA convention; only Optionals/Extras vary
- App-level CompareBar surfaces "Already added" / "Maximum 3" feedback
  via transient store error
- ProviderCard logo objectFit cover→contain so wide logos don't crop
- npm scripts demo:dev / demo:build, deps zustand + react-router-dom
This commit is contained in:
2026-04-20 14:55:21 +10:00
parent e67872cb6a
commit 45d73759c1
18 changed files with 1816 additions and 3 deletions

View File

@@ -172,7 +172,10 @@ export const ProviderCard = React.forwardRef<HTMLDivElement, ProviderCardProps>(
width: LOGO_SIZE,
height: LOGO_SIZE,
borderRadius: LOGO_BORDER_RADIUS,
objectFit: 'cover',
// 'contain' so wide/tall logos scale proportionally inside
// the square slot rather than cropping. Background fills any
// letterboxed space so it still reads as a tile.
objectFit: 'contain',
backgroundColor: 'background.paper',
boxShadow: 'var(--fa-shadow-sm)',
border: '2px solid var(--fa-color-white)',

View File

@@ -0,0 +1,22 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useBasketUrlSync } from '../../shared/state/useBasketUrlSync';
import { ProvidersRoute } from './routes/Providers';
import { PackagesRoute } from './routes/Packages';
import { ComparisonRoute } from './routes/Comparison';
import { AppCompareBar } from './AppCompareBar';
export function App() {
useBasketUrlSync();
return (
<>
<Routes>
<Route path="/" element={<ProvidersRoute />} />
<Route path="/providers/:providerId/packages" element={<PackagesRoute />} />
<Route path="/comparison" element={<ComparisonRoute />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<AppCompareBar />
</>
);
}

View File

@@ -0,0 +1,56 @@
import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { CompareBar, type CompareBarPackage } from '../../../components/molecules/CompareBar';
import { useComparisonBasket } from '../../shared/state/useComparisonBasket';
import { resolveComparisonPackage, parseBasketKey } from '../../shared/fixtures/packages';
const ERROR_TIMEOUT_MS = 2500;
/**
* App-level CompareBar — hovers above every route except `/comparison`
* itself. Reads the basket store, resolves keys to display labels, and
* navigates to the comparison page when the user activates it.
*
* Surfaces transient error feedback (already-added / max-reached) by
* forwarding `lastError` to CompareBar and auto-clearing after a moment.
*/
export function AppCompareBar() {
const navigate = useNavigate();
const location = useLocation();
const packageKeys = useComparisonBasket((s) => s.packageKeys);
const lastError = useComparisonBasket((s) => s.lastError);
const clearError = useComparisonBasket((s) => s.clearError);
useEffect(() => {
if (!lastError) return;
const t = setTimeout(clearError, ERROR_TIMEOUT_MS);
return () => clearTimeout(t);
}, [lastError, clearError]);
if (location.pathname.startsWith('/comparison')) return null;
const packages: CompareBarPackage[] = packageKeys
.map((key) => {
const pkg = resolveComparisonPackage(key);
const parsed = parseBasketKey(key);
if (!pkg || !parsed) return null;
return {
id: key,
name: pkg.name,
providerName: pkg.provider.name,
};
})
.filter((p): p is CompareBarPackage => p !== null);
// CompareBar slides in only when packages.length > 0. To surface "already
// added" / "max reached" errors when the bar isn't yet visible (no items),
// we'd need a separate toast. For now: errors only appear once the bar is
// visible — fine for the common dupe case (basket has ≥1).
return (
<CompareBar
packages={packages}
onCompare={() => navigate('/comparison')}
error={lastError ?? undefined}
/>
);
}

View File

@@ -0,0 +1,30 @@
import Box from '@mui/material/Box';
import { Navigation } from '../../../components/organisms/Navigation';
const FALogo = () => (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box
component="img"
src="/brandlogo/logo-full.svg"
alt="Funeral Arranger"
sx={{ height: 28, display: { xs: 'none', md: 'block' } }}
/>
<Box
component="img"
src="/brandlogo/logo-short.svg"
alt="Funeral Arranger"
sx={{ height: 28, display: { xs: 'block', md: 'none' } }}
/>
</Box>
);
export const demoNav = (
<Navigation
logo={<FALogo />}
items={[
{ label: 'FAQ', href: '#' },
{ label: 'Contact Us', href: '#' },
{ label: 'Log in', href: '#' },
]}
/>
);

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Arrangement Demo — Funeral Arranger</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&family=Noto+Serif+SC:wght@400;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,23 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import { BrowserRouter } from 'react-router-dom';
import { theme } from '../../../theme';
import '../../../theme/generated/tokens.css';
import { App } from './App';
// Vite's `base` is `/arrangement/` in production. In dev the root is this app
// folder so base is `/`. import.meta.env.BASE_URL gives us the right value.
const basename = import.meta.env.BASE_URL.replace(/\/$/, '') || '/';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<BrowserRouter basename={basename}>
<App />
</BrowserRouter>
</ThemeProvider>
</React.StrictMode>,
);

View File

@@ -0,0 +1,65 @@
import { useNavigate } from 'react-router-dom';
import Box from '@mui/material/Box';
import { ComparisonPage } from '../../../../components/pages/ComparisonPage';
import { Typography } from '../../../../components/atoms/Typography';
import { Button } from '../../../../components/atoms/Button';
import { useComparisonBasket } from '../../../shared/state/useComparisonBasket';
import { resolveComparisonPackage } from '../../../shared/fixtures/packages';
import { demoNav } from '../DemoNav';
export function ComparisonRoute() {
const navigate = useNavigate();
const packageKeys = useComparisonBasket((s) => s.packageKeys);
const remove = useComparisonBasket((s) => s.remove);
const packages = packageKeys
.map((key) => {
const resolved = resolveComparisonPackage(key);
return resolved ? { key, pkg: resolved } : null;
})
.filter(
(x): x is { key: string; pkg: NonNullable<ReturnType<typeof resolveComparisonPackage>> } =>
x !== null,
);
if (packages.length === 0) {
return (
<Box sx={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
{demoNav}
<Box
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
p: 4,
textAlign: 'center',
}}
>
<Typography variant="h4">Nothing to compare yet</Typography>
<Typography variant="body1" color="text.secondary">
Pick a provider, choose a package, then tap Compare.
</Typography>
<Button onClick={() => navigate('/')}>Browse providers</Button>
</Box>
</Box>
);
}
return (
<ComparisonPage
packages={packages.map((p) => p.pkg)}
onArrange={(id) => alert(`Arrange "${id}" — would route to next wizard step.`)}
onRemove={(id) => {
// ComparisonPackage.id is the bare package id; we need the basket's
// compound key. Find it back via the parallel array.
const entry = packages.find((p) => p.pkg.id === id);
if (entry) remove(entry.key);
}}
onBack={() => navigate(-1)}
navigation={demoNav}
/>
);
}

View File

@@ -0,0 +1,63 @@
import { useState } from 'react';
import { Navigate, useNavigate, useParams } from 'react-router-dom';
import { PackagesStep } from '../../../../components/pages/PackagesStep';
import { providersById, toPackagesStepProvider } from '../../../shared/fixtures/providers';
import {
packagesByProvider,
makeBasketKey,
nearbyVerifiedSamples,
} from '../../../shared/fixtures/packages';
import { useComparisonBasket } from '../../../shared/state/useComparisonBasket';
import { demoNav } from '../DemoNav';
export function PackagesRoute() {
const { providerId = '' } = useParams();
const navigate = useNavigate();
const provider = providersById[providerId];
const bundle = packagesByProvider[providerId];
const basket = useComparisonBasket();
const [selectedId, setSelectedId] = useState<string | null>(bundle?.matching[0]?.id ?? null);
if (!provider || !bundle) return <Navigate to="/" replace />;
// Compare CTA on the PackageDetail panel just adds the selection to the
// basket. The floating CompareBar (mounted in App.tsx) handles navigation
// once the user has 2+ packages selected.
const handleCompare = () => {
if (selectedId) basket.add(makeBasketKey(provider.id, selectedId));
};
// Tier-3 / tier-2 providers show "nearby verified" cards instead of
// "more from this provider".
const secondaryList =
provider.tier === 'verified'
? { kind: 'same-provider-more' as const, packages: bundle.other }
: { kind: 'nearby-verified' as const, packages: nearbyVerifiedSamples };
return (
<PackagesStep
provider={toPackagesStepProvider(provider)}
providerTier={provider.tier}
packages={bundle.matching}
secondaryList={secondaryList.packages.length > 0 ? secondaryList : undefined}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() =>
alert(
provider.tier === 'verified'
? 'Make Arrangement — would route to next wizard step.'
: 'Make an enquiry — would open enquiry form.',
)
}
onCompare={handleCompare}
onNearbyPackageClick={(key) => {
const [otherProviderId] = key.split(':');
if (otherProviderId) navigate(`/providers/${otherProviderId}/packages`);
}}
onProviderClick={() => alert('Provider profile — not built in this demo slice.')}
onBack={() => navigate('/')}
navigation={demoNav}
/>
);
}

View File

@@ -0,0 +1,38 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ProvidersStep,
EMPTY_FILTER_VALUES,
type ProviderFilterValues,
type ProviderSortBy,
type ListViewMode,
} from '../../../../components/pages/ProvidersStep';
import { providers } from '../../../shared/fixtures/providers';
import { demoNav } from '../DemoNav';
export function ProvidersRoute() {
const navigate = useNavigate();
const [query, setQuery] = useState('');
const [filters, setFilters] = useState<ProviderFilterValues>(EMPTY_FILTER_VALUES);
const [sort, setSort] = useState<ProviderSortBy>('recommended');
const [view, setView] = useState<ListViewMode>('list');
const filtered = providers.filter((p) => p.location.toLowerCase().includes(query.toLowerCase()));
return (
<ProvidersStep
providers={filtered}
onSelectProvider={(id) => navigate(`/providers/${id}/packages`)}
searchQuery={query}
onSearchChange={setQuery}
filterValues={filters}
onFilterChange={setFilters}
sortBy={sort}
onSortChange={setSort}
viewMode={view}
onViewModeChange={setView}
onBack={() => window.history.back()}
navigation={demoNav}
/>
);
}

View File

@@ -0,0 +1,908 @@
import type { PackageData, NearbyVerifiedPackage } from '../../../components/pages/PackagesStep';
import type { PackageSection, PackageLineItem } from '../../../components/organisms/PackageDetail';
import type {
ComparisonPackage,
ComparisonSection,
} from '../../../components/organisms/ComparisonTable';
import { providersById } from './providers';
/**
* Packages live keyed by providerId. Each provider has TWO PackageData lists
* (matching = primary "matching your preferences", other = secondary "more
* from this provider") and a parallel ComparisonPackage projection that
* concatenates both for use by ComparisonPage. Same `id` across all three
* lists so the basket can hold a single compound key:
* `${providerId}:${packageId}`.
*
* Verified-provider packages all share the same nine canonical Essentials
* line items per FA convention — only prices/treatment vary. Optionals and
* Extras are free to vary per package. See memory:
* project_canonical_essentials.md for the rule and reasoning.
*/
interface PackageBundle {
matching: PackageData[];
other: PackageData[];
forComparison: ComparisonPackage[];
}
// ─── Canonical Essentials factories ──────────────────────────────────────────
type IncludedTreatment = 'complimentary' | 'included';
interface EssentialsPrices {
coffin: number; // rendered as allowance
cremationCert: number;
crematorium: number;
deathReg: number;
dressing: number | IncludedTreatment;
govLevy: number;
govLevyLabel?: string; // override to "Government Levy — Cremation" for non-NSW
mortuary: number;
service: number;
transport: number | IncludedTreatment;
}
const labelFor = (kind: IncludedTreatment): string =>
kind === 'complimentary' ? 'Complimentary' : 'Included';
function essentialsForStep(p: EssentialsPrices): PackageSection {
const item = (name: string, value: number | IncludedTreatment): PackageLineItem =>
typeof value === 'number'
? { name, price: value }
: { name, price: 0, priceLabel: labelFor(value) };
return {
heading: 'Essentials',
items: [
{ name: 'Allowance for Coffin', price: p.coffin, isAllowance: true },
item('Cremation Certificate/Permit', p.cremationCert),
item('Crematorium', p.crematorium),
item('Death Registration Certificate', p.deathReg),
item('Dressing Fee', p.dressing),
item(p.govLevyLabel ?? 'NSW Government Levy — Cremation', p.govLevy),
item('Professional Mortuary Care', p.mortuary),
item('Professional Service Fee', p.service),
item('Transportation Service Fee', p.transport),
],
};
}
function essentialsForComparison(p: EssentialsPrices): ComparisonSection {
const cell = (value: number | IncludedTreatment) =>
typeof value === 'number'
? ({ type: 'price', amount: value } as const)
: ({ type: value } as const);
return {
heading: 'Essentials',
items: [
{
name: 'Allowance for Coffin',
info: 'Allowance amount — upgrade options available.',
value: { type: 'allowance', amount: p.coffin },
},
{
name: 'Cremation Certificate/Permit',
info: 'Statutory medical referee fee.',
value: cell(p.cremationCert),
},
{ name: 'Crematorium', info: 'Cremation facility fees.', value: cell(p.crematorium) },
{
name: 'Death Registration Certificate',
info: 'Lodgement with the registry.',
value: cell(p.deathReg),
},
{ name: 'Dressing Fee', info: 'Dressing and preparation.', value: cell(p.dressing) },
{
name: p.govLevyLabel ?? 'NSW Government Levy — Cremation',
info: 'Government cremation levy.',
value: cell(p.govLevy),
},
{
name: 'Professional Mortuary Care',
info: 'Preparation and care.',
value: cell(p.mortuary),
},
{
name: 'Professional Service Fee',
info: 'Coordination of arrangements.',
value: cell(p.service),
},
{
name: 'Transportation Service Fee',
info: 'Transfer of the deceased.',
value: cell(p.transport),
},
],
};
}
const sumEssentials = (p: EssentialsPrices): number => {
const num = (v: number | IncludedTreatment) => (typeof v === 'number' ? v : 0);
return (
p.coffin +
num(p.cremationCert) +
num(p.crematorium) +
num(p.deathReg) +
num(p.dressing) +
p.govLevy +
p.mortuary +
p.service +
num(p.transport)
);
};
// ─── Optionals helpers ───────────────────────────────────────────────────────
interface Optional {
name: string;
treatment: IncludedTreatment | 'unknown';
}
const optionalsForStep = (items: Optional[]): PackageSection => ({
heading: 'Optionals',
items: items.map<PackageLineItem>((it) =>
it.treatment === 'unknown'
? { name: it.name, price: 0, priceLabel: '—' }
: { name: it.name, price: 0, priceLabel: labelFor(it.treatment) },
),
});
const optionalsForComparison = (items: Optional[]): ComparisonSection => ({
heading: 'Optionals',
items: items.map((it) => ({
name: it.name,
value: { type: it.treatment === 'unknown' ? 'unknown' : it.treatment },
})),
});
// ─── Extras helpers ──────────────────────────────────────────────────────────
interface Extra {
name: string;
/** number = fixed price; { allowance } = allowance amount; 'poa' = price on application; 'complimentary' = free */
value: number | { allowance: number } | 'poa' | 'complimentary';
}
const extrasForStep = (items: Extra[]): PackageSection => ({
heading: 'Extras',
items: items.map<PackageLineItem>((it) => {
if (typeof it.value === 'number') return { name: it.name, price: it.value };
if (it.value === 'poa') return { name: it.name, price: 0, priceLabel: 'POA' };
if (it.value === 'complimentary')
return { name: it.name, price: 0, priceLabel: 'Complimentary' };
return { name: it.name, price: it.value.allowance, isAllowance: true };
}),
});
const extrasForComparison = (items: Extra[]): ComparisonSection => ({
heading: 'Extras',
items: items.map((it) => ({
name: it.name,
value:
typeof it.value === 'number'
? { type: 'price', amount: it.value }
: it.value === 'poa'
? { type: 'poa' }
: it.value === 'complimentary'
? { type: 'complimentary' }
: { type: 'allowance', amount: it.value.allowance },
})),
});
// ─── H.Parsons (verified — premium tier, complimentary treatments) ───────────
const parsonsEverydayEssentials: EssentialsPrices = {
coffin: 1750,
cremationCert: 350,
crematorium: 660,
deathReg: 70,
dressing: 'complimentary',
govLevy: 45.1,
mortuary: 440,
service: 3650.9,
transport: 'complimentary',
};
const parsonsEssentialEssentials: EssentialsPrices = {
coffin: 1200,
cremationCert: 350,
crematorium: 660,
deathReg: 70,
dressing: 'complimentary',
govLevy: 45.1,
mortuary: 440,
service: 1729.9,
transport: 'complimentary',
};
const parsonsDeluxeEssentials: EssentialsPrices = {
coffin: 2500,
cremationCert: 350,
crematorium: 660,
deathReg: 70,
dressing: 'complimentary',
govLevy: 45.1,
mortuary: 440,
service: 5384.9,
transport: 'complimentary',
};
const parsonsForStep: PackageData[] = [
{
id: 'everyday',
name: 'Everyday Funeral Package',
price: sumEssentials(parsonsEverydayEssentials),
description:
'Funeral service at a chapel or church with a procession. Includes the most commonly selected funeral options.',
sections: [
essentialsForStep(parsonsEverydayEssentials),
optionalsForStep([
{ name: 'Digital Recording', treatment: 'complimentary' },
{ name: 'Online Notice', treatment: 'complimentary' },
{ name: 'Viewing Fee', treatment: 'complimentary' },
{ name: 'Flowers', treatment: 'complimentary' },
]),
],
extras: extrasForStep([
{ name: 'Allowance for Celebrant', value: { allowance: 550 } },
{ name: 'Catering', value: 'poa' },
{ name: 'Newspaper Notice', value: 'poa' },
{ name: 'Saturday Service Fee', value: 880 },
]),
},
{
id: 'essential',
name: 'Essential Funeral Package',
price: sumEssentials(parsonsEssentialEssentials),
description: 'A simple, dignified option covering the essential requirements for a cremation.',
sections: [
essentialsForStep(parsonsEssentialEssentials),
optionalsForStep([
{ name: 'Online Notice', treatment: 'complimentary' },
{ name: 'Viewing Fee', treatment: 'complimentary' },
]),
],
extras: extrasForStep([
{ name: 'Allowance for Celebrant', value: { allowance: 400 } },
{ name: 'Catering', value: 'poa' },
]),
},
{
id: 'deluxe',
name: 'Deluxe Funeral Package',
price: sumEssentials(parsonsDeluxeEssentials),
description:
'Premium inclusions, higher-quality coffin selection, expanded service options for a more personalised farewell.',
sections: [
essentialsForStep(parsonsDeluxeEssentials),
optionalsForStep([
{ name: 'Digital Recording', treatment: 'complimentary' },
{ name: 'Online Notice', treatment: 'complimentary' },
{ name: 'Viewing Fee', treatment: 'complimentary' },
{ name: 'Flowers', treatment: 'complimentary' },
{ name: 'Webstreaming', treatment: 'complimentary' },
]),
],
extras: extrasForStep([
{ name: 'Allowance for Celebrant', value: { allowance: 700 } },
{ name: 'Catering', value: 1200 },
{ name: 'Newspaper Notice', value: 350 },
{ name: 'Saturday Service Fee', value: 'complimentary' },
]),
},
];
const parsonsForComparison: ComparisonPackage[] = parsonsForStep.map((pkg, idx) => {
const ess = [parsonsEverydayEssentials, parsonsEssentialEssentials, parsonsDeluxeEssentials][idx];
const allOptionals: Optional[][] = [
[
{ name: 'Digital Recording', treatment: 'complimentary' },
{ name: 'Online Notice', treatment: 'complimentary' },
{ name: 'Viewing Fee', treatment: 'complimentary' },
{ name: 'Flowers', treatment: 'complimentary' },
],
[
{ name: 'Online Notice', treatment: 'complimentary' },
{ name: 'Viewing Fee', treatment: 'complimentary' },
],
[
{ name: 'Digital Recording', treatment: 'complimentary' },
{ name: 'Online Notice', treatment: 'complimentary' },
{ name: 'Viewing Fee', treatment: 'complimentary' },
{ name: 'Flowers', treatment: 'complimentary' },
{ name: 'Webstreaming', treatment: 'complimentary' },
],
];
const optionals = allOptionals[idx];
const extras: Extra[] = [
[
{ name: 'Allowance for Celebrant', value: { allowance: 550 } },
{ name: 'Catering', value: 'poa' as const },
{ name: 'Newspaper Notice', value: 'poa' as const },
{ name: 'Saturday Service Fee', value: 880 },
],
[
{ name: 'Allowance for Celebrant', value: { allowance: 400 } },
{ name: 'Catering', value: 'poa' as const },
],
[
{ name: 'Allowance for Celebrant', value: { allowance: 700 } },
{ name: 'Catering', value: 1200 },
{ name: 'Newspaper Notice', value: 350 },
{ name: 'Saturday Service Fee', value: 'complimentary' as const },
],
][idx];
return {
id: pkg.id,
name: pkg.name,
price: pkg.price,
provider: {
name: 'H.Parsons Funeral Directors',
location: 'Wentworth, NSW',
logoUrl: providersById['parsons'].logoUrl,
rating: 4.6,
reviewCount: 7,
verified: true,
},
sections: [
essentialsForComparison(ess),
optionalsForComparison(optionals),
extrasForComparison(extras),
],
};
});
// ─── Rankins (verified — mid-market, "included" treatment) ───────────────────
const rankinsStandardEssentials: EssentialsPrices = {
coffin: 1500,
cremationCert: 350,
crematorium: 660,
deathReg: 70,
dressing: 'included',
govLevy: 45.1,
mortuary: 440,
service: 2430.35,
transport: 'included',
};
const rankinsPremiumEssentials: EssentialsPrices = {
coffin: 2200,
cremationCert: 350,
crematorium: 660,
deathReg: 70,
dressing: 'included',
govLevy: 45.1,
mortuary: 440,
service: 4034.9,
transport: 'included',
};
const rankinsForStep: PackageData[] = [
{
id: 'standard',
name: 'Standard Cremation Package',
price: sumEssentials(rankinsStandardEssentials),
description: 'A balanced cremation package suitable for most families.',
sections: [
essentialsForStep(rankinsStandardEssentials),
optionalsForStep([
{ name: 'Digital Recording', treatment: 'unknown' },
{ name: 'Online Notice', treatment: 'included' },
{ name: 'Viewing Fee', treatment: 'included' },
{ name: 'Flowers', treatment: 'included' },
]),
],
extras: extrasForStep([
{ name: 'Allowance for Celebrant', value: { allowance: 450 } },
{ name: 'Catering', value: 'poa' },
{ name: 'Saturday Service Fee', value: 750 },
]),
},
{
id: 'premium',
name: 'Premium Funeral Service',
price: sumEssentials(rankinsPremiumEssentials),
description: 'A more personalised service with venue and celebrant coordination.',
sections: [
essentialsForStep(rankinsPremiumEssentials),
optionalsForStep([
{ name: 'Digital Recording', treatment: 'included' },
{ name: 'Online Notice', treatment: 'included' },
{ name: 'Viewing Fee', treatment: 'included' },
{ name: 'Flowers', treatment: 'included' },
]),
],
extras: extrasForStep([
{ name: 'Allowance for Celebrant', value: { allowance: 600 } },
{ name: 'Catering', value: 950 },
{ name: 'Newspaper Notice', value: 280 },
]),
},
];
const rankinsForComparison: ComparisonPackage[] = rankinsForStep.map((pkg, idx) => {
const ess = [rankinsStandardEssentials, rankinsPremiumEssentials][idx];
const allOptionals: Optional[][] = [
[
{ name: 'Digital Recording', treatment: 'unknown' },
{ name: 'Online Notice', treatment: 'included' },
{ name: 'Viewing Fee', treatment: 'included' },
{ name: 'Flowers', treatment: 'included' },
],
[
{ name: 'Digital Recording', treatment: 'included' },
{ name: 'Online Notice', treatment: 'included' },
{ name: 'Viewing Fee', treatment: 'included' },
{ name: 'Flowers', treatment: 'included' },
],
];
const optionals = allOptionals[idx];
const extras: Extra[] = [
[
{ name: 'Allowance for Celebrant', value: { allowance: 450 } },
{ name: 'Catering', value: 'poa' as const },
{ name: 'Saturday Service Fee', value: 750 },
],
[
{ name: 'Allowance for Celebrant', value: { allowance: 600 } },
{ name: 'Catering', value: 950 },
{ name: 'Newspaper Notice', value: 280 },
],
][idx];
return {
id: pkg.id,
name: pkg.name,
price: pkg.price,
provider: {
name: 'Rankins Funeral Services',
location: 'Wollongong, NSW',
logoUrl: providersById['rankins'].logoUrl,
rating: 4.8,
reviewCount: 23,
verified: true,
},
sections: [
essentialsForComparison(ess),
optionalsForComparison(optionals),
extrasForComparison(extras),
],
};
});
// ─── Killick (verified, QLD — generic levy label) ────────────────────────────
const killickClassicEssentials: EssentialsPrices = {
coffin: 1600,
cremationCert: 350,
crematorium: 660,
deathReg: 70,
dressing: 'complimentary',
govLevy: 45.1,
govLevyLabel: 'Government Levy — Cremation',
mortuary: 440,
service: 2614.9,
transport: 'complimentary',
};
const killickSimpleEssentials: EssentialsPrices = {
coffin: 1100,
cremationCert: 350,
crematorium: 660,
deathReg: 70,
dressing: 'complimentary',
govLevy: 45.1,
govLevyLabel: 'Government Levy — Cremation',
mortuary: 440,
service: 1534.9,
transport: 'complimentary',
};
const killickForStep: PackageData[] = [
{
id: 'classic',
name: 'Classic Farewell Package',
price: sumEssentials(killickClassicEssentials),
description: 'A complete farewell service with chapel use and graveside committal.',
sections: [
essentialsForStep(killickClassicEssentials),
optionalsForStep([
{ name: 'Digital Recording', treatment: 'complimentary' },
{ name: 'Online Notice', treatment: 'complimentary' },
{ name: 'Viewing Fee', treatment: 'complimentary' },
{ name: 'Flowers', treatment: 'unknown' },
]),
],
extras: extrasForStep([
{ name: 'Allowance for Celebrant', value: { allowance: 500 } },
{ name: 'Catering', value: 'poa' },
{ name: 'Saturday Service Fee', value: 800 },
]),
},
{
id: 'simple',
name: 'Simple Cremation',
price: sumEssentials(killickSimpleEssentials),
description: 'A direct cremation without a service.',
sections: [
essentialsForStep(killickSimpleEssentials),
optionalsForStep([
{ name: 'Online Notice', treatment: 'complimentary' },
{ name: 'Viewing Fee', treatment: 'unknown' },
]),
],
extras: extrasForStep([{ name: 'Allowance for Celebrant', value: { allowance: 350 } }]),
},
];
const killickForComparison: ComparisonPackage[] = killickForStep.map((pkg, idx) => {
const ess = [killickClassicEssentials, killickSimpleEssentials][idx];
const allOptionals: Optional[][] = [
[
{ name: 'Digital Recording', treatment: 'complimentary' },
{ name: 'Online Notice', treatment: 'complimentary' },
{ name: 'Viewing Fee', treatment: 'complimentary' },
{ name: 'Flowers', treatment: 'unknown' },
],
[
{ name: 'Online Notice', treatment: 'complimentary' },
{ name: 'Viewing Fee', treatment: 'unknown' },
],
];
const optionals = allOptionals[idx];
const extras: Extra[] = [
[
{ name: 'Allowance for Celebrant', value: { allowance: 500 } },
{ name: 'Catering', value: 'poa' as const },
{ name: 'Saturday Service Fee', value: 800 },
],
[{ name: 'Allowance for Celebrant', value: { allowance: 350 } }],
][idx];
return {
id: pkg.id,
name: pkg.name,
price: pkg.price,
provider: {
name: 'Killick Family Funerals',
location: 'Kingaroy, QLD',
logoUrl: providersById['killick'].logoUrl,
rating: 4.9,
reviewCount: 15,
verified: true,
},
sections: [
essentialsForComparison(ess),
optionalsForComparison(optionals),
extrasForComparison(extras),
],
};
});
// ─── Mackay (verified, NSW) ──────────────────────────────────────────────────
const mackayEverydayEssentials: EssentialsPrices = {
coffin: 1500,
cremationCert: 350,
crematorium: 660,
deathReg: 70,
dressing: 'included',
govLevy: 45.1,
mortuary: 440,
service: 2430.35,
transport: 'included',
};
const mackayForStep: PackageData[] = [
{
id: 'everyday',
name: 'Everyday Funeral Package',
price: sumEssentials(mackayEverydayEssentials),
description: 'A complete funeral service with a chapel ceremony.',
sections: [
essentialsForStep(mackayEverydayEssentials),
optionalsForStep([
{ name: 'Digital Recording', treatment: 'unknown' },
{ name: 'Online Notice', treatment: 'included' },
{ name: 'Viewing Fee', treatment: 'included' },
{ name: 'Flowers', treatment: 'included' },
]),
],
extras: extrasForStep([
{ name: 'Allowance for Celebrant', value: { allowance: 450 } },
{ name: 'Catering', value: 'poa' },
{ name: 'Saturday Service Fee', value: 750 },
]),
},
];
const mackayForComparison: ComparisonPackage[] = mackayForStep.map((pkg) => ({
id: pkg.id,
name: pkg.name,
price: pkg.price,
provider: {
name: 'Mackay Family Funeral Directors',
location: 'Ourimbah, NSW',
logoUrl: providersById['mackay'].logoUrl,
rating: 4.6,
reviewCount: 87,
verified: true,
},
sections: [
essentialsForComparison(mackayEverydayEssentials),
optionalsForComparison([
{ name: 'Digital Recording', treatment: 'unknown' },
{ name: 'Online Notice', treatment: 'included' },
{ name: 'Viewing Fee', treatment: 'included' },
{ name: 'Flowers', treatment: 'included' },
]),
extrasForComparison([
{ name: 'Allowance for Celebrant', value: { allowance: 450 } },
{ name: 'Catering', value: 'poa' },
{ name: 'Saturday Service Fee', value: 750 },
]),
],
}));
// ─── Mannings (verified, NSW) ────────────────────────────────────────────────
const manningsStandardEssentials: EssentialsPrices = {
coffin: 1300,
cremationCert: 350,
crematorium: 660,
deathReg: 70,
dressing: 'included',
govLevy: 45.1,
mortuary: 440,
service: 2114.9,
transport: 'included',
};
const manningsForStep: PackageData[] = [
{
id: 'standard',
name: 'Standard Cremation Package',
price: sumEssentials(manningsStandardEssentials),
description: 'A respectful cremation with chapel service.',
sections: [
essentialsForStep(manningsStandardEssentials),
optionalsForStep([
{ name: 'Online Notice', treatment: 'included' },
{ name: 'Viewing Fee', treatment: 'included' },
{ name: 'Flowers', treatment: 'unknown' },
]),
],
extras: extrasForStep([
{ name: 'Allowance for Celebrant', value: { allowance: 400 } },
{ name: 'Catering', value: 'poa' },
]),
},
];
const manningsForComparison: ComparisonPackage[] = manningsForStep.map((pkg) => ({
id: pkg.id,
name: pkg.name,
price: pkg.price,
provider: {
name: 'Mannings Funerals',
location: 'Bega, NSW',
logoUrl: providersById['mannings'].logoUrl,
rating: 4.7,
reviewCount: 31,
verified: true,
},
sections: [
essentialsForComparison(manningsStandardEssentials),
optionalsForComparison([
{ name: 'Online Notice', treatment: 'included' },
{ name: 'Viewing Fee', treatment: 'included' },
{ name: 'Flowers', treatment: 'unknown' },
]),
extrasForComparison([
{ name: 'Allowance for Celebrant', value: { allowance: 400 } },
{ name: 'Catering', value: 'poa' },
]),
],
}));
// ─── Wollongong City (tier 3 — itemised but unverified, mostly unknowns) ─────
// Tier-3 step view: simpler — show what we know, omit unknowns from breakdown.
const wollongongForStep: PackageData[] = [
{
id: 'standard',
name: 'Standard Funeral Service',
price: 3400,
description:
'Itemised package based on publicly available information. Make an enquiry to confirm details.',
sections: [
{
heading: 'Essentials (estimated)',
items: [
{ name: 'Allowance for Coffin', price: 1400, isAllowance: true },
{ name: 'Cremation Certificate/Permit', price: 350 },
{ name: 'Crematorium', price: 660 },
{ name: 'Professional Service Fee', price: 990 },
],
},
],
total: 3400,
},
];
const wollongongForComparison: ComparisonPackage[] = [
{
id: 'standard',
name: 'Standard Funeral Service',
price: 3400,
provider: {
name: 'Wollongong City Funerals',
location: 'Wollongong, NSW',
rating: 4.2,
reviewCount: 15,
verified: false,
},
sections: [
{
heading: 'Essentials',
items: [
{ name: 'Allowance for Coffin', value: { type: 'allowance', amount: 1400 } },
{ name: 'Cremation Certificate/Permit', value: { type: 'price', amount: 350 } },
{ name: 'Crematorium', value: { type: 'price', amount: 660 } },
{ name: 'Death Registration Certificate', value: { type: 'unknown' } },
{ name: 'Dressing Fee', value: { type: 'unknown' } },
{ name: 'NSW Government Levy — Cremation', value: { type: 'unknown' } },
{ name: 'Professional Mortuary Care', value: { type: 'unknown' } },
{ name: 'Professional Service Fee', value: { type: 'price', amount: 990 } },
{ name: 'Transportation Service Fee', value: { type: 'unknown' } },
],
},
],
},
];
// ─── Botanical (tier 2 — price only, no breakdown) ──────────────────────────
const botanicalForStep: PackageData[] = [
{
id: 'standard',
name: 'Standard Funeral Service',
price: 5200,
description:
'A full-service package based on publicly available information. Breakdown not available.',
sections: [],
},
{
id: 'basic',
name: 'Basic Cremation',
price: 3400,
description: 'Entry-level package. Pricing is indicative only.',
sections: [],
},
];
const botanicalForComparison: ComparisonPackage[] = [
{
id: 'standard',
name: 'Standard Funeral Service',
price: 5200,
provider: {
name: 'Botanical Funerals',
location: 'Newtown, NSW',
rating: 4.9,
reviewCount: 8,
verified: false,
},
itemizedAvailable: false,
sections: [],
},
{
id: 'basic',
name: 'Basic Cremation',
price: 3400,
provider: {
name: 'Botanical Funerals',
location: 'Newtown, NSW',
rating: 4.9,
reviewCount: 8,
verified: false,
},
itemizedAvailable: false,
sections: [],
},
];
// ─── Bundle map ─────────────────────────────────────────────────────────────
// Per-provider matching/other split. The "matching your preferences" list is
// the recommended set; "other" is everything else from the same provider.
// Tier-3/2 providers don't show an "other" list — they show nearby-verified
// alternatives instead — so their `other` arrays stay empty.
export const packagesByProvider: Record<string, PackageBundle> = {
parsons: {
matching: parsonsForStep.slice(0, 1),
other: parsonsForStep.slice(1),
forComparison: parsonsForComparison,
},
rankins: {
matching: rankinsForStep.slice(0, 1),
other: rankinsForStep.slice(1),
forComparison: rankinsForComparison,
},
killick: {
matching: killickForStep.slice(0, 1),
other: killickForStep.slice(1),
forComparison: killickForComparison,
},
mackay: {
matching: mackayForStep,
other: [],
forComparison: mackayForComparison,
},
mannings: {
matching: manningsForStep,
other: [],
forComparison: manningsForComparison,
},
'wollongong-city': {
matching: wollongongForStep,
other: [],
forComparison: wollongongForComparison,
},
botanical: {
matching: botanicalForStep,
other: [],
forComparison: botanicalForComparison,
},
};
/** Compound basket key: `${providerId}:${packageId}` */
export type BasketKey = string;
export const makeBasketKey = (providerId: string, packageId: string): BasketKey =>
`${providerId}:${packageId}`;
export const parseBasketKey = (
key: BasketKey,
): { providerId: string; packageId: string } | null => {
const [providerId, packageId] = key.split(':');
if (!providerId || !packageId) return null;
return { providerId, packageId };
};
/** Resolve a basket key to its ComparisonPackage, or null if missing. */
export function resolveComparisonPackage(key: BasketKey): ComparisonPackage | null {
const parsed = parseBasketKey(key);
if (!parsed) return null;
const bundle = packagesByProvider[parsed.providerId];
if (!bundle) return null;
return bundle.forComparison.find((p) => p.id === parsed.packageId) ?? null;
}
/** "Nearby verified" cards shown under tier-3 / tier-2 lists. */
export const nearbyVerifiedSamples: NearbyVerifiedPackage[] = [
{
id: 'rankins:standard',
packageName: 'Standard Cremation Package',
price: rankinsForStep[0].price,
providerName: 'Rankins Funeral Services',
location: 'Wollongong, NSW',
rating: 4.8,
reviewCount: 23,
},
{
id: 'mannings:standard',
packageName: 'Standard Cremation Package',
price: manningsForStep[0].price,
providerName: 'Mannings Funerals',
location: 'Bega, NSW',
rating: 4.7,
reviewCount: 31,
},
{
id: 'killick:classic',
packageName: 'Classic Farewell Package',
price: killickForStep[0].price,
providerName: 'Killick Family Funerals',
location: 'Kingaroy, QLD',
rating: 4.9,
reviewCount: 15,
},
];

View File

@@ -0,0 +1,121 @@
import type { ProviderData } from '../../../components/pages/ProvidersStep';
import type { PackagesStepProvider, ProviderTier } from '../../../components/pages/PackagesStep';
export interface DemoProvider extends ProviderData {
id: string;
tier: ProviderTier;
}
export const providers: DemoProvider[] = [
{
id: 'parsons',
name: 'H.Parsons Funeral Directors',
location: 'Wentworth, NSW',
verified: true,
tier: 'verified',
imageUrl: '/images/venues/hparsons-funeral-home-wollongong/01.jpg',
logoUrl: '/images/providers/hparsons-funeral-directors/logo.png',
rating: 4.6,
reviewCount: 7,
startingPrice: 1800,
distanceKm: 2.3,
description:
'H.Parsons delivers premium funeral services with exceptional care and support, guiding families through every step with empathy and expertise.',
},
{
id: 'rankins',
name: 'Rankins Funeral Services',
location: 'Wollongong, NSW',
verified: true,
tier: 'verified',
imageUrl: '/images/venues/rankins-funeral-home-warrawong/01.jpg',
logoUrl: '/images/providers/rankins-funerals/logo.png',
rating: 4.8,
reviewCount: 23,
startingPrice: 2450,
distanceKm: 5.1,
},
{
id: 'wollongong-city',
name: 'Wollongong City Funerals',
location: 'Wollongong, NSW',
verified: false,
tier: 'tier3',
rating: 4.2,
reviewCount: 15,
startingPrice: 3400,
distanceKm: 6.8,
},
{
id: 'killick',
name: 'Killick Family Funerals',
location: 'Kingaroy, QLD',
verified: true,
tier: 'verified',
imageUrl: '/images/venues/killick-family-funerals-chapel-kingaroy/01.jpg',
logoUrl: '/images/providers/killick-family-funerals/logo.png',
rating: 4.9,
reviewCount: 15,
startingPrice: 3100,
distanceKm: 8.4,
},
{
id: 'mackay',
name: 'Mackay Family Funeral Directors',
location: 'Ourimbah, NSW',
verified: true,
tier: 'verified',
imageUrl: '/images/venues/mackay-family-garden-estate/01.jpg',
logoUrl: '/images/providers/mackay-family-funerals/logo.webp',
rating: 4.6,
reviewCount: 87,
startingPrice: 2800,
distanceKm: 18.2,
},
{
id: 'mannings',
name: 'Mannings Funerals',
location: 'Bega, NSW',
verified: true,
tier: 'verified',
imageUrl: '/images/venues/mannings-chapel/01.jpg',
logoUrl: '/images/providers/mannings-funerals/logo.png',
rating: 4.7,
reviewCount: 31,
startingPrice: 2600,
distanceKm: 22.0,
},
{
id: 'botanical',
name: 'Botanical Funerals',
location: 'Newtown, NSW',
verified: false,
tier: 'tier2',
rating: 4.9,
reviewCount: 8,
startingPrice: 5200,
distanceKm: 15.0,
},
];
export const providersById: Record<string, DemoProvider> = providers.reduce(
(acc, p) => {
acc[p.id] = p;
return acc;
},
{} as Record<string, DemoProvider>,
);
/**
* Strip demo-only fields so the value matches PackagesStepProvider exactly.
* (PackagesStepProvider is a structural subset of ProviderData — no `id`, no `tier`.)
*/
export function toPackagesStepProvider(p: DemoProvider): PackagesStepProvider {
return {
name: p.name,
location: p.location,
imageUrl: p.imageUrl,
rating: p.rating,
reviewCount: p.reviewCount,
};
}

View File

@@ -0,0 +1,59 @@
import { useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useComparisonBasket } from './useComparisonBasket';
const PARAM = 'compare';
const serialise = (keys: string[]): string => keys.join(',');
const deserialise = (raw: string | null): string[] =>
raw
? raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: [];
/**
* Two-way sync between the basket store and the `?compare=a:b,c:d` search param.
*
* Mount once near the router root. URL is the source of truth on initial load
* (so a shared link restores the basket); after that, store changes write
* through to the URL and external URL changes (back/forward, manual edits)
* push back into the store.
*/
export function useBasketUrlSync(): void {
const [searchParams, setSearchParams] = useSearchParams();
const initialised = useRef(false);
useEffect(() => {
const urlKeys = deserialise(searchParams.get(PARAM));
const storeKeys = useComparisonBasket.getState().packageKeys;
if (!initialised.current) {
initialised.current = true;
if (urlKeys.length > 0 && serialise(urlKeys) !== serialise(storeKeys)) {
useComparisonBasket.getState().setAll(urlKeys);
}
return;
}
if (serialise(urlKeys) !== serialise(storeKeys)) {
useComparisonBasket.getState().setAll(urlKeys);
}
}, [searchParams]);
useEffect(() => {
return useComparisonBasket.subscribe((state, prev) => {
if (serialise(state.packageKeys) === serialise(prev.packageKeys)) return;
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
if (state.packageKeys.length === 0) next.delete(PARAM);
else next.set(PARAM, serialise(state.packageKeys));
return next;
},
{ replace: true },
);
});
}, [setSearchParams]);
}

View File

@@ -0,0 +1,49 @@
import { create } from 'zustand';
import type { BasketKey } from '../fixtures/packages';
// ComparisonPage caps user-selected packages at 3 (recommended is shown as a
// separate column). Keep the basket aligned so we can't add a 4th and have it
// silently dropped at render time.
const MAX_BASKET = 3;
interface BasketState {
packageKeys: BasketKey[];
/** Transient feedback message — set when add() is rejected (dupe/full) */
lastError: string | null;
add: (key: BasketKey) => void;
remove: (key: BasketKey) => void;
toggle: (key: BasketKey) => void;
clear: () => void;
clearError: () => void;
setAll: (keys: BasketKey[]) => void;
has: (key: BasketKey) => boolean;
isFull: () => boolean;
}
export const useComparisonBasket = create<BasketState>((set, get) => ({
packageKeys: [],
lastError: null,
add: (key) =>
set((state) => {
if (state.packageKeys.includes(key)) {
return { ...state, lastError: 'Already added' };
}
if (state.packageKeys.length >= MAX_BASKET) {
return { ...state, lastError: `Maximum ${MAX_BASKET} packages` };
}
return { packageKeys: [...state.packageKeys, key], lastError: null };
}),
remove: (key) => set((state) => ({ packageKeys: state.packageKeys.filter((k) => k !== key) })),
toggle: (key) => {
const { has, add, remove } = get();
if (has(key)) remove(key);
else add(key);
},
clear: () => set({ packageKeys: [], lastError: null }),
clearError: () => set({ lastError: null }),
setAll: (keys) => set({ packageKeys: keys.slice(0, MAX_BASKET), lastError: null }),
has: (key) => get().packageKeys.includes(key),
isFull: () => get().packageKeys.length >= MAX_BASKET,
}));
export const BASKET_MAX = MAX_BASKET;