Add arrangement demo site from original project
Copies the multi-page demo app (Providers → Packages → Comparison flow) with Zustand basket state, URL sync, and per-slice Vite build config. All pages render correctly on React 19 + MUI v7 with zero code changes needed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -40,7 +40,9 @@
|
|||||||
"format": "prettier --write 'src/**/*.{ts,tsx}'",
|
"format": "prettier --write 'src/**/*.{ts,tsx}'",
|
||||||
"format:check": "prettier --check 'src/**/*.{ts,tsx}'",
|
"format:check": "prettier --check 'src/**/*.{ts,tsx}'",
|
||||||
"test": "vitest run --passWithNoTests",
|
"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": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.13.0",
|
"@emotion/react": "^11.13.0",
|
||||||
|
|||||||
22
src/demo/apps/arrangement/App.tsx
Normal file
22
src/demo/apps/arrangement/App.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { useBasketUrlSync } from '../../shared/state/useBasketUrlSync';
|
||||||
|
import { ProvidersRoute } from './routes/Providers';
|
||||||
|
import { PackagesRoute } from './routes/Packages';
|
||||||
|
import { ComparisonRoute } from './routes/Comparison';
|
||||||
|
import { AppCompareBar } from './AppCompareBar';
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
useBasketUrlSync();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<ProvidersRoute />} />
|
||||||
|
<Route path="/providers/:providerId/packages" element={<PackagesRoute />} />
|
||||||
|
<Route path="/comparison" element={<ComparisonRoute />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
<AppCompareBar />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/demo/apps/arrangement/AppCompareBar.tsx
Normal file
56
src/demo/apps/arrangement/AppCompareBar.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { CompareBar, type CompareBarPackage } from '../../../components/molecules/CompareBar';
|
||||||
|
import { useComparisonBasket } from '../../shared/state/useComparisonBasket';
|
||||||
|
import { resolveComparisonPackage, parseBasketKey } from '../../shared/fixtures/packages';
|
||||||
|
|
||||||
|
const ERROR_TIMEOUT_MS = 2500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-level CompareBar — hovers above every route except `/comparison`
|
||||||
|
* itself. Reads the basket store, resolves keys to display labels, and
|
||||||
|
* navigates to the comparison page when the user activates it.
|
||||||
|
*
|
||||||
|
* Surfaces transient error feedback (already-added / max-reached) by
|
||||||
|
* forwarding `lastError` to CompareBar and auto-clearing after a moment.
|
||||||
|
*/
|
||||||
|
export function AppCompareBar() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const packageKeys = useComparisonBasket((s) => s.packageKeys);
|
||||||
|
const lastError = useComparisonBasket((s) => s.lastError);
|
||||||
|
const clearError = useComparisonBasket((s) => s.clearError);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastError) return;
|
||||||
|
const t = setTimeout(clearError, ERROR_TIMEOUT_MS);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [lastError, clearError]);
|
||||||
|
|
||||||
|
if (location.pathname.startsWith('/comparison')) return null;
|
||||||
|
|
||||||
|
const packages: CompareBarPackage[] = packageKeys
|
||||||
|
.map((key) => {
|
||||||
|
const pkg = resolveComparisonPackage(key);
|
||||||
|
const parsed = parseBasketKey(key);
|
||||||
|
if (!pkg || !parsed) return null;
|
||||||
|
return {
|
||||||
|
id: key,
|
||||||
|
name: pkg.name,
|
||||||
|
providerName: pkg.provider.name,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((p): p is CompareBarPackage => p !== null);
|
||||||
|
|
||||||
|
// CompareBar slides in only when packages.length > 0. To surface "already
|
||||||
|
// added" / "max reached" errors when the bar isn't yet visible (no items),
|
||||||
|
// we'd need a separate toast. For now: errors only appear once the bar is
|
||||||
|
// visible — fine for the common dupe case (basket has ≥1).
|
||||||
|
return (
|
||||||
|
<CompareBar
|
||||||
|
packages={packages}
|
||||||
|
onCompare={() => navigate('/comparison')}
|
||||||
|
error={lastError ?? undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/demo/apps/arrangement/DemoNav.tsx
Normal file
31
src/demo/apps/arrangement/DemoNav.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { Navigation } from '../../../components/organisms/Navigation';
|
||||||
|
import { assetUrl } from '../../shared/assets';
|
||||||
|
|
||||||
|
const FALogo = () => (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={assetUrl('/brandlogo/logo-full.svg')}
|
||||||
|
alt="Funeral Arranger"
|
||||||
|
sx={{ height: 28, display: { xs: 'none', md: 'block' } }}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={assetUrl('/brandlogo/logo-short.svg')}
|
||||||
|
alt="Funeral Arranger"
|
||||||
|
sx={{ height: 28, display: { xs: 'block', md: 'none' } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const demoNav = (
|
||||||
|
<Navigation
|
||||||
|
logo={<FALogo />}
|
||||||
|
items={[
|
||||||
|
{ label: 'FAQ', href: '#' },
|
||||||
|
{ label: 'Contact Us', href: '#' },
|
||||||
|
{ label: 'Log in', href: '#' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
18
src/demo/apps/arrangement/index.html
Normal file
18
src/demo/apps/arrangement/index.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Arrangement Demo — Funeral Arranger</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&family=Noto+Serif+SC:wght@400;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
23
src/demo/apps/arrangement/main.tsx
Normal file
23
src/demo/apps/arrangement/main.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { ThemeProvider } from '@mui/material/styles';
|
||||||
|
import CssBaseline from '@mui/material/CssBaseline';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { theme } from '../../../theme';
|
||||||
|
import '../../../theme/generated/tokens.css';
|
||||||
|
import { App } from './App';
|
||||||
|
|
||||||
|
// Vite's `base` is `/arrangement/` in production. In dev the root is this app
|
||||||
|
// folder so base is `/`. import.meta.env.BASE_URL gives us the right value.
|
||||||
|
const basename = import.meta.env.BASE_URL.replace(/\/$/, '') || '/';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<BrowserRouter basename={basename}>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</ThemeProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
73
src/demo/apps/arrangement/routes/Comparison.tsx
Normal file
73
src/demo/apps/arrangement/routes/Comparison.tsx
Normal file
@@ -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<ReturnType<typeof resolveComparisonPackage>> } =>
|
||||||
|
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 (
|
||||||
|
<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)}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/demo/apps/arrangement/routes/Packages.tsx
Normal file
76
src/demo/apps/arrangement/routes/Packages.tsx
Normal file
@@ -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<string | null>(bundle?.matching[0]?.id ?? null);
|
||||||
|
|
||||||
|
if (!provider || !bundle) return <Navigate to="/" replace />;
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<PackagesStep
|
||||||
|
provider={toPackagesStepProvider(provider)}
|
||||||
|
providerTier={provider.tier}
|
||||||
|
packages={bundle.matching}
|
||||||
|
secondaryList={secondaryHasItems ? 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}
|
||||||
|
isSelectedPackageInCart={isSelectedInCart}
|
||||||
|
onNearbyProviderClick={(id) => navigate(`/providers/${id}/packages`)}
|
||||||
|
onProviderClick={() => alert('Provider profile — not built in this demo slice.')}
|
||||||
|
onBack={() => navigate('/')}
|
||||||
|
navigation={demoNav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/demo/apps/arrangement/routes/Providers.tsx
Normal file
45
src/demo/apps/arrangement/routes/Providers.tsx
Normal file
@@ -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<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}
|
||||||
|
mapPanel={
|
||||||
|
<ProviderMap
|
||||||
|
providers={filtered}
|
||||||
|
onSelectProvider={(id) => navigate(`/providers/${id}/packages`)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/demo/shared/assets.ts
Normal file
17
src/demo/shared/assets.ts
Normal file
@@ -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 <slice>` 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}`;
|
||||||
|
};
|
||||||
1276
src/demo/shared/fixtures/packages.ts
Normal file
1276
src/demo/shared/fixtures/packages.ts
Normal file
File diff suppressed because it is too large
Load Diff
129
src/demo/shared/fixtures/providers.ts
Normal file
129
src/demo/shared/fixtures/providers.ts
Normal file
@@ -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<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,
|
||||||
|
};
|
||||||
|
}
|
||||||
82
src/demo/shared/state/useBasketUrlSync.ts
Normal file
82
src/demo/shared/state/useBasketUrlSync.ts
Normal file
@@ -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]);
|
||||||
|
}
|
||||||
49
src/demo/shared/state/useComparisonBasket.ts
Normal file
49
src/demo/shared/state/useComparisonBasket.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { BasketKey } from '../fixtures/packages';
|
||||||
|
|
||||||
|
// ComparisonPage caps user-selected packages at 3 (recommended is shown as a
|
||||||
|
// separate column). Keep the basket aligned so we can't add a 4th and have it
|
||||||
|
// silently dropped at render time.
|
||||||
|
const MAX_BASKET = 3;
|
||||||
|
|
||||||
|
interface BasketState {
|
||||||
|
packageKeys: BasketKey[];
|
||||||
|
/** Transient feedback message — set when add() is rejected (dupe/full) */
|
||||||
|
lastError: string | null;
|
||||||
|
add: (key: BasketKey) => void;
|
||||||
|
remove: (key: BasketKey) => void;
|
||||||
|
toggle: (key: BasketKey) => void;
|
||||||
|
clear: () => void;
|
||||||
|
clearError: () => void;
|
||||||
|
setAll: (keys: BasketKey[]) => void;
|
||||||
|
has: (key: BasketKey) => boolean;
|
||||||
|
isFull: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useComparisonBasket = create<BasketState>((set, get) => ({
|
||||||
|
packageKeys: [],
|
||||||
|
lastError: null,
|
||||||
|
add: (key) =>
|
||||||
|
set((state) => {
|
||||||
|
if (state.packageKeys.includes(key)) {
|
||||||
|
return { ...state, lastError: 'Already added' };
|
||||||
|
}
|
||||||
|
if (state.packageKeys.length >= MAX_BASKET) {
|
||||||
|
return { ...state, lastError: `Maximum ${MAX_BASKET} packages` };
|
||||||
|
}
|
||||||
|
return { packageKeys: [...state.packageKeys, key], lastError: null };
|
||||||
|
}),
|
||||||
|
remove: (key) => set((state) => ({ packageKeys: state.packageKeys.filter((k) => k !== key) })),
|
||||||
|
toggle: (key) => {
|
||||||
|
const { has, add, remove } = get();
|
||||||
|
if (has(key)) remove(key);
|
||||||
|
else add(key);
|
||||||
|
},
|
||||||
|
clear: () => set({ packageKeys: [], lastError: null }),
|
||||||
|
clearError: () => set({ lastError: null }),
|
||||||
|
setAll: (keys) => set({ packageKeys: keys.slice(0, MAX_BASKET), lastError: null }),
|
||||||
|
has: (key) => get().packageKeys.includes(key),
|
||||||
|
isFull: () => get().packageKeys.length >= MAX_BASKET,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const BASKET_MAX = MAX_BASKET;
|
||||||
41
vite.demo.config.ts
Normal file
41
vite.demo.config.ts
Normal file
@@ -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 <name>` and selects
|
||||||
|
* the app folder, base path, and output directory.
|
||||||
|
*
|
||||||
|
* Dev: vite -c vite.demo.config.ts --mode arrangement
|
||||||
|
* Build: vite build -c vite.demo.config.ts --mode arrangement
|
||||||
|
* → dist-demo/arrangement/
|
||||||
|
*/
|
||||||
|
export default defineConfig(({ mode, command }) => {
|
||||||
|
const slice = mode;
|
||||||
|
const appRoot = path.resolve(__dirname, `src/demo/apps/${slice}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
root: appRoot,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user